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:
@@ -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="닫기">×</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) {
|
||||
|
||||
@@ -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="닫기">×</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'};">×</button>
|
||||
<button type="button" class="btn-circle-remove edit-only-btn" style="display: ${this.isEditMode ? 'inline-flex' : 'none'};">×</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'};">×</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'};">×</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">×</button></div>
|
||||
<div class="image-picker-header">
|
||||
<h3>${title} ${isMulti ? `(${currentIdx + 1}/${imagePaths.length})` : ''}</h3>
|
||||
<button class="btn-icon btn-close-picker" aria-label="닫기">×</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;">❮</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;">❯</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">×</button></div>
|
||||
<div class="image-picker-header"><h3>${title}</h3><button class="btn-icon btn-close-picker" aria-label="닫기">×</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}%`; }
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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="닫기">×</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>
|
||||
`;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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="닫기">×</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">×</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) {
|
||||
|
||||
@@ -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">×</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}">×</button>
|
||||
</div>
|
||||
</td>
|
||||
`;
|
||||
|
||||
@@ -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="닫기">×</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>
|
||||
`;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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()}
|
||||
`;
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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">×</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';">
|
||||
×
|
||||
</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}대`;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user