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">
<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,31 +714,105 @@ 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;" />
${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>
<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 = '';
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 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}%`;
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());
@@ -757,6 +822,7 @@ class HwAssetModal extends BaseModal {
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;" />
${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>
<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">
<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">
<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">
<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>
`;
}
}

View File

@@ -24,7 +24,7 @@ export interface FilterOptions {
* 전역 액션 버튼 그룹 생성 (자산 추가 등)
*/
export function getActionButtonsHTML(): string {
return `<div id="filter-bar-actions" class="header-action-group" style="display: flex; gap: 8px; margin-left: auto; align-self: flex-end;"></div>`;
return `<div id="filter-bar-actions" class="header-action-group"></div>`;
}
export function renderFilterBar(container: HTMLElement, options: FilterOptions) {
@@ -41,6 +41,8 @@ export function renderFilterBar(container: HTMLElement, options: FilterOptions)
initialFilters = { keyword: '', corp: '', dept: '', loc: '', field: '', type: '', status: '' }
} = options;
container.classList.add('search-bar'); // Restored class
container.innerHTML = `
<div class="search-item flex-1">
<label>${keywordLabel}</label>
@@ -88,7 +90,7 @@ export function renderFilterBar(container: HTMLElement, options: FilterOptions)
</div>` : ''}
${extraHTML}
<button id="btn-reset-filters" class="btn btn-outline btn-reset">
<i data-lucide="refresh-ccw"></i> ${UI_TEXT.ACTION.RESET_FILTER}
<i data-lucide="refresh-ccw" class="icon-sm"></i> ${UI_TEXT.ACTION.RESET_FILTER}
</button>
${getActionButtonsHTML()}
`;

View File

@@ -31,18 +31,18 @@
--success: #0070f3;
--header-height: 64px;
/* --- Global Typography Scale (Enhanced Fluid Base) --- */
--fs-xs: clamp(10px, 1.2vmin + 0.2vw, 15px);
--fs-sm: clamp(12px, 1.4vmin + 0.3vw, 18px);
--fs-base: clamp(14px, 1.6vmin + 0.4vw, 22px);
--fs-md: clamp(18px, 2.5vmin + 0.5vw, 30px);
--fs-lg: clamp(24px, 4vmin + 0.6vw, 48px);
--fs-xl: clamp(32px, 6vmin + 0.8vw, 72px);
/* --- Global Typography Scale (Tighter Clamps) --- */
--fs-xs: clamp(10px, 1vmin + 0.1vw, 13px);
--fs-sm: clamp(12px, 1.2vmin + 0.2vw, 15px);
--fs-base: clamp(13px, 1.4vmin + 0.2vw, 16px);
--fs-md: clamp(16px, 2vmin + 0.3vw, 24px);
--fs-lg: clamp(20px, 3vmin + 0.4vw, 32px);
--fs-xl: clamp(28px, 5vmin + 0.6vw, 48px);
/* --- Fluid Layout Units (Aggressive) --- */
--header-height: clamp(50px, 8vmin, 90px);
--spacing-base: clamp(0.75rem, 3vmin, 3rem);
--radius-base: clamp(6px, 1.5vmin, 16px);
/* --- Layout Units --- */
--header-height: 64px;
--spacing-base: 1.5rem;
--radius-base: 8px;
}
* {
@@ -433,7 +433,30 @@ input:checked + .role-slider:before {
.justify-center { justify-content: center; }
.gap-1 { gap: 0.25rem; }
.gap-2 { gap: 0.5rem; }
.gap-3 { gap: 0.75rem; }
.gap-4 { gap: 1rem; }
.gap-6 { gap: 1.5rem; }
.gap-y-3 { row-gap: 0.75rem; }
.gap-x-4 { column-gap: 1rem; }
.mb-0 { margin-bottom: 0 !important; }
.mb-4 { margin-bottom: 1rem !important; }
.mb-6 { margin-bottom: 1.5rem !important; }
.pb-4 { padding-bottom: 1rem !important; }
.p-4 { padding: 1rem !important; }
.p-2 { padding: 0.5rem !important; }
.p-8 { padding: 2rem !important; }
.ml-auto { margin-left: auto !important; }
.self-end { align-self: flex-end !important; }
.font-medium { font-weight: 500; }
.text-muted { color: var(--mute) !important; }
.mt-12 { margin-top: 3rem !important; }
.icon-sm { width: 16px; height: 16px; }
.h-90vh { height: 90vh !important; }
.pt-0 { padding-top: 0 !important; }
.font-semibold { font-weight: 600; }
.w-full { width: 100%; }
.h-full { height: 100%; }
@@ -442,9 +465,154 @@ input:checked + .role-slider:before {
.text-right { text-align: right !important; }
.text-left { text-align: left !important; }
.font-bold { font-weight: 700; }
.font-semibold { font-weight: 600; }
.bg-primary-light { background-color: var(--primary-light) !important; }
.text-success { color: var(--success) !important; }
.text-danger { color: var(--danger) !important; }
.text-blue { color: var(--color-blue) !important; }
.text-orange { color: var(--color-orange) !important; }
/* --- Unified Search & Filter Bar --- */
.search-bar {
display: flex;
flex-wrap: wrap;
gap: var(--spacing-base);
padding: 1.25rem var(--spacing-base);
border-bottom: 1px solid var(--hairline);
align-items: flex-end;
background: var(--canvas);
box-sizing: border-box;
}
.search-item {
display: flex;
flex-direction: column;
gap: 0.5rem;
justify-content: flex-end;
}
.search-item.flex-1 {
flex: 1;
min-width: 300px;
}
.search-item label {
font-size: var(--fs-xs);
font-weight: 600;
color: var(--mute);
text-transform: uppercase;
letter-spacing: 0.05em;
}
.search-item input,
.search-item select {
height: clamp(34px, 4.5vmin, 44px);
padding: 0 0.75rem;
border: 1px solid var(--hairline);
border-radius: 6px;
font-size: var(--fs-sm);
outline: none;
background-color: var(--canvas);
color: var(--primary);
transition: border-color 0.2s;
box-sizing: border-box;
}
.search-item select {
cursor: pointer;
min-width: 120px;
}
.search-item input:focus,
.search-item select:focus {
border-color: var(--primary);
}
.header-action-group {
margin-left: auto;
align-self: flex-end;
display: flex;
align-items: center;
gap: 8px;
}
.list-view-toggle-label {
display: flex;
align-items: center;
gap: 0.5rem;
cursor: pointer;
font-weight: 600;
color: var(--primary);
height: clamp(34px, 4.5vmin, 44px);
padding: 0 0.5rem;
font-size: var(--fs-sm);
user-select: none;
}
.list-view-toggle-label input[type="checkbox"] {
width: 16px;
height: 16px;
cursor: pointer;
}
.map-pagination-group {
display: flex;
align-items: center;
gap: 0.75rem;
margin-left: 0.5rem;
padding-left: 1rem;
border-left: 1px solid var(--hairline);
height: clamp(34px, 4.5vmin, 44px);
}
.page-info {
font-size: var(--fs-xs);
color: var(--mute);
font-weight: 500;
white-space: nowrap;
}
/* --- Modal & View Header Layouts --- */
.header-left {
display: flex;
align-items: center;
gap: 1rem;
}
/* --- Asset Identity & Header Styling (Global) --- */
.header-identity {
display: flex;
align-items: center;
gap: 8px;
flex: 1;
flex-wrap: wrap;
}
.asset-code-title {
font-size: var(--fs-md);
font-weight: 600;
color: var(--primary);
letter-spacing: -0.05em;
line-height: 1;
}
.service-type-badge {
font-size: var(--fs-xs);
font-weight: 600;
color: var(--on-primary);
background: var(--primary);
padding: 4px 8px;
border-radius: 9999px;
text-transform: uppercase;
line-height: 1;
}
.asset-type-label {
font-size: var(--fs-sm);
font-weight: 500;
color: var(--mute);
line-height: 1;
}
/* --- Footer --- */
.main-footer {
border-top: 1px solid var(--border-color);
background-color: var(--canvas);

View File

@@ -140,57 +140,6 @@
overflow: hidden;
}
.location-filter-bar {
display: flex;
justify-content: space-between;
align-items: center;
padding: 1rem 1.5rem;
border-bottom: 1px solid var(--hairline);
background: var(--canvas);
flex-shrink: 0;
}
.filter-actions-group {
display: flex;
align-items: center;
gap: 1.5rem;
}
.filter-group {
display: flex;
align-items: center;
gap: 0.75rem;
}
.filter-group label {
font-size: var(--fs-xs);
font-weight: 600;
color: var(--mute);
text-transform: uppercase;
letter-spacing: 0.05em;
}
.filter-row {
display: flex;
align-items: center;
gap: 0.5rem;
}
.map-pagination-group {
margin-left: 0;
padding-left: 0.5rem;
border-left: 1px solid var(--hairline);
display: flex;
align-items: center;
gap: 0.5rem;
}
.page-info {
font-size: var(--fs-xs);
color: var(--mute);
font-weight: 500;
}
.location-main-content {
flex: 1;
display: grid;
@@ -361,18 +310,161 @@
margin-bottom: 4px;
}
.detail-value-lg {
font-size: var(--fs-base);
color: var(--primary);
font-weight: 500;
line-height: 1.4;
.dashboard-layout-2col {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 2rem;
padding: 0 2rem 2rem;
}
.text-danger {
color: var(--danger) !important;
font-weight: 600;
.dashboard-card {
background: var(--canvas);
border: 1px solid var(--hairline);
border-radius: 12px;
padding: 2rem;
display: flex;
flex-direction: column;
gap: 1rem;
transition: all 0.2s;
}
.dashboard-card.clickable:hover {
border-color: var(--primary);
box-shadow: 0 12px 30px rgba(0,0,0,0.08);
transform: translateY(-2px);
}
.stat-progress-bar {
height: 8px;
background: var(--canvas-soft-2);
border-radius: 9999px;
overflow: hidden;
margin-top: 0.5rem;
}
.progress-fill {
height: 100%;
background: var(--primary);
border-radius: 9999px;
}
.dashboard-card .stat-label {
font-size: var(--fs-xs);
font-weight: 600;
color: var(--mute);
text-transform: uppercase;
letter-spacing: 0.05em;
}
.dashboard-card .stat-value {
font-size: var(--fs-xl);
font-weight: 700;
color: var(--primary);
}
.dashboard-card .stat-sub {
font-size: var(--fs-sm);
color: var(--body);
}
.bg-soft {
background-color: var(--canvas-soft) !important;
}
.chart-placeholder {
width: 140px;
height: 140px;
display: flex;
align-items: center;
justify-content: center;
}
.circular-progress {
width: 100px;
height: 100px;
border-radius: 50%;
background: conic-gradient(var(--primary) calc(var(--val) * 1%), var(--hairline) 0);
display: flex;
align-items: center;
justify-content: center;
position: relative;
}
.circular-progress::before {
content: "";
position: absolute;
width: 70px;
height: 70px;
background: var(--canvas);
border-radius: 50%;
}
.circular-progress::after {
content: attr(style); /* This is a hack to get the value, but we'll use innerHTML in TS if needed */
position: absolute;
font-size: var(--fs-sm);
font-weight: 700;
}
.system-dashboard {
flex: 1;
display: flex;
flex-direction: column;
overflow: hidden;
}
.warning-badge-orange { background-color: var(--color-orange); color: var(--white); padding: 2px 8px; border-radius: 9999px; font-size: var(--fs-xs); font-weight: 600; }
.warning-badge { background-color: var(--danger); color: var(--white); padding: 2px 8px; border-radius: 9999px; font-size: var(--fs-xs); font-weight: 600; }
.list-section {
flex: 1.3;
display: flex;
flex-direction: column;
min-height: 0;
padding: 1rem 1.5rem 0 0;
border-right: 1px solid var(--hairline);
}
.detail-panel {
flex: 0.7;
display: flex;
flex-direction: column;
min-height: 0;
padding: 1rem 0 0 1.5rem;
overflow: hidden;
}
.detail-empty-state {
flex: 1;
display: flex;
flex-direction: column;
justify-content: center;
align-items: center;
color: var(--mute);
}
.detail-photo-wrapper {
width: 100%;
flex: 1;
overflow: hidden;
display: flex;
align-items: center;
justify-content: center;
position: relative;
border: 1px solid var(--hairline);
background: #f0f0f0;
border-radius: 8px;
}
.no-photo-state {
padding: 3rem;
text-align: center;
color: var(--mute);
}
/* Responsive Overrides */
@media (max-width: 1440px) {
.location-main-content {

View File

@@ -30,7 +30,10 @@
border: 1px solid var(--hairline);
}
.modal-overlay:not(.hidden) .modal-content { transform: translateY(0); }
.modal-overlay.sub-modal {
z-index: 1100;
}
.modal-header {
background-color: var(--canvas);
@@ -59,10 +62,71 @@
transition: color 0.2s;
}
.modal-header .btn-icon:hover {
color: var(--primary) !important;
.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;
@@ -174,11 +238,20 @@
gap: 0.75rem;
}
/* Wide Modal for History/Detail */
/* 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;
@@ -459,25 +532,31 @@
.layout-map-container {
position: relative;
width: 100%;
height: 100%;
display: flex;
align-items: center;
justify-content: center;
display: inline-block;
cursor: crosshair;
background-color: var(--canvas-soft-2);
border-radius: 4px;
overflow: hidden;
}
.layout-map-img {
max-width: 100%;
max-height: 100%;
object-fit: contain;
display: block;
.layout-map-container.readonly {
cursor: default;
}
.layout-map-container:not(.readonly) .layout-map-img {
cursor: crosshair;
.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;

View File

@@ -27,65 +27,7 @@
line-height: 1.5;
}
/* --- Table View & Filter Styles --- */
.search-bar {
display: flex;
flex-wrap: wrap;
gap: var(--spacing-base);
padding: 1.25rem var(--spacing-base);
border-bottom: 1px solid var(--hairline);
align-items: flex-end; /* This aligns inputs and buttons at the bottom */
background: var(--canvas);
}
.search-item {
display: flex;
flex-direction: column;
gap: 0.5rem;
justify-content: flex-end;
}
.search-item.flex-1 {
flex: 1;
min-width: 300px;
}
.search-item label {
font-size: var(--fs-xs);
font-weight: 600;
color: var(--mute);
text-transform: uppercase;
letter-spacing: 0.05em;
}
.search-item input,
.search-item select {
height: clamp(34px, 4.5vmin, 44px);
padding: 0 0.75rem;
border: 1px solid var(--hairline);
border-radius: 6px;
font-size: var(--fs-sm);
outline: none;
background-color: var(--canvas);
color: var(--primary);
transition: border-color 0.2s;
}
.search-item select {
padding-right: 2.5rem !important;
cursor: pointer;
}
.search-item input:focus,
.search-item select:focus {
border-color: var(--primary);
}
.btn-reset {
height: 38px !important;
color: var(--mute) !important;
}
/* --- Table View Styles --- */
.table-container {
flex: 1;
background-color: var(--canvas);
@@ -186,15 +128,19 @@ th.sortable.desc::after { content: '▼'; opacity: 1; color: var(--primary); }
font-weight: 600;
color: var(--mute);
border-bottom: 1px solid var(--hairline);
background: var(--canvas);
background: var(--canvas-soft);
text-transform: uppercase;
letter-spacing: 0.05em;
}
.compact-table td {
padding: 0.75rem 0.5rem;
font-size: var(--fs-base);
font-size: var(--fs-sm);
border-bottom: 1px solid var(--hairline-soft, #f5f5f5);
color: var(--primary);
}
.compact-table tr.clickable-row:hover {
background: var(--canvas-soft);
cursor: pointer;

View File

@@ -10,157 +10,285 @@ let jobChartInstance: any = null;
let donutChartInstance: any = null;
export function renderHwDashboard(container: HTMLElement) {
// 1. 개인용 PC 데이터 추출 (유형이 '개인PC'이거나 상태가 '재고' 또는 '대기' 상태인 PC 집계)
const pcs = (state.masterData.pc || []).filter((a: any) =>
a.asset_type === '개인PC' ||
((a.hw_status === '재고' || a.hw_status === '대기') && a.category === 'PC')
);
// 2. 1페이지 매거진 리포트(제목바 제거, '| 제목' 미니멀리즘 스타일) HTML 빌드
container.innerHTML = `
<div class="view-container" style="overflow-y: auto; background-color: var(--canvas-soft);">
<div class="view-container bg-soft" style="padding: 1.5rem 2rem; height: calc(100vh - var(--header-height) - 28px); box-sizing: border-box; display: flex; flex-direction: column; gap: 1.25rem;">
<div class="location-filter-bar">
<h2 class="dashboard-section-title" style="margin:0;">개인 PC 자산 대시보드</h2>
<!-- 대시보드 타이틀 및 사용조직 필터 -->
<div class="flex justify-between items-end flex-shrink-0 mb-4">
<div style="border-left: 4px solid var(--primary); padding-left: 8px;">
<h2 class="dashboard-section-title mb-0">개인 PC 자산 대시보드</h2>
</div>
<div class="view-toggle-container">
<div id="dashboard-dept-buttons" class="view-toggle">
<button class="toggle-btn active" data-dept="">전체</button>
<button class="toggle-btn" data-dept="한맥">한맥</button>
<button class="toggle-btn" data-dept="삼안">삼안</button>
<button class="toggle-btn" data-dept="장헌">장헌</button>
<button class="toggle-btn" data-dept="한라">한라</button>
<button class="toggle-btn" data-dept="기술개발센터">기술개발센터</button>
<button class="toggle-btn" data-dept="총괄기획실">총괄기획실</button>
<!-- 사용조직 필터 (브랜드 그린 매칭 칩 디자인) -->
<div class="flex items-center gap-3">
<span class="detail-label-sm font-bold">조직 필터:</span>
<div id="dashboard-dept-buttons" class="flex gap-1 p-1 bg-canvas-soft border border-hairline rounded-lg">
<button class="dept-filter-btn active" data-dept="">전체</button>
<button class="dept-filter-btn" data-dept="한맥">한맥</button>
<button class="dept-filter-btn" data-dept="삼안">삼안</button>
<button class="dept-filter-btn" data-dept="장헌">장헌</button>
<button class="dept-filter-btn" data-dept="한라">한라</button>
<button class="dept-filter-btn" data-dept="기술개발센터">기술개발센터</button>
<button class="dept-filter-btn" data-dept="총괄기획실">총괄기획실</button>
</div>
</div>
</div>
<div class="dashboard-stats-row">
<div class="stat-group-item">
<div class="stat-label">보유 자산 수량</div>
<div class="stat-value"><span id="metric-total-pcs">0</span><span>대</span></div>
<div class="stat-sub">전사 보유 개인용 PC</div>
</div>
<!-- 메인 2단 컬럼 레이아웃 (5:5 비율) -->
<div style="display: grid; grid-template-columns: 1fr 1fr; gap: 1.5rem; flex: 1; min-height: 0; margin-bottom: 0.25rem;">
<div id="card-under-spec" class="stat-group-item bordered clickable">
<div class="stat-label" style="color: var(--danger);">사양 부족 검토</div>
<div class="stat-value" style="color: var(--danger);"><span id="metric-under-spec">0</span><span>명</span></div>
<div class="stat-sub">사양 교체 권고 자산</div>
</div>
<!-- 좌측 컬럼 (Left Column) -->
<div class="flex-col gap-4 min-h-0">
<div id="card-over-spec" class="stat-group-item bordered clickable">
<div class="stat-label" style="color: var(--color-orange);">오버스펙 검토</div>
<div class="stat-value" style="color: var(--color-orange);"><span id="metric-over-spec">0</span><span>명</span></div>
<div class="stat-sub">사양 회수 권고 자산</div>
</div>
<!-- 상단 핵심 지표 그룹 카드 (1개 카드로 통합, 4개 지표 가로 배치) -->
<div class="stat-card border-b border-hairline pb-4 flex flex-row items-center justify-between flex-shrink-0 gap-0">
<div id="card-win11-incompatible" class="stat-group-item bordered clickable">
<div class="stat-label" style="color: var(--color-blue);">윈도우 11 불가 PC</div>
<div class="stat-value" style="color: var(--color-blue);"><span id="metric-win11-incompatible">0</span><span>대</span></div>
<div class="stat-sub">업데이트 미지원 기기</div>
<!-- 1. 보유 자산 수량 -->
<div class="flex-1 border-r border-hairline pr-4">
<div style="border-left: 4px solid var(--primary); padding-left: 8px; margin-bottom: 0.5rem;" class="flex items-center">
<span class="detail-label-sm font-bold text-primary">보유 자산 수량</span>
</div>
<div class="flex items-end justify-between">
<div>
<div id="metric-total-pcs" class="stat-value" style="line-height: 1; margin-bottom: 0.2rem;">0대</div>
<span class="detail-label-sm text-muted">전사 보유 개인용 PC</span>
</div>
</div>
</div>
<div style="display: grid; grid-template-columns: 1fr 1fr; gap: 2rem; padding: 0 2rem 2rem;">
<div style="display: flex; flex-direction: column; gap: 2rem;">
<div class="stat-card">
<div class="detail-section-title">등급별 보유 현황</div>
<div style="display: grid; grid-template-columns: 1.2fr 1fr; gap: 2rem; align-items: center;">
<div style="display: flex; flex-direction: column; gap: 1rem;">
<div id="grade-premium" class="grade-item clickable">
<div class="detail-label-sm">최상급 PC (85점 이상)</div>
<div class="detail-value-lg"><span class="grade-count">0대</span> <span class="grade-rate">(0%)</span></div>
<!-- 2. 사양 부족 검토 -->
<div id="card-under-spec" class="flex-1 border-r border-hairline px-4 cursor-pointer hover:opacity-70 transition-opacity">
<div style="border-left: 4px solid var(--danger); padding-left: 8px; margin-bottom: 0.5rem;" class="flex items-center">
<span class="detail-label-sm font-bold text-primary">사양 부족 검토</span>
</div>
<div id="grade-high" class="grade-item clickable">
<div class="detail-label-sm">상급 PC (70점 ~ 85점)</div>
<div class="detail-value-lg"><span class="grade-count">0대</span> <span class="grade-rate">(0%)</span></div>
</div>
<div id="grade-normal" class="grade-item clickable">
<div class="detail-label-sm">중급 PC (40점 ~ 70점)</div>
<div class="detail-value-lg"><span class="grade-count">0대</span> <span class="grade-rate">(0%)</span></div>
</div>
<div id="grade-entry" class="grade-item clickable">
<div class="detail-label-sm">보급 PC (40점 미만)</div>
<div class="detail-value-lg"><span class="grade-count">0대</span> <span class="grade-rate">(0%)</span></div>
<div class="flex items-end justify-between">
<div>
<div id="metric-under-spec" class="stat-value text-danger" style="line-height: 1; margin-bottom: 0.2rem;">0명</div>
<span class="detail-label-sm text-muted">사양 교체 권고 자산</span>
</div>
</div>
<div style="width: 160px; height: 160px; margin: 0 auto;">
</div>
<!-- 3. 오버스펙 검토 -->
<div id="card-over-spec" class="flex-1 border-r border-hairline px-4 cursor-pointer hover:opacity-70 transition-opacity">
<div style="border-left: 4px solid var(--color-orange); padding-left: 8px; margin-bottom: 0.5rem;" class="flex items-center">
<span class="detail-label-sm font-bold text-primary">오버스펙 검토</span>
</div>
<div class="flex items-end justify-between">
<div>
<div id="metric-over-spec" class="stat-value text-orange" style="line-height: 1; margin-bottom: 0.2rem;">0명</div>
<span class="detail-label-sm text-muted">사양 회수 권고 자산</span>
</div>
</div>
</div>
<!-- 4. 윈도우 11 불가 PC -->
<div id="card-win11-incompatible" class="flex-1 pl-4 cursor-pointer hover:opacity-70 transition-opacity">
<div style="border-left: 4px solid var(--color-blue); padding-left: 8px; margin-bottom: 0.5rem;" class="flex items-center">
<span class="detail-label-sm font-bold text-primary">윈도우 11 불가 PC</span>
</div>
<div class="flex items-end justify-between">
<div>
<div id="metric-win11-incompatible" class="stat-value text-blue" style="line-height: 1; margin-bottom: 0.2rem;">0대</div>
<span class="detail-label-sm text-muted">업데이트 미지원 하드웨어</span>
</div>
</div>
</div>
</div>
<!-- PC 성능 등급별 분포 현황 (등급별 게이지 + 우측 사양 적정성 도넛차트) -->
<div class="border-b border-hairline flex flex-row items-center gap-6" style="padding: 1.25rem 0.25rem; border: none; border-bottom: 1px solid var(--hairline); flex: 1.1; min-height: 0;">
<!-- 1열: 등급별 보유 현황 리스트 영역 -->
<div class="flex-1 flex flex-col gap-4 justify-center pl-2">
<!-- 메인 제목 -->
<div style="border-left: 4px solid var(--primary); padding-left: 8px; margin-bottom: 0.35rem;" class="flex items-center">
<span class="detail-label-sm font-bold text-primary">PC 성능 등급별 분포 현황</span>
</div>
<!-- 등급 리스트 (바 그래프 제거 및 폰트 확대, 간격 조정) -->
<div class="flex flex-col gap-1 py-1">
<!-- 최상급 -->
<div id="grade-premium" class="cursor-pointer flex flex-col p-1 hover:bg-canvas-soft rounded transition-all">
<div class="flex items-center gap-3" style="font-size: 1.25rem; font-weight: 800;">
<span style="color: #11302B; white-space: nowrap; width: 220px; display: inline-block;">최상급 PC (85점 이상)</span>
<span class="grade-info flex items-center gap-1 text-primary"><span class="grade-count">0대</span><span class="grade-rate text-muted text-lg">(0%)</span></span>
</div>
</div>
<!-- 상급 -->
<div id="grade-high" class="cursor-pointer flex flex-col p-1 hover:bg-canvas-soft rounded transition-all">
<div class="flex items-center gap-3" style="font-size: 1.25rem; font-weight: 800;">
<span style="color: #1E8E7C; white-space: nowrap; width: 220px; display: inline-block;">상급 PC (70점 ~ 85점)</span>
<span class="grade-info flex items-center gap-1 text-primary"><span class="grade-count">0대</span><span class="grade-rate text-muted text-lg">(0%)</span></span>
</div>
</div>
<!-- 중급 -->
<div id="grade-normal" class="cursor-pointer flex flex-col p-1 hover:bg-canvas-soft rounded transition-all">
<div class="flex items-center gap-3" style="font-size: 1.25rem; font-weight: 800;">
<span style="color: #10B981; white-space: nowrap; width: 220px; display: inline-block;">중급 PC (40점 ~ 70점)</span>
<span class="grade-info flex items-center gap-1 text-primary"><span class="grade-count">0대</span><span class="grade-rate text-muted text-lg">(0%)</span></span>
</div>
</div>
<!-- 보급 -->
<div id="grade-entry" class="cursor-pointer flex flex-col p-1 hover:bg-canvas-soft rounded transition-all">
<div class="flex items-center gap-3" style="font-size: 1.25rem; font-weight: 800;">
<span style="color: #64748B; white-space: nowrap; width: 220px; display: inline-block;">보급 PC (40점 미만)</span>
<span class="grade-info flex items-center gap-1 text-primary"><span class="grade-count">0대</span><span class="grade-rate text-muted text-lg">(0%)</span></span>
</div>
</div>
</div>
</div>
<!-- 2열: 등급별 보유 비율 도넛 영역 -->
<div class="flex flex-col items-center justify-center gap-3">
<div class="detail-label-sm font-bold text-muted uppercase">등급별 보유 비율</div>
<div class="flex flex-col items-center justify-center flex-shrink-0 w-full">
<div style="width: 160px; height: 140px; position: relative;">
<canvas id="chart-overall-donut"></canvas>
</div>
<!-- 커스텀 범례 -->
<div class="flex gap-2 justify-center items-center mt-1 font-bold text-xs text-muted">
<div class="flex items-center gap-1"><span class="w-2 h-2 rounded-full" style="background: #11302B;"></span>최상</div>
<div class="flex items-center gap-1"><span class="w-2 h-2 rounded-full" style="background: #1E8E7C;"></span>상</div>
<div class="flex items-center gap-1"><span class="w-2 h-2 rounded-full" style="background: #10B981;"></span>중</div>
<div class="flex items-center gap-1"><span class="w-2 h-2 rounded-full" style="background: #94A3B8;"></span>보급</div>
</div>
</div>
</div>
<div class="stat-card">
<div class="detail-section-title">유효 재고 현황</div>
<div style="display: grid; grid-template-columns: 1fr 1fr; gap: 1rem; text-align: center;">
<div id="stock-premium-card" class="clickable">
<div class="stat-label">최상급 재고</div>
<div class="detail-value-lg summary-grade-stock-premium">0대</div>
</div>
<div id="stock-high-card" class="clickable">
<div class="stat-label">상급 재고</div>
<div class="detail-value-lg summary-grade-stock-high">0대</div>
<!-- 유효 재고 현황 -->
<div class="flex flex-col gap-4 flex-1 min-h-0" style="padding: 1.25rem 0.25rem; border-bottom: 1px solid var(--hairline);">
<div style="border-left: 4px solid var(--primary); padding-left: 8px; margin-bottom: 0.35rem;" class="flex items-center">
<span class="detail-label-sm font-bold text-primary">유효 재고 현황</span>
</div>
<div id="stock-normal-card" class="clickable">
<div class="stat-label">중급 재고</div>
<div class="detail-value-lg summary-grade-stock-normal">0대</div>
<div class="grid grid-cols-[1fr,1px,1fr] gap-2 flex-1 items-center">
<div class="flex flex-col gap-4 w-full">
<div id="stock-premium-card" class="text-center w-full cursor-pointer hover:opacity-70 transition-opacity">
<div class="summary-grade-stock-premium stat-value" style="color: #11302B;">0대</div>
<span class="detail-label-sm font-bold text-muted">최상급 재고</span>
</div>
<div id="stock-entry-card" class="clickable">
<div class="stat-label">보급 재고</div>
<div class="detail-value-lg summary-grade-stock-entry">0대</div>
<div id="stock-normal-card" class="text-center w-full cursor-pointer hover:opacity-70 transition-opacity">
<div class="summary-grade-stock-normal stat-value" style="color: #10B981;">0대</div>
<span class="detail-label-sm font-bold text-muted">중급 재고</span>
</div>
</div>
<div class="w-px h-4/5 bg-hairline self-center"></div>
<div class="flex flex-col gap-4 w-full">
<div id="stock-high-card" class="text-center w-full cursor-pointer hover:opacity-70 transition-opacity">
<div class="summary-grade-stock-high stat-value" style="color: #1E8E7C;">0대</div>
<span class="detail-label-sm font-bold text-muted">상급 재고</span>
</div>
<div id="stock-entry-card" class="text-center w-full cursor-pointer hover:opacity-70 transition-opacity">
<div class="summary-grade-stock-entry stat-value" style="color: #94A3B8;">0대</div>
<span class="detail-label-sm font-bold text-muted">보급 재고</span>
</div>
</div>
</div>
</div>
<div style="display: flex; flex-direction: column; gap: 2rem;">
<div class="stat-card" style="flex: 1; display: flex; flex-direction: column;">
<div class="detail-section-title">직무별 사양 적정성 분석</div>
<div style="height: 25vh; width: 100%;">
<canvas id="chart-job-scores"></canvas>
</div>
<!-- 우측 컬럼 (Right Column) -->
<div class="flex-col gap-4 min-h-0">
<!-- 직무별 사양 적정성 분석 차트 카드 -->
<div class="flex flex-col flex-1 min-h-0" style="padding: 1.5rem 0.25rem; border-bottom: 1px solid var(--hairline);">
<div style="border-left: 4px solid var(--primary); padding-left: 8px; margin-bottom: 0.9rem;" class="flex items-center flex-shrink-0">
<span class="detail-label-sm font-bold text-primary">직무별 사양 적정성 분석</span>
</div>
<div class="flex-1 min-h-0 w-full relative">
<canvas id="chart-job-scores" style="width: 100%; height: 100%;"></canvas>
</div>
</div>
<div class="stat-card">
<div class="detail-section-title">PC 노후도 및 교체 주기 예측</div>
<table class="compact-table" style="width: 100%;">
<thead>
<tr>
<th>구분 (연한)</th>
<th style="text-align: center;">보유 대수</th>
<th style="text-align: center;">권장 조치</th>
<!-- 연도별 PC 노후도 및 교체 주기 예측 카드 -->
<div class="flex flex-col flex-1 min-h-0" style="padding: 1.5rem 0.25rem; border-bottom: 1px solid var(--hairline);">
<div style="border-left: 4px solid var(--primary); padding-left: 8px; margin-bottom: 0.9rem;" class="flex items-center flex-shrink-0">
<span class="detail-label-sm font-bold text-primary">연도별 PC 노후도 및 교체 주기 예측</span>
</div>
<div class="flex-1 overflow-hidden min-h-0">
<table class="compact-table w-full text-left">
<thead class="sticky top-0 bg-canvas z-10">
<tr class="border-b-2 border-primary text-muted font-bold">
<th class="p-2 w-1/2">구분 (사용 연한)</th>
<th class="p-2 w-1/4 text-center">보유 대수</th>
<th class="p-2 w-1/4 text-center">권장 조치</th>
</tr>
</thead>
<tbody id="pc-aging-tbody"></tbody>
</table>
</div>
</div>
</div>
</div>
</div>
<style>
.dept-filter-btn { padding: 6px 14px; font-size: 0.85rem; font-weight: 700; border-radius: 6px; border: none; background: transparent; color: var(--mute); cursor: pointer; transition: all 0.2s; }
.dept-filter-btn.active { background: var(--primary); color: var(--on-primary); }
.aging-row:hover { background: var(--canvas-soft); }
.donut-text-overlay { position: absolute; top: 50%; left: 50%; transform: translate(-50%, -46%); font-size: 1.25rem; font-weight: 900; color: var(--primary); pointer-events: none; white-space: nowrap; }
</style>
`;
createIcons({ icons: { Laptop, Cpu, Shield, Zap, Monitor, AlertTriangle, ChevronRight, HelpCircle } });
const btnGroup = container.querySelector('#dashboard-dept-buttons') as HTMLElement;
btnGroup.addEventListener('click', (e) => {
const btn = (e.target as HTMLElement).closest('.toggle-btn') as HTMLButtonElement;
if (!btn) return;
btnGroup.querySelectorAll('.toggle-btn').forEach(b => b.classList.remove('active'));
btn.classList.add('active');
updateDashboardData(pcs, btn.getAttribute('data-dept') || '');
// 3. Lucide 아이콘 초기화
createIcons({
icons: { Laptop, Cpu, Shield, Zap, Monitor, AlertTriangle, ChevronRight, HelpCircle }
});
// 4. 사용조직 버튼 그룹 필터 이벤트 연동
const btnGroup = container.querySelector('#dashboard-dept-buttons') as HTMLElement;
btnGroup.addEventListener('click', (e) => {
const btn = (e.target as HTMLElement).closest('.dept-filter-btn') as HTMLButtonElement;
if (!btn) return;
btnGroup.querySelectorAll('.dept-filter-btn').forEach(b => {
const button = b as HTMLButtonElement;
button.classList.remove('active');
button.style.background = 'transparent';
button.style.color = '#475569';
});
btn.classList.add('active');
btn.style.background = '#1E5149';
btn.style.color = 'white';
const selectedDept = btn.getAttribute('data-dept') || '';
updateDashboardData(pcs, selectedDept);
});
// 5. 첫 로딩 시 전체 부서 대상 통계 로드
updateDashboardData(pcs, '');
}
/**
* 대시보드 데이터 수치 및 차트, 테이블 실시간 갱신
*/
function updateDashboardData(pcs: any[], selectedDept: string) {
// 1. 선택 부서 필터 적용
const filtered = selectedDept
? pcs.filter((p: any) => String(p[ASSET_SCHEMA.CURRENT_DEPT.key] || '').trim().includes(selectedDept))
: pcs;
// 2. 개별 PC의 성능 감점식 점수 실시간 재연산
filtered.forEach((p: any) => {
p._pc_score = calculatePcScoreDeductive(p.cpu, p.ram, p.gpu, p.purchase_date);
});
// 3. 전사 직무군별 평균 점수 산출
const jobScores: Record<string, { totalScore: number; count: number; avg: number }> = {};
pcs.forEach((p: any) => {
const score = calculatePcScoreDeductive(p.cpu, p.ram, p.gpu, p.purchase_date);
@@ -173,7 +301,12 @@ function updateDashboardData(pcs: any[], selectedDept: string) {
jobScores[job].avg = jobScores[job].count > 0 ? jobScores[job].totalScore / jobScores[job].count : 0;
});
const isStock = (p: any) => p.hw_status === '재고' || p.hw_status === '대기' || !(p.user_current || '').trim();
// 4. 등급 집계 (보유량 vs 유효 재고량)
const isStock = (p: any) => {
return p.hw_status === '재고' ||
p.hw_status === '대기' ||
!(p.user_current || '').trim();
};
const gradeCounts = {
premium: { total: 0, stock: 0 },
@@ -182,38 +315,69 @@ function updateDashboardData(pcs: any[], selectedDept: string) {
entry: { total: 0, stock: 0 }
};
let scoreSum = 0;
let underSpecCount = 0;
let overSpecCount = 0;
let win11IncompatibleCount = 0;
const criticalList: any[] = [];
filtered.forEach((p: any) => {
const score = p._pc_score;
scoreSum += score;
const stockYn = isStock(p);
if (score >= 85) { gradeCounts.premium.total++; if (stockYn) gradeCounts.premium.stock++; }
else if (score >= 70) { gradeCounts.high.total++; if (stockYn) gradeCounts.high.stock++; }
else if (score >= 40) { gradeCounts.normal.total++; if (stockYn) gradeCounts.normal.stock++; }
else { gradeCounts.entry.total++; if (stockYn) gradeCounts.entry.stock++; }
if (score >= 85) {
gradeCounts.premium.total++;
if (stockYn) gradeCounts.premium.stock++;
} else if (score >= 70) {
gradeCounts.high.total++;
if (stockYn) gradeCounts.high.stock++;
} else if (score >= 40) {
gradeCounts.normal.total++;
if (stockYn) gradeCounts.normal.stock++;
} else {
gradeCounts.entry.total++;
if (stockYn) gradeCounts.entry.stock++;
}
// 직무 적정성 계산 (재직 중이고 실 사용자 매핑 자산만 검토 대상)
const job = p[ASSET_SCHEMA.USER_POSITION.key] || '미분류';
const avg = jobScores[job]?.avg || 0;
if (avg > 0 && job !== '재고PC' && !stockYn) {
if (score < avg * 0.6) { p._spec_status = '사양 부족'; underSpecCount++; }
else if (score > avg * 1.5) { p._spec_status = '오버스펙'; overSpecCount++; }
else { p._spec_status = '적정'; }
if (score < avg * 0.6) {
p._spec_status = '사양 부족';
criticalList.push(p);
underSpecCount++;
} else if (score > avg * 1.5) {
p._spec_status = '오버스펙';
criticalList.push(p);
overSpecCount++;
} else {
p._spec_status = '적정';
}
}
// Windows 11 업그레이드 지원 불가 검사
if (isWindows11Incompatible(p.cpu, p.ram)) {
win11IncompatibleCount++;
}
if (isWindows11Incompatible(p.cpu, p.ram)) win11IncompatibleCount++;
});
document.getElementById('metric-total-pcs')!.textContent = `${filtered.length}`;
document.getElementById('metric-under-spec')!.textContent = `${underSpecCount}`;
document.getElementById('metric-over-spec')!.textContent = `${overSpecCount}`;
document.getElementById('metric-win11-incompatible')!.textContent = `${win11IncompatibleCount}`;
// 5. 핵심 텍스트형 지표 갱신
document.getElementById('metric-total-pcs')!.textContent = `${filtered.length}`;
document.getElementById('metric-under-spec')!.textContent = `${underSpecCount}`;
document.getElementById('metric-over-spec')!.textContent = `${overSpecCount}`;
document.getElementById('metric-win11-incompatible')!.textContent = `${win11IncompatibleCount}`;
// 6. 등급별 리스트 데이터 바 업데이트
const total = filtered.length || 1;
const updateCard = (id: string, counts: { total: number; stock: number }) => {
const card = document.getElementById(id)!;
const rate = Math.round((counts.total / total) * 100);
card.querySelector('.grade-count')!.textContent = `${counts.total}`;
card.querySelector('.grade-rate')!.textContent = `(${rate}%)`;
};
@@ -223,6 +387,7 @@ function updateDashboardData(pcs: any[], selectedDept: string) {
updateCard('grade-normal', gradeCounts.normal);
updateCard('grade-entry', gradeCounts.entry);
// 6.2 Inventory Summary 수치 업데이트 (골드/민트 텍스트 영역)
const container = document.getElementById('view-body')?.parentElement || document.body;
const setStockVal = (cls: string, val: number) => {
const el = container.querySelector(`.${cls}`);
@@ -233,190 +398,447 @@ function updateDashboardData(pcs: any[], selectedDept: string) {
setStockVal('summary-grade-stock-normal', gradeCounts.normal.stock);
setStockVal('summary-grade-stock-entry', gradeCounts.entry.stock);
const agingCounts = { immediate: [] as any[], review: [] as any[], normal: [] as any[], fresh: [] as any[] };
// 7. 연도별 PC 노후도 집계 및 렌더링
const agingCounts = {
immediate: [] as any[], // 7년 이상
review: [] as any[], // 3년 이상 7년 미만
normal: [] as any[], // 1년 이상 3년 미만
fresh: [] as any[] // 1년 미만
};
filtered.forEach((p: any) => {
const age = calculateAssetAge(p.purchase_date);
if (age >= 7.0) agingCounts.immediate.push(p);
else if (age >= 3.0) agingCounts.review.push(p);
else if (age >= 1.0) agingCounts.normal.push(p);
else agingCounts.fresh.push(p);
if (age >= 7.0) {
agingCounts.immediate.push(p);
} else if (age >= 3.0) {
agingCounts.review.push(p);
} else if (age >= 1.0) {
agingCounts.normal.push(p);
} else {
agingCounts.fresh.push(p);
}
});
const agingTbody = document.getElementById('pc-aging-tbody')!;
const renderAgingRow = (label: string, list: any[], badgeText: string, badgeStyle: string, ageGroupKey: string) => `
<tr class="aging-row clickable-row" data-group="${ageGroupKey}">
<td style="font-weight:600;">${label}</td>
<td style="text-align:center; font-weight:600;">${list.length}대</td>
<td style="text-align:center;"><span class="badge" style="${badgeStyle}">${badgeText}</span></td>
const renderAgingRow = (label: string, list: any[], badgeText: string, badgeStyle: string, ageGroupKey: string) => {
return `
<tr style="border-bottom:1px solid #F1F5F9; cursor:pointer; transition: background 0.2s;" class="aging-row" data-group="${ageGroupKey}" onmouseover="this.style.background='#F8FAFC'" onmouseout="this.style.background='none'">
<td style="padding:14px 4px; font-weight:700; color:#334155;">${label}</td>
<td style="padding:14px 4px; text-align:center; font-weight:700; color:#334155;">${list.length}대</td>
<td style="padding:14px 4px; text-align:center;">
<span style="padding:2px 8px; border-radius:4px; font-size:14px; font-weight:800; ${badgeStyle}">${badgeText}</span>
</td>
</tr>
`;
};
agingTbody.innerHTML = `
${renderAgingRow('즉시 교체 (7년 이상)', agingCounts.immediate, '즉시 교체', 'background:var(--danger); color:white;', 'immediate')}
${renderAgingRow('교체 검토 (3년 ~ 7년)', agingCounts.review, '교체 검토', 'background:var(--color-orange); color:white;', 'review')}
${renderAgingRow('정상 운용 (1년 ~ 3년)', agingCounts.normal, '정상 운용', 'background:var(--primary); color:white;', 'normal')}
${renderAgingRow('최신 도입 (1년 미만)', agingCounts.fresh, '최신 도입', 'background:var(--color-blue); color:white;', 'fresh')}
${renderAgingRow('즉시 교체 (7년 이상)', agingCounts.immediate, '즉시 교체', 'background:#FFF1F2; color:#EF4444; border:1px solid #FCA5A5;', 'immediate')}
${renderAgingRow('교체 검토 (3년 ~ 7년)', agingCounts.review, '교체 검토', 'background:#FFF7ED; color:#D97706; border:1px solid #FCD34D;', 'review')}
${renderAgingRow('정상 운용 (1년 ~ 3년)', agingCounts.normal, '정상 운용', 'background:#ECFDF5; color:#059669; border:1px solid #A7F3D0;', 'normal')}
${renderAgingRow('최신 도입 (1년 미만)', agingCounts.fresh, '최신 도입', 'background:#F0FDF4; color:#16A34A; border:1px solid #BBF7D0;', 'fresh')}
`;
agingTbody.querySelectorAll('.aging-row').forEach(row => {
row.addEventListener('click', () => {
const groupKey = row.getAttribute('data-group') as any;
const groupList = agingCounts[groupKey as keyof typeof agingCounts];
const groupLabels = { immediate: '즉시 교체 대상', review: '교체 검토 대상', normal: '정상 운용 장비', fresh: '최신 도입 장비' };
const groupLabels = {
immediate: '즉시 교체 대상 (7년 이상)',
review: '교체 검토 대상 (3년 ~ 7년)',
normal: '정상 운용 장비 (1년 ~ 3년)',
fresh: '최신 도입 장비 (1년 미만)'
};
showMiniListModal(groupLabels[groupKey as keyof typeof groupLabels], groupList);
});
});
// 8. 각 등급 행 클릭 리스너 설정
const bindCardClick = (id: string, gradeTitle: string, filterFn: (p: any) => boolean) => {
const card = document.getElementById(id)!;
if (card) card.onclick = () => showMiniListModal(gradeTitle, filtered.filter(filterFn));
if (!card) return;
card.style.cursor = 'pointer';
card.style.transition = 'opacity 0.2s';
card.onmouseover = () => { card.style.opacity = '0.7'; };
card.onmouseout = () => { card.style.opacity = '1'; };
card.onclick = () => {
const pcsInGrade = filtered.filter(filterFn);
showMiniListModal(gradeTitle, pcsInGrade);
};
};
bindCardClick('grade-premium', '최상급 PC', p => p._pc_score >= 85);
bindCardClick('grade-high', '상급 PC', p => p._pc_score >= 70 && p._pc_score < 85);
bindCardClick('grade-normal', '중급 PC', p => p._pc_score >= 40 && p._pc_score < 70);
bindCardClick('grade-entry', '보급 PC', p => p._pc_score < 40);
// 사양 부족 / 오버스펙 / 윈도우 11 불가 클릭 리스너 설정
bindCardClick('card-under-spec', '사양 부족 검토 대상', p => p._spec_status === '사양 부족');
bindCardClick('card-over-spec', '오버스펙 검토 대상', p => p._spec_status === '오버스펙');
bindCardClick('card-win11-incompatible', '윈도우 11 업그레이드 불가 PC', p => isWindows11Incompatible(p.cpu, p.ram));
// 8.2 유효 재고 현황 클릭 리스너 설정
bindCardClick('stock-premium-card', '최상급 유효 재고', p => p._pc_score >= 85 && isStock(p));
bindCardClick('stock-high-card', '상급 유효 재고', p => p._pc_score >= 70 && p._pc_score < 85 && isStock(p));
bindCardClick('stock-normal-card', '중급 유효 재고', p => p._pc_score >= 40 && p._pc_score < 70 && isStock(p));
bindCardClick('stock-entry-card', '보급 유효 재고', p => p._pc_score < 40 && isStock(p));
const activeJobs = Array.from(new Set(filtered.map((p: any) => p[ASSET_SCHEMA.USER_POSITION.key] || '미분류').filter(j => j !== '재고PC'))).sort();
const underData: number[] = []; const normalData: number[] = []; const overData: number[] = [];
// 9. 직무별 사양 적정성 대수 연산 및 차트 데이터 셋 구성 (누적 막대 그래프화)
const activeJobs = Array.from(
new Set(filtered.map((p: any) => p[ASSET_SCHEMA.USER_POSITION.key] || '미분류').filter(j => j !== '재고PC'))
).sort();
const underData: number[] = [];
const normalData: number[] = [];
const overData: number[] = [];
activeJobs.forEach(job => {
const jobPcs = filtered.filter((p: any) => (p[ASSET_SCHEMA.USER_POSITION.key] || '미분류') === job);
let u = 0; let n = 0; let o = 0;
jobPcs.forEach(p => { if (p._spec_status === '사양 부족') u++; else if (p._spec_status === '오버스펙') o++; else n++; });
underData.push(u); normalData.push(n); overData.push(o);
const totalCount = jobPcs.length;
if (totalCount === 0) {
underData.push(0);
normalData.push(0);
overData.push(0);
return;
}
let under = 0;
let normal = 0;
let over = 0;
jobPcs.forEach(p => {
const stockYn = isStock(p);
if (!stockYn) {
if (p._spec_status === '사양 부족') { under++; }
else if (p._spec_status === '오버스펙') { over++; }
else { normal++; }
} else {
normal++; // 예외 폴백
}
});
underData.push(under);
normalData.push(normal);
overData.push(over);
});
// 10. 차트들 렌더링 호출
renderChart(activeJobs, underData, normalData, overData, filtered);
renderDonutChart(gradeCounts.premium.total, gradeCounts.high.total, gradeCounts.normal.total, gradeCounts.entry.total);
// 전역 상태 등록
state.activeCharts = [jobChartInstance, donutChartInstance];
}
/**
* 등급 클릭 시 열리는 심플 미니 리스트 모달 (라이트 글래스 헤더 적용)
*/
function showMiniListModal(title: string, list: any[]) {
const oldModal = document.getElementById('dashboard-mini-modal');
if (oldModal) oldModal.remove();
const modal = document.createElement('div');
modal.id = 'dashboard-mini-modal';
modal.className = 'modal-overlay';
modal.style.cssText = `
position: fixed;
top: 0;
left: 0;
width: 100vw;
height: 100vh;
background: rgba(15, 23, 42, 0.4);
backdrop-filter: blur(4px);
z-index: 1000;
display: flex;
align-items: center;
justify-content: center;
font-family: 'Pretendard', sans-serif;
color: #1E293B;
`;
modal.innerHTML = `
<div class="modal-content wide">
<div class="modal-header">
<h2 class="modal-title">${title} 자산 목록 <span class="badge badge-primary" style="margin-left:8px;">${list.length}대</span></h2>
<button id="btn-close-mini-modal" class="btn-icon">&times;</button>
<div style="background: white; border-radius: 12px; width: 680px; max-width: 90%; max-height: 80%; display: flex; flex-direction: column; box-shadow: 0 20px 40px rgba(0, 0, 0, 0.15); overflow: hidden; border: 1px solid #E2E8F0; animation: modalFadeIn 0.2s ease-out; color: #1E293B;">
<div style="padding: 1.25rem 1.75rem; border-bottom: 1px solid #F1F5F9; display: flex; justify-content: space-between; align-items: center; background: #F8FAFC;">
<h3 style="margin: 0; font-size: 1.26rem; font-weight: 850; color: #1E5149; display: flex; align-items: center; gap: 0.5rem;">
<span style="display:inline-block; width:8px; height:8px; border-radius:50%; background:#1E5149;"></span>
${title} 자산 목록
<span style="font-size: 0.96rem; font-weight: 700; color: white; background: #1E5149; padding: 2px 8px; border-radius: 9999px; margin-left: 0.25rem;">${list.length}대</span>
</h3>
<button id="btn-close-mini-modal" style="background: none; border: none; font-size: 1.25rem; color: #94A3B8; cursor: pointer; display: flex; align-items: center; justify-content: center; width: 28px; height: 28px; border-radius: 6px; transition: background 0.2s;" onmouseover="this.style.background='#EEF2F6'; this.style.color='#1E5149';" onmouseout="this.style.background='none'; this.style.color='#94A3B8';">
&times;
</button>
</div>
<div class="modal-body">
<table class="compact-table" style="width: 100%;">
<thead>
<tr>
<th style="width: 18%;">사용자</th>
<th style="width: 35%;">조직 (직무)</th>
<th style="width: 30%;">주요 사양</th>
<th style="text-align: center;">자산코드</th>
<div style="padding: 0 1.75rem 1rem 1.75rem; overflow-y: auto; flex: 1;">
<table style="width: 100%; border-collapse: collapse; text-align: left; font-size: 1.01rem; table-layout: fixed;">
<thead style="position: sticky; top: 0; background: white; z-index: 10;">
<tr style="border-bottom: 2px solid #E2E8F0; color: #64748B; font-weight: 800; background: white;">
<th style="padding: 10px 4px; width: 18%; background: white;">사용자</th>
<th style="padding: 10px 4px; width: 35%; background: white;">조직 (직무)</th>
<th style="padding: 10px 4px; width: 30%; background: white;">주요 사양</th>
<th style="padding: 10px 4px; text-align: center; background: white;">자산코드</th>
</tr>
</thead>
<tbody>
${list.length === 0 ? '<tr><td colspan="4" class="empty-state">해당 자산이 없습니다.</td></tr>' : list.map(pc => `
<tr class="mini-modal-row clickable-row" data-id="${pc.id}">
<td style="font-weight: 700;">${pc.user_current || '(재고)'}</td>
<td>${pc.current_dept || '-'} (${pc.user_position || '-'})</td>
<td class="detail-label-sm">${pc.cpu || ''} / ${pc.ram || ''} / ${pc.gpu || '-'}</td>
<td style="text-align: center;">${pc.asset_code || '-'}</td>
${list.length === 0
? `<tr><td colspan="4" style="text-align:center; padding:3rem; color:#94A3B8; font-weight:500;">해당 등급의 자산이 없습니다.</td></tr>`
: list.map(pc => {
const spec = `${pc.cpu || ''} / ${pc.ram || ''} / ${pc.gpu || '-'}`;
const user = pc.user_current || '(재고)';
return `
<tr style="border-bottom: 1px solid #F1F5F9; cursor: pointer; transition: background 0.2s;" class="mini-modal-row" data-id="${pc.id}" onmouseover="this.style.background='#F8FAFC'" onmouseout="this.style.background='none'">
<td style="padding: 12px 4px; font-weight: 700; color: #334155; white-space: nowrap; overflow: hidden; text-overflow: ellipsis;" title="${user}">${user}</td>
<td style="padding: 12px 4px; color: #475569; white-space: nowrap; overflow: hidden; text-overflow: ellipsis;" title="${pc.current_dept || '-'} (${pc.user_position || '-'})">${pc.current_dept || '-'} (${pc.user_position || '-'})</td>
<td style="padding: 12px 4px; color: #64748B; white-space: nowrap; overflow: hidden; text-overflow: ellipsis;" title="${spec}">${spec}</td>
<td style="padding: 12px 4px; font-family: monospace; color: #475569; text-align: center; white-space: nowrap; overflow: hidden; text-overflow: ellipsis;" title="${pc.asset_code || '-'}">${pc.asset_code || '-'}</td>
</tr>
`).join('')}
`;
}).join('')
}
</tbody>
</table>
</div>
<div class="modal-footer">
<div></div>
<button id="btn-confirm-mini-modal" class="btn btn-primary">확인</button>
<div style="padding: 1rem 1.75rem; border-top: 1px solid #F1F5F9; display: flex; justify-content: flex-end; background: #F8FAFC;">
<button id="btn-confirm-mini-modal" style="padding: 6px 20px; font-size: 1.01rem; font-weight: 700; background: #1E5149; color: white; border: none; border-radius: 6px; cursor: pointer; transition: opacity 0.2s;" onmouseover="this.style.opacity='0.9'" onmouseout="this.style.opacity='1'">
확인
</button>
</div>
</div>
`;
const style = document.createElement('style');
style.innerHTML = `
@keyframes modalFadeIn {
from { transform: scale(0.96); opacity: 0; }
to { transform: scale(1); opacity: 1; }
}
`;
modal.appendChild(style);
document.body.appendChild(modal);
const closeModal = () => modal.remove();
modal.addEventListener('click', (e) => { if (e.target === modal) closeModal(); });
const closeModal = () => { modal.remove(); };
modal.addEventListener('click', (e) => {
if (e.target === modal) closeModal();
});
document.getElementById('btn-close-mini-modal')?.addEventListener('click', closeModal);
document.getElementById('btn-confirm-mini-modal')?.addEventListener('click', closeModal);
modal.querySelectorAll('.mini-modal-row').forEach(row => {
row.addEventListener('click', () => {
const asset = list.find(p => String(p.id) === String(row.getAttribute('data-id')));
if (asset) { closeModal(); openHwModal(asset, 'view'); }
const id = row.getAttribute('data-id');
const asset = list.find(p => String(p.id) === String(id));
if (asset) {
closeModal();
openHwModal(asset, 'view');
}
});
});
}
/**
* Chart.js 가로형 100% 스택 막대 차트 (라이트 테마 튜닝)
*/
function renderChart(labels: string[], underData: number[], normalData: number[], overData: number[], currentFiltered: any[]) {
const ctx = document.getElementById('chart-job-scores') as HTMLCanvasElement;
if (!ctx || typeof Chart === 'undefined') return;
if (jobChartInstance) jobChartInstance.destroy();
if (jobChartInstance) {
jobChartInstance.destroy();
jobChartInstance = null;
}
jobChartInstance = new Chart(ctx, {
type: 'bar',
data: {
labels: labels,
datasets: [
{ label: '사양 부족', data: underData, backgroundColor: 'rgba(239, 68, 68, 0.85)', borderColor: 'rgb(239, 68, 68)', borderWidth: 1, borderRadius: 4, barPercentage: 0.45 },
{ label: '적정 사양', data: normalData, backgroundColor: 'rgba(23, 23, 23, 0.85)', borderColor: 'rgb(23, 23, 23)', borderWidth: 1, borderRadius: 4, barPercentage: 0.45 },
{ label: '오버스펙', data: overData, backgroundColor: 'rgba(245, 158, 11, 0.85)', borderColor: 'rgb(245, 158, 11)', borderWidth: 1, borderRadius: 4, barPercentage: 0.45 }
{
label: '사양 부족',
data: underData,
backgroundColor: 'rgba(239, 68, 68, 0.85)', // Rose Red
borderColor: 'rgb(239, 68, 68)',
borderWidth: 1,
borderRadius: 4,
barPercentage: 0.45,
categoryPercentage: 0.8
},
{
label: '적정 사양',
data: normalData,
backgroundColor: 'rgba(30, 81, 73, 0.85)', // Hanmac Green
borderColor: 'rgb(30, 81, 73)',
borderWidth: 1,
borderRadius: 4,
barPercentage: 0.45,
categoryPercentage: 0.8
},
{
label: '오버스펙',
data: overData,
backgroundColor: 'rgba(217, 119, 6, 0.85)', // Amber Orange
borderColor: 'rgb(217, 119, 6)',
borderWidth: 1,
borderRadius: 4,
barPercentage: 0.45,
categoryPercentage: 0.8
}
]
},
options: {
indexAxis: 'y', responsive: true, maintainAspectRatio: false,
animation: false, // 애니메이션 비활성화 (플로팅 방지)
onClick: (e: any, active: any[]) => {
if (active.length > 0) {
const { datasetIndex, index } = active[0];
const clickedStatus = ['사양 부족', '적정', '오버스펙'][datasetIndex];
const matched = currentFiltered.filter(p => (p[ASSET_SCHEMA.USER_POSITION.key] || '미분류') === labels[index] && (p._spec_status || '적정') === clickedStatus);
showMiniListModal(`${labels[index]} - ${clickedStatus} 자산`, matched);
indexAxis: 'y',
responsive: true,
maintainAspectRatio: false,
onHover: (event: any, activeElements: any[]) => {
event.chart.canvas.style.cursor = activeElements.length ? 'pointer' : 'default';
},
onClick: (event: any, activeElements: any[]) => {
if (activeElements && activeElements.length > 0) {
const activeElement = activeElements[0];
const datasetIndex = activeElement.datasetIndex; // 0: 사양 부족, 1: 적정 사양, 2: 오버스펙
const index = activeElement.index; // 직무군 인덱스
const clickedJob = labels[index];
const statusLabels = ['사양 부족', '적정', '오버스펙'];
const clickedStatus = statusLabels[datasetIndex] || '적정';
// 해당 직무군과 사양 상태가 매칭되는 자산 목록 필터링
const matchedPcs = currentFiltered.filter((p: any) => {
const job = p[ASSET_SCHEMA.USER_POSITION.key] || '미분류';
if (job !== clickedJob) return false;
const stockYn = p.hw_status === '재고' ||
p.hw_status === '대기' ||
!(p.user_current || '').trim();
let specStatus = '적정';
if (!stockYn) {
specStatus = p._spec_status || '적정';
}
return specStatus === clickedStatus;
});
showMiniListModal(`${clickedJob} - ${clickedStatus === '적정' ? '적정 사양' : clickedStatus} 자산`, matchedPcs);
}
},
plugins: {
legend: { position: 'top', align: 'end', labels: { font: { family: 'inherit', size: 11, weight: '600' }, usePointStyle: true } },
datalabels: { display: false } // 숫자 플로팅 방지
legend: {
position: 'top',
align: 'end',
labels: {
font: { family: 'Pretendard', size: 11, weight: '700' },
color: '#475569',
boxWidth: 8,
boxHeight: 8,
usePointStyle: true
}
},
scales: { x: { stacked: true, grid: { display: false } }, y: { stacked: true, grid: { display: false } } }
tooltip: {
titleFont: { family: 'Pretendard', size: 12, weight: '700' },
bodyFont: { family: 'Pretendard', size: 12 },
callbacks: {
label: function (context: any) {
const datasetLabel = context.dataset.label;
const value = context.raw; // 실제 대수
const total = context.chart.data.datasets.reduce((sum: number, dataset: any) => sum + dataset.data[context.dataIndex], 0);
const percentage = total > 0 ? Math.round((value / total) * 100) : 0;
return `${datasetLabel}: ${value}대 (${percentage}%)`;
}
}
}
},
scales: {
x: {
stacked: true,
ticks: {
callback: (val: any) => `${val}`,
font: { family: 'Pretendard', size: 10, weight: '600' },
color: '#64748B'
},
grid: { color: '#EEF2F6' }
},
y: {
stacked: true,
ticks: {
font: { family: 'Pretendard', size: 11, weight: '700' },
color: '#475569'
},
grid: { display: false }
}
}
}
});
}
/**
* 실시간 사양 적정률 원형 도넛 그래프 (Active Spec Rate)
*/
function renderDonutChart(premium: number, high: number, normal: number, entry: number) {
const ctx = document.getElementById('chart-overall-donut') as HTMLCanvasElement;
if (!ctx || typeof Chart === 'undefined') return;
if (donutChartInstance) donutChartInstance.destroy();
if (donutChartInstance) {
donutChartInstance.destroy();
donutChartInstance = null;
}
const total = premium + high + normal + entry;
donutChartInstance = new Chart(ctx, {
type: 'doughnut',
data: {
labels: ['최상급', '상급', '중급', '보급'],
datasets: [{ data: [premium, high, normal, entry], backgroundColor: ['#171717', '#4b5563', '#9ca3af', '#e5e7eb'], borderColor: '#ffffff', borderWidth: 2 }]
datasets: [{
data: [premium, high, normal, entry],
backgroundColor: [
'#11302B', // premium (Hanmac Dark Green)
'#1E8E7C', // high (Hanmac Teal)
'#10B981', // normal (Hanmac Mint)
'#94A3B8' // entry (Slate Gray)
],
borderColor: '#ffffff',
borderWidth: 2
}]
},
options: {
responsive: true,
maintainAspectRatio: false,
cutout: '75%',
animation: false, // 애니메이션 비활성화
cutout: '70%',
plugins: {
legend: { display: false },
datalabels: { display: false } // 숫자 플로팅 방지
tooltip: {
titleFont: { family: 'Pretendard', size: 12 },
bodyFont: { family: 'Pretendard', size: 12 },
callbacks: {
label: (context: any) => `${context.label}: ${context.raw}`
}
}
}
}
});
// 도넛 차트 중앙에 총 자산 대수 텍스트 오버레이 배치
const parent = ctx.parentElement!;
parent.style.position = 'relative'; // 부모 컨테이너에 relative 추가하여 위치 고정
let overlay = parent.querySelector('.donut-text-overlay') as HTMLElement;
if (!overlay) {
overlay = document.createElement('div');
overlay.className = 'donut-text-overlay';
overlay.style.cssText = 'position:absolute; top:50%; left:50%; transform:translate(-50%,-50%); font-size:1.5rem; font-weight:700; color:var(--primary); pointer-events:none;';
parent.appendChild(overlay);
let textOverlay = parent.querySelector('.donut-text-overlay') as HTMLElement;
if (!textOverlay) {
textOverlay = document.createElement('div');
textOverlay.className = 'donut-text-overlay';
textOverlay.style.cssText = `
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, -46%);
font-size: 1.56rem;
font-weight: 900;
color: #1E5149;
font-family: 'Pretendard', sans-serif;
pointer-events: none;
white-space: nowrap;
`;
parent.appendChild(textOverlay);
}
overlay.textContent = `${total}`;
textOverlay.textContent = `${total}`;
}

View File

@@ -1,15 +1,16 @@
import { state } from '../../core/state';
import { openSwUsageDetail } from '../../components/Modal/DashboardDetailModal';
import { openSwDashboardDetail, openSwUsageDetail } from '../../components/Modal/DashboardDetailModal';
import { normalizeDate } from '../../core/utils';
import { ASSET_SCHEMA } from '../../core/schema';
export function renderSwDashboard(container: HTMLElement) {
let extQty = 0, extUsed = 0, extTotal = 0;
let intQty = 0, intUsed = 0, intTotal = 0;
let extQty = 0, extUsed = 0, extExp = 0, extTotal = 0;
let intQty = 0, intUsed = 0, intExp = 0, intTotal = 0;
let extCost2026 = 0;
let intCost2026 = 0;
// 통합 SW 데이터
const allSw = [...state.masterData.swExternal, ...state.masterData.swInternal];
allSw.forEach(sw => {
@@ -20,6 +21,7 @@ export function renderSwDashboard(container: HTMLElement) {
if (sw.asset_type === '외부SW' || sw.type === '외부SW') {
extQty += qty; extUsed += assigned; extTotal++;
if (isSWExpiring(sw)) extExp++;
if (sw[ASSET_SCHEMA.PURCHASE_DATE.key]?.startsWith('2026')) extCost2026 += price;
} else {
intQty += qty; intUsed += assigned; intTotal++;
@@ -31,14 +33,14 @@ export function renderSwDashboard(container: HTMLElement) {
const intPer = intQty > 0 ? Math.round((intUsed/intQty)*100) : 0;
container.innerHTML = `
<div class="view-container">
<div class="view-container bg-soft">
<h3 class="dashboard-section-title">소프트웨어 라이선스 현황</h3>
<div class="dashboard-layout-2col">
<div class="dashboard-layout-2col mb-6">
<div class="dashboard-card clickable" data-action="ext-usage">
<div class="stat-label">외부 소프트웨어 사용율</div>
<div class="stat-sub">${extQty}카피 중 ${extUsed}개 할당</div>
<div class="stat-value"><span>${extPer}</span><span>%</span></div>
<div class="stat-value text-primary">${extPer}%</div>
<div class="stat-progress-bar">
<div class="progress-fill" style="width: ${extPer}%;"></div>
</div>
@@ -46,7 +48,7 @@ export function renderSwDashboard(container: HTMLElement) {
<div class="dashboard-card clickable" data-action="int-usage">
<div class="stat-label">내부 소프트웨어 현황</div>
<div class="stat-sub">등록된 내부 솔루션: ${intTotal}개</div>
<div class="stat-value"><span>${intPer}</span><span>%</span></div>
<div class="stat-value text-primary">${intPer}%</div>
<div class="stat-progress-bar">
<div class="progress-fill" style="width: ${intPer}%;"></div>
</div>
@@ -58,11 +60,11 @@ export function renderSwDashboard(container: HTMLElement) {
<div class="dashboard-layout-2col">
<div class="dashboard-card">
<div class="stat-label">외부 SW 누적 비용 (2026)</div>
<div class="stat-value"><span>₩ ${extCost2026.toLocaleString()}</span></div>
<div class="stat-value text-primary">₩ ${extCost2026.toLocaleString()}</div>
</div>
<div class="dashboard-card">
<div class="stat-label">내부 SW 누적 비용 (2026)</div>
<div class="stat-value" style="color: var(--color-blue);"><span>₩ ${intCost2026.toLocaleString()}</span></div>
<div class="stat-value text-blue">₩ ${intCost2026.toLocaleString()}</div>
</div>
</div>
</div>
@@ -71,3 +73,11 @@ export function renderSwDashboard(container: HTMLElement) {
container.querySelector('[data-action="ext-usage"]')?.addEventListener('click', () => openSwUsageDetail('외부 소프트웨어 사용 목록', state.masterData.swExternal));
container.querySelector('[data-action="int-usage"]')?.addEventListener('click', () => openSwUsageDetail('내부 소프트웨어 사용 목록', state.masterData.swInternal));
}
function isSWExpiring(sw: any) {
const expiry = sw[ASSET_SCHEMA.EXPIRED_DATE.key];
if (!expiry) return false;
const endMs = new Date(normalizeDate(expiry)).getTime();
const diffDays = (endMs - Date.now()) / (1000 * 60 * 60 * 24);
return diffDays >= 0 && diffDays <= 30;
}

View File

@@ -189,25 +189,10 @@ export function createListView(container: HTMLElement, config: ListViewConfig) {
// 2. 필터 바 생성 (자산 목록에서만 사용)
const filterBar = document.createElement('div');
filterBar.className = 'search-bar';
filterBar.className = 'filter-bar';
// 자산 추가 버튼 및 목록 보기 체크박스 추가 로직
const showPcFlowBtn = config.title === 'PC';
const extraActionHTML = `
<div class="header-action-group flex items-center gap-2" style="margin-left: auto; align-self: flex-end;">
${showPcFlowBtn ? `
<button id="btn-goto-parts-master" class="btn btn-outline btn-sm">
<i data-lucide="settings" class="icon-sm"></i> 부품 마스터
</button>
<button id="btn-pc-flow" class="btn btn-outline btn-sm">
PC 이동/반납
</button>
` : ''}
<button id="btn-add-asset" class="btn btn-primary">
<i data-lucide="plus" class="icon-sm"></i> 자산 추가
</button>
</div>
`;
container.appendChild(filterBar);
container.appendChild(contentWrapper);
@@ -242,15 +227,12 @@ export function createListView(container: HTMLElement, config: ListViewConfig) {
selectedLocation = validLocations[0] || '';
}
const locationCounts: Record<string, number> = {};
const pcTypeCounts = { public: 0, server: 0, personal: 0 };
// 동적 통계 수집 객체 (Hardcoding 제거)
// 동적 통계 수집 객체
const extStats = {
total: 0,
locCounts: {} as Record<string, number>,
typeCounts: {} as Record<string, number>,
typeLocMap: {} as Record<string, Record<string, number>>, // 유형별 위치 분포
typeLocMap: {} as Record<string, Record<string, number>>,
locWarning: 0,
typeWarning: 0
};
@@ -326,10 +308,10 @@ export function createListView(container: HTMLElement, config: ListViewConfig) {
<div class="stat-group-item bordered">${generateDetailStatHTML('내부 (테스트) 상세', intStats)}</div>
</div>
<div style="display: flex; flex: 1; min-height: 0; border-top: 1px solid var(--border-color);">
<!-- 좌측: 자산 현황 목록 (Border-based Separation) -->
<div class="list-section" style="flex: 1.3; display: flex; flex-direction: column; min-height: 0; padding: 1rem 1.5rem 0 0; border-right: 1px solid var(--hairline);">
<div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 1rem; flex-shrink: 0;">
<div class="flex flex-1 min-h-0 border-t border-hairline">
<!-- 좌측: 자산 현황 목록 -->
<div class="list-section">
<div class="flex justify-between items-center mb-4 flex-shrink-0">
<h4 id="list-section-title" class="sidebar-title">
${isPcView ? `🔄 PC 유동 이력 (${new Date().getMonth() + 1}월)` : '자산 현황 목록'}
</h4>
@@ -344,7 +326,7 @@ export function createListView(container: HTMLElement, config: ListViewConfig) {
</div>
` : ''}
</div>
<div style="flex: 1; overflow-y: auto;">
<div class="flex-1 overflow-y-auto">
<table class="compact-table">
<thead>
${isPcView ? `
@@ -372,17 +354,17 @@ export function createListView(container: HTMLElement, config: ListViewConfig) {
</div>
</div>
<!-- 우측: 상세 정보 패널 (Box-less, Line-based) -->
<div id="system-detail-panel" style="flex: 0.7; display: flex; flex-direction: column; min-height: 0; padding: 1rem 0 0 1.5rem; overflow: hidden;">
<div id="detail-empty-state" class="detail-empty-state" style="justify-content: ${isPcView ? 'flex-start' : 'center'}; align-items: ${isPcView ? 'stretch' : 'center'};">
<!-- 우측: 상세 정보 패널 -->
<div id="system-detail-panel" class="detail-panel">
<div id="detail-empty-state" class="detail-empty-state">
${isPcView ? `
<div style="display: flex; flex-direction: column; min-height: 0; height: 100%; text-align: left;">
<div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 1rem; flex-shrink: 0;">
<div class="flex-col h-full text-left">
<div class="flex justify-between items-center mb-4 flex-shrink-0">
<h4 class="sidebar-title text-danger">
⚠️ 사양 주의 장비 현황 (부족/오버스펙)
</h4>
</div>
<div style="flex: 1; overflow-y: auto;">
<div class="flex-1 overflow-y-auto">
<table class="compact-table">
<thead>
<tr>
@@ -402,8 +384,8 @@ export function createListView(container: HTMLElement, config: ListViewConfig) {
<p class="empty-list-message">목록에서 자산을 선택하면<br>상세 정보와 배치도가 표시됩니다.</p>
`}
</div>
<div id="detail-content" class="detail-content hidden" style="flex: 1; display: flex; flex-direction: column; overflow: hidden;">
<div class="detail-header-actions" style="padding: 1.25rem 1.5rem; border-bottom: 1px solid var(--hairline); background: white;">
<div id="detail-content" class="detail-content hidden flex-col flex-1 overflow-hidden">
<div class="detail-header-actions bg-canvas p-4 border-b border-hairline">
<div class="header-identity">
<span class="asset-code-title" id="detail-asset-code"></span>
<span class="asset-type-label" id="detail-asset-type"></span>
@@ -412,15 +394,15 @@ export function createListView(container: HTMLElement, config: ListViewConfig) {
</div>
<!-- 메인 배치도 영역 -->
<div style="flex: 1; display: flex; flex-direction: column; min-height: 0; overflow: hidden; padding: 1rem;">
<div id="detail-photo-wrapper" style="width: 100%; flex: 1; overflow: hidden; display: flex; align-items: center; justify-content: center; position: relative; border: 1px solid var(--hairline); background: #f0f0f0; border-radius: 8px;">
<div class="layout-map-container readonly" style="position: relative; display: flex; align-items: center; justify-content: center; width: 100%; height: 100%;">
<img id="detail-photo" src="" style="display: block; max-width: 100%; max-height: 100%; width: auto; height: auto; object-fit: contain; pointer-events: none;" />
<iframe id="detail-html-map" src="" style="display: none; width: 100%; height: 100%; border: none;"></iframe>
<div id="detail-marker" class="layout-marker pulse-marker" style="display: none; position: absolute; z-index: 20;"></div>
<div id="detail-overlay-layer" style="position: absolute; pointer-events: none;"></div>
<div class="flex-col flex-1 overflow-hidden p-4">
<div id="detail-photo-wrapper" class="detail-photo-wrapper">
<div class="layout-map-container readonly w-full h-full justify-center">
<img id="detail-photo" src="" class="layout-map-img pointer-events-none" />
<iframe id="detail-html-map" src="" class="hidden w-full h-full border-none"></iframe>
<div id="detail-marker" class="layout-marker pulse-marker hidden absolute z-20"></div>
<div id="detail-overlay-layer" class="absolute pointer-events-none"></div>
</div>
<div id="detail-no-photo" class="no-photo-state hidden" style="padding: 3rem; text-align: center; color: var(--mute);">
<div id="detail-no-photo" class="no-photo-state hidden">
<span>등록된 배치도가 없습니다.</span>
</div>
</div>
@@ -438,16 +420,13 @@ export function createListView(container: HTMLElement, config: ListViewConfig) {
emptyState.classList.add('hidden');
content.classList.remove('hidden');
content.style.display = 'flex';
const codeEl = document.getElementById('detail-asset-code');
const typeEl = document.getElementById('detail-asset-type');
const memoEl = document.getElementById('detail-memo');
const viewBtn = document.getElementById('btn-view-full-detail') as HTMLButtonElement;
if (codeEl) codeEl.textContent = asset.asset_code || '미지정';
if (typeEl) typeEl.textContent = asset.asset_type || '-';
if (memoEl) memoEl.textContent = asset.memo || '-';
if (viewBtn) viewBtn.onclick = () => config.onRowClick && config.onRowClick(asset);
const photo = document.getElementById('detail-photo') as HTMLImageElement;
@@ -474,11 +453,13 @@ export function createListView(container: HTMLElement, config: ListViewConfig) {
if (overlayLayer) overlayLayer.innerHTML = '';
if (htmlMap) {
htmlMap.src = `${imgPath}?markerX=${x}&markerY=${y}`;
htmlMap.classList.remove('hidden');
htmlMap.style.display = 'block';
}
} else {
if (htmlMap) {
htmlMap.src = '';
htmlMap.classList.add('hidden');
htmlMap.style.display = 'none';
}
photo.src = imgPath;
@@ -545,27 +526,6 @@ export function createListView(container: HTMLElement, config: ListViewConfig) {
if (overlayLayer) overlayLayer.innerHTML = '';
if (noPhoto) { noPhoto.classList.remove('hidden'); noPhoto.style.display = 'flex'; }
}
const flowLogsBtn = document.getElementById('btn-view-flow-logs');
if (flowLogsBtn) {
flowLogsBtn.onclick = () => {
const emptyState = document.getElementById('detail-empty-state');
const content = document.getElementById('detail-content');
if (emptyState && content) {
content.style.display = 'none';
emptyState.style.display = 'flex';
}
const tbody = document.getElementById('system-status-tbody');
if (tbody) {
tbody.querySelectorAll('.mini-row').forEach(r => {
r.classList.remove('active');
});
}
if (isPcView) {
updateTableOnly();
}
};
}
};
// [자산 현황] 테이블 렌더러
@@ -588,19 +548,19 @@ export function createListView(container: HTMLElement, config: ListViewConfig) {
const tbody = document.getElementById('system-status-tbody');
if (tbody) {
tbody.innerHTML = finalDisplayList.length === 0
? `<tr><td colspan="5" class="empty-cell">조회된 자산이 없습니다.</td></tr>`
? `<tr><td colspan="5" class="empty-cell text-center">조회된 자산이 없습니다.</td></tr>`
: finalDisplayList.map(asset => {
const purpose = asset[ASSET_SCHEMA.ASSET_PURPOSE.key] || '';
const serviceType = asset.service_type || '외부';
const type = asset[ASSET_SCHEMA.ASSET_TYPE.key] || '';
const loc = asset[ASSET_SCHEMA.LOCATION.key] || '';
const isWarning = serviceType === '외부SW' && (loc !== 'IDC' || type.toLowerCase().includes('서버pc'));
const isWarning = serviceType === '외부' && (loc !== 'IDC' || type.toLowerCase().includes('서버pc'));
const managerMain = asset[ASSET_SCHEMA.MANAGER_MAIN.key] || '-';
const managerSub = asset[ASSET_SCHEMA.MANAGER_SUB.key] || '-';
return `
<tr class="mini-row ${isWarning ? 'warning' : ''}" data-id="${asset.id}">
<tr class="mini-row clickable-row ${isWarning ? 'warning' : ''}" data-id="${asset.id}">
<td class="text-center">
<span class="badge ${isWarning ? 'badge-danger' : 'badge-primary'}">${serviceType}</span>
</td>
@@ -644,12 +604,10 @@ export function createListView(container: HTMLElement, config: ListViewConfig) {
let filtered = applyCommonFilters(fullList, currentFilters, config.searchKeys as any[]);
if (sortState.key) filtered = dynamicSort(filtered, sortState.key, sortState.direction);
// Headers are naturally centered via CSS now. Only apply specific widths or sorting.
thead.innerHTML = `<tr>${config.columns.map(col => `<th ${col.sortKey ? `data-sort="${col.sortKey}"` : ''} style="${col.width ? `width:${col.width};` : ''}">${col.header}</th>`).join('')}</tr>`;
tbody.innerHTML = filtered.length === 0 ? `<tr><td colspan="${config.columns.length}" class="text-center empty-cell">${UI_TEXT.MESSAGES.NO_DATA}</td></tr>`
: filtered.map(asset => `<tr class="asset-row clickable" data-id="${asset.id}">${config.columns.map(col => {
// Date columns should remain centered. Everything else defaults to left (via CSS).
const isDateCol = col.header.includes('일') || col.header.includes('날짜') || col.header.includes('연월');
return `<td class="${isDateCol ? 'text-center' : ''}">${col.render(asset)}</td>`;
}).join('')}</tr>`).join('');
@@ -674,9 +632,9 @@ export function createListView(container: HTMLElement, config: ListViewConfig) {
...config.filterOptions,
initialFilters: currentFilters,
extraHTML: isServer ? `
<div class="search-item">
<label class="flex items-center gap-2 cursor-pointer font-semibold" style="color: var(--primary); height: clamp(34px, 4.5vmin, 44px); padding: 0 0.5rem;">
<input type="checkbox" id="chk-list-view" ${(state as any).currentViewMode === 'asset' ? 'checked' : ''} style="width: 16px; height: 16px; cursor: pointer;" />
<div class="filter-group">
<label class="list-view-toggle-label">
<input type="checkbox" id="chk-list-view" ${(state as any).currentViewMode === 'asset' ? 'checked' : ''} />
목록보기
</label>
</div>
@@ -684,24 +642,24 @@ export function createListView(container: HTMLElement, config: ListViewConfig) {
onFilterChange: (filters) => { Object.assign(currentFilters, filters); updateTable(); }
});
// 3. 필터 바 내 액션 버튼 배치 (자산 추가, 부품 마스터 등)
// 3. 필터 바 내 액션 버튼 배치
const actionContainer = filterBar.querySelector('#filter-bar-actions');
if (actionContainer) {
actionContainer.className = "header-action-group flex items-center gap-2 ml-auto self-end";
actionContainer.innerHTML = `
${showPcFlowBtn ? `
<button id="btn-goto-parts-master" class="btn btn-outline">
<i data-lucide="settings" style="width: 18px; height: 18px;"></i> 부품 마스터
<i data-lucide="settings" class="icon-sm"></i> 부품 마스터
</button>
<button id="btn-pc-flow" class="btn btn-outline">
PC 이동/반납
</button>
` : ''}
<button id="btn-add-asset" class="btn btn-primary">
<i data-lucide="plus" style="width: 18px; height: 18px;"></i> 자산 추가
<i data-lucide="plus" class="icon-sm"></i> 자산 추가
</button>
`;
// 버튼 이벤트 바인딩
actionContainer.querySelector('#btn-add-asset')?.addEventListener('click', () => {
const dummyAsset = { id: '', category: config.title };
config.onRowClick && config.onRowClick(dummyAsset);
@@ -717,7 +675,6 @@ export function createListView(container: HTMLElement, config: ListViewConfig) {
// 서버 탭 전용 목록보기 체크박스 이벤트
if (isServer) {
const toggleBtn = filterBar.querySelector('#btn-toggle-list-view');
const chkBox = filterBar.querySelector('#chk-list-view') as HTMLInputElement;
const handleToggle = () => {
@@ -731,10 +688,6 @@ export function createListView(container: HTMLElement, config: ListViewConfig) {
}
window.dispatchEvent(new Event('refresh-view'));
};
toggleBtn?.addEventListener('click', (e) => {
if (e.target !== chkBox) handleToggle();
});
chkBox?.addEventListener('change', handleToggle);
}