Compare commits
2 Commits
73ef13f3a5
...
89d3ac2e89
| Author | SHA1 | Date | |
|---|---|---|---|
| 89d3ac2e89 | |||
| b37981506e |
@@ -111,9 +111,16 @@ export function closeModals() {
|
||||
}
|
||||
|
||||
export function initBaseModal() {
|
||||
// ESC 키로 모든 모달 닫기
|
||||
// ESC 키로 모든 모달 닫기 (위치보기 팝업이 있으면 그것부터 닫음)
|
||||
window.addEventListener('keydown', (e) => {
|
||||
if (e.key === 'Escape') closeModals();
|
||||
if (e.key === 'Escape') {
|
||||
const picker = document.querySelector('.image-picker-overlay');
|
||||
if (picker) {
|
||||
picker.remove();
|
||||
} else {
|
||||
closeModals();
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
return { closeAllModals: closeModals };
|
||||
|
||||
@@ -2,7 +2,7 @@ import { state, saveAsset, deleteAsset } from '../../core/state';
|
||||
import { BaseModal } from './BaseModal';
|
||||
import { CORP_LIST } from './SharedData';
|
||||
import { generateOptionsHTML, setFieldValue, getFieldValue } from './ModalUtils';
|
||||
import { createIcons, X, Save, Database, CalendarClock, Edit2, History, Plus } from 'lucide';
|
||||
import { createIcons, X, Save, History, Plus } from 'lucide';
|
||||
import { formatExcelDate } from '../../core/excelHandler';
|
||||
import { UI_TEXT } from '../../core/schema';
|
||||
|
||||
@@ -16,8 +16,11 @@ class DomainAssetModal extends BaseModal {
|
||||
<div id="domain-asset-modal" class="modal-overlay hidden">
|
||||
<div class="modal-content wide">
|
||||
<div class="modal-header">
|
||||
<h2 id="domain-modal-title">${this.title}</h2>
|
||||
<button id="btn-close-domain-modal" class="btn-icon" aria-label="닫기"><i data-lucide="x"></i></button>
|
||||
<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">
|
||||
<div class="modal-body-split">
|
||||
@@ -58,7 +61,7 @@ class DomainAssetModal extends BaseModal {
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>비용 (연간/월간)</label>
|
||||
<input type="text" id="domain-price" name="price" oninput="this.value = this.value.replace(/[^0-9]/g, '').replace(/\\\\B(?=(\\\\d{3})+(?!\\\\d))/g, ',')" />
|
||||
<input type="text" id="domain-price" name="price" oninput="this.value = this.value.replace(/[^0-9]/g, '').replace(/\\B(?=(\\d{3})+(?!\\d))/g, ',')" />
|
||||
</div>
|
||||
|
||||
<div class="form-section-title">담당자 및 비고</div>
|
||||
@@ -78,9 +81,9 @@ class DomainAssetModal extends BaseModal {
|
||||
</div>
|
||||
<div class="modal-history-area">
|
||||
<div class="history-header">
|
||||
<h3><i data-lucide="history" style="width:16px; height:16px;"></i> 변경 이력</h3>
|
||||
<h3><i data-lucide="history"></i> 변경 이력</h3>
|
||||
<button type="button" id="btn-add-domain-log" class="btn btn-outline btn-sm">
|
||||
이력 추가 <i data-lucide="plus" style="width:14px; height:14px;"></i>
|
||||
이력 추가 <i data-lucide="plus"></i>
|
||||
</button>
|
||||
</div>
|
||||
<div id="domain-history-list" class="history-timeline"></div>
|
||||
@@ -141,7 +144,7 @@ class DomainAssetModal extends BaseModal {
|
||||
}
|
||||
});
|
||||
|
||||
createIcons({ icons: { History, Plus, Save, CalendarClock, Database } });
|
||||
createIcons({ icons: { History, Plus, Save, X } });
|
||||
}
|
||||
|
||||
protected fillFormData(asset: any): void {
|
||||
@@ -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) {
|
||||
@@ -173,16 +199,10 @@ class DomainAssetModal extends BaseModal {
|
||||
if (!container) return;
|
||||
const logs = (state.masterData.logs || []).filter(l => l.assetId === assetId);
|
||||
if (logs.length === 0) { container.innerHTML = '<div class="empty-history">이력이 없습니다.</div>'; return; }
|
||||
container.innerHTML = logs.map(l => `<div class=\"history-item\"><div class=\"history-date\">${l.date}</div><div class=\"history-user\">${l.user}</div><div class=\"history-details\">${l.details}</div></div>`).join('');
|
||||
container.innerHTML = logs.map(l => `<div class="history-item"><div class="history-date">${l.date}</div><div class="history-user">${l.user}</div><div class="history-details">${l.details}</div></div>`).join('');
|
||||
}
|
||||
}
|
||||
|
||||
export const domainModal = new DomainAssetModal();
|
||||
|
||||
export function initDomainModal(onSave: () => void, closeModals: () => void) {
|
||||
domainModal.init(onSave, closeModals);
|
||||
}
|
||||
|
||||
export function openDomainModal(asset: any, mode: 'view' | 'edit' | 'add' = 'view') {
|
||||
domainModal.open(asset, mode);
|
||||
}
|
||||
export function initDomainModal(onSave: () => void, closeModals: () => void) { domainModal.init(onSave, closeModals); }
|
||||
export function openDomainModal(asset: any, mode: 'view' | 'edit' | 'add' = 'view') { domainModal.open(asset, mode); }
|
||||
|
||||
@@ -1,38 +0,0 @@
|
||||
import { ASSET_SCHEMA } from '../../../core/schema';
|
||||
import { generateOptionsHTML } from '../ModalUtils';
|
||||
import { CORP_LIST, ORG_LIST } from '../SharedData';
|
||||
|
||||
export function renderCommonHwFields(): string {
|
||||
return `
|
||||
<div class="form-section-title">구매 및 증빙 정보</div>
|
||||
<div class="form-group">
|
||||
<label>${ASSET_SCHEMA.PURCHASE_DATE.ui}</label>
|
||||
<input type="text" id="hw-purchase_date" name="purchase_date" placeholder="YYYY-MM-DD" />
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>${ASSET_SCHEMA.PURCHASE_VENDOR.ui}</label>
|
||||
<input type="text" id="hw-purchase_vendor" name="purchase_vendor" />
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>${ASSET_SCHEMA.PURCHASE_AMOUNT.ui}</label>
|
||||
<input type="text" id="hw-purchase_amount" name="purchase_amount" placeholder="0" oninput="this.value = this.value.replace(/[^0-9]/g, '').replace(/\\\\B(?=(\\\\d{3})+(?!\\\\d))/g, ',')" />
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>${ASSET_SCHEMA.APPROVAL_DOC.ui} (첨부파일)</label>
|
||||
<div class="file-upload-wrapper">
|
||||
<input type="file" id="hw-approval_document_file" style="display:none;" />
|
||||
<div class="input-with-btn">
|
||||
<button type="button" id="btn-file-select" onclick="document.getElementById('hw-approval_document_file').click()" class="btn btn-outline btn-loc-action">
|
||||
<span id="hw-file-name-display">파일 선택...</span>
|
||||
</button>
|
||||
</div>
|
||||
<input type="hidden" id="hw-approval_document" name="approval_document" />
|
||||
<div id="hw-file-link-container"></div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="form-group full-width">
|
||||
<label>${ASSET_SCHEMA.MEMO.ui}</label>
|
||||
<textarea id="hw-memo" name="memo" rows="3"></textarea>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
@@ -1,72 +0,0 @@
|
||||
import { ASSET_SCHEMA } from '../../../core/schema';
|
||||
import { generateOptionsHTML } from '../ModalUtils';
|
||||
import { CORP_LIST, ORG_LIST, HW_STATUS_LIST } from '../SharedData';
|
||||
|
||||
export function renderPcForm(): string {
|
||||
return `
|
||||
<div class="form-section-title">기본 정보 (PC/노트북)</div>
|
||||
<div class="form-group">
|
||||
<label>${ASSET_SCHEMA.ASSET_CODE.ui}</label>
|
||||
<div class="input-with-btn">
|
||||
<input type="text" id="hw-asset_code" name="asset_code" placeholder="자동 생성" readonly />
|
||||
<button type="button" id="btn-gen-hw-code" class="btn btn-outline">생성</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>${ASSET_SCHEMA.PURCHASE_CORP.ui}</label>
|
||||
<select id="hw-purchase_corp" name="purchase_corp">${generateOptionsHTML(CORP_LIST)}</select>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>${ASSET_SCHEMA.HW_STATUS.ui}</label>
|
||||
<select id="hw-hw_status" name="hw_status">${generateOptionsHTML(HW_STATUS_LIST)}</select>
|
||||
</div>
|
||||
<div class="form-group full-width">
|
||||
<label>${ASSET_SCHEMA.ASSET_PURPOSE.ui}</label>
|
||||
<input type="text" id="hw-asset_purpose" name="asset_purpose" placeholder="자산의 용도를 입력하세요" />
|
||||
</div>
|
||||
|
||||
<div class="form-section-title">사용자 및 조직</div>
|
||||
<div class="form-group">
|
||||
<label>${ASSET_SCHEMA.CURRENT_DEPT.ui}</label>
|
||||
<select id="hw-current_dept" name="current_dept">${generateOptionsHTML(ORG_LIST)}</select>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>${ASSET_SCHEMA.CURRENT_USER.ui}</label>
|
||||
<input type="text" id="hw-user_current" name="user_current" />
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>${ASSET_SCHEMA.USER_POSITION.ui}</label>
|
||||
<input type="text" id="hw-user_position" name="user_position" />
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>${ASSET_SCHEMA.EMP_NO.ui}</label>
|
||||
<input type="text" id="hw-emp_no" name="emp_no" />
|
||||
</div>
|
||||
|
||||
<div class="form-section-title">시스템 사양</div>
|
||||
<div class="form-group">
|
||||
<label>${ASSET_SCHEMA.MODEL_NAME.ui}</label>
|
||||
<input type="text" id="hw-model_name" name="model_name" />
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>${ASSET_SCHEMA.OS.ui}</label>
|
||||
<input type="text" id="hw-os" name="os" />
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>${ASSET_SCHEMA.CPU.ui}</label>
|
||||
<input type="text" id="hw-cpu" name="cpu" />
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>${ASSET_SCHEMA.RAM.ui}</label>
|
||||
<input type="text" id="hw-ram" name="ram" />
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>${ASSET_SCHEMA.GPU.ui}</label>
|
||||
<input type="text" id="hw-gpu" name="gpu" />
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>${ASSET_SCHEMA.MAC_ADDR.ui}</label>
|
||||
<input type="text" id="hw-mac_address" name="mac_address" />
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
@@ -1,90 +0,0 @@
|
||||
import { ASSET_SCHEMA } from '../../../core/schema';
|
||||
import { generateOptionsHTML } from '../ModalUtils';
|
||||
import { CORP_LIST, LOCATION_DATA, HW_STATUS_LIST } from '../SharedData';
|
||||
|
||||
export function renderServerForm(): string {
|
||||
return `
|
||||
<div class="form-section-title">기본 정보 (서버)</div>
|
||||
<div class="form-group">
|
||||
<label>${ASSET_SCHEMA.ASSET_CODE.ui}</label>
|
||||
<div class="input-with-btn">
|
||||
<input type="text" id="hw-asset_code" name="asset_code" placeholder="자동 생성" readonly />
|
||||
<button type="button" id="btn-gen-hw-code" class="btn btn-outline">생성</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>${ASSET_SCHEMA.PURCHASE_CORP.ui}</label>
|
||||
<select id="hw-purchase_corp" name="purchase_corp">${generateOptionsHTML(CORP_LIST)}</select>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>${ASSET_SCHEMA.HW_STATUS.ui}</label>
|
||||
<select id="hw-hw_status" name="hw_status">${generateOptionsHTML(HW_STATUS_LIST)}</select>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>${ASSET_SCHEMA.MONITORING.ui}</label>
|
||||
<select id="hw-monitoring" name="monitoring">
|
||||
<option value="비대상">비대상</option>
|
||||
<option value="대상">대상</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="form-group full-width">
|
||||
<label>${ASSET_SCHEMA.ASSET_PURPOSE.ui}</label>
|
||||
<input type="text" id="hw-asset_purpose" name="asset_purpose" placeholder="서버의 용도를 입력하세요" />
|
||||
</div>
|
||||
|
||||
<div class="form-section-title">시스템 사양</div>
|
||||
<div class="form-group">
|
||||
<label>${ASSET_SCHEMA.MODEL_NAME.ui}</label>
|
||||
<input type="text" id="hw-model_name" name="model_name" />
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>${ASSET_SCHEMA.OS.ui}</label>
|
||||
<input type="text" id="hw-os" name="os" />
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>${ASSET_SCHEMA.CPU.ui}</label>
|
||||
<input type="text" id="hw-cpu" name="cpu" />
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>${ASSET_SCHEMA.RAM.ui}</label>
|
||||
<input type="text" id="hw-ram" name="ram" />
|
||||
</div>
|
||||
|
||||
<div class="form-section-title">네트워크 및 접속 정보</div>
|
||||
<div class="form-group">
|
||||
<label>${ASSET_SCHEMA.IP_ADDR.ui}</label>
|
||||
<input type="text" id="hw-ip_address" name="ip_address" />
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>${ASSET_SCHEMA.IP_ADDR2.ui}</label>
|
||||
<input type="text" id="hw-ip_address_2" name="ip_address_2" />
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>${ASSET_SCHEMA.REMOTE_TOOL.ui}</label>
|
||||
<input type="text" id="hw-remote_tool" name="remote_tool" />
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>${ASSET_SCHEMA.REMOTE_ID.ui}</label>
|
||||
<input type="text" id="hw-remote_id" name="remote_id" />
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>${ASSET_SCHEMA.REMOTE_PW.ui}</label>
|
||||
<input type="text" id="hw-remote_pw" name="remote_pw" />
|
||||
</div>
|
||||
|
||||
<div class="form-section-title">설치 위치</div>
|
||||
<div class="form-group">
|
||||
<label>건물/위치</label>
|
||||
<select id="hw-bldg-select" name="location">${generateOptionsHTML(Object.keys(LOCATION_DATA))}</select>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>${ASSET_SCHEMA.LOC_DETAIL.ui}</label>
|
||||
<div class="input-with-btn">
|
||||
<select id="hw-location_detail" name="location_detail" style="flex: 1;"><option value="">선택</option></select>
|
||||
<button type="button" id="btn-reg-loc-map" class="btn btn-primary hidden">위치등록</button>
|
||||
<button type="button" id="btn-view-loc-map" class="btn btn-primary hidden">위치보기</button>
|
||||
</div>
|
||||
<input type="hidden" id="hw-loc_x" name="loc_x" /><input type="hidden" id="hw-loc_y" name="loc_y" /><input type="hidden" id="hw-location_photo" name="location_photo" />
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -117,13 +117,22 @@ export function setEditLock(
|
||||
form.classList.remove('is-view-mode');
|
||||
form.classList.add('is-edit-mode');
|
||||
saveBtn.textContent = '저장';
|
||||
revertBtn.classList.toggle('hidden', mode === 'add'); // 신규 추가 시에는 취소 버튼 숨김
|
||||
revertBtn.classList.toggle('hidden', mode === 'add');
|
||||
|
||||
// 모든 필드 활성화
|
||||
const inputs = form.querySelectorAll('input, select, textarea');
|
||||
inputs.forEach(input => {
|
||||
const el = input as HTMLInputElement | HTMLSelectElement | HTMLTextAreaElement;
|
||||
if (el.name !== 'asset_code' && !el.id.includes('asset-id')) { // 자산번호 등 일부는 편집 모드에서도 잠금 유지
|
||||
el.disabled = false;
|
||||
if ('readOnly' in el) (el as HTMLInputElement).readOnly = false;
|
||||
}
|
||||
});
|
||||
|
||||
// 번호 생성 버튼은 '추가(add)' 시에만 노출
|
||||
if (generateBtn) {
|
||||
generateBtn.style.display = mode === 'add' ? 'flex' : 'none';
|
||||
}
|
||||
// 내역 추가 버튼 노출
|
||||
if (addLogBtn) addLogBtn.style.display = 'flex';
|
||||
} else {
|
||||
// 조회 모드 (잠금)
|
||||
@@ -132,7 +141,13 @@ export function setEditLock(
|
||||
saveBtn.textContent = '수정';
|
||||
revertBtn.classList.add('hidden');
|
||||
|
||||
// 조회 모드에서는 버튼들 숨김
|
||||
// 모든 필드 잠금
|
||||
const inputs = form.querySelectorAll('input, select, textarea');
|
||||
inputs.forEach(input => {
|
||||
const el = input as HTMLInputElement | HTMLSelectElement | HTMLTextAreaElement;
|
||||
el.disabled = true; // select의 경우 disabled 필요
|
||||
});
|
||||
|
||||
if (generateBtn) generateBtn.style.display = 'none';
|
||||
if (addLogBtn) addLogBtn.style.display = 'none';
|
||||
}
|
||||
@@ -169,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>
|
||||
|
||||
@@ -61,7 +61,12 @@ export class PCFlowModal {
|
||||
this.currentFlowType = 'checkout';
|
||||
|
||||
const radioCheckout = document.querySelector('input[name="flow-type"][value="checkout"]') as HTMLInputElement;
|
||||
if (radioCheckout) radioCheckout.checked = true;
|
||||
if (radioCheckout) {
|
||||
radioCheckout.checked = true;
|
||||
document.querySelectorAll('.flow-type-label').forEach(l => {
|
||||
l.classList.toggle('active', l.contains(radioCheckout));
|
||||
});
|
||||
}
|
||||
|
||||
// Reset text fields
|
||||
const userSearch = document.getElementById('pc-flow-user-search') as HTMLInputElement;
|
||||
@@ -460,7 +465,6 @@ export class PCFlowModal {
|
||||
return `
|
||||
<div id="pc-flow-modal" class="modal-overlay hidden">
|
||||
<div class="modal-content wide">
|
||||
|
||||
<div class="modal-header">
|
||||
<h2 class="modal-title">
|
||||
<i data-lucide="refresh-cw"></i> PC 이동/반납 (불출/반납/이동)
|
||||
@@ -504,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="사원명, 부서, 사번 검색..." />
|
||||
@@ -514,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="자산코드 또는 모델명 검색..." />
|
||||
@@ -543,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>
|
||||
@@ -552,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>
|
||||
@@ -561,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>
|
||||
|
||||
@@ -582,7 +586,6 @@ export class PCFlowModal {
|
||||
<button id="btn-submit-pc-flow" class="btn btn-primary">이동/반납 처리 완료</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { state, savePartsMaster, deletePartsMaster } from '../../core/state';
|
||||
import { BaseModal } from './BaseModal';
|
||||
import { generateOptionsHTML, setFieldValue, getFieldValue } from './ModalUtils';
|
||||
import { createIcons, X, Save, Database, Edit2, Plus } from 'lucide';
|
||||
import { createIcons, X, Save, Plus } from 'lucide';
|
||||
import { UI_TEXT } from '../../core/schema';
|
||||
|
||||
class PartsMasterModal extends BaseModal {
|
||||
@@ -10,52 +10,51 @@ class PartsMasterModal extends BaseModal {
|
||||
}
|
||||
|
||||
protected renderFrameHTML(): string {
|
||||
const sharedStyle = 'height: 38px !important; box-sizing: border-box !important; font-size: 13px; margin: 0;';
|
||||
const inputStyle = sharedStyle;
|
||||
const selectStyle = sharedStyle;
|
||||
|
||||
return `
|
||||
<div id="parts-master-asset-modal" class="modal-overlay hidden">
|
||||
<div class="modal-content" style="max-width: 500px; width: 100%;">
|
||||
<div class="modal-content narrow">
|
||||
<div class="modal-header">
|
||||
<h2 id="parts-master-modal-title" style="margin: 0; font-size: 18px; font-weight: 800; color: white;">${this.title}</h2>
|
||||
<button id="btn-close-parts-master-modal" class="btn-icon" aria-label="닫기" style="font-size: 28px; color: white; background: none; border: none; cursor: pointer; line-height: 1;">×</button>
|
||||
<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>
|
||||
<div class="modal-body" style="padding: 24px; overflow-y: auto;">
|
||||
<form id="parts-master-asset-form" class="grid-form" style="display: flex; flex-direction: column; gap: 16px;">
|
||||
<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 vertical-form">
|
||||
<input type="hidden" id="parts-master-id" name="id" />
|
||||
|
||||
<div class="form-group" style="display: flex; flex-direction: column; gap: 6px;">
|
||||
<label style="font-size: 11px; font-weight: 700; color: var(--text-muted);">부품 분류</label>
|
||||
<select id="parts-master-category" name="category" style="${selectStyle}">
|
||||
<div class="form-group">
|
||||
<label>부품 분류</label>
|
||||
<select id="parts-master-category" name="category">
|
||||
<option value="CPU">CPU</option>
|
||||
<option value="GPU">GPU</option>
|
||||
<option value="RAM">RAM</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div class="form-group" style="display: flex; flex-direction: column; gap: 6px;">
|
||||
<label style="font-size: 11px; font-weight: 700; color: var(--text-muted);">부품 표준 명칭</label>
|
||||
<input type="text" id="parts-master-component-name" name="component_name" placeholder="예: Intel Core i7-14700K" required style="${inputStyle} width: 100%;" />
|
||||
<div class="form-group">
|
||||
<label>부품 표준 명칭</label>
|
||||
<input type="text" id="parts-master-component-name" name="component_name" placeholder="예: Intel Core i7-14700K" required />
|
||||
</div>
|
||||
|
||||
<div class="form-group" style="display: flex; flex-direction: column; gap: 6px;">
|
||||
<label style="font-size: 11px; font-weight: 700; color: var(--text-muted);">성능 등급</label>
|
||||
<input type="text" id="parts-master-score-tier" name="score_tier" placeholder="예: i7 / S / 최적" required style="${inputStyle} width: 100%;" />
|
||||
<div class="form-group">
|
||||
<label>성능 등급</label>
|
||||
<input type="text" id="parts-master-score-tier" name="score_tier" placeholder="예: i7 / S / 최적" required />
|
||||
</div>
|
||||
|
||||
<div class="form-group" style="display: flex; flex-direction: column; gap: 6px;">
|
||||
<label style="font-size: 11px; font-weight: 700; color: var(--text-muted);">감점 점수 (양수로 입력)</label>
|
||||
<input type="number" id="parts-master-deduction" name="deduction" placeholder="예: 5" required style="${inputStyle} width: 100%;" />
|
||||
<div class="form-group">
|
||||
<label>감점 점수 (양수로 입력)</label>
|
||||
<input type="number" id="parts-master-deduction" name="deduction" placeholder="예: 5" required />
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
<div class="modal-footer" style="display: flex; justify-content: space-between; align-items: center; padding: 16px 24px; background: #f8fafc; border-top: 1px solid var(--border-color);">
|
||||
<button id="btn-delete-parts-master-asset" class="btn btn-outline btn-danger" style="height: 42px;">삭제</button>
|
||||
<div class="footer-actions" style="display: flex; gap: 8px;">
|
||||
<button id="btn-revert-parts-master-edit" class="btn btn-outline hidden" style="height: 42px;">수정 취소</button>
|
||||
<button id="btn-cancel-parts-master-modal" class="btn btn-outline" style="height: 42px;">닫기</button>
|
||||
<button id="btn-save-parts-master-asset" class="btn btn-primary" style="height: 42px;">수정</button>
|
||||
<div class="modal-footer">
|
||||
<button id="btn-delete-parts-master-asset" class="btn btn-outline btn-danger">삭제</button>
|
||||
<div class="footer-actions">
|
||||
<button id="btn-revert-parts-master-edit" class="btn btn-outline hidden">수정 취소</button>
|
||||
<button id="btn-cancel-parts-master-modal" class="btn btn-outline">닫기</button>
|
||||
<button id="btn-save-parts-master-asset" class="btn btn-primary">수정</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -109,11 +108,13 @@ class PartsMasterModal extends BaseModal {
|
||||
if (!this.currentAsset || !this.currentAsset.id) return;
|
||||
if (!confirm('정말로 이 부품 마스터 정보를 삭제하시겠습니까?\n삭제 시 기존 등록 PC 중 이 부품명을 사용하는 PC의 자동완성 정합성 체크에 영향을 줄 수 있습니다.')) return;
|
||||
|
||||
if (await deletePartsMaster(this.currentAsset.id)) {
|
||||
if (await deletePartsMaster(Number(this.currentAsset.id))) {
|
||||
alert('성공적으로 삭제되었습니다.');
|
||||
onSave(); this.close(); closeModals();
|
||||
}
|
||||
});
|
||||
|
||||
createIcons({ icons: { Plus, X, Save } });
|
||||
}
|
||||
|
||||
protected fillFormData(asset: any): void {
|
||||
@@ -122,23 +123,18 @@ 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 {
|
||||
const titleEl = document.getElementById('parts-master-modal-title');
|
||||
|
||||
if (titleEl) {
|
||||
if (mode === 'add') {
|
||||
titleEl.textContent = '신규 부품 마스터 등록';
|
||||
} else {
|
||||
titleEl.textContent = '부품 마스터 상세 편집';
|
||||
}
|
||||
titleEl.textContent = (mode === 'add') ? '신규 부품 마스터 등록' : '부품 마스터 상세 편집';
|
||||
}
|
||||
|
||||
const deleteBtn = document.getElementById('btn-delete-parts-master-asset')!;
|
||||
const saveBtn = document.getElementById('btn-save-parts-master-asset')!;
|
||||
|
||||
// 추가 모드일 때는 삭제 버튼 숨김
|
||||
deleteBtn.style.display = (mode === 'add') ? 'none' : 'block';
|
||||
|
||||
if (mode === 'add') {
|
||||
@@ -152,15 +148,28 @@ 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>
|
||||
`;
|
||||
}
|
||||
}
|
||||
|
||||
export const partsMasterModal = new PartsMasterModal();
|
||||
|
||||
export function initPartsMasterModal(onSave: () => void, closeModals: () => void) {
|
||||
partsMasterModal.init(onSave, closeModals);
|
||||
}
|
||||
|
||||
export function openPartsMasterModal(asset: any, mode: 'view' | 'edit' | 'add' = 'view') {
|
||||
partsMasterModal.open(asset, mode);
|
||||
}
|
||||
export function initPartsMasterModal(onSave: () => void, closeModals: () => void) { partsMasterModal.init(onSave, closeModals); }
|
||||
export function openPartsMasterModal(asset: any, mode: 'view' | 'edit' | 'add' = 'view') { partsMasterModal.open(asset, mode); }
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { state, saveAsset, deleteAsset } from '../../core/state';
|
||||
import { BaseModal } from './BaseModal';
|
||||
import { openSwUserModal } from './SWUserModal';
|
||||
import { createIcons, History, Plus, X, Save, Edit2, RotateCcw, Calendar, Users } from 'lucide';
|
||||
import { createIcons, History, Plus, X, Save, RotateCcw, Calendar, Users } from 'lucide';
|
||||
import { CORP_LIST } from './SharedData';
|
||||
import { ASSET_SCHEMA, UI_TEXT } from '../../core/schema';
|
||||
import { API_BASE_URL } from '../../core/utils';
|
||||
@@ -22,8 +22,11 @@ class SwAssetModal extends BaseModal {
|
||||
<div id="sw-asset-modal" class="modal-overlay hidden">
|
||||
<div class="modal-content wide">
|
||||
<div class="modal-header">
|
||||
<h2 id="sw-modal-title">${this.title}</h2>
|
||||
<button id="btn-close-sw-modal" class="btn-icon" aria-label="닫기"><i data-lucide="x"></i></button>
|
||||
<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">
|
||||
<div class="modal-body-split">
|
||||
@@ -81,7 +84,7 @@ class SwAssetModal extends BaseModal {
|
||||
</div>
|
||||
<div class="form-group sw-standard-field">
|
||||
<label>${ASSET_SCHEMA.PURCHASE_AMOUNT.ui}</label>
|
||||
<input type="text" id="sw-금액" name="purchase_amount" oninput="this.value = this.value.replace(/[^0-9]/g, '').replace(/\\\\B(?=(\\\\d{3})+(?!\\\\d))/g, ',')" />
|
||||
<input type="text" id="sw-금액" name="purchase_amount" oninput="this.value = this.value.replace(/[^0-9]/g, '').replace(/\\B(?=(\\d{3})+(?!\\d))/g, ',')" />
|
||||
</div>
|
||||
|
||||
<div class="form-group cloud-only">
|
||||
@@ -151,7 +154,7 @@ class SwAssetModal extends BaseModal {
|
||||
<div class="history-header">
|
||||
<h3><i data-lucide="history"></i> 업데이트 내역</h3>
|
||||
<button type="button" id="btn-open-sw-update" class="btn btn-outline btn-sm">
|
||||
계약 업데이트 <i data-lucide="refresh-ccw"></i>
|
||||
계약 업데이트 <i data-lucide="rotate-ccw"></i>
|
||||
</button>
|
||||
</div>
|
||||
<div id="sw-history-list" class="history-timeline"></div>
|
||||
@@ -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"><i data-lucide="x"></i></button>
|
||||
<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" />
|
||||
@@ -209,6 +212,15 @@ class SwAssetModal extends BaseModal {
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<style>
|
||||
.hidden-picker {
|
||||
position: absolute;
|
||||
width: 0;
|
||||
height: 0;
|
||||
opacity: 0;
|
||||
pointer-events: none;
|
||||
}
|
||||
</style>
|
||||
`;
|
||||
}
|
||||
|
||||
@@ -231,7 +243,6 @@ class SwAssetModal extends BaseModal {
|
||||
if (this.currentAsset) openSwUserModal(this.currentAsset);
|
||||
});
|
||||
|
||||
// 업데이트 모달 로직
|
||||
const subModal = document.getElementById('sw-update-modal')!;
|
||||
const closeUpdate = () => subModal.classList.add('hidden');
|
||||
document.getElementById('btn-close-sw-update')?.addEventListener('click', closeUpdate);
|
||||
@@ -258,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])
|
||||
});
|
||||
|
||||
@@ -322,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) {
|
||||
@@ -356,16 +389,10 @@ class SwAssetModal extends BaseModal {
|
||||
if (!container) return;
|
||||
const logs = (state.masterData.logs || []).filter(l => l.assetId === swId);
|
||||
if (logs.length === 0) { container.innerHTML = '<div class="empty-history">수정 이력이 없습니다.</div>'; return; }
|
||||
container.innerHTML = logs.map(l => `<div class=\"history-item\"><div class=\"history-date\">${l.date}</div><div class=\"history-user\">${l.user}</div><div class=\"history-details\">${l.details}</div></div>`).join('');
|
||||
container.innerHTML = logs.map(l => `<div class="history-item"><div class="history-date">${l.date}</div><div class="history-user">${l.user}</div><div class="history-details">${l.details}</div></div>`).join('');
|
||||
}
|
||||
}
|
||||
|
||||
export const swModal = new SwAssetModal();
|
||||
|
||||
export function initSwModal(onSave: () => void, closeModals: () => void) {
|
||||
swModal.init(onSave, closeModals);
|
||||
}
|
||||
|
||||
export function openSwModal(asset: any, mode: 'view' | 'add' | 'edit' = 'view') {
|
||||
swModal.open(asset, mode);
|
||||
}
|
||||
export function initSwModal(onSave: () => void, closeModals: () => void) { swModal.init(onSave, closeModals); }
|
||||
export function openSwModal(asset: any, mode: 'view' | 'add' | 'edit' = 'view') { swModal.open(asset, mode); }
|
||||
|
||||
@@ -16,15 +16,15 @@ class SwUserModal extends BaseModal {
|
||||
<div id="sw-user-asset-modal" class="modal-overlay hidden">
|
||||
<div class="modal-content wide">
|
||||
<div class="modal-header">
|
||||
<h2 id="sw-user-title">${this.title}</h2>
|
||||
<button id="btn-close-sw-user-modal" class="btn-icon"><i data-lucide="x"></i></button>
|
||||
<h2 id="sw-user-title" class="modal-title">${this.title}</h2>
|
||||
<button id="btn-close-sw-user-modal" class="btn-icon" aria-label="닫기">×</button>
|
||||
</div>
|
||||
<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 style="font-size:1rem; font-weight:600;">할당된 사용자 목록</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="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">사용자 정보</h3>
|
||||
<button id="btn-close-user-edit" class="btn-icon"><i data-lucide="x"></i></button>
|
||||
<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>
|
||||
@@ -81,22 +81,22 @@ class SwUserModal extends BaseModal {
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>사용 시작일</label>
|
||||
<div style="display:flex; gap:0.25rem; align-items:center; position:relative;">
|
||||
<input type="text" id="new-user-시작일" style="flex:1;" />
|
||||
<button type="button" class="btn-icon" onclick="const p = document.getElementById('new-user-시작일-picker'); p.value = document.getElementById('new-user-시작일').value; p.showPicker();" style="padding:0.25rem;">
|
||||
<i data-lucide="calendar" style="width:18px; height:18px; color:var(--primary-color);"></i>
|
||||
<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" class="icon-sm"></i>
|
||||
</button>
|
||||
<input type="date" id="new-user-시작일-picker" style="position:absolute; width:0; height:0; opacity:0; pointer-events:none;" onchange="document.getElementById('new-user-시작일').value = this.value" tabindex="-1" />
|
||||
<input type="date" id="new-user-시작일-picker" class="hidden-picker" onchange="document.getElementById('new-user-시작일').value = this.value" tabindex="-1" />
|
||||
</div>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>사용 종료일</label>
|
||||
<div style="display:flex; gap:0.25rem; align-items:center; position:relative;">
|
||||
<input type="text" id="new-user-종료일" style="flex:1;" />
|
||||
<button type="button" class="btn-icon" onclick="const p = document.getElementById('new-user-종료일-picker'); p.value = document.getElementById('new-user-종료일').value; p.showPicker();" style="padding:0.25rem;">
|
||||
<i data-lucide="calendar" style="width:18px; height:18px; color:var(--primary-color);"></i>
|
||||
<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" class="icon-sm"></i>
|
||||
</button>
|
||||
<input type="date" id="new-user-종료일-picker" style="position:absolute; width:0; height:0; opacity:0; pointer-events:none;" onchange="document.getElementById('new-user-종료일').value = this.value" tabindex="-1" />
|
||||
<input type="date" id="new-user-종료일-picker" class="hidden-picker" onchange="document.getElementById('new-user-종료일').value = this.value" tabindex="-1" />
|
||||
</div>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
@@ -111,6 +111,15 @@ class SwUserModal extends BaseModal {
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<style>
|
||||
.hidden-picker {
|
||||
position: absolute;
|
||||
width: 0;
|
||||
height: 0;
|
||||
opacity: 0;
|
||||
pointer-events: none;
|
||||
}
|
||||
</style>
|
||||
`;
|
||||
}
|
||||
|
||||
@@ -140,7 +149,6 @@ class SwUserModal extends BaseModal {
|
||||
onSave(); this.close(); closeModals();
|
||||
});
|
||||
|
||||
// 닫기 이벤트들 (BaseModal의 공통 버튼 외 추가분)
|
||||
document.getElementById('btn-close-sw-user-modal')?.addEventListener('click', () => this.close());
|
||||
document.getElementById('btn-cancel-sw-user')?.addEventListener('click', () => this.close());
|
||||
|
||||
@@ -155,9 +163,9 @@ class SwUserModal extends BaseModal {
|
||||
protected fillFormData(asset: any): void {
|
||||
const swInfo = document.getElementById('sw-user-sw-info')!;
|
||||
swInfo.innerHTML = `
|
||||
<div style="background:var(--bg-light); padding:1rem; border-radius:6px; margin-bottom:1.5rem;">
|
||||
<div style="font-size:0.8rem; color:var(--text-muted); margin-bottom:0.25rem;">${asset.purchase_corp || asset.법인 || ''}</div>
|
||||
<div style="font-size:1.1rem; font-weight:700; color:var(--primary-color);">${asset.product_name || asset.제품명 || ''}</div>
|
||||
<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>
|
||||
`;
|
||||
|
||||
@@ -173,9 +181,10 @@ class SwUserModal extends BaseModal {
|
||||
|
||||
private renderUserList() {
|
||||
const tbody = document.getElementById('sw-user-table-body')!;
|
||||
if (!tbody) return;
|
||||
tbody.innerHTML = '';
|
||||
if (this.tempSwUsers.length === 0) {
|
||||
tbody.innerHTML = '<tr><td colspan="7" style="text-align:center; padding:2rem; color:var(--text-muted);">할당된 사용자가 없습니다.</td></tr>';
|
||||
tbody.innerHTML = '<tr><td colspan="7" class="empty-cell text-center p-8">할당된 사용자가 없습니다.</td></tr>';
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -186,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>
|
||||
`;
|
||||
@@ -257,11 +266,5 @@ class SwUserModal extends BaseModal {
|
||||
}
|
||||
|
||||
export const swUserModal = new SwUserModal();
|
||||
|
||||
export function initSwUserModal(onSave: () => void, closeModals: () => void) {
|
||||
swUserModal.init(onSave, closeModals);
|
||||
}
|
||||
|
||||
export function openSwUserModal(asset: any) {
|
||||
swUserModal.open(asset);
|
||||
}
|
||||
export function initSwUserModal(onSave: () => void, closeModals: () => void) { swUserModal.init(onSave, closeModals); }
|
||||
export function openSwUserModal(asset: any) { swUserModal.open(asset); }
|
||||
|
||||
@@ -10,55 +10,55 @@ class UserModal extends BaseModal {
|
||||
}
|
||||
|
||||
protected renderFrameHTML(): string {
|
||||
const sharedStyle = 'height: 38px !important; box-sizing: border-box !important; font-size: 13px; margin: 0;';
|
||||
const inputStyle = sharedStyle;
|
||||
|
||||
return `
|
||||
<div id="user-asset-modal" class="modal-overlay hidden">
|
||||
<div class="modal-content" style="max-width: 500px; width: 100%;">
|
||||
<div class="modal-content narrow">
|
||||
<div class="modal-header">
|
||||
<h2 id="user-modal-title" style="margin: 0; font-size: 18px; font-weight: 800; color: white;">\${this.title}</h2>
|
||||
<button id="btn-close-user-modal" class="btn-icon" aria-label="닫기" style="font-size: 28px; color: white; background: none; border: none; cursor: pointer; line-height: 1;">×</button>
|
||||
<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>
|
||||
<div class="modal-body" style="padding: 24px; overflow-y: auto;">
|
||||
<form id="user-asset-form" class="grid-form" style="display: flex; flex-direction: column; gap: 16px;">
|
||||
<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 vertical-form">
|
||||
<input type="hidden" id="user-id" name="id" />
|
||||
|
||||
<div class="form-group" style="display: flex; flex-direction: column; gap: 6px;">
|
||||
<label style="font-size: 11px; font-weight: 700; color: var(--text-muted);">사번</label>
|
||||
<input type="text" id="user-emp-no" name="emp_no" placeholder="예: HM202601" required style="\${inputStyle} width: 100%;" />
|
||||
<div class="form-group">
|
||||
<label>사번</label>
|
||||
<input type="text" id="user-emp-no" name="emp_no" placeholder="예: HM202601" required />
|
||||
</div>
|
||||
|
||||
<div class="form-group" style="display: flex; flex-direction: column; gap: 6px;">
|
||||
<label style="font-size: 11px; font-weight: 700; color: var(--text-muted);">사용자명</label>
|
||||
<input type="text" id="user-name-input" name="user_name" placeholder="예: 홍길동" required style="\${inputStyle} width: 100%;" />
|
||||
<div class="form-group">
|
||||
<label>사용자명</label>
|
||||
<input type="text" id="user-name-input" name="user_name" placeholder="예: 홍길동" required />
|
||||
</div>
|
||||
|
||||
<div class="form-group" style="display: flex; flex-direction: column; gap: 6px;">
|
||||
<label style="font-size: 11px; font-weight: 700; color: var(--text-muted);">사용조직 (부서)</label>
|
||||
<input type="text" id="user-dept" name="dept_name" placeholder="예: 기술개발센터" required style="\${inputStyle} width: 100%;" />
|
||||
<div class="form-group">
|
||||
<label>사용조직 (부서)</label>
|
||||
<input type="text" id="user-dept" name="dept_name" placeholder="예: 기술개발센터" required />
|
||||
</div>
|
||||
|
||||
<div class="form-group" style="display: flex; flex-direction: column; gap: 6px;">
|
||||
<label style="font-size: 11px; font-weight: 700; color: var(--text-muted);">직무 (직급)</label>
|
||||
<input type="text" id="user-position-input" name="position" placeholder="예: BIM모델러" required style="\${inputStyle} width: 100%;" />
|
||||
<div class="form-group">
|
||||
<label>직무 (직급)</label>
|
||||
<input type="text" id="user-position-input" name="position" placeholder="예: BIM모델러" required />
|
||||
</div>
|
||||
|
||||
<div class="form-group" style="display: flex; flex-direction: column; gap: 6px;">
|
||||
<label style="font-size: 11px; font-weight: 700; color: var(--text-muted);">상태</label>
|
||||
<select id="user-status" name="status" style="\${sharedStyle}">
|
||||
<div class="form-group">
|
||||
<label>상태</label>
|
||||
<select id="user-status" name="status">
|
||||
<option value="재직">재직</option>
|
||||
<option value="퇴직">퇴직</option>
|
||||
</select>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
<div class="modal-footer" style="display: flex; justify-content: space-between; align-items: center; padding: 16px 24px; background: #f8fafc; border-top: 1px solid var(--border-color);">
|
||||
<button id="btn-delete-user-asset" class="btn btn-outline btn-danger" style="height: 42px;">삭제</button>
|
||||
<div class="footer-actions" style="display: flex; gap: 8px;">
|
||||
<button id="btn-revert-user-edit" class="btn btn-outline hidden" style="height: 42px;">수정 취소</button>
|
||||
<button id="btn-cancel-user-modal" class="btn btn-outline" style="height: 42px;">닫기</button>
|
||||
<button id="btn-save-user-asset" class="btn btn-primary" style="height: 42px;">수정</button>
|
||||
<div class="modal-footer">
|
||||
<button id="btn-delete-user-asset" class="btn btn-outline btn-danger">삭제</button>
|
||||
<div class="footer-actions">
|
||||
<button id="btn-revert-user-edit" class="btn btn-outline hidden">수정 취소</button>
|
||||
<button id="btn-cancel-user-modal" class="btn btn-outline">닫기</button>
|
||||
<button id="btn-save-user-asset" class="btn btn-primary">수정</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -119,6 +119,8 @@ class UserModal extends BaseModal {
|
||||
onSave(); this.close(); closeModals();
|
||||
}
|
||||
});
|
||||
|
||||
createIcons({ icons: { Save, X } });
|
||||
}
|
||||
|
||||
protected fillFormData(asset: any): void {
|
||||
@@ -128,17 +130,13 @@ 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 {
|
||||
const titleEl = document.getElementById('user-modal-title');
|
||||
|
||||
if (titleEl) {
|
||||
if (mode === 'add') {
|
||||
titleEl.textContent = '신규 임직원 등록';
|
||||
} else {
|
||||
titleEl.textContent = '임직원 정보 수정';
|
||||
}
|
||||
titleEl.textContent = (mode === 'add') ? '신규 임직원 등록' : '임직원 정보 수정';
|
||||
}
|
||||
|
||||
const deleteBtn = document.getElementById('btn-delete-user-asset')!;
|
||||
@@ -157,15 +155,30 @@ 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>
|
||||
`;
|
||||
}
|
||||
}
|
||||
|
||||
export const userModal = new UserModal();
|
||||
|
||||
export function initUserModal(onSave: () => void, closeModals: () => void) {
|
||||
userModal.init(onSave, closeModals);
|
||||
}
|
||||
|
||||
export function openUserModal(asset: any, mode: 'view' | 'edit' | 'add' = 'view') {
|
||||
userModal.open(asset, mode);
|
||||
}
|
||||
export function initUserModal(onSave: () => void, closeModals: () => void) { userModal.init(onSave, closeModals); }
|
||||
export function openUserModal(asset: any, mode: 'view' | 'edit' | 'add' = 'view') { userModal.open(asset, mode); }
|
||||
|
||||
@@ -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}대`;
|
||||
}
|
||||
|
||||
@@ -33,38 +33,38 @@ 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" style="margin-bottom: 1.5rem;">
|
||||
<div class="dashboard-card" data-action="ext-usage" style="cursor:pointer; min-height:auto;">
|
||||
<span style="font-size:1.21rem; font-weight:700; color:var(--text-main);">외부 소프트웨어 사용율</span>
|
||||
<div style="font-size: 1.02rem; color:var(--text-muted); margin-bottom: 1rem;">${extQty}카피 중 ${extUsed}개 할당</div>
|
||||
<div style="font-size: 2.21rem; font-weight:700; color:var(--dash-primary);">${extPer}%</div>
|
||||
<div style="width: 100%; height: 4px; background-color: var(--border-color); border-radius: 2px; overflow: hidden; margin-top: 0.5rem;">
|
||||
<div style="width: ${extPer}%; height: 100%; background-color: var(--dash-primary);"></div>
|
||||
<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 text-primary">${extPer}%</div>
|
||||
<div class="stat-progress-bar">
|
||||
<div class="progress-fill" style="width: ${extPer}%;"></div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="dashboard-card" data-action="int-usage" style="cursor:pointer; min-height:auto;">
|
||||
<span style="font-size:1.21rem; font-weight:700; color:var(--text-main);">내부 소프트웨어 현황</span>
|
||||
<div style="font-size: 1.02rem; color:var(--text-muted); margin-bottom: 1rem;">등록된 내부 솔루션: ${intTotal}개</div>
|
||||
<div style="font-size: 2.21rem; font-weight:700; color:var(--dash-primary);">${intPer}%</div>
|
||||
<div style="width: 100%; height: 4px; background-color: var(--border-color); border-radius: 2px; overflow: hidden; margin-top: 0.5rem;">
|
||||
<div style="width: ${intPer}%; height: 100%; background-color: var(--dash-primary);"></div>
|
||||
<div class="dashboard-card clickable" data-action="int-usage">
|
||||
<div class="stat-label">내부 소프트웨어 현황</div>
|
||||
<div class="stat-sub">등록된 내부 솔루션: ${intTotal}개</div>
|
||||
<div class="stat-value text-primary">${intPer}%</div>
|
||||
<div class="stat-progress-bar">
|
||||
<div class="progress-fill" style="width: ${intPer}%;"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<h3 class="dashboard-section-title">2026년 누적 도입 비용 분석</h3>
|
||||
|
||||
<div style="display:grid; grid-template-columns: repeat(2, 1fr); gap:1.5rem; margin-bottom:1.5rem;">
|
||||
<div class="dashboard-card" style="min-height:auto;">
|
||||
<span style="font-size:1.21rem; font-weight:700; color:var(--text-main);">외부 SW 누적 비용 (2026)</span>
|
||||
<div style="font-size: 2.21rem; font-weight:700; color:var(--dash-primary);">₩ ${extCost2026.toLocaleString()}</div>
|
||||
<div class="dashboard-layout-2col">
|
||||
<div class="dashboard-card">
|
||||
<div class="stat-label">외부 SW 누적 비용 (2026)</div>
|
||||
<div class="stat-value text-primary">₩ ${extCost2026.toLocaleString()}</div>
|
||||
</div>
|
||||
<div class="dashboard-card" style="min-height:auto;">
|
||||
<span style="font-size:1.21rem; font-weight:700; color:var(--text-main);">내부 SW 누적 비용 (2026)</span>
|
||||
<div style="font-size: 2.21rem; font-weight:700; color:#3b82f6;">₩ ${intCost2026.toLocaleString()}</div>
|
||||
<div class="dashboard-card">
|
||||
<div class="stat-label">내부 SW 누적 비용 (2026)</div>
|
||||
<div class="stat-value text-blue">₩ ${intCost2026.toLocaleString()}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
|
||||
@@ -58,7 +58,7 @@ export function renderPartsMasterList(container: HTMLElement) {
|
||||
let color = '#3b82f6'; // blue
|
||||
if (score >= 20) color = '#ef4444'; // red
|
||||
else if (score >= 10) color = '#f59e0b'; // orange
|
||||
return `<strong style="color: ${color}; font-size: 14px;">-${score}점</strong>`;
|
||||
return `<strong style="color: ${color};">-${score}점</strong>`;
|
||||
}
|
||||
}
|
||||
]
|
||||
|
||||
Reference in New Issue
Block a user