Merge remote-tracking branch 'origin/main' into ux_setting
This commit is contained in:
@@ -55,6 +55,9 @@ export abstract class BaseModal {
|
||||
this.currentAsset = asset;
|
||||
this.isEditMode = (mode === 'add' || mode === 'edit');
|
||||
|
||||
// 폼 초기화 추가
|
||||
if (this.formEl) this.formEl.reset();
|
||||
|
||||
this.setEditLockMode(mode);
|
||||
this.fillFormData(asset);
|
||||
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { state, saveAsset, deleteAsset } from '../../core/state';
|
||||
import { ASSET_SCHEMA, UI_TEXT } from '../../core/schema';
|
||||
import { calculatePcScoreDeductive, getPcGrade } from '../../core/utils';
|
||||
import {
|
||||
generateOptionsHTML,
|
||||
setFieldValue,
|
||||
@@ -13,6 +14,7 @@ import { BaseModal } from './BaseModal';
|
||||
|
||||
class HwAssetModal extends BaseModal {
|
||||
private dynamicMapConfig: Record<string, any[]> = {};
|
||||
private masterComponents: any[] = [];
|
||||
|
||||
constructor() {
|
||||
super('hw', '자산 상세 정보');
|
||||
@@ -20,6 +22,39 @@ class HwAssetModal extends BaseModal {
|
||||
|
||||
protected renderFrameHTML(): string {
|
||||
return `
|
||||
<style>
|
||||
.autocomplete-list {
|
||||
position: absolute;
|
||||
top: 100%;
|
||||
left: 0;
|
||||
right: 0;
|
||||
max-height: 150px;
|
||||
overflow-y: auto;
|
||||
background-color: white;
|
||||
border: 1px solid var(--border-color, #E2E8F0);
|
||||
border-top: none;
|
||||
border-radius: 0 0 4px 4px;
|
||||
z-index: 1000;
|
||||
box-shadow: 0 4px 6px -1px rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
.autocomplete-item {
|
||||
padding: 8px 12px;
|
||||
font-size: 13px;
|
||||
color: #334155;
|
||||
cursor: pointer;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
.autocomplete-item:hover {
|
||||
background-color: #F1F5F9;
|
||||
color: #1E5149;
|
||||
font-weight: 600;
|
||||
}
|
||||
.hidden {
|
||||
display: none !important;
|
||||
}
|
||||
</style>
|
||||
<div id="hw-asset-modal" class="modal-overlay hidden">
|
||||
<div class="modal-content wide">
|
||||
<div class="modal-header">
|
||||
@@ -63,6 +98,17 @@ class HwAssetModal extends BaseModal {
|
||||
<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.SERVICE_TYPE.ui}</label>
|
||||
<select id="hw-service_type" name="service_type" style="${inputStyle}">
|
||||
<option value="외부">외부</option>
|
||||
<option value="내부">내부</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="form-group full-width" style="grid-column: span 2;">
|
||||
<label>${ASSET_SCHEMA.ASSET_PURPOSE.ui}</label>
|
||||
<input type="text" id="hw-asset_purpose" name="asset_purpose" placeholder="자산의 용도를 입력하세요" style="${inputStyle} width: 100%;" />
|
||||
</div>
|
||||
<div class="form-group infra-only monitoring-field">
|
||||
<label>${ASSET_SCHEMA.MONITORING.ui}</label>
|
||||
<select id="hw-monitoring" name="monitoring">
|
||||
@@ -72,15 +118,19 @@ class HwAssetModal extends BaseModal {
|
||||
</div>
|
||||
|
||||
<!-- [SECTION 2] 조직 및 사용자 정보 -->
|
||||
<div class="form-section-title org-user-section">사용자 및 조직 정보</div>
|
||||
<div class="form-group org-user-field">
|
||||
<div class="form-section-title" style="margin-top: 24px; margin-bottom: 12px;">사용자 및 조직 정보</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 org-user-field">
|
||||
<div class="form-group">
|
||||
<label>${ASSET_SCHEMA.MANAGER_MAIN.ui}</label>
|
||||
<input type="text" id="hw-manager_primary" name="manager_primary" />
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>${ASSET_SCHEMA.MANAGER_SUB.ui}</label>
|
||||
<input type="text" id="hw-manager_secondary" name="manager_secondary" style="${inputStyle}" />
|
||||
</div>
|
||||
<div class="form-group personal-only">
|
||||
<label>${ASSET_SCHEMA.CURRENT_USER.ui}</label>
|
||||
<input type="text" id="hw-user_current" name="user_current" />
|
||||
@@ -89,10 +139,6 @@ class HwAssetModal extends BaseModal {
|
||||
<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.MANAGER_SUB.ui}</label>
|
||||
<input type="text" id="hw-manager_secondary" name="manager_secondary" />
|
||||
</div>
|
||||
<div class="form-group personal-only">
|
||||
<label>${ASSET_SCHEMA.PREV_USER.ui}</label>
|
||||
<input type="text" id="hw-previous_user" name="previous_user" />
|
||||
@@ -116,22 +162,31 @@ class HwAssetModal extends BaseModal {
|
||||
<label>${ASSET_SCHEMA.OS.ui}</label>
|
||||
<input type="text" id="hw-os" name="os" />
|
||||
</div>
|
||||
<div class="form-group spec-only">
|
||||
<div class="form-group spec-only" style="position: relative;">
|
||||
<label>${ASSET_SCHEMA.CPU.ui}</label>
|
||||
<input type="text" id="hw-cpu" name="cpu" />
|
||||
<input type="text" id="hw-cpu" name="cpu" autocomplete="off" placeholder="CPU 부품 검색..." style="${inputStyle}" />
|
||||
<div id="hw-cpu-autocomplete" class="autocomplete-list hidden"></div>
|
||||
</div>
|
||||
<div class="form-group spec-only">
|
||||
<div class="form-group spec-only" style="position: relative;">
|
||||
<label>${ASSET_SCHEMA.RAM.ui}</label>
|
||||
<input type="text" id="hw-ram" name="ram" />
|
||||
<input type="text" id="hw-ram" name="ram" autocomplete="off" placeholder="RAM 부품 검색..." style="${inputStyle}" />
|
||||
<div id="hw-ram-autocomplete" class="autocomplete-list hidden"></div>
|
||||
</div>
|
||||
<div class="form-group spec-only">
|
||||
<div class="form-group spec-only" style="position: relative;">
|
||||
<label>${ASSET_SCHEMA.GPU.ui}</label>
|
||||
<input type="text" id="hw-gpu" name="gpu" />
|
||||
<input type="text" id="hw-gpu" name="gpu" autocomplete="off" placeholder="GPU 부품 검색..." style="${inputStyle}" />
|
||||
<div id="hw-gpu-autocomplete" class="autocomplete-list hidden"></div>
|
||||
</div>
|
||||
<div class="form-group spec-only">
|
||||
<label>${ASSET_SCHEMA.MAINBOARD.ui}</label>
|
||||
<input type="text" id="hw-mainboard" name="mainboard" />
|
||||
</div>
|
||||
<div class="form-group spec-only">
|
||||
<label>성능 등급</label>
|
||||
<div id="hw-pc-grade-container" style="display: flex; align-items: center; height: 38px;">
|
||||
<span class="badge b-yellow" id="hw-pc-grade-badge">-</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="form-group monitor-only">
|
||||
<label>${ASSET_SCHEMA.MONITOR_INCH.ui}</label>
|
||||
<input type="text" id="hw-monitor_inch" name="monitor_inch" />
|
||||
@@ -242,6 +297,18 @@ class HwAssetModal extends BaseModal {
|
||||
bindLocationEvents('hw-bldg-select', 'hw-location_detail', '', '');
|
||||
applyDateMask(document.getElementById('hw-purchase_date') as HTMLInputElement);
|
||||
|
||||
this.fetchMasterComponents().then(() => {
|
||||
this.bindAutocomplete('hw-cpu', 'hw-cpu-autocomplete', 'CPU');
|
||||
this.bindAutocomplete('hw-ram', 'hw-ram-autocomplete', 'RAM');
|
||||
this.bindAutocomplete('hw-gpu', 'hw-gpu-autocomplete', 'GPU');
|
||||
});
|
||||
|
||||
const specInputs = ['hw-cpu', 'hw-ram', 'hw-gpu', 'hw-purchase_date'];
|
||||
specInputs.forEach(id => {
|
||||
document.getElementById(id)?.addEventListener('input', () => this.updatePcGradeBadge());
|
||||
document.getElementById(id)?.addEventListener('change', () => this.updatePcGradeBadge());
|
||||
});
|
||||
|
||||
categorySelect.addEventListener('change', () => {
|
||||
const types = CATEGORY_TYPE_MAP[categorySelect.value] || [];
|
||||
typeSelect.innerHTML = types.length > 0 ? generateOptionsHTML(types, '', true) : '<option value="">구분을 먼저 선택하세요</option>';
|
||||
@@ -253,10 +320,21 @@ class HwAssetModal extends BaseModal {
|
||||
});
|
||||
|
||||
document.getElementById('btn-gen-hw-code')?.addEventListener('click', async () => {
|
||||
const type = typeSelect.value;
|
||||
const cat = categorySelect.value;
|
||||
if (!cat) { alert('구분을 먼저 선택해주세요.'); return; }
|
||||
const prefix = TYPE_PREFIX_MAP[cat] || 'ETC';
|
||||
const purchaseDate = (document.getElementById('hw-purchase_date') as HTMLInputElement)?.value || '';
|
||||
if (!type) { alert('유형을 먼저 선택해주세요.'); return; }
|
||||
|
||||
const purchaseDateEl = document.getElementById('hw-purchase_date') as HTMLInputElement;
|
||||
const purchaseDate = purchaseDateEl?.value || '';
|
||||
|
||||
if (!purchaseDate) {
|
||||
alert('구매일자를 먼저 입력해야 자산번호 생성이 가능합니다.');
|
||||
purchaseDateEl?.focus();
|
||||
return;
|
||||
}
|
||||
|
||||
// 유형 기반 매핑 우선, 없으면 구분 기반, 그래도 없으면 ETC
|
||||
const prefix = TYPE_PREFIX_MAP[type] || TYPE_PREFIX_MAP[cat] || 'ETC';
|
||||
try {
|
||||
const res = await fetch(`http://${location.hostname}:3000/api/generate-asset-code?prefix=${prefix}&purchaseDate=${purchaseDate}`);
|
||||
const data = await res.json();
|
||||
@@ -297,6 +375,7 @@ class HwAssetModal extends BaseModal {
|
||||
});
|
||||
|
||||
revertBtn.addEventListener('click', () => {
|
||||
this.isEditMode = false;
|
||||
this.setEditLockMode('view');
|
||||
if (this.currentAsset) this.fillFormData(this.currentAsset);
|
||||
this.updateMapButtonVisibility();
|
||||
@@ -346,6 +425,35 @@ class HwAssetModal extends BaseModal {
|
||||
return;
|
||||
}
|
||||
|
||||
// CPU, RAM, GPU 마스터 테이블 기반 유효성 검사 (완전 강제 방식)
|
||||
const category = categorySelect.value;
|
||||
const type = typeSelect.value;
|
||||
const specCategories = ['PC', '서버', '노트북', '스토리지', '워크스테이션'];
|
||||
const hasSpec = specCategories.includes(category) || type.includes('서버PC');
|
||||
|
||||
if (hasSpec) {
|
||||
const cpuVal = (document.getElementById('hw-cpu') as HTMLInputElement)?.value || '';
|
||||
const ramVal = (document.getElementById('hw-ram') as HTMLInputElement)?.value || '';
|
||||
const gpuVal = (document.getElementById('hw-gpu') as HTMLInputElement)?.value || '';
|
||||
|
||||
const cpuMaster = this.masterComponents.filter(c => c.category === 'CPU').map(c => c.component_name);
|
||||
const ramMaster = this.masterComponents.filter(c => c.category === 'RAM').map(c => c.component_name);
|
||||
const gpuMaster = this.masterComponents.filter(c => c.category === 'GPU').map(c => c.component_name);
|
||||
|
||||
if (cpuVal && !cpuMaster.includes(cpuVal)) {
|
||||
alert(`[입력 오류] '${cpuVal}'은(는) 마스터 테이블에 존재하지 않는 CPU 부품명입니다. 자동완성 추천 목록에서 올바른 부품명을 골라 선택해 주세요.`);
|
||||
return;
|
||||
}
|
||||
if (ramVal && !ramMaster.includes(ramVal)) {
|
||||
alert(`[입력 오류] '${ramVal}'은(는) 마스터 테이블에 존재하지 않는 RAM 부품명입니다. 자동완성 추천 목록에서 올바른 부품명을 골라 선택해 주세요.`);
|
||||
return;
|
||||
}
|
||||
if (gpuVal && !gpuMaster.includes(gpuVal)) {
|
||||
alert(`[입력 오류] '${gpuVal}'은(는) 마스터 테이블에 존재하지 않는 GPU 부품명입니다. 자동완성 추천 목록에서 올바른 부품명을 골라 선택해 주세요.`);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// 동적 볼륨 데이터 수집
|
||||
const vols: any[] = [];
|
||||
document.querySelectorAll('#hw-volume-container .volume-row').forEach((row, idx) => {
|
||||
@@ -389,25 +497,24 @@ class HwAssetModal extends BaseModal {
|
||||
private addVolumeRow(vol: any = { type: 'SSD', capacity: '', unit: 'GB' }) {
|
||||
const container = document.getElementById('hw-volume-container');
|
||||
if (!container) return;
|
||||
|
||||
const row = document.createElement('div');
|
||||
row.className = 'volume-row';
|
||||
|
||||
row.style.display = 'flex'; row.style.gap = '8px'; row.style.alignItems = 'center';
|
||||
const inputStyle = 'height: 38px !important; box-sizing: border-box !important; font-size: 13px; margin: 0; padding: 0 8px;';
|
||||
row.innerHTML = `
|
||||
<select class="vol-type" ${!this.isEditMode ? 'disabled' : ''}>
|
||||
<option value="SSD" ${vol.type === 'SSD' ? 'selected' : ''}>SSD</option>
|
||||
<option value="HDD" ${vol.type === 'HDD' ? 'selected' : ''}>HDD</option>
|
||||
<option value="NVMe" ${vol.type === 'NVMe' ? 'selected' : ''}>NVMe</option>
|
||||
</select>
|
||||
<input type="number" class="vol-cap" value="${vol.capacity || ''}" placeholder="용량" ${!this.isEditMode ? 'readonly' : ''} />
|
||||
<select class="vol-unit" ${!this.isEditMode ? 'disabled' : ''}>
|
||||
<input type="number" class="vol-cap" value="${vol.capacity || ''}" placeholder="용량" style="${inputStyle} flex: 1;" ${!this.isEditMode ? 'readonly' : ''} />
|
||||
<select class="vol-unit" style="${inputStyle} width: 60px;" ${!this.isEditMode ? 'disabled' : ''}>
|
||||
<option value="GB" ${vol.unit === 'GB' ? 'selected' : ''}>GB</option>
|
||||
<option value="TB" ${vol.unit === 'TB' ? 'selected' : ''}>TB</option>
|
||||
</select>
|
||||
<button type="button" class="btn btn-outline btn-remove-vol edit-only-btn" style="display: ${this.isEditMode ? 'inline-flex' : 'none'};">×</button>
|
||||
<button type="button" class="btn btn-outline btn-remove-row edit-only-btn" style="height: 38px !important; padding: 0 12px; color: #E11D48; border-color: #E11D48; display: ${this.isEditMode ? 'inline-flex' : 'none'};">×</button>
|
||||
`;
|
||||
|
||||
row.querySelector('.btn-remove-vol')?.addEventListener('click', () => row.remove());
|
||||
row.querySelector('.btn-remove-row')?.addEventListener('click', () => row.remove());
|
||||
container.appendChild(row);
|
||||
}
|
||||
|
||||
@@ -512,6 +619,8 @@ class HwAssetModal extends BaseModal {
|
||||
if (typeSelect) typeSelect.innerHTML = types.length > 0 ? generateOptionsHTML(types, asset.asset_type, true) : '<option value="">구분을 먼저 선택하세요</option>';
|
||||
setFieldValue('hw-asset_type', asset.asset_type || '');
|
||||
setFieldValue('hw-hw_status', asset.hw_status || '운영');
|
||||
setFieldValue('hw-service_type', asset.service_type || '외부');
|
||||
setFieldValue('hw-asset_purpose', asset.asset_purpose || '');
|
||||
setFieldValue('hw-current_dept', asset.current_dept || '');
|
||||
setFieldValue('hw-manager_primary', asset.manager_primary || '');
|
||||
setFieldValue('hw-manager_secondary', asset.manager_secondary || '');
|
||||
@@ -526,16 +635,12 @@ class HwAssetModal extends BaseModal {
|
||||
setFieldValue('hw-gpu', asset.gpu || '');
|
||||
setFieldValue('hw-mainboard', asset.mainboard || '');
|
||||
|
||||
// 동적 볼륨 렌더링 초기화 및 생성
|
||||
// 동적 볼륨 렌더링
|
||||
const volumeContainer = document.getElementById('hw-volume-container');
|
||||
if (volumeContainer) {
|
||||
volumeContainer.innerHTML = '';
|
||||
let vols = [];
|
||||
try {
|
||||
vols = asset.volumes ? (typeof asset.volumes === 'string' ? JSON.parse(asset.volumes) : asset.volumes) : [];
|
||||
} catch(e) {}
|
||||
vols.forEach((v: any) => this.addVolumeRow(v));
|
||||
}
|
||||
if (volumeContainer) volumeContainer.innerHTML = '';
|
||||
let vols = [];
|
||||
try { vols = asset.volumes ? (typeof asset.volumes === 'string' ? JSON.parse(asset.volumes) : asset.volumes) : []; } catch(e) {}
|
||||
vols.forEach((v: any) => this.addVolumeRow(v));
|
||||
|
||||
// 통합 원격 접속 정보 렌더링 초기화 및 생성
|
||||
const remoteInfoContainer = document.getElementById('hw-remote-info-container');
|
||||
@@ -573,6 +678,7 @@ class HwAssetModal extends BaseModal {
|
||||
setFieldValue('hw-purchase_vendor', asset.purchase_vendor || '');
|
||||
setFieldValue('hw-purchase_amount', asset.purchase_amount || '');
|
||||
setFieldValue('hw-approval_document', asset.approval_document || '');
|
||||
|
||||
const docName = document.getElementById('hw-file-name-display');
|
||||
if (docName) docName.textContent = asset.approval_document ? asset.approval_document.split('/').pop() : '파일 선택...';
|
||||
const fileLinkContainer = document.getElementById('hw-file-link-container');
|
||||
@@ -581,6 +687,7 @@ class HwAssetModal extends BaseModal {
|
||||
} else if (fileLinkContainer) {
|
||||
fileLinkContainer.innerHTML = '';
|
||||
}
|
||||
|
||||
setFieldValue('hw-memo', asset.memo || '');
|
||||
setFieldValue('hw-location_detail', asset.location_detail || '');
|
||||
setFieldValue('hw-loc_x', asset.loc_x || '');
|
||||
@@ -589,6 +696,7 @@ class HwAssetModal extends BaseModal {
|
||||
parseAndSetLocation(asset.location || '', asset.location_detail || '', 'hw-bldg-select', 'hw-location_detail');
|
||||
this.renderHistory(asset.id);
|
||||
this.applyRoleVisibility();
|
||||
this.updatePcGradeBadge();
|
||||
}
|
||||
|
||||
protected onAfterOpen(asset: any, mode: string): void {
|
||||
@@ -676,29 +784,110 @@ class HwAssetModal extends BaseModal {
|
||||
overlay.className = 'image-picker-overlay';
|
||||
const renderContent = () => {
|
||||
const imgPath = imagePaths[currentIdx];
|
||||
const digitalMap = this.generateDynamicSVG(imgPath);
|
||||
const isMulti = imagePaths.length > 1;
|
||||
const isHtmlMap = imgPath.toLowerCase().endsWith('.html');
|
||||
const digitalMap = isHtmlMap ? '' : this.generateDynamicSVG(imgPath);
|
||||
|
||||
overlay.innerHTML = `
|
||||
<div class="image-picker-header"><h3>${title}</h3><button class="btn-close-picker" style="background:none; border:none; color:white; font-size:24px; cursor:pointer;">×</button></div>
|
||||
<div class="image-picker-content"><div class="layout-map-container" id="picker-container"><img src="${imgPath}" class="layout-map-img" /><div id="picker-marker" class="layout-marker hidden"></div><div class="digital-overlay-layer">${digitalMap}</div></div></div>
|
||||
<div class="image-picker-header">
|
||||
<h3>${title} ${isMulti ? `(${currentIdx + 1}/${imagePaths.length})` : ''}</h3>
|
||||
<button class="btn-close-picker" style="background:none; border:none; color:white; font-size:24px; cursor:pointer;">×</button>
|
||||
</div>
|
||||
<div class="image-picker-content">
|
||||
${isMulti ? `
|
||||
<div class="picker-nav prev ${currentIdx === 0 ? 'disabled' : ''}" style="position: absolute; left: 10px; top: 50%; transform: translateY(-50%); z-index: 100; cursor: pointer; background: rgba(0,0,0,0.5); color: white; padding: 20px 10px; border-radius: 5px; font-size: 24px; user-select: none;">◀</div>
|
||||
<div class="picker-nav next ${currentIdx === imagePaths.length - 1 ? 'disabled' : ''}" style="position: absolute; right: 10px; top: 50%; transform: translateY(-50%); z-index: 100; cursor: pointer; background: rgba(0,0,0,0.5); color: white; padding: 20px 10px; border-radius: 5px; font-size: 24px; user-select: none;">▶</div>
|
||||
` : ''}
|
||||
<div class="layout-map-container" id="picker-container">
|
||||
${isHtmlMap
|
||||
? `<iframe src="${imgPath}" style="width:100%; height:100%; border:none; display:block;"></iframe>`
|
||||
: `<img src="${imgPath}" class="layout-map-img" /><div id="picker-marker" class="layout-marker hidden"></div><div class="digital-overlay-layer">${digitalMap}</div>`
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
<div class="image-picker-footer"><button id="btn-picker-cancel" class="btn btn-outline" style="color:white; border-color:white;">취소</button><button id="btn-picker-save" class="btn btn-primary">위치 확정</button></div>`;
|
||||
|
||||
let selectedX = ''; let selectedY = '';
|
||||
const container = overlay.querySelector('#picker-container') as HTMLElement;
|
||||
const marker = overlay.querySelector('#picker-marker') as HTMLElement;
|
||||
container.addEventListener('click', (e) => {
|
||||
const rect = container.getBoundingClientRect();
|
||||
const x = ((e.clientX - rect.left) / rect.width) * 100;
|
||||
const y = ((e.clientY - rect.top) / rect.height) * 100;
|
||||
selectedX = x.toFixed(2); selectedY = y.toFixed(2);
|
||||
marker.style.left = `${selectedX}%`; marker.style.top = `${selectedY}%`; marker.classList.remove('hidden');
|
||||
});
|
||||
overlay.querySelector('.btn-close-picker')?.addEventListener('click', () => overlay.remove());
|
||||
overlay.querySelector('#btn-picker-cancel')?.addEventListener('click', () => overlay.remove());
|
||||
overlay.querySelector('#btn-picker-save')?.addEventListener('click', () => {
|
||||
if (!selectedX || !selectedY) { alert('위치를 선택해주세요.'); return; }
|
||||
setFieldValue('hw-loc_x', selectedX); setFieldValue('hw-loc_y', selectedY);
|
||||
setFieldValue('hw-location_photo', imagePaths[currentIdx]);
|
||||
this.updateMapButtonVisibility(); overlay.remove();
|
||||
});
|
||||
|
||||
if (isMulti) {
|
||||
overlay.querySelector('.picker-nav.prev')?.addEventListener('click', (e) => { e.stopPropagation(); if (currentIdx > 0) { currentIdx--; renderContent(); } });
|
||||
overlay.querySelector('.picker-nav.next')?.addEventListener('click', (e) => { e.stopPropagation(); if (currentIdx < imagePaths.length - 1) { currentIdx++; renderContent(); } });
|
||||
}
|
||||
|
||||
if (isHtmlMap) {
|
||||
// HTML 지도 메시지 리스너
|
||||
const handleMessage = (e: MessageEvent) => {
|
||||
if (e.data.type === 'PICK_LOCATION') {
|
||||
selectedX = e.data.x;
|
||||
selectedY = e.data.y;
|
||||
}
|
||||
};
|
||||
window.addEventListener('message', handleMessage);
|
||||
overlay.querySelector('.btn-close-picker')?.addEventListener('click', () => { window.removeEventListener('message', handleMessage); overlay.remove(); });
|
||||
overlay.querySelector('#btn-picker-cancel')?.addEventListener('click', () => { window.removeEventListener('message', handleMessage); overlay.remove(); });
|
||||
overlay.querySelector('#btn-picker-save')?.addEventListener('click', () => {
|
||||
if (!selectedX || !selectedY) { alert('위치를 선택해주세요.'); return; }
|
||||
setFieldValue('hw-loc_x', selectedX); setFieldValue('hw-loc_y', selectedY);
|
||||
setFieldValue('hw-location_photo', imagePaths[currentIdx]);
|
||||
this.updateMapButtonVisibility();
|
||||
window.removeEventListener('message', handleMessage);
|
||||
overlay.remove();
|
||||
});
|
||||
} else {
|
||||
const container = overlay.querySelector('#picker-container') as HTMLElement;
|
||||
const marker = overlay.querySelector('#picker-marker') as HTMLElement;
|
||||
container.addEventListener('click', (e) => {
|
||||
const rectBound = container.getBoundingClientRect();
|
||||
const clickX = ((e.clientX - rectBound.left) / rectBound.width) * 100;
|
||||
const clickY = ((e.clientY - rectBound.top) / rectBound.height) * 100;
|
||||
|
||||
let snapped = false;
|
||||
overlay.querySelectorAll('rect').forEach(rect => {
|
||||
const rx = parseFloat(rect.getAttribute('x') || '0');
|
||||
const ry = parseFloat(rect.getAttribute('y') || '0');
|
||||
const rw = parseFloat(rect.getAttribute('width') || '0');
|
||||
const rh = parseFloat(rect.getAttribute('height') || '0');
|
||||
|
||||
if (clickX >= rx && clickX <= rx + rw && clickY >= ry && clickY <= ry + rh) {
|
||||
overlay.querySelectorAll('rect').forEach(r => {
|
||||
r.style.fill = 'rgba(30,81,73,0.05)';
|
||||
r.style.stroke = 'rgba(30,81,73,0.2)';
|
||||
r.style.strokeWidth = '0.2';
|
||||
});
|
||||
rect.style.fill = 'rgba(255, 61, 0, 0.4)';
|
||||
rect.style.stroke = '#FF3D00';
|
||||
rect.style.strokeWidth = '0.8';
|
||||
|
||||
selectedX = rx.toFixed(2);
|
||||
selectedY = ry.toFixed(2);
|
||||
|
||||
marker.style.left = `${rx + rw/2}%`;
|
||||
marker.style.top = `${ry + rh/2}%`;
|
||||
marker.classList.remove('hidden');
|
||||
snapped = true;
|
||||
}
|
||||
});
|
||||
|
||||
if (!snapped) {
|
||||
selectedX = '';
|
||||
selectedY = '';
|
||||
marker.classList.add('hidden');
|
||||
overlay.querySelectorAll('rect').forEach(r => {
|
||||
r.style.fill = 'rgba(30,81,73,0.05)';
|
||||
r.style.stroke = 'rgba(30,81,73,0.2)';
|
||||
r.style.strokeWidth = '0.2';
|
||||
});
|
||||
}
|
||||
});
|
||||
overlay.querySelector('.btn-close-picker')?.addEventListener('click', () => overlay.remove());
|
||||
overlay.querySelector('#btn-picker-cancel')?.addEventListener('click', () => overlay.remove());
|
||||
overlay.querySelector('#btn-picker-save')?.addEventListener('click', () => {
|
||||
if (!selectedX || !selectedY) { alert('위치를 선택해주세요.'); return; }
|
||||
setFieldValue('hw-loc_x', selectedX); setFieldValue('hw-loc_y', selectedY);
|
||||
setFieldValue('hw-location_photo', imagePaths[currentIdx]);
|
||||
this.updateMapButtonVisibility(); overlay.remove();
|
||||
});
|
||||
}
|
||||
};
|
||||
renderContent(); document.body.appendChild(overlay);
|
||||
}
|
||||
@@ -706,13 +895,26 @@ class HwAssetModal extends BaseModal {
|
||||
private openImagePreview(imagePath: string, title: string, x: string, y: string) {
|
||||
const overlay = document.createElement('div');
|
||||
overlay.className = 'image-picker-overlay';
|
||||
const digitalMap = this.generateDynamicSVG(imagePath);
|
||||
const isHtmlMap = imagePath.toLowerCase().endsWith('.html');
|
||||
const digitalMap = isHtmlMap ? '' : this.generateDynamicSVG(imagePath);
|
||||
|
||||
// HTML 지도인 경우 좌표를 쿼리 파라미터로 전달
|
||||
const finalPath = isHtmlMap ? `${imagePath}?markerX=${x}&markerY=${y}` : imagePath;
|
||||
|
||||
overlay.innerHTML = `
|
||||
<div class="image-picker-header"><h3>${title}</h3><button class="btn-close-picker" style="background:none; border:none; color:white; font-size:24px; cursor:pointer;">×</button></div>
|
||||
<div class="image-picker-content"><div class="layout-map-container readonly"><img src="${imagePath}" class="layout-map-img" /><div id="preview-marker" class="layout-marker pulse-marker" style="left:${x}%; top:${y}%;"></div><div class="digital-overlay-layer">${digitalMap}</div></div></div>
|
||||
<div class="image-picker-content">
|
||||
<div class="layout-map-container readonly">
|
||||
${isHtmlMap
|
||||
? `<iframe src="${finalPath}" style="width:100%; height:100%; border:none; display:block;"></iframe>`
|
||||
: `<img src="${imagePath}" class="layout-map-img" /><div id="preview-marker" class="layout-marker pulse-marker" style="left:${x}%; top:${y}%;"></div><div class="digital-overlay-layer">${digitalMap}</div>`
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
<div class="image-picker-footer"><button id="btn-preview-close" class="btn btn-primary">확인</button></div>`;
|
||||
|
||||
document.body.appendChild(overlay);
|
||||
if (digitalMap) {
|
||||
if (!isHtmlMap && digitalMap) {
|
||||
const curX = parseFloat(x || '0'); const curY = parseFloat(y || '0');
|
||||
overlay.querySelectorAll('rect').forEach(rect => {
|
||||
const sx = parseFloat(rect.getAttribute('x') || '0');
|
||||
@@ -733,9 +935,51 @@ class HwAssetModal extends BaseModal {
|
||||
private renderHistory(assetId: string) {
|
||||
const container = document.getElementById('hw-history-list');
|
||||
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('');
|
||||
|
||||
// state.masterData.logs에서 해당 자산의 이력 필터링 (최신순)
|
||||
const logs = (state.masterData.logs || [])
|
||||
.filter(l => l.asset_id === assetId)
|
||||
.sort((a, b) => new Date(b.created_at || b.log_date || '').getTime() - new Date(a.created_at || a.log_date || '').getTime());
|
||||
|
||||
if (logs.length === 0) {
|
||||
container.innerHTML = '<div class="empty-history">기록된 변동 이력이 없습니다.</div>';
|
||||
return;
|
||||
}
|
||||
|
||||
container.innerHTML = logs.map(l => {
|
||||
let eventTag = '기타';
|
||||
let tagClass = 'tag-default';
|
||||
let itemClass = '';
|
||||
|
||||
switch(l.event_type) {
|
||||
case 'DEPT_CHANGE':
|
||||
eventTag = '조직'; tagClass = 'tag-dept'; itemClass = 'evt-dept';
|
||||
break;
|
||||
case 'USER_CHANGE':
|
||||
eventTag = '사용자'; tagClass = 'tag-user'; itemClass = 'evt-user';
|
||||
break;
|
||||
case 'ROLE_CHANGE':
|
||||
eventTag = '용도'; tagClass = 'tag-role'; itemClass = 'evt-role';
|
||||
break;
|
||||
case 'STATUS_CHANGE':
|
||||
eventTag = '상태'; tagClass = 'tag-status'; itemClass = 'evt-status';
|
||||
break;
|
||||
}
|
||||
|
||||
// 화살표 기호(➔)를 사용하여 변경 사항 강조
|
||||
const formattedDetails = (l.details || '').replace(' -> ', ' <span class="history-arrow">➔</span> ');
|
||||
|
||||
return `
|
||||
<div class="history-item ${itemClass}">
|
||||
<div class="history-header-row">
|
||||
<span class="history-tag ${tagClass}">${eventTag}</span>
|
||||
<span class="history-date">${l.log_date || ''}</span>
|
||||
</div>
|
||||
<span class="history-user">${l.log_user || '시스템'}</span>
|
||||
<div class="history-details">${formattedDetails}</div>
|
||||
</div>
|
||||
`;
|
||||
}).join('');
|
||||
}
|
||||
|
||||
private getCategoryKey(asset: any): string {
|
||||
@@ -750,6 +994,77 @@ class HwAssetModal extends BaseModal {
|
||||
if (cat === 'PC부품') return 'pcParts';
|
||||
return (cat === 'PC' || code.startsWith('PC')) ? 'pc' : 'officeSupplies';
|
||||
}
|
||||
|
||||
private async fetchMasterComponents(): Promise<void> {
|
||||
try {
|
||||
const res = await fetch(`http://${location.hostname}:3000/api/hardware-components`);
|
||||
this.masterComponents = await res.json();
|
||||
} catch (err) {
|
||||
console.error('Failed to fetch master components:', err);
|
||||
}
|
||||
}
|
||||
|
||||
private bindAutocomplete(inputId: string, autocompleteId: string, category: string) {
|
||||
const input = document.getElementById(inputId) as HTMLInputElement;
|
||||
const list = document.getElementById(autocompleteId) as HTMLDivElement;
|
||||
if (!input || !list) return;
|
||||
|
||||
const showList = (filterText: string = '') => {
|
||||
if (!this.isEditMode) return;
|
||||
const items = this.masterComponents.filter(c => c.category === category);
|
||||
const filtered = filterText
|
||||
? items.filter(c => c.component_name.toLowerCase().includes(filterText.toLowerCase()))
|
||||
: items;
|
||||
|
||||
if (filtered.length === 0) {
|
||||
list.innerHTML = '<div class="autocomplete-item" style="color: #94a3b8; cursor: default;">검색 결과 없음</div>';
|
||||
} else {
|
||||
list.innerHTML = filtered.map(c => `<div class="autocomplete-item" data-val="${c.component_name}">${c.component_name}</div>`).join('');
|
||||
}
|
||||
list.classList.remove('hidden');
|
||||
};
|
||||
|
||||
input.addEventListener('focus', () => {
|
||||
showList(input.value);
|
||||
});
|
||||
|
||||
input.addEventListener('input', () => {
|
||||
showList(input.value);
|
||||
});
|
||||
|
||||
// 아이템 클릭 이벤트 위임
|
||||
list.addEventListener('mousedown', (e) => {
|
||||
const item = (e.target as HTMLElement).closest('.autocomplete-item');
|
||||
if (item && item.getAttribute('data-val')) {
|
||||
input.value = item.getAttribute('data-val') || '';
|
||||
list.classList.add('hidden');
|
||||
this.updatePcGradeBadge(); // 뱃지 즉시 업데이트
|
||||
}
|
||||
});
|
||||
|
||||
// 아웃사이드 클릭 시 닫기
|
||||
document.addEventListener('mousedown', (e) => {
|
||||
if (e.target !== input && !list.contains(e.target as Node)) {
|
||||
list.classList.add('hidden');
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
private updatePcGradeBadge(): void {
|
||||
const cpu = (document.getElementById('hw-cpu') as HTMLInputElement)?.value || '';
|
||||
const ram = (document.getElementById('hw-ram') as HTMLInputElement)?.value || '';
|
||||
const gpu = (document.getElementById('hw-gpu') as HTMLInputElement)?.value || '';
|
||||
const date = (document.getElementById('hw-purchase_date') as HTMLInputElement)?.value || '';
|
||||
|
||||
const score = calculatePcScoreDeductive(cpu, ram, gpu, date);
|
||||
const grade = getPcGrade(score);
|
||||
|
||||
const badge = document.getElementById('hw-pc-grade-badge');
|
||||
if (badge) {
|
||||
badge.textContent = `${grade.name} (${score}점)`;
|
||||
badge.className = `badge ${grade.class}`;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export const hwModal = new HwAssetModal();
|
||||
|
||||
625
src/components/Modal/PCFlowModal.ts
Normal file
625
src/components/Modal/PCFlowModal.ts
Normal file
@@ -0,0 +1,625 @@
|
||||
import { state, loadMasterDataFromDB } from '../../core/state';
|
||||
import { createIcons, Search, Monitor, RefreshCw } from 'lucide';
|
||||
import { API_BASE_URL } from '../../core/utils';
|
||||
|
||||
export class PCFlowModal {
|
||||
private static instance: PCFlowModal | null = null;
|
||||
|
||||
private modalEl: HTMLElement | null = null;
|
||||
private currentFlowType: 'checkout' | 'return' | 'move' = 'checkout';
|
||||
|
||||
// Selected state
|
||||
private selectedUser: any = null;
|
||||
private selectedTargetUser: any = null;
|
||||
private selectedPC: any = null;
|
||||
|
||||
private constructor() {}
|
||||
|
||||
public static getInstance(): PCFlowModal {
|
||||
if (!PCFlowModal.instance) {
|
||||
PCFlowModal.instance = new PCFlowModal();
|
||||
}
|
||||
return PCFlowModal.instance;
|
||||
}
|
||||
|
||||
public init(onSave: () => void) {
|
||||
if (document.getElementById('pc-flow-modal')) return;
|
||||
|
||||
// Inject HTML
|
||||
document.body.insertAdjacentHTML('beforeend', this.renderHTML());
|
||||
|
||||
this.modalEl = document.getElementById('pc-flow-modal');
|
||||
this.setupEventListeners(onSave);
|
||||
|
||||
// Set default date to today
|
||||
const dateInput = document.getElementById('pc-flow-date') as HTMLInputElement;
|
||||
if (dateInput) {
|
||||
dateInput.value = new Date().toISOString().split('T')[0];
|
||||
}
|
||||
|
||||
createIcons({ icons: { Search, Monitor, RefreshCw } });
|
||||
}
|
||||
|
||||
public open() {
|
||||
this.resetState();
|
||||
if (this.modalEl) {
|
||||
this.modalEl.classList.remove('hidden');
|
||||
}
|
||||
this.updateUI();
|
||||
}
|
||||
|
||||
public close() {
|
||||
if (this.modalEl) {
|
||||
this.modalEl.classList.add('hidden');
|
||||
}
|
||||
}
|
||||
|
||||
private resetState() {
|
||||
this.selectedUser = null;
|
||||
this.selectedTargetUser = null;
|
||||
this.selectedPC = null;
|
||||
this.currentFlowType = 'checkout';
|
||||
|
||||
const radioCheckout = document.querySelector('input[name="flow-type"][value="checkout"]') as HTMLInputElement;
|
||||
if (radioCheckout) radioCheckout.checked = true;
|
||||
|
||||
// Reset text fields
|
||||
const userSearch = document.getElementById('pc-flow-user-search') as HTMLInputElement;
|
||||
if (userSearch) userSearch.value = '';
|
||||
|
||||
const targetUserSearch = document.getElementById('pc-flow-target-user-search') as HTMLInputElement;
|
||||
if (targetUserSearch) targetUserSearch.value = '';
|
||||
|
||||
const stockSearch = document.getElementById('pc-flow-stock-search') as HTMLInputElement;
|
||||
if (stockSearch) stockSearch.value = '';
|
||||
|
||||
const details = document.getElementById('pc-flow-details') as HTMLTextAreaElement;
|
||||
if (details) details.value = '';
|
||||
}
|
||||
|
||||
private setupEventListeners(onSave: () => void) {
|
||||
const btnClose = document.getElementById('btn-close-pc-flow-modal');
|
||||
const btnCancel = document.getElementById('btn-cancel-pc-flow-modal');
|
||||
const btnSubmit = document.getElementById('btn-submit-pc-flow');
|
||||
|
||||
btnClose?.addEventListener('click', () => this.close());
|
||||
btnCancel?.addEventListener('click', () => this.close());
|
||||
|
||||
// Flow Type Radio Buttons
|
||||
const labels = document.querySelectorAll('.flow-type-label');
|
||||
labels.forEach(label => {
|
||||
const radio = label.querySelector('input[name="flow-type"]') as HTMLInputElement;
|
||||
label.addEventListener('click', () => {
|
||||
labels.forEach(l => l.classList.remove('active'));
|
||||
label.classList.add('active');
|
||||
radio.checked = true;
|
||||
this.currentFlowType = radio.value as any;
|
||||
|
||||
// Reset selected PC when switching flow types
|
||||
this.selectedPC = null;
|
||||
this.updateUI();
|
||||
});
|
||||
});
|
||||
|
||||
// 1. Source User Autocomplete Search
|
||||
const userSearch = document.getElementById('pc-flow-user-search') as HTMLInputElement;
|
||||
const userSuggestions = document.getElementById('pc-flow-user-suggestions')!;
|
||||
|
||||
userSearch?.addEventListener('input', () => {
|
||||
const query = userSearch.value.trim().toLowerCase();
|
||||
if (!query) {
|
||||
userSuggestions.classList.add('hidden');
|
||||
return;
|
||||
}
|
||||
|
||||
const users = state.masterData.users || [];
|
||||
const filtered = users.filter((u: any) =>
|
||||
(u.user_name && u.user_name.toLowerCase().includes(query)) ||
|
||||
(u.dept_name && u.dept_name.toLowerCase().includes(query)) ||
|
||||
(u.emp_no && u.emp_no.toString().includes(query))
|
||||
);
|
||||
|
||||
const uniqueFiltered: any[] = [];
|
||||
const seen = new Set();
|
||||
filtered.forEach((u: any) => {
|
||||
const key = u.emp_no || u.user_name;
|
||||
if (!seen.has(key)) {
|
||||
seen.add(key);
|
||||
uniqueFiltered.push(u);
|
||||
}
|
||||
});
|
||||
|
||||
this.renderUserSuggestions(uniqueFiltered, userSuggestions, (user) => {
|
||||
this.selectedUser = user;
|
||||
userSearch.value = `${user.user_name} (${user.dept_name} / 사번:${user.emp_no || '-'})`;
|
||||
userSuggestions.classList.add('hidden');
|
||||
|
||||
// Automatically populate details if return or move
|
||||
if (this.currentFlowType === 'return' || this.currentFlowType === 'move') {
|
||||
this.selectedPC = null; // Reset selection
|
||||
}
|
||||
this.updateUI();
|
||||
});
|
||||
});
|
||||
|
||||
// Close suggestion overlays on clicking outside
|
||||
document.addEventListener('click', (e) => {
|
||||
const target = e.target as HTMLElement;
|
||||
if (!target.closest('#pc-flow-user-search') && !target.closest('#pc-flow-user-suggestions')) {
|
||||
userSuggestions.classList.add('hidden');
|
||||
}
|
||||
if (!target.closest('#pc-flow-target-user-search') && !target.closest('#pc-flow-target-user-suggestions')) {
|
||||
const targetSuggestions = document.getElementById('pc-flow-target-user-suggestions');
|
||||
targetSuggestions?.classList.add('hidden');
|
||||
}
|
||||
if (!target.closest('#pc-flow-stock-search') && !target.closest('#pc-flow-stock-suggestions')) {
|
||||
const stockSuggestions = document.getElementById('pc-flow-stock-suggestions');
|
||||
stockSuggestions?.classList.add('hidden');
|
||||
}
|
||||
});
|
||||
|
||||
// 2. Target User Autocomplete Search (For Moves)
|
||||
const targetUserSearch = document.getElementById('pc-flow-target-user-search') as HTMLInputElement;
|
||||
const targetSuggestions = document.getElementById('pc-flow-target-user-suggestions')!;
|
||||
|
||||
targetUserSearch?.addEventListener('input', () => {
|
||||
const query = targetUserSearch.value.trim().toLowerCase();
|
||||
if (!query) {
|
||||
targetSuggestions.classList.add('hidden');
|
||||
return;
|
||||
}
|
||||
|
||||
const users = state.masterData.users || [];
|
||||
const filtered = users.filter((u: any) =>
|
||||
(u.user_name && u.user_name.toLowerCase().includes(query)) ||
|
||||
(u.dept_name && u.dept_name.toLowerCase().includes(query)) ||
|
||||
(u.emp_no && u.emp_no.toString().includes(query))
|
||||
);
|
||||
|
||||
const uniqueFiltered: any[] = [];
|
||||
const seen = new Set();
|
||||
filtered.forEach((u: any) => {
|
||||
const key = u.emp_no || u.user_name;
|
||||
if (!seen.has(key)) {
|
||||
seen.add(key);
|
||||
uniqueFiltered.push(u);
|
||||
}
|
||||
});
|
||||
|
||||
this.renderUserSuggestions(uniqueFiltered, targetSuggestions, (user) => {
|
||||
this.selectedTargetUser = user;
|
||||
targetUserSearch.value = `${user.user_name} (${user.dept_name} / 사번:${user.emp_no || '-'})`;
|
||||
targetSuggestions.classList.add('hidden');
|
||||
this.updateUI();
|
||||
});
|
||||
});
|
||||
|
||||
// 3. Stock PC Autocomplete Search (For Checkout)
|
||||
const stockSearch = document.getElementById('pc-flow-stock-search') as HTMLInputElement;
|
||||
const stockSuggestions = document.getElementById('pc-flow-stock-suggestions')!;
|
||||
|
||||
const showStockSuggestions = () => {
|
||||
const query = stockSearch.value.trim().toLowerCase();
|
||||
|
||||
// Filter available PCs (category PC, status '대기', '미할당', or '재고')
|
||||
const pcs = state.masterData.pc || [];
|
||||
const filtered = pcs.filter((p: any) => {
|
||||
const status = (p.hw_status || '').trim();
|
||||
const matchesQuery = !query ||
|
||||
(p.asset_code && p.asset_code.toLowerCase().includes(query)) ||
|
||||
(p.model_name && p.model_name.toLowerCase().includes(query)) ||
|
||||
(p.cpu && p.cpu.toLowerCase().includes(query));
|
||||
|
||||
return (status === '대기' || status === '미할당' || status === '재고') && matchesQuery;
|
||||
});
|
||||
|
||||
this.renderPCSuggestions(filtered, stockSuggestions, (pc) => {
|
||||
this.selectedPC = pc;
|
||||
stockSearch.value = `${pc.asset_code} - ${pc.model_name}`;
|
||||
stockSuggestions.classList.add('hidden');
|
||||
this.updateUI();
|
||||
});
|
||||
};
|
||||
|
||||
stockSearch?.addEventListener('input', showStockSuggestions);
|
||||
stockSearch?.addEventListener('focus', showStockSuggestions);
|
||||
stockSearch?.addEventListener('click', showStockSuggestions);
|
||||
|
||||
// 4. Submit Transaction
|
||||
btnSubmit?.addEventListener('click', async () => {
|
||||
if (!this.validateInputs()) return;
|
||||
|
||||
const dateVal = (document.getElementById('pc-flow-date') as HTMLInputElement).value;
|
||||
const detailsVal = (document.getElementById('pc-flow-details') as HTMLTextAreaElement).value.trim();
|
||||
const loginUser = state.currentUserRole === 'admin' ? '관리자' : '실무담당자';
|
||||
|
||||
// Build Details Message as JSON
|
||||
const logData = {
|
||||
type: this.currentFlowType,
|
||||
user: this.selectedUser ? this.selectedUser.user_name : '',
|
||||
dept: this.selectedUser ? this.selectedUser.dept_name : '',
|
||||
targetUser: this.selectedTargetUser ? this.selectedTargetUser.user_name : '',
|
||||
targetDept: this.selectedTargetUser ? this.selectedTargetUser.dept_name : '',
|
||||
assetCode: this.selectedPC ? this.selectedPC.asset_code : '',
|
||||
memo: detailsVal
|
||||
};
|
||||
const finalDetails = JSON.stringify(logData);
|
||||
|
||||
const payload: any = {
|
||||
action: this.currentFlowType,
|
||||
assetId: this.selectedPC.id,
|
||||
date: dateVal,
|
||||
details: finalDetails,
|
||||
manager: loginUser
|
||||
};
|
||||
|
||||
if (this.currentFlowType === 'checkout') {
|
||||
payload.userName = this.selectedUser.user_name;
|
||||
payload.dept = this.selectedUser.dept_name;
|
||||
payload.empNo = this.selectedUser.emp_no;
|
||||
payload.position = this.selectedUser.position || '사원';
|
||||
} else if (this.currentFlowType === 'move') {
|
||||
payload.userName = this.selectedTargetUser.user_name;
|
||||
payload.dept = this.selectedTargetUser.dept_name;
|
||||
payload.empNo = this.selectedTargetUser.emp_no;
|
||||
payload.position = this.selectedTargetUser.position || '사원';
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await fetch(`${API_BASE_URL}/api/pc/flow`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(payload)
|
||||
});
|
||||
|
||||
if (response.ok) {
|
||||
alert('PC 이동/반납 처리가 완료되었습니다.');
|
||||
this.close();
|
||||
onSave(); // Refresh views
|
||||
} else {
|
||||
const errData = await response.json();
|
||||
alert(`오류 발생: ${errData.error || '처리 실패'}`);
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('API Error:', err);
|
||||
alert('서버 전송 중 오류가 발생했습니다.');
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
private validateInputs(): boolean {
|
||||
if (this.currentFlowType === 'checkout') {
|
||||
if (!this.selectedUser) { alert('대상 사원을 선택해주세요.'); return false; }
|
||||
if (!this.selectedPC) { alert('불출할 재고 PC를 선택해주세요.'); return false; }
|
||||
} else if (this.currentFlowType === 'return') {
|
||||
if (!this.selectedUser) { alert('반납 대상 사원을 선택해주세요.'); return false; }
|
||||
if (!this.selectedPC) { alert('반납할 PC 자산을 선택해주세요.'); return false; }
|
||||
} else if (this.currentFlowType === 'move') {
|
||||
if (!this.selectedUser) { alert('인계 사원을 선택해주세요.'); return false; }
|
||||
if (!this.selectedPC) { alert('이동할 PC 자산을 선택해주세요.'); return false; }
|
||||
if (!this.selectedTargetUser) { alert('인수 사원을 선택해주세요.'); return false; }
|
||||
if (this.selectedUser.emp_no === this.selectedTargetUser.emp_no) {
|
||||
alert('인계자와 인수자는 동일할 수 없습니다.');
|
||||
return false;
|
||||
}
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
private renderUserSuggestions(users: any[], container: HTMLElement, onSelect: (user: any) => void) {
|
||||
container.innerHTML = '';
|
||||
if (users.length === 0) {
|
||||
container.innerHTML = '<div style="padding: 10px; color: var(--text-muted); font-size: 13px;">일치하는 사원이 없습니다.</div>';
|
||||
container.classList.remove('hidden');
|
||||
return;
|
||||
}
|
||||
|
||||
users.forEach(u => {
|
||||
const item = document.createElement('div');
|
||||
item.style.padding = '8px 12px';
|
||||
item.style.cursor = 'pointer';
|
||||
item.style.fontSize = '13px';
|
||||
item.style.borderBottom = '1px solid #F3F4F6';
|
||||
item.className = 'suggestion-item';
|
||||
item.innerHTML = `
|
||||
<div style="font-weight: 700; color: var(--text-main);">${u.user_name}</div>
|
||||
<div style="font-size: 11px; color: var(--text-muted); display: flex; gap: 8px;">
|
||||
<span>부서: ${u.dept_name}</span>
|
||||
<span>|</span>
|
||||
<span>사번: ${u.emp_no || '-'}</span>
|
||||
</div>
|
||||
`;
|
||||
item.addEventListener('click', () => onSelect(u));
|
||||
container.appendChild(item);
|
||||
});
|
||||
container.classList.remove('hidden');
|
||||
}
|
||||
|
||||
private renderPCSuggestions(pcs: any[], container: HTMLElement, onSelect: (pc: any) => void) {
|
||||
container.innerHTML = '';
|
||||
if (pcs.length === 0) {
|
||||
container.innerHTML = '<div style="padding: 10px; color: var(--text-muted); font-size: 13px;">불출 가능한 대기 PC 재고가 없습니다.</div>';
|
||||
container.classList.remove('hidden');
|
||||
return;
|
||||
}
|
||||
|
||||
pcs.forEach(p => {
|
||||
const item = document.createElement('div');
|
||||
item.style.padding = '8px 12px';
|
||||
item.style.cursor = 'pointer';
|
||||
item.style.fontSize = '13px';
|
||||
item.style.borderBottom = '1px solid #F3F4F6';
|
||||
item.className = 'suggestion-item';
|
||||
item.innerHTML = `
|
||||
<div style="font-weight: 700; color: var(--primary-color);">${p.asset_code} (${p.model_name || '모델명 없음'})</div>
|
||||
<div style="font-size: 11px; color: var(--text-muted);">
|
||||
사양: CPU ${p.cpu || '-'} / RAM ${p.ram || '-'} / 위치: ${p.location || '-'}
|
||||
</div>
|
||||
`;
|
||||
item.addEventListener('click', () => onSelect(p));
|
||||
container.appendChild(item);
|
||||
});
|
||||
container.classList.remove('hidden');
|
||||
}
|
||||
|
||||
private updateUI() {
|
||||
// 1. Hide/Show dynamic sections based on flow type
|
||||
const stockContainer = document.getElementById('stock-pc-search-container')!;
|
||||
const targetUserContainer = document.getElementById('target-user-search-container')!;
|
||||
const userPcsContainer = document.getElementById('user-pcs-container')!;
|
||||
const labelStep2 = document.getElementById('user-search-label')!;
|
||||
|
||||
if (this.currentFlowType === 'checkout') {
|
||||
stockContainer.classList.remove('hidden');
|
||||
targetUserContainer.classList.add('hidden');
|
||||
userPcsContainer.classList.add('hidden');
|
||||
labelStep2.textContent = '2. 불출 대상 사원 검색';
|
||||
} else if (this.currentFlowType === 'return') {
|
||||
stockContainer.classList.add('hidden');
|
||||
targetUserContainer.classList.add('hidden');
|
||||
userPcsContainer.classList.remove('hidden');
|
||||
labelStep2.textContent = '2. 반납 대상 사원 검색';
|
||||
} else if (this.currentFlowType === 'move') {
|
||||
stockContainer.classList.add('hidden');
|
||||
targetUserContainer.classList.remove('hidden');
|
||||
userPcsContainer.classList.remove('hidden');
|
||||
labelStep2.textContent = '2. 인계 사원 검색';
|
||||
}
|
||||
|
||||
// 2. Update summary panels on the right
|
||||
const summaryUserName = document.getElementById('summary-user-name')!;
|
||||
const summaryUserDept = document.getElementById('summary-user-dept')!;
|
||||
if (this.selectedUser) {
|
||||
summaryUserName.textContent = this.selectedUser.user_name;
|
||||
summaryUserDept.textContent = `${this.selectedUser.dept_name} / 사번: ${this.selectedUser.emp_no || '-'}`;
|
||||
} else {
|
||||
summaryUserName.textContent = '선택된 사원 없음';
|
||||
summaryUserDept.textContent = '-';
|
||||
}
|
||||
|
||||
const summaryTargetCard = document.getElementById('summary-target-user-card')!;
|
||||
const summaryTargetUserName = document.getElementById('summary-target-user-name')!;
|
||||
const summaryTargetUserDept = document.getElementById('summary-target-user-dept')!;
|
||||
if (this.currentFlowType === 'move') {
|
||||
summaryTargetCard.classList.remove('hidden');
|
||||
if (this.selectedTargetUser) {
|
||||
summaryTargetUserName.textContent = this.selectedTargetUser.user_name;
|
||||
summaryTargetUserDept.textContent = `${this.selectedTargetUser.dept_name} / 사번: ${this.selectedTargetUser.emp_no || '-'}`;
|
||||
} else {
|
||||
summaryTargetUserName.textContent = '선택된 사원 없음';
|
||||
summaryTargetUserDept.textContent = '-';
|
||||
}
|
||||
} else {
|
||||
summaryTargetCard.classList.add('hidden');
|
||||
}
|
||||
|
||||
const summaryPcCode = document.getElementById('summary-pc-code')!;
|
||||
const summaryPcModel = document.getElementById('summary-pc-model')!;
|
||||
if (this.selectedPC) {
|
||||
summaryPcCode.textContent = this.selectedPC.asset_code;
|
||||
summaryPcModel.textContent = `${this.selectedPC.model_name || '모델명 없음'} (${this.selectedPC.cpu || '-'} / ${this.selectedPC.ram || '-'})`;
|
||||
} else {
|
||||
summaryPcCode.textContent = '선택된 PC 없음';
|
||||
summaryPcModel.textContent = '-';
|
||||
}
|
||||
|
||||
// 3. Render user's active PCs list on the right (For Return & Move)
|
||||
const userPcsList = document.getElementById('user-pcs-list')!;
|
||||
if (this.selectedUser && (this.currentFlowType === 'return' || this.currentFlowType === 'move')) {
|
||||
const allPcs = state.masterData.pc || [];
|
||||
const userPcs = allPcs.filter((p: any) =>
|
||||
(p.emp_no && p.emp_no.toString() === this.selectedUser.emp_no?.toString()) ||
|
||||
(p.user_current && p.user_current === this.selectedUser.user_name)
|
||||
);
|
||||
|
||||
if (userPcs.length === 0) {
|
||||
userPcsList.innerHTML = '<div style="font-size: 12px; color: var(--text-muted); padding: 8px 0;">이 사용자가 소유한 PC 자산이 없습니다.</div>';
|
||||
} else {
|
||||
userPcsList.innerHTML = userPcs.map(p => {
|
||||
const isSelected = this.selectedPC && this.selectedPC.id === p.id;
|
||||
return `
|
||||
<div class="user-pc-item ${isSelected ? 'selected' : ''}" data-id="${p.id}" style="padding: 10px; border: 1px solid ${isSelected ? 'var(--primary-color)' : 'var(--border-color)'}; border-radius: 4px; cursor: pointer; background: ${isSelected ? 'var(--primary-light)' : 'white'}; transition: all 0.2s;">
|
||||
<div style="font-weight: 700; font-size: 13px; color: ${isSelected ? 'var(--primary-color)' : 'var(--text-main)'};">${p.asset_code}</div>
|
||||
<div style="font-size: 11px; color: var(--text-muted); margin-top: 2px;">
|
||||
${p.model_name || '모델명 없음'} | CPU: ${p.cpu || '-'} | RAM: ${p.ram || '-'}
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}).join('');
|
||||
|
||||
// Bind clicks to list items
|
||||
userPcsList.querySelectorAll('.user-pc-item').forEach(item => {
|
||||
item.addEventListener('click', () => {
|
||||
const pcId = item.getAttribute('data-id');
|
||||
const foundPC = userPcs.find(p => p.id === pcId);
|
||||
if (foundPC) {
|
||||
this.selectedPC = foundPC;
|
||||
this.updateUI();
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
} else {
|
||||
userPcsList.innerHTML = '';
|
||||
}
|
||||
}
|
||||
|
||||
private renderHTML(): string {
|
||||
const overlayStyle = `
|
||||
position: fixed; top: 0; left: 0; right: 0; bottom: 0;
|
||||
background: rgba(0, 0, 0, 0.4); display: flex; align-items: center; justify-content: center;
|
||||
z-index: 1000; transition: opacity 0.3s;
|
||||
`;
|
||||
const contentStyle = `
|
||||
background: white; border-radius: 12px; box-shadow: 0 10px 25px rgba(0, 0, 0, 0.15);
|
||||
overflow: hidden; max-height: 90vh; width: 950px; display: flex; flex-direction: column;
|
||||
`;
|
||||
const labelStyle = 'display: block; font-size: 13px; font-weight: 700; color: var(--text-muted); margin-bottom: 8px;';
|
||||
const inputStyle = 'width: 100%; height: 38px; padding: 0 12px; border: 1px solid var(--border-color); border-radius: 4px; font-size: 13px; outline: none; box-sizing: border-box;';
|
||||
const inputWithIconStyle = 'width: 100%; height: 38px; padding: 0 12px 0 36px; border: 1px solid var(--border-color); border-radius: 4px; font-size: 13px; outline: none; box-sizing: border-box;';
|
||||
|
||||
return `
|
||||
<div id="pc-flow-modal" class="modal-overlay hidden" style="${overlayStyle}">
|
||||
<div class="modal-content" style="${contentStyle}">
|
||||
|
||||
<div class="modal-header" style="background: var(--primary-color); padding: 16px 24px; display: flex; justify-content: space-between; align-items: center; border-bottom: 1px solid var(--border-color);">
|
||||
<h2 style="margin: 0; font-size: 18px; font-weight: 800; color: white; display: flex; align-items: center; gap: 8px;">
|
||||
<i data-lucide="refresh-cw"></i> PC 이동/반납 (불출/반납/이동)
|
||||
</h2>
|
||||
<button id="btn-close-pc-flow-modal" class="btn-icon" aria-label="닫기" style="font-size: 28px; color: white; background: none; border: none; cursor: pointer; line-height: 1;">×</button>
|
||||
</div>
|
||||
|
||||
<div class="modal-body" style="padding: 24px; overflow-y: auto; display: flex; gap: 24px;">
|
||||
<!-- 왼쪽 영역: 입력 폼 -->
|
||||
<div style="flex: 1.2; display: flex; flex-direction: column; gap: 20px;">
|
||||
|
||||
<!-- 1. 처리 유형 -->
|
||||
<div>
|
||||
<label style="${labelStyle}">1. 처리 유형 선택</label>
|
||||
<div style="display: flex; gap: 12px;">
|
||||
<label class="flow-type-label active" style="flex: 1; display: flex; align-items: center; justify-content: center; gap: 8px; padding: 12px; border: 1px solid var(--border-color); border-radius: 6px; cursor: pointer; font-size: 14px; font-weight: 600;">
|
||||
<input type="radio" name="flow-type" value="checkout" checked style="display:none;" />
|
||||
불출 (지급)
|
||||
</label>
|
||||
<label class="flow-type-label" style="flex: 1; display: flex; align-items: center; justify-content: center; gap: 8px; padding: 12px; border: 1px solid var(--border-color); border-radius: 6px; cursor: pointer; font-size: 14px; font-weight: 600;">
|
||||
<input type="radio" name="flow-type" value="return" style="display:none;" />
|
||||
입고 (반납)
|
||||
</label>
|
||||
<label class="flow-type-label" style="flex: 1; display: flex; align-items: center; justify-content: center; gap: 8px; padding: 12px; border: 1px solid var(--border-color); border-radius: 6px; cursor: pointer; font-size: 14px; font-weight: 600;">
|
||||
<input type="radio" name="flow-type" value="move" style="display:none;" />
|
||||
이동 (이관)
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 2. 대상 사용자 검색 -->
|
||||
<div style="position: relative;">
|
||||
<label id="user-search-label" style="${labelStyle}">2. 대상 사원 검색</label>
|
||||
<div style="position: relative; display: flex; align-items: center;">
|
||||
<input type="text" id="pc-flow-user-search" placeholder="사원명, 부서, 사번 검색..." style="${inputWithIconStyle}" />
|
||||
<i data-lucide="search" style="position: absolute; left: 10px; width: 16px; height: 16px; color: var(--text-muted);"></i>
|
||||
</div>
|
||||
<div id="pc-flow-user-suggestions" class="hidden" style="position: absolute; top: 100%; left: 0; right: 0; max-height: 200px; overflow-y: auto; background: white; border: 1px solid var(--border-color); border-radius: 4px; box-shadow: 0 4px 12px rgba(0,0,0,0.1); z-index: 1000; margin-top: 4px;"></div>
|
||||
</div>
|
||||
|
||||
<!-- 3. 새 인수자 검색 (이동 시 노출) -->
|
||||
<div id="target-user-search-container" class="hidden" style="position: relative;">
|
||||
<label style="${labelStyle}">새 인수 사원 검색</label>
|
||||
<div style="position: relative; display: flex; align-items: center;">
|
||||
<input type="text" id="pc-flow-target-user-search" placeholder="사원명, 부서, 사번 검색..." style="${inputWithIconStyle}" />
|
||||
<i data-lucide="search" style="position: absolute; left: 10px; width: 16px; height: 16px; color: var(--text-muted);"></i>
|
||||
</div>
|
||||
<div id="pc-flow-target-user-suggestions" class="hidden" style="position: absolute; top: 100%; left: 0; right: 0; max-height: 200px; overflow-y: auto; background: white; border: 1px solid var(--border-color); border-radius: 4px; box-shadow: 0 4px 12px rgba(0,0,0,0.1); z-index: 1000; margin-top: 4px;"></div>
|
||||
</div>
|
||||
|
||||
<!-- 4. 재고 PC 검색 (불출 시 노출) -->
|
||||
<div id="stock-pc-search-container" style="position: relative;">
|
||||
<label style="${labelStyle}">3. 불출할 재고 PC 선택</label>
|
||||
<div style="position: relative; display: flex; align-items: center;">
|
||||
<input type="text" id="pc-flow-stock-search" placeholder="자산코드 또는 모델명 검색..." style="${inputWithIconStyle}" />
|
||||
<i data-lucide="monitor" style="position: absolute; left: 10px; width: 16px; height: 16px; color: var(--text-muted);"></i>
|
||||
</div>
|
||||
<div id="pc-flow-stock-suggestions" class="hidden" style="position: absolute; top: 100%; left: 0; right: 0; max-height: 200px; overflow-y: auto; background: white; border: 1px solid var(--border-color); border-radius: 4px; box-shadow: 0 4px 12px rgba(0,0,0,0.1); z-index: 1000; margin-top: 4px;"></div>
|
||||
</div>
|
||||
|
||||
<!-- 5. 상세 공통 입력 -->
|
||||
<div style="display: flex; gap: 16px;">
|
||||
<div style="flex: 1;">
|
||||
<label style="${labelStyle.replace('margin-bottom: 8px;', 'margin-bottom: 6px;')}">처리 일자</label>
|
||||
<input type="date" id="pc-flow-date" style="${inputStyle}" />
|
||||
</div>
|
||||
<div style="flex: 2;">
|
||||
<label style="${labelStyle.replace('margin-bottom: 8px;', 'margin-bottom: 6px;')}">상세 사유</label>
|
||||
<textarea id="pc-flow-details" rows="2" placeholder="미입력 시 기본 문구로 자동 입력됩니다." style="width: 100%; padding: 10px; border: 1px solid var(--border-color); border-radius: 4px; font-family: inherit; font-size: 13px; resize: none; box-sizing: border-box; outline: none;"></textarea>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
<!-- 오른쪽 영역: 선택 요약 & 사원 소유 자산 목록 -->
|
||||
<div style="flex: 0.8; border-left: 1px solid var(--border-color); padding-left: 24px; display: flex; flex-direction: column; gap: 16px;">
|
||||
<h3 style="margin: 0; font-size: 14px; font-weight: 800; border-bottom: 1px solid var(--border-color); padding-bottom: 8px;">선택 내역 요약</h3>
|
||||
|
||||
<!-- 사원 요약 카드 -->
|
||||
<div id="summary-user-card" style="padding: 12px; background: var(--bg-light); border: 1px solid var(--border-color); border-radius: 6px; display: flex; flex-direction: column; gap: 4px;">
|
||||
<div style="font-size: 11px; color: var(--text-muted);">대상 사원</div>
|
||||
<div id="summary-user-name" style="font-weight: 700; font-size: 14px;">선택된 사원 없음</div>
|
||||
<div id="summary-user-dept" style="font-size: 12px; color: var(--text-muted);">-</div>
|
||||
</div>
|
||||
|
||||
<!-- 인수 사원 요약 카드 (이동 전용) -->
|
||||
<div id="summary-target-user-card" class="summary-card hidden" style="padding: 12px; background: #EEF2F6; border: 1px solid var(--border-color); border-radius: 6px; display: flex; flex-direction: column; gap: 4px;">
|
||||
<div style="font-size: 11px; color: var(--text-muted);">새 인수 사원</div>
|
||||
<div id="summary-target-user-name" style="font-weight: 700; font-size: 14px;">선택된 사원 없음</div>
|
||||
<div id="summary-target-user-dept" style="font-size: 12px; color: var(--text-muted);">-</div>
|
||||
</div>
|
||||
|
||||
<!-- 대상 PC 자산 요약 카드 -->
|
||||
<div id="summary-pc-card" style="padding: 12px; background: var(--bg-light); border: 1px solid var(--border-color); border-radius: 6px; display: flex; flex-direction: column; gap: 4px;">
|
||||
<div style="font-size: 11px; color: var(--text-muted);">대상 PC 자산</div>
|
||||
<div id="summary-pc-code" style="font-weight: 700; font-size: 14px; color: var(--primary-color);">선택된 PC 없음</div>
|
||||
<div id="summary-pc-model" style="font-size: 12px; color: var(--text-muted);">-</div>
|
||||
</div>
|
||||
|
||||
<!-- 사용자 보유 PC 목록 선택 (반납/이동 시) -->
|
||||
<div id="user-pcs-container" class="hidden" style="display: flex; flex-direction: column; gap: 8px;">
|
||||
<div style="font-size: 12px; font-weight: 700; color: var(--text-muted);">사원 보유 PC 선택 (클릭하여 매핑)</div>
|
||||
<div id="user-pcs-list" style="display: flex; flex-direction: column; gap: 8px; max-height: 200px; overflow-y: auto;"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
<div class="modal-footer" style="padding: 16px 24px; border-top: 1px solid var(--border-color); display: flex; justify-content: flex-end; gap: 12px; background: var(--bg-light);">
|
||||
<button id="btn-cancel-pc-flow-modal" class="btn btn-outline" style="height: 42px;">취소</button>
|
||||
<button id="btn-submit-pc-flow" class="btn btn-primary" style="height: 42px;">이동/반납 처리 완료</button>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.flow-type-label {
|
||||
transition: all 0.2s;
|
||||
border-color: var(--border-color);
|
||||
background: white;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
.flow-type-label:hover {
|
||||
border-color: var(--primary-color);
|
||||
color: var(--primary-color);
|
||||
}
|
||||
.flow-type-label.active {
|
||||
border-color: var(--primary-color);
|
||||
background: var(--primary-light);
|
||||
color: var(--primary-color);
|
||||
}
|
||||
.suggestion-item:hover {
|
||||
background-color: var(--primary-light) !important;
|
||||
}
|
||||
</style>
|
||||
`;
|
||||
}
|
||||
}
|
||||
|
||||
export const pcFlowModal = PCFlowModal.getInstance();
|
||||
166
src/components/Modal/PartsMasterModal.ts
Normal file
166
src/components/Modal/PartsMasterModal.ts
Normal file
@@ -0,0 +1,166 @@
|
||||
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 { UI_TEXT } from '../../core/schema';
|
||||
|
||||
class PartsMasterModal extends BaseModal {
|
||||
constructor() {
|
||||
super('parts-master', '부품 표준 정보');
|
||||
}
|
||||
|
||||
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-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>
|
||||
<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;">
|
||||
<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}">
|
||||
<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>
|
||||
|
||||
<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>
|
||||
|
||||
<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>
|
||||
</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>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
protected initChildLogic(onSave: () => void, closeModals: () => void): void {
|
||||
const saveBtn = document.getElementById('btn-save-parts-master-asset')!;
|
||||
const revertBtn = document.getElementById('btn-revert-parts-master-edit')!;
|
||||
const deleteBtn = document.getElementById('btn-delete-parts-master-asset')!;
|
||||
|
||||
saveBtn.addEventListener('click', async () => {
|
||||
if (!this.currentAsset) return;
|
||||
if (!this.isEditMode) {
|
||||
this.setEditLockMode('edit');
|
||||
this.isEditMode = true;
|
||||
return;
|
||||
}
|
||||
|
||||
const category = (document.getElementById('parts-master-category') as HTMLSelectElement).value;
|
||||
const compName = (document.getElementById('parts-master-component-name') as HTMLInputElement).value.trim();
|
||||
const tier = (document.getElementById('parts-master-score-tier') as HTMLInputElement).value.trim();
|
||||
const deductStr = (document.getElementById('parts-master-deduction') as HTMLInputElement).value;
|
||||
|
||||
if (!compName || !tier || deductStr === '') {
|
||||
alert('모든 필드를 올바르게 입력해 주세요.');
|
||||
return;
|
||||
}
|
||||
|
||||
const updated = {
|
||||
id: this.currentAsset.id || null,
|
||||
category,
|
||||
component_name: compName,
|
||||
score_tier: tier,
|
||||
deduction: parseInt(deductStr, 10)
|
||||
};
|
||||
|
||||
if (await savePartsMaster(updated)) {
|
||||
alert(UI_TEXT.MESSAGES.SAVE_SUCCESS);
|
||||
onSave(); this.close(); closeModals();
|
||||
}
|
||||
});
|
||||
|
||||
revertBtn.addEventListener('click', () => {
|
||||
this.setEditLockMode('view');
|
||||
if (this.currentAsset) this.fillFormData(this.currentAsset);
|
||||
});
|
||||
|
||||
deleteBtn.addEventListener('click', async () => {
|
||||
if (!this.currentAsset || !this.currentAsset.id) return;
|
||||
if (!confirm('정말로 이 부품 마스터 정보를 삭제하시겠습니까?\n삭제 시 기존 등록 PC 중 이 부품명을 사용하는 PC의 자동완성 정합성 체크에 영향을 줄 수 있습니다.')) return;
|
||||
|
||||
if (await deletePartsMaster(this.currentAsset.id)) {
|
||||
alert('성공적으로 삭제되었습니다.');
|
||||
onSave(); this.close(); closeModals();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
protected fillFormData(asset: any): void {
|
||||
setFieldValue('parts-master-id', asset.id || '');
|
||||
setFieldValue('parts-master-category', asset.category || 'CPU');
|
||||
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');
|
||||
}
|
||||
|
||||
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 = '부품 마스터 상세 편집';
|
||||
}
|
||||
}
|
||||
|
||||
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') {
|
||||
this.setEditLockMode('edit');
|
||||
this.isEditMode = true;
|
||||
saveBtn.textContent = '등록';
|
||||
saveBtn.style.display = 'block';
|
||||
} else {
|
||||
this.setEditLockMode('view');
|
||||
this.isEditMode = false;
|
||||
saveBtn.textContent = '수정';
|
||||
saveBtn.style.display = 'block';
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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);
|
||||
}
|
||||
@@ -3,7 +3,7 @@
|
||||
*/
|
||||
|
||||
// 구매법인 목록
|
||||
export const CORP_LIST = ['한맥', '삼안', '장헌', '한라', 'PTC', '바론'];
|
||||
export const CORP_LIST = ['한맥', '삼안', 'PTC', '바론'];
|
||||
|
||||
// 사용조직 목록
|
||||
export const ORG_LIST = ['한맥', '삼안', '장헌', '한라', 'PTC', '기술개발센터', '총괄기획실'];
|
||||
@@ -30,7 +30,7 @@ export const CATEGORY_TYPE_MAP: Record<string, string[]> = {
|
||||
// 설치위치 종속성 데이터
|
||||
export const LOCATION_DATA: Record<string, string[]> = {
|
||||
'한맥빌딩': ['MDF실', '1층', '2층', '3층', '4층', '5층', '6층', '7층', '파고라'],
|
||||
'기술개발센터': ['서버실', 'BLUE ZONE', 'GREEN ZONE', 'ORANGE ZONE', '회의실2', '회의실3', '회의실5', '회의실6', '회의실7', '사이니지룸'],
|
||||
'기술개발센터': ['서버실', '센터내부'],
|
||||
'유니온빌딩': ['4층', '5층', '6층'],
|
||||
'뉴코아빌딩': ['4층', '6층', '7층'],
|
||||
'IDC': ['서관202', '서관203', '서관204', '서관205', '동관53', '동관54']
|
||||
@@ -60,10 +60,17 @@ export const IMAGE_LOCATIONS: Record<string, Record<string, string[]>> = {
|
||||
'서버실': [
|
||||
'img/location_photo/기술개발센터/서버실/서버실_1.png',
|
||||
'img/location_photo/기술개발센터/서버실/서버실_2.png'
|
||||
]
|
||||
],
|
||||
'센터내부': ['img/location_photo/기술개발센터/센터내부/센터내부.png']
|
||||
},
|
||||
'한맥빌딩': {
|
||||
'7층': ['img/location_photo/한맥빌딩/7층_로비.png'],
|
||||
'1층': ['img/location_photo/한맥빌딩/1층.png'],
|
||||
'2층': ['img/location_photo/한맥빌딩/2층.png'],
|
||||
'3층': ['img/location_photo/한맥빌딩/3층.png'],
|
||||
'4층': ['img/location_photo/한맥빌딩/4층.png'],
|
||||
'5층': ['img/location_photo/한맥빌딩/5층.png'],
|
||||
'6층': ['img/location_photo/한맥빌딩/6층.png'],
|
||||
'7층': ['img/location_photo/한맥빌딩/7층.png'],
|
||||
'MDF실': [
|
||||
'img/location_photo/한맥빌딩/MDF실/MDF_1.png',
|
||||
'img/location_photo/한맥빌딩/MDF실/MDF_2.png',
|
||||
|
||||
171
src/components/Modal/UserModal.ts
Normal file
171
src/components/Modal/UserModal.ts
Normal file
@@ -0,0 +1,171 @@
|
||||
import { state, saveSystemUser, deleteSystemUser } from '../../core/state';
|
||||
import { BaseModal } from './BaseModal';
|
||||
import { setFieldValue } from './ModalUtils';
|
||||
import { createIcons, X, Save } from 'lucide';
|
||||
import { UI_TEXT } from '../../core/schema';
|
||||
|
||||
class UserModal extends BaseModal {
|
||||
constructor() {
|
||||
super('user', '임직원 정보');
|
||||
}
|
||||
|
||||
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-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>
|
||||
<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;">
|
||||
<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>
|
||||
|
||||
<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>
|
||||
|
||||
<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>
|
||||
|
||||
<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>
|
||||
|
||||
<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}">
|
||||
<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>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
protected initChildLogic(onSave: () => void, closeModals: () => void): void {
|
||||
const saveBtn = document.getElementById('btn-save-user-asset')!;
|
||||
const revertBtn = document.getElementById('btn-revert-user-edit')!;
|
||||
const deleteBtn = document.getElementById('btn-delete-user-asset')!;
|
||||
|
||||
saveBtn.addEventListener('click', async () => {
|
||||
if (!this.currentAsset) return;
|
||||
if (!this.isEditMode) {
|
||||
this.setEditLockMode('edit');
|
||||
this.isEditMode = true;
|
||||
return;
|
||||
}
|
||||
|
||||
const empNo = (document.getElementById('user-emp-no') as HTMLInputElement).value.trim();
|
||||
const userName = (document.getElementById('user-name-input') as HTMLInputElement).value.trim();
|
||||
const deptName = (document.getElementById('user-dept') as HTMLInputElement).value.trim();
|
||||
const position = (document.getElementById('user-position-input') as HTMLInputElement).value.trim();
|
||||
const status = (document.getElementById('user-status') as HTMLSelectElement).value;
|
||||
|
||||
if (!empNo || !userName || !deptName || !position) {
|
||||
alert('모든 필수 입력 필드를 채워주세요.');
|
||||
return;
|
||||
}
|
||||
|
||||
const updated = {
|
||||
id: this.currentAsset.id || null,
|
||||
emp_no: empNo,
|
||||
user_name: userName,
|
||||
dept_name: deptName,
|
||||
position: position,
|
||||
status: status
|
||||
};
|
||||
|
||||
if (await saveSystemUser(updated)) {
|
||||
alert(UI_TEXT.MESSAGES.SAVE_SUCCESS);
|
||||
onSave(); this.close(); closeModals();
|
||||
}
|
||||
});
|
||||
|
||||
revertBtn.addEventListener('click', () => {
|
||||
this.setEditLockMode('view');
|
||||
if (this.currentAsset) this.fillFormData(this.currentAsset);
|
||||
});
|
||||
|
||||
deleteBtn.addEventListener('click', async () => {
|
||||
if (!this.currentAsset || !this.currentAsset.id) return;
|
||||
if (!confirm('정말로 이 임직원 정보를 삭제하시겠습니까?')) return;
|
||||
|
||||
if (await deleteSystemUser(this.currentAsset.id)) {
|
||||
alert('성공적으로 삭제되었습니다.');
|
||||
onSave(); this.close(); closeModals();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
protected fillFormData(asset: any): void {
|
||||
setFieldValue('user-id', asset.id || '');
|
||||
setFieldValue('user-emp-no', asset.emp_no || '');
|
||||
setFieldValue('user-name-input', asset.user_name || '');
|
||||
setFieldValue('user-dept', asset.dept_name || '');
|
||||
setFieldValue('user-position-input', asset.position || '');
|
||||
setFieldValue('user-status', asset.status || '재직');
|
||||
}
|
||||
|
||||
protected onAfterOpen(asset: any, mode: string): void {
|
||||
const titleEl = document.getElementById('user-modal-title');
|
||||
|
||||
if (titleEl) {
|
||||
if (mode === 'add') {
|
||||
titleEl.textContent = '신규 임직원 등록';
|
||||
} else {
|
||||
titleEl.textContent = '임직원 정보 수정';
|
||||
}
|
||||
}
|
||||
|
||||
const deleteBtn = document.getElementById('btn-delete-user-asset')!;
|
||||
const saveBtn = document.getElementById('btn-save-user-asset')!;
|
||||
|
||||
deleteBtn.style.display = (mode === 'add') ? 'none' : 'block';
|
||||
|
||||
if (mode === 'add') {
|
||||
this.setEditLockMode('edit');
|
||||
this.isEditMode = true;
|
||||
saveBtn.textContent = '등록';
|
||||
saveBtn.style.display = 'block';
|
||||
} else {
|
||||
this.setEditLockMode('view');
|
||||
this.isEditMode = false;
|
||||
saveBtn.textContent = '수정';
|
||||
saveBtn.style.display = 'block';
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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);
|
||||
}
|
||||
@@ -3,7 +3,7 @@ import { state } from '../core/state';
|
||||
const MENU_CONFIG: any = {
|
||||
hw: {
|
||||
label: '하드웨어',
|
||||
tabs: ['서버', 'PC', '스토리지', '공간정보장비', 'PC부품', '네트워크', '업무지원장비']
|
||||
tabs: ['대시보드', '서버', 'PC', '스토리지', '공간정보장비', 'PC부품', '부품 마스터', '네트워크', '업무지원장비']
|
||||
},
|
||||
sw: {
|
||||
label: '소프트웨어',
|
||||
@@ -11,7 +11,7 @@ const MENU_CONFIG: any = {
|
||||
},
|
||||
ops: {
|
||||
label: '운영지원',
|
||||
tabs: ['클라우드', '도메인', '비용관리']
|
||||
tabs: ['클라우드', '도메인', '비용관리', '사용자']
|
||||
},
|
||||
vip: {
|
||||
label: '내빈/외빈',
|
||||
@@ -32,6 +32,23 @@ export function renderNavigation(onTabChange: (tab: string) => void) {
|
||||
// 기존 메뉴 렌더링
|
||||
(Object.keys(MENU_CONFIG) as Array<keyof typeof MENU_CONFIG>).forEach(catKey => {
|
||||
const config = MENU_CONFIG[catKey];
|
||||
|
||||
// 역할에 따라 노출할 서브탭 필터링
|
||||
const visibleTabs = config.tabs.filter((tab: string) => {
|
||||
if (state.currentUserRole === 'admin') {
|
||||
// 관리자(admin)일 경우 대시보드 탭만 노출
|
||||
return tab === '대시보드';
|
||||
} else {
|
||||
// 실무자(user)일 경우 대시보드 제외한 모든 탭 노출
|
||||
return tab !== '대시보드';
|
||||
}
|
||||
});
|
||||
|
||||
// 노출할 서브탭이 없으면 해당 대분류 GNB 메뉴도 렌더링하지 않음
|
||||
if (visibleTabs.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
const isActive = state.activeCategory === catKey;
|
||||
|
||||
const group = document.createElement('div');
|
||||
@@ -40,11 +57,11 @@ export function renderNavigation(onTabChange: (tab: string) => void) {
|
||||
const trigger = document.createElement('div');
|
||||
trigger.className = 'gnb-trigger';
|
||||
trigger.textContent = config.label;
|
||||
|
||||
|
||||
trigger.addEventListener('click', () => {
|
||||
if (state.activeCategory !== catKey) {
|
||||
state.activeCategory = catKey as any;
|
||||
const firstTab = config.tabs[0];
|
||||
const firstTab = visibleTabs[0] || config.tabs[0];
|
||||
state.activeSubTab = firstTab;
|
||||
render();
|
||||
onTabChange(firstTab);
|
||||
@@ -55,7 +72,8 @@ export function renderNavigation(onTabChange: (tab: string) => void) {
|
||||
const shelf = document.createElement('div');
|
||||
shelf.className = 'lnb-shelf';
|
||||
|
||||
config.tabs.forEach((tab: string) => {
|
||||
visibleTabs.forEach((tab: string) => {
|
||||
if (tab === '부품 마스터') return; // 메뉴바에서 표시 생략
|
||||
const item = document.createElement('div');
|
||||
item.className = `lnb-item ${isActive && state.activeSubTab === tab ? 'active' : ''}`;
|
||||
item.textContent = tab;
|
||||
@@ -73,20 +91,26 @@ export function renderNavigation(onTabChange: (tab: string) => void) {
|
||||
navContainer.appendChild(group);
|
||||
});
|
||||
|
||||
// ─── '관리자' 메뉴 별도 추가 (GNB 스타일) ───
|
||||
const adminGroup = document.createElement('div');
|
||||
adminGroup.className = 'nav-group';
|
||||
|
||||
const adminTrigger = document.createElement('div');
|
||||
adminTrigger.className = 'gnb-trigger admin-trigger';
|
||||
adminTrigger.innerHTML = '관리자';
|
||||
|
||||
adminTrigger.addEventListener('click', () => {
|
||||
window.open('/map_editor.html', '_blank');
|
||||
});
|
||||
|
||||
adminGroup.appendChild(adminTrigger);
|
||||
navContainer.appendChild(adminGroup);
|
||||
// ─── '관리자' 메뉴 별도 추가 (GNB 스타일 - 관리자 역할일 때만 노출) ───
|
||||
if (state.currentUserRole === 'admin') {
|
||||
const adminGroup = document.createElement('div');
|
||||
adminGroup.className = 'nav-group';
|
||||
|
||||
const adminTrigger = document.createElement('div');
|
||||
adminTrigger.className = 'gnb-trigger';
|
||||
adminTrigger.innerHTML = '관리자';
|
||||
adminTrigger.style.color = 'var(--text-muted)';
|
||||
adminTrigger.style.borderLeft = '1px solid var(--border-color)';
|
||||
adminTrigger.style.marginLeft = '1rem';
|
||||
adminTrigger.style.paddingLeft = '1.5rem';
|
||||
|
||||
adminTrigger.addEventListener('click', () => {
|
||||
window.open('/map_editor.html', '_blank');
|
||||
});
|
||||
|
||||
adminGroup.appendChild(adminTrigger);
|
||||
navContainer.appendChild(adminGroup);
|
||||
}
|
||||
};
|
||||
|
||||
render();
|
||||
|
||||
10695
src/core/dummyData.ts
Normal file
10695
src/core/dummyData.ts
Normal file
File diff suppressed because it is too large
Load Diff
@@ -27,10 +27,15 @@ export interface SWUser {
|
||||
|
||||
export interface HardwareLog {
|
||||
id: string;
|
||||
assetId: string;
|
||||
date: string;
|
||||
assetId?: string;
|
||||
asset_id?: string;
|
||||
date?: string;
|
||||
log_date?: string;
|
||||
created_at?: string;
|
||||
details: string;
|
||||
user: string;
|
||||
user?: string;
|
||||
log_user?: string;
|
||||
event_type?: string;
|
||||
}
|
||||
|
||||
export interface MasterAssetData {
|
||||
|
||||
@@ -15,17 +15,30 @@ export interface FilterOptions {
|
||||
showLoc?: boolean;
|
||||
showField?: boolean;
|
||||
showType?: boolean;
|
||||
showStatus?: boolean;
|
||||
extraHTML?: string;
|
||||
onFilterChange: (filters: any) => void;
|
||||
initialFilters?: any;
|
||||
}
|
||||
|
||||
export function renderFilterBar(container: HTMLElement, options: FilterOptions) {
|
||||
const { keywordLabel = '통합 검색', showCorp = false, showDept = false, showLoc = false, showField = false, showType = false, extraHTML = '', onFilterChange } = options;
|
||||
const {
|
||||
keywordLabel = '통합 검색',
|
||||
showCorp = false,
|
||||
showDept = false,
|
||||
showLoc = false,
|
||||
showField = false,
|
||||
showType = false,
|
||||
showStatus = false,
|
||||
extraHTML = '',
|
||||
onFilterChange,
|
||||
initialFilters = { keyword: '', corp: '', dept: '', loc: '', field: '', type: '', status: '' }
|
||||
} = options;
|
||||
|
||||
container.innerHTML = `
|
||||
<div class="search-item flex-1">
|
||||
<label>${keywordLabel}</label>
|
||||
<input type="text" id="filter-keyword" placeholder="검색어를 입력하세요..." autocomplete="off">
|
||||
<input type="text" id="filter-keyword" placeholder="검색어를 입력하세요..." autocomplete="off" value="${initialFilters.keyword || ''}">
|
||||
</div>
|
||||
${showType ? `
|
||||
<div class="search-item">
|
||||
@@ -34,21 +47,28 @@ export function renderFilterBar(container: HTMLElement, options: FilterOptions)
|
||||
<option value="">전체 유형</option>
|
||||
</select>
|
||||
</div>` : ''}
|
||||
${showStatus ? `
|
||||
<div class="search-item">
|
||||
<label>${ASSET_SCHEMA.HW_STATUS.ui}</label>
|
||||
<select id="filter-status">
|
||||
<option value="">전체 상태</option>
|
||||
</select>
|
||||
</div>` : ''}
|
||||
${showField ? `
|
||||
<div class="search-item">
|
||||
<label>${ASSET_SCHEMA.SW_FIELD.ui}</label>
|
||||
<select id="filter-field">
|
||||
<option value="">전체 분야</option>
|
||||
<option value="업무공통">업무공통</option>
|
||||
<option value="개발S/W">개발S/W</option>
|
||||
<option value="디자인">디자인</option>
|
||||
<option value="설계S/W">설계S/W</option>
|
||||
<option value="업무공통" ${initialFilters.field === '업무공통' ? 'selected' : ''}>업무공통</option>
|
||||
<option value="개발S/W" ${initialFilters.field === '개발S/W' ? 'selected' : ''}>개발S/W</option>
|
||||
<option value="디자인" ${initialFilters.field === '디자인' ? 'selected' : ''}>디자인</option>
|
||||
<option value="설계S/W" ${initialFilters.field === '설계S/W' ? 'selected' : ''}>설계S/W</option>
|
||||
</select>
|
||||
</div>` : ''}
|
||||
${showCorp ? `
|
||||
<div class="search-item">
|
||||
<label>${ASSET_SCHEMA.PURCHASE_CORP.ui}</label>
|
||||
<select id="filter-corp">${generateOptionsHTML(CORP_LIST, '', true)}</select>
|
||||
<select id="filter-corp">${generateOptionsHTML(CORP_LIST, initialFilters.corp || '', true)}</select>
|
||||
</div>` : ''}
|
||||
${showLoc ? `
|
||||
<div class="search-item">
|
||||
@@ -75,7 +95,8 @@ export function renderFilterBar(container: HTMLElement, options: FilterOptions)
|
||||
dept: (container.querySelector('#filter-dept') as HTMLSelectElement)?.value || '',
|
||||
loc: (container.querySelector('#filter-loc') as HTMLSelectElement)?.value || '',
|
||||
field: (container.querySelector('#filter-field') as HTMLSelectElement)?.value || '',
|
||||
type: (container.querySelector('#filter-type') as HTMLSelectElement)?.value || ''
|
||||
type: (container.querySelector('#filter-type') as HTMLSelectElement)?.value || '',
|
||||
status: (container.querySelector('#filter-status') as HTMLSelectElement)?.value || ''
|
||||
};
|
||||
onFilterChange(filters);
|
||||
};
|
||||
@@ -86,9 +107,10 @@ export function renderFilterBar(container: HTMLElement, options: FilterOptions)
|
||||
container.querySelector('#filter-loc')?.addEventListener('change', triggerChange);
|
||||
container.querySelector('#filter-field')?.addEventListener('change', triggerChange);
|
||||
container.querySelector('#filter-type')?.addEventListener('change', triggerChange);
|
||||
container.querySelector('#filter-status')?.addEventListener('change', triggerChange);
|
||||
|
||||
container.querySelector('#btn-reset-filters')?.addEventListener('click', () => {
|
||||
['filter-keyword', 'filter-corp', 'filter-dept', 'filter-loc', 'filter-field', 'filter-type'].forEach(id => {
|
||||
['filter-keyword', 'filter-corp', 'filter-dept', 'filter-loc', 'filter-field', 'filter-type', 'filter-status'].forEach(id => {
|
||||
const el = container.querySelector(`#${id}`);
|
||||
if (el) (el as any).value = '';
|
||||
});
|
||||
@@ -109,7 +131,8 @@ export function applyCommonFilters(list: any[], filters: any, searchKeys: (keyof
|
||||
const matchLoc = !filters.loc || (item[ASSET_SCHEMA.LOCATION.key] || item[ASSET_SCHEMA.LOCATION.db]) === filters.loc;
|
||||
const matchField = !filters.field || (item[ASSET_SCHEMA.SW_FIELD.key] || item[ASSET_SCHEMA.SW_FIELD.db]) === filters.field;
|
||||
const matchType = !filters.type || (item[ASSET_SCHEMA.ASSET_TYPE.key] || item[ASSET_SCHEMA.ASSET_TYPE.db]) === filters.type;
|
||||
const matchStatus = !filters.status || (item[ASSET_SCHEMA.HW_STATUS.key] || item[ASSET_SCHEMA.HW_STATUS.db]) === filters.status;
|
||||
|
||||
return matchKeyword && matchCorp && matchDept && matchLoc && matchField && matchType;
|
||||
return matchKeyword && matchCorp && matchDept && matchLoc && matchField && matchType && matchStatus;
|
||||
});
|
||||
}
|
||||
|
||||
@@ -17,6 +17,7 @@ export const ASSET_SCHEMA = {
|
||||
PURCHASE_AMOUNT:{ key: 'purchase_amount', db: 'purchase_amount', ui: '구매금액' },
|
||||
PURCHASE_VENDOR:{ key: 'purchase_vendor', db: 'purchase_vendor', ui: '구매업체' },
|
||||
APPROVAL_DOC: { key: 'approval_document', db: 'approval_document', ui: '품의서' },
|
||||
SERVICE_TYPE: { key: 'service_type', db: 'service_type', ui: '서비스 구분' },
|
||||
MANAGER_MAIN: { key: 'manager_primary', db: 'manager_primary', ui: '담당자(정)' },
|
||||
MANAGER_SUB: { key: 'manager_secondary', db: 'manager_secondary', ui: '담당자(부)' },
|
||||
LOCATION: { key: 'location', db: 'location', ui: '자산위치' },
|
||||
@@ -154,6 +155,11 @@ export const PAGE_DESCRIPTIONS: Record<string, { title: string; description: str
|
||||
title: '사무용 가구 관리',
|
||||
description: '책상, 의자, 캐비닛 등 사무 환경 구성을 위한 가구 자산의 배치 현황을 관리합니다.',
|
||||
icon: 'armchair'
|
||||
},
|
||||
'사용자': {
|
||||
title: '임직원 사용자 관리',
|
||||
description: 'IT 자산 할당 및 관리의 기준이 되는 사내 임직원(사용자) 정보를 데이터베이스 기반으로 직접 등록하고 수정합니다.',
|
||||
icon: 'users'
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { HardwareAsset, SoftwareAsset, SWUser, HardwareLog } from './excelHandler';
|
||||
import { API_BASE_URL } from './utils';
|
||||
import { dummyPCs, dummyServers, dummyStorages, dummyEquips, dummySubSw, dummyPermSw, dummyCloud, dummyDomain, dummySwUsers, dummyLogs } from './dummyData';
|
||||
|
||||
// --- State Definitions ---
|
||||
export interface MasterAssetData {
|
||||
@@ -10,6 +11,7 @@ export interface MasterAssetData {
|
||||
network: any[];
|
||||
survey: any[];
|
||||
pcParts: any[];
|
||||
partsMaster: any[];
|
||||
equipment: any[];
|
||||
officeSupplies: any[];
|
||||
swInternal: any[];
|
||||
@@ -40,19 +42,21 @@ export interface AppState {
|
||||
masterData: MasterAssetData;
|
||||
activeCharts: any[];
|
||||
currentUserRole: 'admin' | 'user';
|
||||
listFilters?: Record<string, any>;
|
||||
}
|
||||
|
||||
// 초기 상태
|
||||
export const state: AppState = {
|
||||
activeCategory: 'hw',
|
||||
activeSubTab: '서버', // 대시보드 제거됨에 따라 기본값 변경
|
||||
activeSubTab: '대시보드',
|
||||
viewMode: 'location',
|
||||
activeCharts: [],
|
||||
currentUserRole: 'user',
|
||||
listFilters: {},
|
||||
masterData: {
|
||||
users: [],
|
||||
pc: [], server: [], storage: [], network: [],
|
||||
survey: [], pcParts: [], equipment: [], officeSupplies: [],
|
||||
survey: [], pcParts: [], partsMaster: [], equipment: [], officeSupplies: [],
|
||||
swInternal: [], swExternal: [], cloud: [], domain: [],
|
||||
cost: [], vip: [],
|
||||
subSw: [], permSw: [],
|
||||
@@ -74,9 +78,22 @@ export async function loadMasterDataFromDB() {
|
||||
// 전역 상태 업데이트
|
||||
state.masterData = {
|
||||
...state.masterData,
|
||||
...data
|
||||
...data,
|
||||
logs: (data.logs || []).map((l: any) => ({
|
||||
...l,
|
||||
assetId: l.asset_id || l.assetId,
|
||||
date: l.log_date || l.date,
|
||||
user: l.log_user || l.user,
|
||||
log_date: l.log_date || l.date,
|
||||
log_user: l.log_user || l.user
|
||||
}))
|
||||
};
|
||||
|
||||
// Mapping for backward compatibility
|
||||
state.masterData.equip = state.masterData.equipment;
|
||||
state.masterData.subSw = state.masterData.swExternal;
|
||||
state.masterData.permSw = state.masterData.swInternal;
|
||||
|
||||
// 하드웨어 통합 (대시보드 호환용)
|
||||
state.masterData.hw = [
|
||||
...state.masterData.pc,
|
||||
@@ -98,7 +115,7 @@ export async function loadMasterDataFromDB() {
|
||||
console.log('✅ V2 Normalized data loaded successfully');
|
||||
return true;
|
||||
} catch (err) {
|
||||
console.warn('⚠️ 서버 연결 실패:', err);
|
||||
console.warn('⚠️ Dummy 로드 실패:', err);
|
||||
}
|
||||
return false;
|
||||
}
|
||||
@@ -146,3 +163,69 @@ export async function deleteAsset(category: string, assetId: string) {
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
export async function savePartsMaster(component: any) {
|
||||
try {
|
||||
const url = `${API_BASE_URL}/api/hardware-components/save`;
|
||||
const response = await fetch(url, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(component)
|
||||
});
|
||||
|
||||
if (response.ok) {
|
||||
await loadMasterDataFromDB(); // 전역 상태 갱신
|
||||
return true;
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('부품 마스터 저장 실패:', err);
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
export async function deletePartsMaster(id: number) {
|
||||
try {
|
||||
const url = `${API_BASE_URL}/api/hardware-components/${id}`;
|
||||
const response = await fetch(url, { method: 'DELETE' });
|
||||
|
||||
if (response.ok) {
|
||||
await loadMasterDataFromDB(); // 전역 상태 갱신
|
||||
return true;
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('부품 마스터 삭제 실패:', err);
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
export async function saveSystemUser(user: any) {
|
||||
try {
|
||||
const url = `${API_BASE_URL}/api/system-users/save`;
|
||||
const response = await fetch(url, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(user)
|
||||
});
|
||||
if (response.ok) {
|
||||
await loadMasterDataFromDB(); // 전역 상태 갱신
|
||||
return true;
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('사용자 정보 저장 실패:', err);
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
export async function deleteSystemUser(id: string) {
|
||||
try {
|
||||
const url = `${API_BASE_URL}/api/system-users/${id}`;
|
||||
const response = await fetch(url, { method: 'DELETE' });
|
||||
if (response.ok) {
|
||||
await loadMasterDataFromDB(); // 전역 상태 갱신
|
||||
return true;
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('사용자 정보 삭제 실패:', err);
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
@@ -17,7 +17,7 @@ export function renderPageHeader(container: HTMLElement, pageId: string) {
|
||||
header.className = 'page-header';
|
||||
header.innerHTML = `
|
||||
<div class="page-title-group">
|
||||
<h2 class="page-title"><i data-lucide="${config.icon}"></i> ${config.title}</h2>
|
||||
<h2 class="page-title">${config.title}</h2>
|
||||
<p class="page-description">${config.description}</p>
|
||||
</div>
|
||||
`;
|
||||
@@ -158,3 +158,201 @@ export function dynamicSort<T>(list: T[], key: string, direction: 'asc' | 'desc'
|
||||
export function getActionButtonsHTML(): string {
|
||||
return '';
|
||||
}
|
||||
|
||||
/**
|
||||
* 100점 만점 감점형 PC 성능 점수 계산 (CPU + RAM + GPU + 연식)
|
||||
*/
|
||||
export function calculatePcScoreDeductive(cpu: string, ram: string, gpu: string, purchaseDate: string): number {
|
||||
let score = 100;
|
||||
if (!cpu) cpu = '';
|
||||
if (!ram) ram = '';
|
||||
if (!gpu) gpu = '';
|
||||
|
||||
const cpuUpper = cpu.toUpperCase();
|
||||
const ramUpper = ram.toUpperCase();
|
||||
const gpuUpper = gpu.toUpperCase();
|
||||
|
||||
// 1. CPU 등급 감점 (최대 -30점)
|
||||
let cpuDeduction = 0;
|
||||
if (cpuUpper.includes('I9') || cpuUpper.includes('RYZEN 9') || cpuUpper.includes('RYZEN9')) {
|
||||
cpuDeduction = 0;
|
||||
} else if (cpuUpper.includes('I7') || cpuUpper.includes('RYZEN 7') || cpuUpper.includes('RYZEN7')) {
|
||||
cpuDeduction = 5;
|
||||
} else if (cpuUpper.includes('I5') || cpuUpper.includes('RYZEN 5') || cpuUpper.includes('RYZEN5')) {
|
||||
cpuDeduction = 15;
|
||||
} else if (cpuUpper.includes('I3') || cpuUpper.includes('RYZEN 3') || cpuUpper.includes('RYZEN3')) {
|
||||
cpuDeduction = 25;
|
||||
} else {
|
||||
cpuDeduction = 30;
|
||||
}
|
||||
score -= cpuDeduction;
|
||||
|
||||
// 2. CPU 세대 노후 감점 (최대 -15점)
|
||||
let genDeduction = 0;
|
||||
const intelMatch = cpuUpper.match(/I\d-?(\d+)/);
|
||||
let gen = 0;
|
||||
if (intelMatch && intelMatch[1]) {
|
||||
const numStr = intelMatch[1];
|
||||
if (numStr.length === 5) gen = parseInt(numStr.substring(0, 2), 10);
|
||||
else if (numStr.length === 4) gen = parseInt(numStr.substring(0, 1), 10);
|
||||
}
|
||||
|
||||
const amdMatch = cpuUpper.match(/RYZEN\s?\d\s?-?(\d+)/);
|
||||
let amdGen = 0;
|
||||
if (amdMatch && amdMatch[1] && !intelMatch) {
|
||||
const numStr = amdMatch[1];
|
||||
if (numStr.length === 4) amdGen = parseInt(numStr.substring(0, 1), 10);
|
||||
}
|
||||
|
||||
if (intelMatch) {
|
||||
if (gen >= 12) genDeduction = 0;
|
||||
else if (gen >= 10) genDeduction = 5;
|
||||
else if (gen >= 8) genDeduction = 10;
|
||||
else genDeduction = 15;
|
||||
} else if (amdMatch) {
|
||||
if (amdGen >= 5) genDeduction = 0;
|
||||
else if (amdGen >= 3) genDeduction = 5;
|
||||
else genDeduction = 10;
|
||||
} else {
|
||||
genDeduction = 15;
|
||||
}
|
||||
score -= genDeduction;
|
||||
|
||||
// 3. RAM 용량 감점 (최대 -25점)
|
||||
const ramMatch = ramUpper.match(/(\d+)\s*GB/);
|
||||
let ramDeduction = 25;
|
||||
if (ramMatch && ramMatch[1]) {
|
||||
const ramVal = parseInt(ramMatch[1], 10);
|
||||
if (ramVal >= 32) ramDeduction = 0;
|
||||
else if (ramVal >= 16) ramDeduction = 10;
|
||||
else if (ramVal >= 8) ramDeduction = 20;
|
||||
else ramDeduction = 25;
|
||||
}
|
||||
score -= ramDeduction;
|
||||
|
||||
// 4. GPU 성능 감점 (최대 -25점)
|
||||
let gpuDeduction = 25;
|
||||
if (!gpuUpper || gpuUpper === '-' || gpuUpper.trim() === '') {
|
||||
gpuDeduction = 25;
|
||||
} else if (
|
||||
gpuUpper.includes('RTX 4090') || gpuUpper.includes('RTX 4080') || gpuUpper.includes('RTX 4070') ||
|
||||
gpuUpper.includes('RTX 3090') || gpuUpper.includes('RTX 3080') ||
|
||||
gpuUpper.includes('RTX A5000') || gpuUpper.includes('RTX A6000') || gpuUpper.includes('RTX A4000')
|
||||
) {
|
||||
gpuDeduction = 0;
|
||||
} else if (
|
||||
gpuUpper.includes('RTX 3070') || gpuUpper.includes('RTX 3060') || gpuUpper.includes('RTX 2060') ||
|
||||
gpuUpper.includes('RTX A2000') || gpuUpper.includes('RTX A3000') || gpuUpper.includes('QUADRO')
|
||||
) {
|
||||
gpuDeduction = 5;
|
||||
} else if (
|
||||
gpuUpper.includes('GTX 1660') || gpuUpper.includes('GTX 1080') || gpuUpper.includes('GTX 1070') ||
|
||||
gpuUpper.includes('GTX 1060') || gpuUpper.includes('RX 6700') || gpuUpper.includes('RX 6600')
|
||||
) {
|
||||
gpuDeduction = 15;
|
||||
} else {
|
||||
gpuDeduction = 25;
|
||||
}
|
||||
score -= gpuDeduction;
|
||||
|
||||
// 5. 연식(노후도) 감점 (최대 -15점)
|
||||
let age = 0;
|
||||
if (purchaseDate && purchaseDate !== '-') {
|
||||
let normalized = purchaseDate.replace(/\./g, '-').trim();
|
||||
if (/^\d{6}$/.test(normalized)) {
|
||||
normalized = `${normalized.substring(0, 4)}-${normalized.substring(4, 6)}`;
|
||||
}
|
||||
const purchase = new Date(normalized);
|
||||
if (!isNaN(purchase.getTime())) {
|
||||
// 2026년 5월 31일 기준 경과연수 계산
|
||||
const mockToday = new Date('2026-05-31');
|
||||
const diffMs = mockToday.getTime() - purchase.getTime();
|
||||
age = diffMs / (1000 * 60 * 60 * 24 * 365.25);
|
||||
age = Math.max(0, parseFloat(age.toFixed(1)));
|
||||
}
|
||||
}
|
||||
|
||||
let ageDeduction = 0;
|
||||
if (age < 1) ageDeduction = 0;
|
||||
else if (age < 2) ageDeduction = 3;
|
||||
else if (age < 3) ageDeduction = 6;
|
||||
else if (age < 4) ageDeduction = 9;
|
||||
else if (age < 5) ageDeduction = 12;
|
||||
else ageDeduction = 15;
|
||||
|
||||
score -= ageDeduction;
|
||||
|
||||
return Math.max(10, score);
|
||||
}
|
||||
|
||||
/**
|
||||
* 성능 점수 기준 등급 뱃지 메타 정보 가져오기
|
||||
*/
|
||||
export function getPcGrade(score: number): { name: string; class: string; color: string } {
|
||||
if (score >= 85) return { name: '최상급', class: 'b-purple', color: '#7C3AED' };
|
||||
if (score >= 70) return { name: '상급', class: 'b-primary', color: '#4F46E5' };
|
||||
if (score >= 40) return { name: '중급', class: 'b-green', color: '#10B981' };
|
||||
return { name: '보급', class: 'b-yellow', color: '#F59E0B' };
|
||||
}
|
||||
|
||||
/**
|
||||
* Windows 11 업그레이드 지원 불가능한 하드웨어 조건인지 판별
|
||||
*/
|
||||
export function isWindows11Incompatible(cpu: string, ram: string): boolean {
|
||||
if (!cpu) return true;
|
||||
const cpuUpper = cpu.toUpperCase();
|
||||
|
||||
// 1. RAM 4GB 미만은 공식 미지원
|
||||
if (ram) {
|
||||
const ramMatch = ram.toUpperCase().match(/(\d+)\s*GB/);
|
||||
if (ramMatch && ramMatch[1]) {
|
||||
const ramVal = parseInt(ramMatch[1], 10);
|
||||
if (ramVal < 4) return true;
|
||||
}
|
||||
}
|
||||
|
||||
// 2. CPU 세대 검사
|
||||
// Intel CPU 세대 판정
|
||||
const intelMatch = cpuUpper.match(/I\d-?(\d+)/);
|
||||
if (intelMatch && intelMatch[1]) {
|
||||
const numStr = intelMatch[1];
|
||||
let gen = 0;
|
||||
if (numStr.length === 5) gen = parseInt(numStr.substring(0, 2), 10);
|
||||
else if (numStr.length === 4) gen = parseInt(numStr.substring(0, 1), 10);
|
||||
else if (numStr.length === 3) gen = parseInt(numStr.substring(0, 1), 10); // 3자리수 구형 세대 (예: i5-750)
|
||||
|
||||
if (gen > 0 && gen < 8) return true; // 8세대 미만 불가
|
||||
return false;
|
||||
}
|
||||
|
||||
// AMD Ryzen CPU 세대 판정
|
||||
const amdMatch = cpuUpper.match(/RYZEN\s?\d\s?-?(\d+)/);
|
||||
if (amdMatch && amdMatch[1]) {
|
||||
const numStr = amdMatch[1];
|
||||
let amdGen = 0;
|
||||
if (numStr.length === 4) amdGen = parseInt(numStr.substring(0, 1), 10); // 1xxx, 2xxx 등
|
||||
|
||||
if (amdGen > 0 && amdGen < 2) return true; // Ryzen 1세대 이하는 불가
|
||||
return false;
|
||||
}
|
||||
|
||||
// Apple Silicon은 지원
|
||||
if (cpuUpper.includes('APPLE') || cpuUpper.includes('M1') || cpuUpper.includes('M2') || cpuUpper.includes('M3') || cpuUpper.includes('M4')) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// 그 외 확실한 구형 CPU 제품군
|
||||
const knownOldCpus = ['CORE2', 'CORE 2', 'PENTIUM', 'CELERON', 'ATHLON', 'PHENOM', 'XEON'];
|
||||
if (knownOldCpus.some(name => cpuUpper.includes(name))) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// 세대 매칭은 안되었으나 Intel Core i 시리즈 구조이면 구형(1세대 등)으로 간주
|
||||
if (cpuUpper.includes('I3') || cpuUpper.includes('I5') || cpuUpper.includes('I7') || cpuUpper.includes('I9')) {
|
||||
// i5-620M 처럼 옛날 구형 모바일 칩 등
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
|
||||
198
src/main.ts
198
src/main.ts
@@ -8,46 +8,24 @@ import { initHwModal, openHwModal } from './components/Modal/HWModal';
|
||||
import { initSwModal, openSwModal } from './components/Modal/SWModal';
|
||||
import { initSwUserModal } from './components/Modal/SWUserModal';
|
||||
import { initDomainModal, openDomainModal } from './components/Modal/DomainModal';
|
||||
import { initPartsMasterModal, openPartsMasterModal } from './components/Modal/PartsMasterModal';
|
||||
import { initUserModal, openUserModal } from './components/Modal/UserModal';
|
||||
import { initDashboardDetailModal } from './components/Modal/DashboardDetailModal';
|
||||
import { initGuide } from './components/Guide';
|
||||
import { pcFlowModal } from './components/Modal/PCFlowModal';
|
||||
import { createIcons, Plus, X, LayoutDashboard, Monitor, Server, Database, Laptop, CalendarClock, Key, Cpu, Layers, Users, Paperclip, Edit2, History, RefreshCcw, BookOpen, Settings } from 'lucide';
|
||||
|
||||
// --- DB 저장을 위한 세분화된 헬퍼 함수들 ---
|
||||
async function apiBatchSave(url: string, data: any[], label: string) {
|
||||
try {
|
||||
const response = await fetch(url, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(data)
|
||||
});
|
||||
if (!response.ok) {
|
||||
const errorData = await response.json().catch(() => ({}));
|
||||
throw new Error(`${label} DB 저장 실패: ${errorData.error || response.statusText}`);
|
||||
}
|
||||
console.log(`✅ ${label} DB 저장 완료`);
|
||||
} catch (err) {
|
||||
console.error(`❌ ${label} DB 저장 오류:`, err);
|
||||
alert(`${label} 저장 중 오류가 발생했습니다: ${(err as any).message}`);
|
||||
}
|
||||
}
|
||||
|
||||
const savePcToDB = () => apiBatchSave(`http://${location.hostname}:3000/api/pc/batch`, state.masterData.pc, '개인PC');
|
||||
const saveServerToDB = () => apiBatchSave(`http://${location.hostname}:3000/api/server/batch`, state.masterData.server, '서버');
|
||||
const saveStorageToDB = () => apiBatchSave(`http://${location.hostname}:3000/api/storage/batch`, state.masterData.storage, '스토리지');
|
||||
const saveNetworkToDB = () => apiBatchSave(`http://${location.hostname}:3000/api/network/batch`, state.masterData.network, '네트워크');
|
||||
const saveEquipToDB = () => apiBatchSave(`http://${location.hostname}:3000/api/equipment/batch`, state.masterData.equipment, '업무지원장비');
|
||||
const saveSwInternalToDB = () => apiBatchSave(`http://${location.hostname}:3000/api/sw/internal/batch`, state.masterData.swInternal, '내부SW');
|
||||
const saveSwExternalToDB = () => apiBatchSave(`http://${location.hostname}:3000/api/sw/external/batch`, state.masterData.swExternal, '외부SW');
|
||||
const saveCloudToDB = () => apiBatchSave(`http://${location.hostname}:3000/api/cloud/batch`, state.masterData.cloud, '클라우드');
|
||||
const saveSwUsersToDB = () => apiBatchSave(`http://${location.hostname}:3000/api/asset/software/assignment/batch`, state.masterData.swUsers, 'SW사용자');
|
||||
const saveLogsToDB = () => apiBatchSave(`http://${location.hostname}:3000/api/asset/history/batch`, state.masterData.logs, '자산 로그');
|
||||
const saveUsersToDB = () => apiBatchSave(`http://${location.hostname}:3000/api/users/batch`, state.masterData.users, '사용자마스터');
|
||||
|
||||
// 화면 갱신 통합 핸들러
|
||||
function refreshView() {
|
||||
const mainContent = document.getElementById('main-content')!;
|
||||
if (!mainContent) return;
|
||||
|
||||
if (state.activeSubTab === '대시보드') {
|
||||
renderDashboard(mainContent);
|
||||
return;
|
||||
}
|
||||
|
||||
// 서버 탭이 아닐 경우 '자산현황(위치)' 뷰 진입 방지 및 강제 리스트 모드 전환
|
||||
if (state.activeSubTab !== '서버' && state.viewMode === 'location') {
|
||||
state.viewMode = 'list';
|
||||
@@ -82,13 +60,8 @@ function refreshView() {
|
||||
}
|
||||
}
|
||||
|
||||
// 통합 저장 및 갱신
|
||||
async function saveAllDataToDB() {
|
||||
await Promise.all([
|
||||
savePcToDB(), saveServerToDB(), saveStorageToDB(), saveNetworkToDB(),
|
||||
saveEquipToDB(), saveSwInternalToDB(), saveSwExternalToDB(),
|
||||
saveCloudToDB(), saveSwUsersToDB(), saveLogsToDB(), saveUsersToDB()
|
||||
]);
|
||||
// 통합 갱신 (저장은 이미 개별 모달에서 처리됨)
|
||||
async function refreshAllData() {
|
||||
await loadMasterDataFromDB();
|
||||
refreshView();
|
||||
}
|
||||
@@ -105,17 +78,20 @@ function initApp() {
|
||||
refreshView();
|
||||
});
|
||||
|
||||
initHwModal(() => saveAllDataToDB(), closeAllModals);
|
||||
initSwModal(() => saveAllDataToDB(), closeAllModals);
|
||||
initHwModal(() => refreshAllData(), closeAllModals);
|
||||
initSwModal(() => refreshAllData(), closeAllModals);
|
||||
initSwUserModal(() => {
|
||||
saveSwUsersToDB().then(() => {
|
||||
loadMasterDataFromDB().then(() => refreshView());
|
||||
});
|
||||
}, closeAllModals);
|
||||
initDomainModal(() => saveAllDataToDB(), closeAllModals);
|
||||
initDomainModal(() => refreshAllData(), closeAllModals);
|
||||
initPartsMasterModal(() => refreshAllData(), closeAllModals);
|
||||
initUserModal(() => refreshAllData(), closeAllModals);
|
||||
|
||||
initDashboardDetailModal();
|
||||
initGuide();
|
||||
pcFlowModal.init(() => {
|
||||
loadMasterDataFromDB().then(() => refreshView());
|
||||
});
|
||||
|
||||
loadMasterDataFromDB().then((success) => {
|
||||
if (success) {
|
||||
@@ -136,22 +112,36 @@ function initApp() {
|
||||
const cat = state.activeCategory;
|
||||
const newId = Math.random().toString(36).substring(2, 9);
|
||||
|
||||
if (cat === 'users') {
|
||||
// 사용자 추가는 renderUserList 내부에서 별도로 처리하거나 여기서 호출 가능
|
||||
// 현재 renderUserList에서 별도로 핸들링하고 있으므로 중복 실행 방지
|
||||
return;
|
||||
}
|
||||
|
||||
if (cat === 'hw') {
|
||||
openHwModal({ id: newId, asset_code: '', category: tab } as any, 'add');
|
||||
if (tab === '부품 마스터') {
|
||||
openPartsMasterModal({ id: '' } as any, 'add');
|
||||
} else {
|
||||
openHwModal({ id: newId, asset_code: '', category: tab } as any, 'add');
|
||||
}
|
||||
} else if (cat === 'sw') {
|
||||
const swType = tab === '외부SW' ? '외부SW' : (tab === '내부SW' ? '내부SW' : '외부SW');
|
||||
openSwModal({ id: newId, asset_type: swType } as any, 'add');
|
||||
} else if (cat === 'ops') {
|
||||
if (tab === '도메인') openDomainModal(null);
|
||||
else if (tab === '사용자') openUserModal({ id: '' }, 'add');
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// 부품 마스터 탭으로 바로가기 연동
|
||||
if (target.closest('#btn-goto-parts-master')) {
|
||||
state.activeCategory = 'hw';
|
||||
state.activeSubTab = '부품 마스터';
|
||||
renderNavigation((tab) => { refreshView(); });
|
||||
refreshView();
|
||||
return;
|
||||
}
|
||||
|
||||
// PC 이동/반납 모달 열기
|
||||
if (target.closest('#btn-pc-flow')) {
|
||||
pcFlowModal.open();
|
||||
return;
|
||||
}
|
||||
});
|
||||
|
||||
createIcons({
|
||||
@@ -171,66 +161,80 @@ function initRoleSwitcher() {
|
||||
if (!checkbox || !userLabel || !adminLabel) return;
|
||||
|
||||
checkbox.addEventListener('change', () => {
|
||||
const mainContent = document.getElementById('main-content')!;
|
||||
if (checkbox.checked) {
|
||||
alert('관리자 모드는 현재 준비 중입니다. 나중에 관리자 전용 페이지와 연결될 예정입니다.');
|
||||
checkbox.checked = false; // UI 강제 되돌리기
|
||||
return;
|
||||
state.currentUserRole = 'admin';
|
||||
userLabel.classList.remove('active');
|
||||
adminLabel.classList.add('active');
|
||||
document.body.classList.add('admin-mode');
|
||||
|
||||
// 관리자 모드 전환 시 대시보드로 이동
|
||||
state.activeCategory = 'hw';
|
||||
state.activeSubTab = '대시보드';
|
||||
refreshView();
|
||||
renderNavigation((tab) => {
|
||||
if (tab === '대시보드') {
|
||||
renderDashboard(mainContent);
|
||||
} else {
|
||||
renderSWTable(mainContent);
|
||||
}
|
||||
});
|
||||
} else {
|
||||
state.currentUserRole = 'user';
|
||||
adminLabel.classList.remove('active');
|
||||
userLabel.classList.add('active');
|
||||
document.body.classList.remove('admin-mode');
|
||||
|
||||
// 실무자 모드 전환 시 서버 목록으로 이동
|
||||
state.activeCategory = 'hw';
|
||||
state.activeSubTab = '서버';
|
||||
refreshView();
|
||||
renderNavigation((tab) => {
|
||||
if (tab === '대시보드') {
|
||||
renderDashboard(mainContent);
|
||||
} else {
|
||||
renderSWTable(mainContent);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// 실무자 모드 전환 (현재는 Admin 진입이 차단되므로 사실상 fallback 로직)
|
||||
state.currentUserRole = 'user';
|
||||
adminLabel.classList.remove('active');
|
||||
userLabel.classList.add('active');
|
||||
document.body.classList.remove('admin-mode');
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 로그인 처리 로직
|
||||
* 앱 초기화 (로그인 과정 없이 즉시 시작)
|
||||
*/
|
||||
function handleLogin() {
|
||||
function initializeAppDirectly() {
|
||||
const loginContainer = document.getElementById('login-container');
|
||||
const appLayout = document.getElementById('app-layout');
|
||||
const roleCards = document.querySelectorAll('.role-card');
|
||||
const checkbox = document.getElementById('role-toggle-checkbox') as HTMLInputElement;
|
||||
const userLabel = document.querySelector('.role-label.user');
|
||||
const adminLabel = document.querySelector('.role-label.admin');
|
||||
|
||||
if (!loginContainer || !appLayout || roleCards.length === 0) return;
|
||||
// 기본 권한 설정: 실무자 (User)
|
||||
state.currentUserRole = 'user';
|
||||
state.activeCategory = 'hw';
|
||||
state.activeSubTab = '서버'; // 실무자 기본 탭
|
||||
|
||||
roleCards.forEach(card => {
|
||||
card.addEventListener('click', () => {
|
||||
const role = card.getAttribute('data-role');
|
||||
|
||||
if (role === 'admin') {
|
||||
alert('관리자 모드는 현재 준비 중입니다. 실무자 모드를 이용해 주세요.');
|
||||
return;
|
||||
}
|
||||
// UI 상태 동기화
|
||||
if (checkbox) checkbox.checked = false;
|
||||
if (userLabel) userLabel.classList.add('active');
|
||||
if (adminLabel) adminLabel.classList.remove('active');
|
||||
document.body.classList.remove('admin-mode');
|
||||
|
||||
if (role === 'user') {
|
||||
console.log('🔓 Entering as Practitioner');
|
||||
|
||||
// 초기 토글 상태 설정 (실무자 고정)
|
||||
const checkbox = document.getElementById('role-toggle-checkbox') as HTMLInputElement;
|
||||
if (checkbox) checkbox.checked = false;
|
||||
state.currentUserRole = 'user';
|
||||
// 화면 전환
|
||||
if (loginContainer) loginContainer.style.display = 'none';
|
||||
if (appLayout) appLayout.style.display = 'flex';
|
||||
|
||||
// UI 전환
|
||||
loginContainer.style.display = 'none';
|
||||
appLayout.style.display = 'flex';
|
||||
|
||||
// 역할 스위처 및 앱 초기화 시작
|
||||
initRoleSwitcher();
|
||||
initApp();
|
||||
|
||||
// 로고 클릭 시 초기화면 복귀 로직 (한 번만 등록)
|
||||
const brand = document.querySelector('.brand') as HTMLElement;
|
||||
if (brand) {
|
||||
brand.style.cursor = 'pointer';
|
||||
brand.onclick = () => {
|
||||
location.reload(); // 즉시 초기화면으로 복귀
|
||||
};
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
// 앱 초기화
|
||||
initRoleSwitcher();
|
||||
initApp();
|
||||
|
||||
// 로고 클릭 시 새로고침 (초기 화면 복귀 효과)
|
||||
const brand = document.querySelector('.brand') as HTMLElement;
|
||||
if (brand) {
|
||||
brand.style.cursor = 'pointer';
|
||||
brand.onclick = () => location.reload();
|
||||
}
|
||||
}
|
||||
|
||||
document.addEventListener('DOMContentLoaded', handleLogin);
|
||||
document.addEventListener('DOMContentLoaded', initializeAppDirectly);
|
||||
|
||||
@@ -177,17 +177,126 @@ input:checked + .slider:before { transform: translateX(16px); }
|
||||
.icon-btn:hover { background-color: var(--primary-light); border-color: var(--primary-color); color: var(--primary-color); }
|
||||
|
||||
/* --- Footer --- */
|
||||
.main-footer { height: 40px; background-color: var(--white); border-top: 1px solid var(--border-color); display: flex; align-items: center; justify-content: flex-end; padding: 0 1.5rem; flex-shrink: 0; }
|
||||
.main-footer p { font-size: 0.75rem; color: var(--text-muted); }
|
||||
.main-footer {
|
||||
height: 28px;
|
||||
background-color: var(--white);
|
||||
border-top: 1px solid var(--border-color);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: flex-end;
|
||||
padding: 0 1.5rem;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
/* --- Utility --- */
|
||||
.main-footer p {
|
||||
font-family: 'Pretendard Variable', Pretendard, sans-serif;
|
||||
font-size: 0.75rem;
|
||||
font-weight: 300;
|
||||
line-height: 1.25rem;
|
||||
letter-spacing: -0.0175rem;
|
||||
color: #777777;
|
||||
user-select: none;
|
||||
pointer-events: all;
|
||||
-webkit-user-drag: none;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.hidden {
|
||||
display: none !important;
|
||||
}
|
||||
|
||||
.text-nowrap {
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
/* --- Utility Styles --- */
|
||||
.btn { display: inline-flex; align-items: center; justify-content: center; gap: 0.35rem; padding: 0 0.8rem; font-size: 12px; font-weight: 600; border-radius: 4px; cursor: pointer; height: 28px; }
|
||||
.btn-primary { background-color: var(--primary-color); color: var(--white); border: none; }
|
||||
.btn-outline { background-color: transparent; color: var(--text-muted); border: 1px solid var(--border-color); }
|
||||
.badge { padding: 2px 6px; border-radius: 4px; font-size: 11px; font-weight: 700; }
|
||||
.badge-primary { background-color: var(--primary-color); color: white; }
|
||||
.badge-light { background: var(--bg-color); color: var(--text-muted); border: 1px solid var(--border-color); }
|
||||
.hidden { display: none !important; }
|
||||
|
||||
.badge {
|
||||
padding: 2px 6px;
|
||||
border-radius: 4px;
|
||||
font-size: 16px;
|
||||
font-weight: 700;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.badge-primary {
|
||||
background-color: var(--primary-color);
|
||||
color: white;
|
||||
}
|
||||
|
||||
.badge-muted {
|
||||
background-color: #9CA3AF;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.badge-light {
|
||||
background: var(--bg-color);
|
||||
color: var(--text-muted);
|
||||
border: 1px solid var(--border-color);
|
||||
}
|
||||
|
||||
/* PC 성능 등급 뱃지 컬러 스타일 */
|
||||
.badge.b-purple {
|
||||
background-color: #EDE9FE;
|
||||
color: #7C3AED;
|
||||
border: 1px solid #DDD6FE;
|
||||
font-size: 11px;
|
||||
padding: 2px 6px;
|
||||
}
|
||||
.badge.b-primary {
|
||||
background-color: #DBEAFE;
|
||||
color: #1D4ED8;
|
||||
border: 1px solid #BFDBFE;
|
||||
font-size: 11px;
|
||||
padding: 2px 6px;
|
||||
}
|
||||
.badge.b-green {
|
||||
background-color: #D1FAE5;
|
||||
color: #047857;
|
||||
border: 1px solid #A7F3D0;
|
||||
font-size: 11px;
|
||||
padding: 2px 6px;
|
||||
}
|
||||
.badge.b-yellow {
|
||||
background-color: #FEF3C7;
|
||||
color: #D97706;
|
||||
border: 1px solid #FDE68A;
|
||||
font-size: 11px;
|
||||
padding: 2px 6px;
|
||||
}
|
||||
|
||||
.text-tag {
|
||||
color: var(--text-muted);
|
||||
font-size: 16px;
|
||||
padding: 1px 5px;
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: 3px;
|
||||
background-color: var(--bg-light);
|
||||
}
|
||||
|
||||
.font-bold {
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
/* --- Responsive Design (Tablet & Mobile) --- */
|
||||
@media (max-width: 1200px) {
|
||||
.header-container { gap: 0.75rem; padding: 0 1rem; }
|
||||
.brand h1 { font-size: 1rem; }
|
||||
.brand h1 .sub-title { font-size: 0.75rem; }
|
||||
}
|
||||
|
||||
@media (max-width: 992px) {
|
||||
.main-header { height: auto; padding: 0.5rem 0; }
|
||||
.header-container { flex-direction: column; align-items: flex-start; gap: 0.5rem; }
|
||||
.integrated-nav { width: 100%; justify-content: flex-start; border-top: 1px solid var(--border-color); padding-top: 0.5rem; }
|
||||
.header-actions { width: 100%; justify-content: flex-end; padding-top: 0.5rem; }
|
||||
.content-area { padding: 0 1rem; }
|
||||
}
|
||||
|
||||
.gnb-trigger.admin-trigger {
|
||||
color: var(--text-muted);
|
||||
|
||||
@@ -1,39 +1,41 @@
|
||||
/* --- Dashboard View Specific Styles --- */
|
||||
|
||||
/* --- Premium Executive Dashboard View Specific Styles --- */
|
||||
.dashboard-section-title {
|
||||
padding: 0 0 1rem 0;
|
||||
font-size: 1.1rem;
|
||||
font-weight: 700;
|
||||
padding: 0 0 0 8px;
|
||||
font-size: 1.55rem;
|
||||
font-weight: 800;
|
||||
color: var(--text-main);
|
||||
letter-spacing: -0.02em;
|
||||
border-left: 4px solid var(--primary-color);
|
||||
margin-bottom: 1rem;
|
||||
line-height: 1.2;
|
||||
}
|
||||
|
||||
.dashboard-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(240px, 1fr));
|
||||
grid-template-columns: repeat(auto-fit, minmax(280px, 1fr));
|
||||
gap: 1.5rem;
|
||||
margin-bottom: 2rem;
|
||||
}
|
||||
|
||||
.stat-card {
|
||||
background-color: var(--white);
|
||||
padding: 1.5rem;
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: 8px;
|
||||
/* Premium Executive Divider-based Style (Line-based Division) */
|
||||
.dashboard-card, .stat-card {
|
||||
background: transparent;
|
||||
backdrop-filter: none;
|
||||
-webkit-backdrop-filter: none;
|
||||
border: none;
|
||||
border-bottom: 1px solid var(--border-color);
|
||||
box-shadow: none;
|
||||
border-radius: 0;
|
||||
padding: 1.5rem 0.5rem;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
transition: opacity 0.2s ease;
|
||||
}
|
||||
|
||||
.stat-card .title {
|
||||
font-size: 0.875rem;
|
||||
color: var(--text-muted);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.stat-card .value {
|
||||
font-size: 2.2rem;
|
||||
font-weight: 800;
|
||||
color: var(--primary-color);
|
||||
margin-top: 0.5rem;
|
||||
.dashboard-card:hover, .stat-card:hover {
|
||||
transform: none;
|
||||
box-shadow: none;
|
||||
opacity: 0.85;
|
||||
}
|
||||
|
||||
.dashboard-layout-2col {
|
||||
@@ -42,14 +44,14 @@
|
||||
gap: 1.5rem;
|
||||
}
|
||||
|
||||
.dashboard-layout-3col {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, 1fr);
|
||||
gap: 1.5rem;
|
||||
}
|
||||
|
||||
.dashboard-card {
|
||||
background-color: var(--white);
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: 8px;
|
||||
padding: 1.5rem;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-height: 360px;
|
||||
min-height: 380px;
|
||||
}
|
||||
|
||||
.dashboard-stats-grid {
|
||||
@@ -59,93 +61,169 @@
|
||||
margin-bottom: 2rem;
|
||||
}
|
||||
|
||||
.dashboard-card-mini {
|
||||
min-height: auto;
|
||||
/* Premium KPI Value Styling */
|
||||
.stat-value {
|
||||
font-size: 2.41rem;
|
||||
font-weight: 800;
|
||||
background: linear-gradient(135deg, #1E5149 0%, #3B82F6 100%);
|
||||
-webkit-background-clip: text;
|
||||
-webkit-text-fill-color: transparent;
|
||||
margin-top: 0.5rem;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.dashboard-card h4 {
|
||||
font-size: 1rem;
|
||||
font-weight: 700;
|
||||
color: var(--text-main);
|
||||
margin-bottom: 0.25rem;
|
||||
.stat-value-danger {
|
||||
background: linear-gradient(135deg, #E11D48 0%, #F59E0B 100%);
|
||||
-webkit-background-clip: text;
|
||||
-webkit-text-fill-color: transparent;
|
||||
}
|
||||
|
||||
.dashboard-card .description {
|
||||
font-size: 0.8125rem;
|
||||
.stat-label {
|
||||
font-size: 1.36rem;
|
||||
color: var(--text-muted);
|
||||
font-weight: 700;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
}
|
||||
|
||||
.stat-icon {
|
||||
width: 48px;
|
||||
height: 48px;
|
||||
border-radius: 12px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.dashboard-card .big-value {
|
||||
font-size: 2rem;
|
||||
font-weight: 700;
|
||||
color: var(--primary-color);
|
||||
}
|
||||
.icon-blue { background: rgba(59, 130, 246, 0.1); color: #3B82F6; }
|
||||
.icon-green { background: rgba(30, 81, 73, 0.1); color: #1E5149; }
|
||||
.icon-red { background: rgba(225, 29, 72, 0.1); color: #E11D48; }
|
||||
.icon-yellow { background: rgba(245, 158, 11, 0.1); color: #F59E0B; }
|
||||
|
||||
.dashboard-card .big-value.danger {
|
||||
color: var(--danger);
|
||||
}
|
||||
|
||||
.progress-container {
|
||||
width: 100%;
|
||||
height: 4px;
|
||||
background-color: var(--border-color);
|
||||
border-radius: 2px;
|
||||
overflow: hidden;
|
||||
margin-top: 0.5rem;
|
||||
}
|
||||
|
||||
.progress-fill {
|
||||
height: 100%;
|
||||
background-color: var(--primary-color);
|
||||
}
|
||||
|
||||
.progress-fill.danger {
|
||||
background-color: var(--danger);
|
||||
}
|
||||
|
||||
.chart-wrapper {
|
||||
height: 250px;
|
||||
}
|
||||
|
||||
.dashboard-table-container {
|
||||
.table-premium {
|
||||
background: white;
|
||||
border-radius: 8px;
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: 12px;
|
||||
box-shadow: 0 4px 15px rgba(0,0,0,0.05);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.dashboard-table-container table {
|
||||
.table-premium table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
}
|
||||
|
||||
.dashboard-table-container th {
|
||||
padding: 0.75rem;
|
||||
font-size: 0.8125rem;
|
||||
.table-premium th {
|
||||
background: #F8FAFC;
|
||||
color: #475569;
|
||||
font-weight: 700;
|
||||
color: var(--text-muted);
|
||||
border-bottom: 2px solid var(--border-color);
|
||||
text-align: center;
|
||||
padding: 1rem;
|
||||
text-transform: uppercase;
|
||||
font-size: 0.96rem;
|
||||
letter-spacing: 0.05em;
|
||||
}
|
||||
|
||||
.dashboard-table-container td {
|
||||
padding: 0.75rem;
|
||||
font-size: 0.8125rem;
|
||||
border-bottom: 1px solid var(--border-color);
|
||||
text-align: center;
|
||||
.table-premium td {
|
||||
padding: 1rem;
|
||||
border-bottom: 1px solid #E2E8F0;
|
||||
color: #1E293B;
|
||||
font-size: 16px;
|
||||
}
|
||||
|
||||
.badge-type {
|
||||
display: inline-block;
|
||||
padding: 2px 8px;
|
||||
border-radius: 4px;
|
||||
background: var(--bg-color);
|
||||
.table-premium tr:hover td {
|
||||
background: #F1F5F9;
|
||||
}
|
||||
|
||||
/* --- Slider/Carousel Specific Styles --- */
|
||||
.dashboard-header-wrapper {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 2rem;
|
||||
}
|
||||
|
||||
.slider-controls {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.slider-nav-btn {
|
||||
background: white;
|
||||
border: 1px solid var(--border-color);
|
||||
font-size: 0.75rem;
|
||||
box-shadow: 0 2px 8px rgba(0,0,0,0.05);
|
||||
border-radius: 50%;
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
cursor: pointer;
|
||||
color: var(--text-main);
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
.slider-nav-btn:hover {
|
||||
background: var(--primary-color);
|
||||
color: white;
|
||||
border-color: var(--primary-color);
|
||||
}
|
||||
|
||||
.slider-nav-btn:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.page-info {
|
||||
font-size: 0.96rem;
|
||||
color: var(--text-muted);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.page-btns button {
|
||||
padding: 0.3rem 0.75rem;
|
||||
border: 1px solid var(--border-color);
|
||||
background: var(--white);
|
||||
border-radius: 4px;
|
||||
font-size: 0.96rem;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.page-btns button:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.slider-indicator {
|
||||
font-weight: 700;
|
||||
color: var(--text-muted);
|
||||
font-size: 1.41rem;
|
||||
}
|
||||
|
||||
.dashboard-slider-viewport {
|
||||
width: 100%;
|
||||
overflow: hidden;
|
||||
padding: 0.5rem 0;
|
||||
}
|
||||
|
||||
.dashboard-slider-track {
|
||||
display: flex;
|
||||
transition: transform 0.5s cubic-bezier(0.25, 0.8, 0.25, 1);
|
||||
width: 400%; /* For 4 pages */
|
||||
}
|
||||
|
||||
.dashboard-slide {
|
||||
width: 25%; /* 100% / 4 pages */
|
||||
flex-shrink: 0;
|
||||
padding: 0 2px; /* Slight padding to avoid cutting off box-shadows */
|
||||
height: calc(100vh - 150px);
|
||||
min-height: 520px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
/* --- Location View Styles --- */
|
||||
.location-layout {
|
||||
@@ -302,26 +380,6 @@
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.page-info {
|
||||
font-size: 0.75rem;
|
||||
color: var(--text-muted);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.page-btns button {
|
||||
padding: 0.3rem 0.75rem;
|
||||
border: 1px solid var(--border-color);
|
||||
background: var(--white);
|
||||
border-radius: 4px;
|
||||
font-size: 0.75rem;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.page-btns button:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.location-main-content {
|
||||
flex: 1;
|
||||
display: grid;
|
||||
|
||||
@@ -19,7 +19,7 @@
|
||||
|
||||
.guide-tab {
|
||||
padding: 0.75rem 1.25rem;
|
||||
font-size: 13px;
|
||||
font-size: 18px;
|
||||
font-weight: 600;
|
||||
color: var(--text-muted);
|
||||
cursor: pointer;
|
||||
@@ -72,7 +72,7 @@
|
||||
}
|
||||
|
||||
.guide-section h3 {
|
||||
font-size: 1rem;
|
||||
font-size: 1.3rem;
|
||||
padding-bottom: 0.5rem;
|
||||
border-bottom: 2px solid var(--primary-color);
|
||||
color: var(--primary-color);
|
||||
@@ -83,7 +83,7 @@
|
||||
}
|
||||
|
||||
.guide-text {
|
||||
font-size: 13px;
|
||||
font-size: 18px;
|
||||
color: var(--text-main);
|
||||
line-height: 1.7;
|
||||
margin: 0;
|
||||
@@ -127,7 +127,7 @@
|
||||
border-radius: 50%;
|
||||
background-color: var(--primary-color);
|
||||
color: white;
|
||||
font-size: 12px;
|
||||
font-size: 17px;
|
||||
font-weight: 700;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
@@ -138,12 +138,12 @@
|
||||
.flow-step .step-label {
|
||||
font-weight: 700;
|
||||
color: var(--text-main);
|
||||
font-size: 13px;
|
||||
font-size: 18px;
|
||||
display: block;
|
||||
}
|
||||
|
||||
.flow-step .step-desc {
|
||||
font-size: 12px;
|
||||
font-size: 17px;
|
||||
color: var(--text-muted);
|
||||
line-height: 1.5;
|
||||
margin-top: 4px;
|
||||
@@ -159,7 +159,7 @@
|
||||
.guide-info-table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
font-size: 13px;
|
||||
font-size: 18px;
|
||||
}
|
||||
|
||||
.guide-info-table th {
|
||||
@@ -182,7 +182,7 @@
|
||||
background: var(--primary-light);
|
||||
border-left: 4px solid var(--primary-color);
|
||||
padding: 1rem;
|
||||
font-size: 13px;
|
||||
font-size: 18px;
|
||||
color: var(--primary-color);
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
@@ -40,10 +40,9 @@
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.modal-header h2, .modal-title {
|
||||
margin: 0;
|
||||
font-size: 1.125rem;
|
||||
font-weight: 800;
|
||||
.modal-header h2 {
|
||||
font-size: 1.4rem;
|
||||
font-weight: 600;
|
||||
letter-spacing: -0.02em;
|
||||
color: var(--white);
|
||||
line-height: 1.2;
|
||||
@@ -99,7 +98,7 @@
|
||||
/* Section Title for Grouping */
|
||||
.form-section-title {
|
||||
grid-column: span 2;
|
||||
font-size: 0.875rem;
|
||||
font-size: 1.15rem;
|
||||
font-weight: 700;
|
||||
color: var(--primary-color);
|
||||
padding: 1.5rem 0 0.5rem 0;
|
||||
@@ -114,7 +113,7 @@
|
||||
}
|
||||
|
||||
.form-group label {
|
||||
font-size: 0.8125rem;
|
||||
font-size: 1.1rem;
|
||||
font-weight: 600;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
@@ -127,7 +126,7 @@
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: 4px;
|
||||
font-family: inherit;
|
||||
font-size: 0.8125rem;
|
||||
font-size: 1.15rem;
|
||||
outline: none;
|
||||
transition: all 0.2s;
|
||||
background-color: var(--white);
|
||||
@@ -210,7 +209,7 @@
|
||||
padding: 0.75rem 1rem;
|
||||
border-radius: 8px;
|
||||
cursor: pointer;
|
||||
font-size: 13px;
|
||||
font-size: 18px;
|
||||
font-weight: 500;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
@@ -267,7 +266,7 @@
|
||||
.preview-table th {
|
||||
padding: 0.75rem 1rem;
|
||||
text-align: left;
|
||||
font-size: 12px;
|
||||
font-size: 17px;
|
||||
font-weight: 600;
|
||||
border-bottom: 1px solid var(--border-color);
|
||||
color: var(--text-muted);
|
||||
@@ -275,7 +274,7 @@
|
||||
|
||||
.preview-table td {
|
||||
padding: 0.75rem 1rem;
|
||||
font-size: 13px;
|
||||
font-size: 18px;
|
||||
border-bottom: 1px solid #f1f5f9;
|
||||
color: var(--text-main);
|
||||
}
|
||||
@@ -310,7 +309,7 @@
|
||||
}
|
||||
|
||||
.history-header h3 {
|
||||
font-size: 0.9375rem;
|
||||
font-size: 1.25rem;
|
||||
font-weight: 600;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
@@ -351,16 +350,21 @@
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
max-height: 500px;
|
||||
padding-right: 0.5rem;
|
||||
padding-right: 8px;
|
||||
margin-top: 8px;
|
||||
}
|
||||
|
||||
.history-item {
|
||||
position: relative;
|
||||
padding-left: 1.25rem;
|
||||
padding-bottom: 1.5rem;
|
||||
padding-left: 20px;
|
||||
padding-bottom: 20px;
|
||||
border-left: 2px solid var(--border-color);
|
||||
}
|
||||
|
||||
.history-item:last-child {
|
||||
border-left: 2px solid transparent;
|
||||
}
|
||||
|
||||
.history-item::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
@@ -371,39 +375,73 @@
|
||||
border-radius: 50%;
|
||||
background-color: var(--white);
|
||||
border: 2px solid var(--primary-color);
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
.history-item:last-child {
|
||||
border-left: 2px solid transparent;
|
||||
/* Event Specific Markers */
|
||||
.history-item.evt-dept::before { border-color: #3b82f6; }
|
||||
.history-item.evt-user::before { border-color: #8b5cf6; }
|
||||
.history-item.evt-role::before { border-color: #10b981; }
|
||||
.history-item.evt-status::before { border-color: #f59e0b; }
|
||||
|
||||
.history-header-row {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.history-date {
|
||||
font-size: 0.75rem;
|
||||
font-size: 11px;
|
||||
color: var(--text-muted);
|
||||
font-weight: 500;
|
||||
margin-bottom: 0.25rem;
|
||||
}
|
||||
|
||||
.history-tag {
|
||||
font-size: 10px;
|
||||
font-weight: 700;
|
||||
padding: 2px 6px;
|
||||
border-radius: 10px;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.tag-dept { background: #eff6ff; color: #3b82f6; }
|
||||
.tag-user { background: #f5f3ff; color: #8b5cf6; }
|
||||
.tag-role { background: #ecfdf5; color: #10b981; }
|
||||
.tag-status { background: #fffbeb; color: #f59e0b; }
|
||||
.tag-default { background: #f3f4f6; color: #6b7280; }
|
||||
|
||||
.history-user {
|
||||
font-size: 0.75rem;
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
color: var(--primary-color);
|
||||
margin-bottom: 0.25rem;
|
||||
color: var(--text-main);
|
||||
margin-bottom: 6px;
|
||||
display: block;
|
||||
}
|
||||
|
||||
.history-details {
|
||||
font-size: 0.8125rem;
|
||||
font-size: 12.5px;
|
||||
color: var(--text-main);
|
||||
line-height: 1.4;
|
||||
white-space: pre-wrap;
|
||||
line-height: 1.5;
|
||||
background: #f8fafc;
|
||||
padding: 8px 10px;
|
||||
border-radius: 6px;
|
||||
border: 1px solid #f1f5f9;
|
||||
word-break: break-all;
|
||||
}
|
||||
|
||||
.history-arrow {
|
||||
display: inline-block;
|
||||
margin: 0 4px;
|
||||
color: var(--text-muted);
|
||||
font-weight: 400;
|
||||
}
|
||||
|
||||
.empty-history {
|
||||
padding: 2rem 0;
|
||||
text-align: center;
|
||||
color: var(--text-muted);
|
||||
font-size: 0.8125rem;
|
||||
font-size: 1.1rem;
|
||||
}
|
||||
|
||||
/* Dashboard Detail Modal Table Fixed Header */
|
||||
@@ -436,7 +474,7 @@
|
||||
border-bottom: 2px solid var(--border-color);
|
||||
box-shadow: none;
|
||||
padding: 0.75rem 1rem;
|
||||
font-size: 0.8125rem;
|
||||
font-size: 1.1rem;
|
||||
font-weight: 600;
|
||||
color: var(--text-main);
|
||||
text-align: left;
|
||||
@@ -446,7 +484,7 @@
|
||||
#dashboard-detail-modal tbody td {
|
||||
padding: 0.75rem 1rem;
|
||||
border-bottom: 1px solid var(--border-color);
|
||||
font-size: 0.8125rem;
|
||||
font-size: 1.1rem;
|
||||
color: var(--text-main);
|
||||
white-space: nowrap;
|
||||
}
|
||||
@@ -464,7 +502,7 @@
|
||||
display: inline-block;
|
||||
padding: 0.125rem 0.5rem;
|
||||
border-radius: 4px;
|
||||
font-size: 0.75rem;
|
||||
font-size: 1.05rem;
|
||||
font-weight: 500;
|
||||
line-height: 1.5;
|
||||
}
|
||||
@@ -663,3 +701,100 @@
|
||||
.location-detail-container select {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
/* Dynamic Remote Info Row */
|
||||
.remote-info-row {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
padding: 8px 0;
|
||||
border-bottom: 1px dashed var(--border-color);
|
||||
}
|
||||
|
||||
.remote-info-row:last-child {
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
.ri-line {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.ri-line select,
|
||||
.ri-line input {
|
||||
height: 38px;
|
||||
box-sizing: border-box;
|
||||
font-size: 13px;
|
||||
padding: 0 10px;
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: 4px;
|
||||
outline: none;
|
||||
background-color: var(--white);
|
||||
color: var(--text-main);
|
||||
transition: border-color 0.2s;
|
||||
}
|
||||
|
||||
.ri-line select:disabled,
|
||||
.ri-line input[readonly] {
|
||||
background-color: var(--bg-muted);
|
||||
border-color: transparent;
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
.ri-line select:focus,
|
||||
.ri-line input:focus {
|
||||
border-color: var(--primary-color);
|
||||
box-shadow: 0 0 0 2px rgba(30, 81, 73, 0.1);
|
||||
}
|
||||
|
||||
.ri-type, .ri-tool {
|
||||
width: 110px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.ri-val1, .ri-id, .ri-pw {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.ri-remove-btn {
|
||||
height: 38px;
|
||||
width: 38px;
|
||||
padding: 0;
|
||||
color: #E11D48;
|
||||
border: 1px solid #E11D48;
|
||||
background: transparent;
|
||||
border-radius: 4px;
|
||||
cursor: pointer;
|
||||
flex-shrink: 0;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 18px;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
.ri-remove-btn:hover {
|
||||
background-color: #FFF1F2;
|
||||
}
|
||||
|
||||
.ri-spacer {
|
||||
width: 46px; /* 38px btn + 8px gap */
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.ri-connector {
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
border-left: 1.5px solid #94a3b8;
|
||||
border-bottom: 1.5px solid #94a3b8;
|
||||
margin-top: -24px;
|
||||
margin-left: 12px;
|
||||
border-bottom-left-radius: 6px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.ri-cred-line {
|
||||
margin-top: -4px;
|
||||
}
|
||||
|
||||
@@ -15,18 +15,10 @@
|
||||
color: var(--primary-color);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.page-title i {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.page-title svg {
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
border-left: 4px solid var(--primary-color);
|
||||
padding-left: 8px;
|
||||
line-height: 1.2;
|
||||
}
|
||||
|
||||
.page-description {
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -37,34 +37,34 @@ export function renderSwDashboard(container: HTMLElement) {
|
||||
<h3 class="dashboard-section-title">소프트웨어 라이선스 현황</h3>
|
||||
|
||||
<div class="dashboard-layout-2col" style="margin-bottom: 1.5rem;">
|
||||
<div class="dashboard-card dashboard-card-mini clickable-card" data-action="ext-usage">
|
||||
<h4>외부 소프트웨어 사용율</h4>
|
||||
<div class="description">${extQty}카피 중 ${extUsed}개 할당</div>
|
||||
<div class="big-value">${extPer}%</div>
|
||||
<div class="progress-container">
|
||||
<div class="progress-fill" style="width: ${extPer}%;"></div>
|
||||
<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>
|
||||
</div>
|
||||
<div class="dashboard-card dashboard-card-mini clickable-card" data-action="int-usage">
|
||||
<h4>내부 소프트웨어 현황</h4>
|
||||
<div class="description">등록된 내부 솔루션: ${intTotal}개</div>
|
||||
<div class="big-value">${intPer}%</div>
|
||||
<div class="progress-container">
|
||||
<div class="progress-fill" style="width: ${intPer}%;"></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>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<h3 class="dashboard-section-title">2026년 누적 도입 비용 분석</h3>
|
||||
|
||||
<div class="dashboard-layout-2col">
|
||||
<div class="dashboard-card dashboard-card-mini">
|
||||
<h4>외부 SW 누적 비용 (2026)</h4>
|
||||
<div class="big-value">₩ ${extCost2026.toLocaleString()}</div>
|
||||
<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>
|
||||
<div class="dashboard-card dashboard-card-mini">
|
||||
<h4>내부 SW 누적 비용 (2026)</h4>
|
||||
<div class="big-value" style="color:#3b82f6;">₩ ${intCost2026.toLocaleString()}</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>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -5,6 +5,133 @@ import { renderFilterBar, applyCommonFilters } from '../../core/filterHandler';
|
||||
import { state } from '../../core/state';
|
||||
import { IMAGE_LOCATIONS } from '../../components/Modal/SharedData';
|
||||
|
||||
declare var Chart: any;
|
||||
let pcFlowChartInstance: any = null;
|
||||
|
||||
// ─── 100점 만점 감점형 성능 점수 계산 (CPU + RAM + GPU + 연식) ───
|
||||
function calculatePcScoreDeductive(cpu: string, ram: string, gpu: string, purchaseDate: string): number {
|
||||
let score = 100;
|
||||
if (!cpu) cpu = '';
|
||||
if (!ram) ram = '';
|
||||
if (!gpu) gpu = '';
|
||||
|
||||
const cpuUpper = cpu.toUpperCase();
|
||||
const ramUpper = ram.toUpperCase();
|
||||
const gpuUpper = gpu.toUpperCase();
|
||||
|
||||
// 1. CPU 등급 감점 (최대 -30점)
|
||||
let cpuDeduction = 0;
|
||||
if (cpuUpper.includes('I9') || cpuUpper.includes('RYZEN 9') || cpuUpper.includes('RYZEN9')) {
|
||||
cpuDeduction = 0;
|
||||
} else if (cpuUpper.includes('I7') || cpuUpper.includes('RYZEN 7') || cpuUpper.includes('RYZEN7')) {
|
||||
cpuDeduction = 5;
|
||||
} else if (cpuUpper.includes('I5') || cpuUpper.includes('RYZEN 5') || cpuUpper.includes('RYZEN5')) {
|
||||
cpuDeduction = 15;
|
||||
} else if (cpuUpper.includes('I3') || cpuUpper.includes('RYZEN 3') || cpuUpper.includes('RYZEN3')) {
|
||||
cpuDeduction = 25;
|
||||
} else {
|
||||
cpuDeduction = 30;
|
||||
}
|
||||
score -= cpuDeduction;
|
||||
|
||||
// 2. CPU 세대 노후 감점 (최대 -15점)
|
||||
let genDeduction = 0;
|
||||
const intelMatch = cpuUpper.match(/I\d-?(\d+)/);
|
||||
let gen = 0;
|
||||
if (intelMatch && intelMatch[1]) {
|
||||
const numStr = intelMatch[1];
|
||||
if (numStr.length === 5) gen = parseInt(numStr.substring(0, 2), 10);
|
||||
else if (numStr.length === 4) gen = parseInt(numStr.substring(0, 1), 10);
|
||||
}
|
||||
|
||||
const amdMatch = cpuUpper.match(/RYZEN\s?\d\s?-?(\d+)/);
|
||||
let amdGen = 0;
|
||||
if (amdMatch && amdMatch[1] && !intelMatch) {
|
||||
const numStr = amdMatch[1];
|
||||
if (numStr.length === 4) amdGen = parseInt(numStr.substring(0, 1), 10);
|
||||
}
|
||||
|
||||
if (intelMatch) {
|
||||
if (gen >= 12) genDeduction = 0;
|
||||
else if (gen >= 10) genDeduction = 5;
|
||||
else if (gen >= 8) genDeduction = 10;
|
||||
else genDeduction = 15;
|
||||
} else if (amdMatch) {
|
||||
if (amdGen >= 5) genDeduction = 0;
|
||||
else if (amdGen >= 3) genDeduction = 5;
|
||||
else genDeduction = 10;
|
||||
} else {
|
||||
genDeduction = 15;
|
||||
}
|
||||
score -= genDeduction;
|
||||
|
||||
// 3. RAM 용량 감점 (최대 -25점)
|
||||
const ramMatch = ramUpper.match(/(\d+)\s*GB/);
|
||||
let ramDeduction = 25;
|
||||
if (ramMatch && ramMatch[1]) {
|
||||
const ramVal = parseInt(ramMatch[1], 10);
|
||||
if (ramVal >= 32) ramDeduction = 0;
|
||||
else if (ramVal >= 16) ramDeduction = 10;
|
||||
else if (ramVal >= 8) ramDeduction = 20;
|
||||
else ramDeduction = 25;
|
||||
}
|
||||
score -= ramDeduction;
|
||||
|
||||
// 4. GPU 성능 감점 (최대 -25점)
|
||||
let gpuDeduction = 25;
|
||||
if (!gpuUpper || gpuUpper === '-' || gpuUpper.trim() === '') {
|
||||
gpuDeduction = 25;
|
||||
} else if (
|
||||
gpuUpper.includes('RTX 4090') || gpuUpper.includes('RTX 4080') || gpuUpper.includes('RTX 4070') ||
|
||||
gpuUpper.includes('RTX 3090') || gpuUpper.includes('RTX 3080') ||
|
||||
gpuUpper.includes('RTX A5000') || gpuUpper.includes('RTX A6000') || gpuUpper.includes('RTX A4000')
|
||||
) {
|
||||
gpuDeduction = 0;
|
||||
} else if (
|
||||
gpuUpper.includes('RTX 3070') || gpuUpper.includes('RTX 3060') || gpuUpper.includes('RTX 2060') ||
|
||||
gpuUpper.includes('RTX A2000') || gpuUpper.includes('RTX A3000') || gpuUpper.includes('QUADRO')
|
||||
) {
|
||||
gpuDeduction = 5;
|
||||
} else if (
|
||||
gpuUpper.includes('GTX 1660') || gpuUpper.includes('GTX 1080') || gpuUpper.includes('GTX 1070') ||
|
||||
gpuUpper.includes('GTX 1060') || gpuUpper.includes('RX 6700') || gpuUpper.includes('RX 6600')
|
||||
) {
|
||||
gpuDeduction = 15;
|
||||
} else {
|
||||
gpuDeduction = 25;
|
||||
}
|
||||
score -= gpuDeduction;
|
||||
|
||||
// 5. 연식(노후도) 감점 (최대 -15점)
|
||||
let age = 0;
|
||||
if (purchaseDate && purchaseDate !== '-') {
|
||||
let normalized = purchaseDate.replace(/\./g, '-').trim();
|
||||
if (/^\d{6}$/.test(normalized)) {
|
||||
normalized = `${normalized.substring(0, 4)}-${normalized.substring(4, 6)}`;
|
||||
}
|
||||
const purchase = new Date(normalized);
|
||||
if (!isNaN(purchase.getTime())) {
|
||||
// 2026년 5월 31일 기준 경과연수 계산
|
||||
const mockToday = new Date('2026-05-31');
|
||||
const diffMs = mockToday.getTime() - purchase.getTime();
|
||||
age = diffMs / (1000 * 60 * 60 * 24 * 365.25);
|
||||
age = Math.max(0, parseFloat(age.toFixed(1)));
|
||||
}
|
||||
}
|
||||
|
||||
let ageDeduction = 0;
|
||||
if (age < 1) ageDeduction = 0;
|
||||
else if (age < 2) ageDeduction = 3;
|
||||
else if (age < 3) ageDeduction = 6;
|
||||
else if (age < 4) ageDeduction = 9;
|
||||
else if (age < 5) ageDeduction = 12;
|
||||
else ageDeduction = 15;
|
||||
|
||||
score -= ageDeduction;
|
||||
|
||||
return Math.max(10, score);
|
||||
}
|
||||
|
||||
export interface ColumnDef {
|
||||
header: string;
|
||||
sortKey?: string;
|
||||
@@ -25,6 +152,7 @@ export interface ListViewConfig {
|
||||
showLoc?: boolean;
|
||||
showField?: boolean;
|
||||
showType?: boolean;
|
||||
showStatus?: boolean;
|
||||
};
|
||||
columns: ColumnDef[];
|
||||
onRowClick?: (asset: any) => void;
|
||||
@@ -39,10 +167,19 @@ export function createListView(container: HTMLElement, config: ListViewConfig) {
|
||||
|
||||
const fullList = config.dataSource();
|
||||
let sortState: SortState = config.persistentSortState || { key: '', direction: 'asc' };
|
||||
let currentFilters: any = { keyword: '', corp: '', dept: '', loc: '', field: '', type: '' };
|
||||
|
||||
const isServer = config.title === '서버';
|
||||
if (!isServer) {
|
||||
if (!(state as any).listFilters) {
|
||||
(state as any).listFilters = {};
|
||||
}
|
||||
const filterKey = config.title;
|
||||
if (!(state as any).listFilters[filterKey]) {
|
||||
(state as any).listFilters[filterKey] = { keyword: '', corp: '', dept: '', loc: '', field: '', type: '', status: '' };
|
||||
}
|
||||
let currentFilters: any = (state as any).listFilters[filterKey];
|
||||
|
||||
// 서버 및 PC 탭이 아닐 경우 '자산 현황' 뷰 진입 방지 및 강제 'asset' 모드
|
||||
const isServerOrPc = config.title === '서버' || config.title === 'PC';
|
||||
if (!isServerOrPc) {
|
||||
(state as any).currentViewMode = 'asset';
|
||||
} else if (!(state as any).currentViewMode) {
|
||||
(state as any).currentViewMode = 'system';
|
||||
@@ -51,15 +188,27 @@ export function createListView(container: HTMLElement, config: ListViewConfig) {
|
||||
// 2. 뷰 전환 토글 버튼 생성
|
||||
const toggleWrapper = document.createElement('div');
|
||||
toggleWrapper.className = 'view-toggle-container';
|
||||
|
||||
const showPcFlowBtn = config.title === 'PC';
|
||||
toggleWrapper.innerHTML = `
|
||||
<div class="view-toggle-flex-row">
|
||||
<div class="view-toggle" style="display: ${isServer ? 'flex' : 'none'};">
|
||||
<div style="display: flex; justify-content: space-between; align-items: center; width: 100%;">
|
||||
<div class="view-toggle" style="display: ${isServerOrPc ? 'flex' : 'none'}; gap: 0;">
|
||||
<button class="toggle-btn ${(state as any).currentViewMode === 'system' ? 'active' : ''}" data-mode="system">자산 현황</button>
|
||||
<button class="toggle-btn ${(state as any).currentViewMode === 'asset' ? 'active' : ''}" data-mode="asset">자산 목록</button>
|
||||
</div>
|
||||
<button id="btn-add-asset" class="btn btn-primary btn-add-new">
|
||||
<span class="plus-icon">+</span> 자산 추가
|
||||
</button>
|
||||
<div style="display: flex; gap: 8px;">
|
||||
${showPcFlowBtn ? `
|
||||
<button id="btn-goto-parts-master" style="padding: 6px 14px !important; font-size: 12px !important; font-weight: 700 !important; background: white !important; color: var(--primary-color) !important; border: 1px solid var(--primary-color) !important; border-radius: 4px !important; cursor: pointer !important; display: flex !important; align-items: center !important; justify-content: center !important; gap: 6px !important; width: fit-content !important; min-width: 0 !important; white-space: nowrap !important;">
|
||||
<i data-lucide="settings" style="width: 14px; height: 14px;"></i> 부품 마스터
|
||||
</button>
|
||||
<button id="btn-pc-flow" style="padding: 6px 14px; font-size: 12px; font-weight: 700; background: white; color: var(--primary-color); border: 1px solid var(--primary-color); border-radius: 4px; cursor: pointer; display: flex; align-items: center; gap: 6px;">
|
||||
PC 이동/반납
|
||||
</button>
|
||||
` : ''}
|
||||
<button id="btn-add-asset" style="padding: 6px 14px; font-size: 12px; font-weight: 700; background: #1E5149; color: white; border: none; border-radius: 4px; cursor: pointer; display: flex; align-items: center; gap: 4px;">
|
||||
<span style="font-size: 16px; line-height: 1;">+</span> 자산 추가
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
container.appendChild(toggleWrapper);
|
||||
@@ -75,7 +224,7 @@ export function createListView(container: HTMLElement, config: ListViewConfig) {
|
||||
container.appendChild(contentWrapper);
|
||||
|
||||
// --- 내부 상태 ---
|
||||
let selectedLocation: string | null = '기술개발센터';
|
||||
let selectedLocation: string | null = null;
|
||||
let selectedDetailLocation: string | null = null;
|
||||
let dynamicMapConfig: Record<string, any[]> = {};
|
||||
|
||||
@@ -91,8 +240,37 @@ export function createListView(container: HTMLElement, config: ListViewConfig) {
|
||||
const renderSystemStatus = () => {
|
||||
const isPcView = config.title === 'PC';
|
||||
|
||||
const extStats = { total: 0, locCounts: {} as Record<string, number>, typeCounts: {} as Record<string, number>, typeLocMap: {} as Record<string, Record<string, number>>, locWarning: 0, typeWarning: 0 };
|
||||
const intStats = { total: 0, locCounts: {} as Record<string, number>, typeCounts: {} as Record<string, number>, typeLocMap: {} as Record<string, Record<string, number>> };
|
||||
// 실제 보유 자산이 존재하는 위치 목록만 추출 (0대인 곳 제외)
|
||||
const validLocations = Array.from(new Set(fullList.map(a => a[ASSET_SCHEMA.LOCATION.key] || '미지정')))
|
||||
.filter(l => {
|
||||
const count = fullList.filter(a => (a[ASSET_SCHEMA.LOCATION.key] || '미지정') === l).length;
|
||||
return count > 0;
|
||||
})
|
||||
.sort();
|
||||
|
||||
// 초기값이나 유효하지 않은 값이 지정되어 있다면 첫 번째 유효 위치로 동적 갱신
|
||||
if (!selectedLocation || !validLocations.includes(selectedLocation)) {
|
||||
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>>, // 유형별 위치 분포
|
||||
locWarning: 0,
|
||||
typeWarning: 0
|
||||
};
|
||||
const intStats = {
|
||||
total: 0,
|
||||
locCounts: {} as Record<string, number>,
|
||||
typeCounts: {} as Record<string, number>,
|
||||
typeLocMap: {} as Record<string, Record<string, number>>
|
||||
};
|
||||
|
||||
const checkAnomaly = (serviceType: string, loc: string, type: string) => {
|
||||
if (serviceType !== '외부') return { isWarning: false, isLocWarning: false, isTypeWarning: false };
|
||||
@@ -159,38 +337,81 @@ export function createListView(container: HTMLElement, config: ListViewConfig) {
|
||||
<div class="stat-group-item bordered">${generateDetailStatHTML('내부 (테스트) 상세', intStats)}</div>
|
||||
</div>
|
||||
|
||||
<div class="dashboard-main-flex">
|
||||
<div class="list-section">
|
||||
<div class="section-header-flex">
|
||||
<h4 id="list-section-title">자산 현황 목록</h4>
|
||||
<div class="filter-controls">
|
||||
<span class="filter-label">위치:</span>
|
||||
<select id="select-loc" class="filter-select">
|
||||
<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(--border-color);">
|
||||
<div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 1rem; flex-shrink: 0;">
|
||||
<h4 id="list-section-title" style="font-size: 14px; font-weight: 700; color: var(--text-main); margin:0;">
|
||||
${isPcView ? `🔄 PC 유동 이력 (${new Date().getMonth() + 1}월)` : '자산 현황 목록'}
|
||||
</h4>
|
||||
${!isPcView ? `
|
||||
<div style="display: flex; align-items: center; gap: 8px;">
|
||||
<span style="font-size: 11px; font-weight: 600; color: var(--text-muted);">위치:</span>
|
||||
<select id="select-loc" style="padding: 2px 8px; font-size: 11px; border-radius: 4px; border: 1px solid var(--border-color); outline: none; background: white; cursor:pointer; font-family: 'Pretendard';">
|
||||
<option value="">전체</option>
|
||||
${Array.from(new Set(fullList.map(a => a[ASSET_SCHEMA.LOCATION.key] || '미지정'))).sort().map(l => `<option value="${l}" ${l === selectedLocation ? 'selected' : ''}>${l}</option>`).join('')}
|
||||
${validLocations.map(l => `<option value="${l}" ${l === selectedLocation ? 'selected' : ''}>${l}</option>`).join('')}
|
||||
</select>
|
||||
<select id="select-detail-loc" class="filter-select select-detail-loc"></select>
|
||||
</div>
|
||||
` : ''}
|
||||
</div>
|
||||
<div class="mini-table-container">
|
||||
<table class="mini-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th style="width: 80px;">분류</th>
|
||||
<th>용도/자산명</th>
|
||||
<th style="width: 90px;">관리자(정)</th>
|
||||
<th style="width: 90px;">관리자(부)</th>
|
||||
<th style="width: 100px;">상세위치</th>
|
||||
</tr>
|
||||
<div style="flex: 1; overflow-y: auto;">
|
||||
<table style="width: 100%; border-collapse: collapse; table-layout: fixed;">
|
||||
<thead style="position: sticky; top: 0; background: #fff; z-index: 10;">
|
||||
${isPcView ? `
|
||||
<tr style="text-align: left; font-size: 11px; color: var(--text-muted);">
|
||||
<th style="padding: 10px 8px; font-weight: 700; border-bottom: 2px solid var(--border-color); width: 120px; background: #fff; white-space: nowrap;">일자</th>
|
||||
<th style="padding: 10px 8px; font-weight: 700; border-bottom: 2px solid var(--border-color); width: 100px; background: #fff; white-space: nowrap; text-align: center;">담당자</th>
|
||||
<th style="padding: 10px 8px; font-weight: 700; border-bottom: 2px solid var(--border-color); width: 60px; background: #fff; white-space: nowrap; text-align: center;">구분</th>
|
||||
<th style="padding: 10px 8px; font-weight: 700; border-bottom: 2px solid var(--border-color); width: 90px; background: #fff; white-space: nowrap; text-align: center;">사용자</th>
|
||||
<th style="padding: 10px 8px; font-weight: 700; border-bottom: 2px solid var(--border-color); width: 90px; background: #fff; white-space: nowrap; text-align: center;">인수자</th>
|
||||
<th style="padding: 10px 8px; font-weight: 700; border-bottom: 2px solid var(--border-color); width: 160px; background: #fff; white-space: nowrap; text-align: center;">자산번호</th>
|
||||
<th style="padding: 10px 8px; font-weight: 700; border-bottom: 2px solid var(--border-color); background: #fff; white-space: nowrap;">상세</th>
|
||||
</tr>
|
||||
` : `
|
||||
<tr style="text-align: left; font-size: 11px; color: var(--text-muted);">
|
||||
<th style="padding: 10px 0; font-weight: 700; border-bottom: 2px solid var(--border-color); width: 80px; text-align:center; background: #fff;">분류</th>
|
||||
<th style="padding: 10px 0; font-weight: 700; border-bottom: 2px solid var(--border-color); width: 130px; background: #fff;">용도/자산명</th>
|
||||
<th style="padding: 10px 0; font-weight: 700; border-bottom: 2px solid var(--border-color); text-align:center; width: 90px; background: #fff;">관리자(정)</th>
|
||||
<th style="padding: 10px 0; font-weight: 700; border-bottom: 2px solid var(--border-color); text-align:center; width: 90px; background: #fff;">관리자(부)</th>
|
||||
<th style="padding: 10px 0; text-align: center; font-weight: 700; border-bottom: 2px solid var(--border-color); width: 100px; background: #fff;">상세위치</th>
|
||||
</tr>
|
||||
`}
|
||||
</thead>
|
||||
<tbody id="system-status-tbody"></tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="system-detail-panel" class="detail-panel">
|
||||
<div id="detail-empty-state" class="detail-empty-state">
|
||||
<p>목록에서 자산을 선택하면 상세 정보가 표시됩니다.</p>
|
||||
<!-- 우측: 상세 정보 패널 (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" style="height: 100%; display: flex; flex-direction: column; color: var(--text-muted); text-align: center; overflow-y: auto; box-sizing: border-box; width: 100%; justify-content: ${isPcView ? 'flex-start' : 'center'}; align-items: ${isPcView ? 'stretch' : 'center'};">
|
||||
${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;">
|
||||
<h4 style="margin: 0; font-size: 14px; font-weight: 700; color: #E11D48; display: flex; align-items: center; gap: 6px; white-space: nowrap;">
|
||||
⚠️ 사양 주의 장비 현황 (부족/오버스펙)
|
||||
</h4>
|
||||
</div>
|
||||
<div style="flex: 1; overflow-y: auto;">
|
||||
<table style="width: 100%; border-collapse: collapse; table-layout: fixed;">
|
||||
<thead style="position: sticky; top: 0; background: #fff; z-index: 10;">
|
||||
<tr style="text-align: left; font-size: 11px; color: var(--text-muted);">
|
||||
<th style="padding: 10px 0; font-weight: 700; border-bottom: 2px solid var(--border-color); width: 65px; background: #fff; white-space: nowrap;">사용자</th>
|
||||
<th style="padding: 10px 0; font-weight: 700; border-bottom: 2px solid var(--border-color); width: 115px; background: #fff; white-space: nowrap;">부서 (직무)</th>
|
||||
<th style="padding: 10px 0; font-weight: 700; border-bottom: 2px solid var(--border-color); width: 65px; background: #fff; white-space: nowrap; text-align: center;">상태</th>
|
||||
<th style="padding: 10px 0; font-weight: 700; border-bottom: 2px solid var(--border-color); background: #fff; white-space: nowrap;">자산코드</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="spec-mismatch-tbody" style="font-size: 12px;">
|
||||
<tr><td colspan="4" style="text-align:center; padding:1.5rem; color:#94A3B8;">사양 주의 자산이 없습니다.</td></tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
` : `
|
||||
<p style="font-size: 1.125rem; font-weight: 500; color: #94A3B8;">목록에서 자산을 선택하면<br>상세 정보와 배치도가 표시됩니다.</p>
|
||||
`}
|
||||
</div>
|
||||
<div id="detail-content" class="detail-content hidden">
|
||||
<div class="detail-summary-header">
|
||||
@@ -199,15 +420,26 @@ export function createListView(container: HTMLElement, config: ListViewConfig) {
|
||||
<div class="summary-item"><label>유형</label><div id="detail-asset-type" class="type-value"></div></div>
|
||||
<div class="summary-item flex-1"><label>메모 요약</label><div id="detail-memo" class="memo-value"></div></div>
|
||||
</div>
|
||||
<button id="btn-view-full-detail" class="btn btn-primary">상세 보기</button>
|
||||
<button id="btn-view-flow-logs" style="flex-shrink: 0; padding: 6px 16px; font-size: 12px; font-weight: 700; background: white; color: var(--primary-color); border: 1px solid var(--primary-color); border-radius: 4px; cursor: pointer; transition: opacity 0.2s; margin-right: 8px;">
|
||||
${isPcView ? '목록 보기' : '이력 보기'}
|
||||
</button>
|
||||
<button id="btn-view-full-detail" style="flex-shrink: 0; padding: 6px 16px; font-size: 12px; font-weight: 700; background: var(--primary-color); color: white; border: none; border-radius: 4px; cursor: pointer; transition: opacity 0.2s;">상세 보기</button>
|
||||
</div>
|
||||
<div class="detail-map-section">
|
||||
<div class="map-label-header"><label>설치 위치 배치도</label></div>
|
||||
<div id="detail-photo-wrapper" class="map-photo-wrapper">
|
||||
<div class="layout-map-container readonly">
|
||||
<img id="detail-photo" src="" class="map-photo" />
|
||||
<div id="detail-marker" class="layout-marker pulse-marker hidden"></div>
|
||||
<div id="detail-overlay-layer" class="digital-overlay-layer"></div>
|
||||
|
||||
<!-- 메인 배치도 영역 -->
|
||||
<div style="flex: 1; display: flex; flex-direction: column; min-height: 0; overflow: hidden;">
|
||||
<div style="margin-bottom: 0.75rem; flex-shrink: 0; display: flex; justify-content: space-between; align-items: center;">
|
||||
<label style="font-size: 11px; font-weight: 700; color: var(--text-main); text-transform: uppercase;">설치 위치 배치도</label>
|
||||
</div>
|
||||
<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(--border-color); background: #f0f0f0;">
|
||||
<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" class="digital-overlay-layer" style="position: absolute; top: 0; left: 0; width: 100%; height: 100%; pointer-events: none; display: flex; align-items: center; justify-content: center;"></div>
|
||||
</div>
|
||||
<div id="detail-no-photo" style="display: none; height: 100%; flex-direction: column; align-items: center; justify-content: center; gap: 1rem;">
|
||||
<span style="color: #94A3B8; font-size: 13px; font-weight: 500;">등록된 배치도가 없습니다.</span>
|
||||
</div>
|
||||
<div id="detail-no-photo" class="no-photo-state hidden"><span>등록된 배치도가 없습니다.</span></div>
|
||||
</div>
|
||||
@@ -251,79 +483,472 @@ export function createListView(container: HTMLElement, config: ListViewConfig) {
|
||||
const locImgs = IMAGE_LOCATIONS[bldg.trim()]?.[detail.trim()] || null;
|
||||
const imgPath = (savedImg && locImgs?.includes(savedImg)) ? savedImg : (locImgs ? locImgs[0] : null);
|
||||
|
||||
const htmlMap = document.getElementById('detail-html-map') as HTMLIFrameElement;
|
||||
const isHtmlMap = imgPath?.toLowerCase().endsWith('.html');
|
||||
|
||||
// 좌표가 없으면 사진이 있어도 '정보 없음' 상태로 유도 (사용자 요청)
|
||||
if (imgPath && hasCoords) {
|
||||
photo.src = imgPath;
|
||||
photo.classList.remove('hidden');
|
||||
if (noPhoto) noPhoto.classList.add('hidden');
|
||||
photo.onload = () => {
|
||||
const updateMarkerPos = () => {
|
||||
const imgW = photo.clientWidth; const imgH = photo.clientHeight;
|
||||
if (marker) {
|
||||
marker.style.left = `calc(50% - ${imgW/2}px + ${ (parseFloat(x as string) * imgW) / 100 }px)`;
|
||||
marker.style.top = `calc(50% - ${imgH/2}px + ${ (parseFloat(y as string) * imgH) / 100 }px)`;
|
||||
marker.classList.remove('hidden');
|
||||
}
|
||||
if (overlayLayer) {
|
||||
overlayLayer.style.width = `${imgW}px`; overlayLayer.style.height = `${imgH}px`;
|
||||
overlayLayer.style.left = `calc(50% - ${imgW/2}px)`; overlayLayer.style.top = `calc(50% - ${imgH/2}px)`;
|
||||
const boxes = dynamicMapConfig[imgPath] || [];
|
||||
if (boxes.length > 0) {
|
||||
overlayLayer.innerHTML = `<svg viewBox="0 0 100 100" preserveAspectRatio="none" style="width:100%; height:100%;"><g class="seat-group">
|
||||
${boxes.map(b => {
|
||||
const isSelected = b.x === x && b.y === y;
|
||||
return `<rect class="map-seat-obj" x="${b.x}" y="${b.y}" width="${b.w}" height="${b.h}" rx="0.5" style="fill:${isSelected ? 'rgba(255, 61, 0, 0.4)' : 'rgba(30, 81, 73, 0.02)'}; stroke:${isSelected ? '#FF3D00' : 'rgba(30, 81, 73, 0.15)'};" />`;
|
||||
}).join('')}</g></svg>`;
|
||||
if (isHtmlMap) {
|
||||
// HTML 지도 처리
|
||||
photo.style.display = 'none';
|
||||
if (marker) marker.style.display = 'none';
|
||||
if (overlayLayer) overlayLayer.innerHTML = '';
|
||||
if (htmlMap) {
|
||||
htmlMap.src = `${imgPath}?markerX=${x}&markerY=${y}`;
|
||||
htmlMap.style.display = 'block';
|
||||
}
|
||||
} else {
|
||||
// 일반 이미지 지도 처리
|
||||
if (htmlMap) {
|
||||
htmlMap.src = '';
|
||||
htmlMap.style.display = 'none';
|
||||
}
|
||||
photo.src = imgPath;
|
||||
photo.style.display = 'block';
|
||||
}
|
||||
|
||||
if (noPhoto) noPhoto.style.display = 'none';
|
||||
|
||||
if (!isHtmlMap) {
|
||||
photo.onload = () => {
|
||||
const updateMarkerPos = () => {
|
||||
const imgW = photo.clientWidth;
|
||||
const imgH = photo.clientHeight;
|
||||
|
||||
if (marker) {
|
||||
marker.style.left = `calc(50% - ${imgW/2}px + ${ (parseFloat(x as string) * imgW) / 100 }px)`;
|
||||
marker.style.top = `calc(50% - ${imgH/2}px + ${ (parseFloat(y as string) * imgH) / 100 }px)`;
|
||||
marker.style.display = 'block';
|
||||
}
|
||||
}
|
||||
|
||||
if (overlayLayer) {
|
||||
overlayLayer.style.width = `${imgW}px`;
|
||||
overlayLayer.style.height = `${imgH}px`;
|
||||
overlayLayer.style.left = `calc(50% - ${imgW/2}px)`;
|
||||
overlayLayer.style.top = `calc(50% - ${imgH/2}px)`;
|
||||
|
||||
const boxes = dynamicMapConfig[imgPath] || [];
|
||||
if (boxes.length > 0) {
|
||||
overlayLayer.innerHTML = `
|
||||
<svg viewBox="0 0 100 100" preserveAspectRatio="none" style="width:100%; height:100%;">
|
||||
<g class="seat-group">
|
||||
${boxes.map((b, i) => {
|
||||
const isSelected = b.x === x && b.y === y;
|
||||
const fill = isSelected ? 'rgba(255, 61, 0, 0.4)' : 'rgba(30, 81, 73, 0.02)';
|
||||
const stroke = isSelected ? '#FF3D00' : 'rgba(30, 81, 73, 0.15)';
|
||||
const strokeWidth = isSelected ? '0.8' : '0.2';
|
||||
|
||||
if (isSelected && marker) {
|
||||
marker.style.left = `calc(50% - ${imgW/2}px + ${ (parseFloat(b.x) + parseFloat(b.w)/2) * imgW / 100 }px)`;
|
||||
marker.style.top = `calc(50% - ${imgH/2}px + ${ (parseFloat(b.y) + parseFloat(b.h)/2) * imgH / 100 }px)`;
|
||||
}
|
||||
|
||||
return `<rect class="map-seat-obj" x="${b.x}" y="${b.y}" width="${b.w}" height="${b.h}" rx="0.5" style="fill:${fill}; stroke:${stroke}; stroke-width:${strokeWidth};" />`;
|
||||
}).join('')}
|
||||
</g>
|
||||
</svg>
|
||||
`;
|
||||
} else {
|
||||
overlayLayer.innerHTML = '';
|
||||
}
|
||||
}
|
||||
};
|
||||
updateMarkerPos();
|
||||
window.addEventListener('resize', updateMarkerPos);
|
||||
};
|
||||
updateMarkerPos();
|
||||
window.addEventListener('resize', updateMarkerPos);
|
||||
};
|
||||
}
|
||||
} else {
|
||||
photo.classList.add('hidden');
|
||||
if (marker) marker.classList.add('hidden');
|
||||
photo.style.display = 'none';
|
||||
if (htmlMap) {
|
||||
htmlMap.src = '';
|
||||
htmlMap.style.display = 'none';
|
||||
}
|
||||
if (marker) marker.style.display = 'none';
|
||||
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 => {
|
||||
const rIsWarning = (r as HTMLElement).style.borderLeftColor === 'rgb(225, 29, 72)';
|
||||
(r as HTMLElement).style.backgroundColor = rIsWarning ? '#FFF1F2' : 'transparent';
|
||||
});
|
||||
}
|
||||
updateFlowLogsSection();
|
||||
};
|
||||
}
|
||||
};
|
||||
|
||||
const updateTableOnly = () => {
|
||||
let filtered = selectedLocation ? fullList.filter(a => (a[ASSET_SCHEMA.LOCATION.key] || '미지정') === selectedLocation) : fullList;
|
||||
if (selectedDetailLocation) filtered = filtered.filter(a => (a[ASSET_SCHEMA.LOC_DETAIL.key] || '미지정') === selectedDetailLocation);
|
||||
const displayList = (!selectedLocation && !selectedDetailLocation) ? filtered.slice(0, 10) : filtered;
|
||||
const now = new Date();
|
||||
const currentYear = now.getFullYear();
|
||||
const currentMonthNum = now.getMonth() + 1;
|
||||
const currentYearMonth = `${currentYear}-${String(currentMonthNum).padStart(2, '0')}`;
|
||||
|
||||
const tbody = document.getElementById('system-status-tbody');
|
||||
if (tbody) {
|
||||
tbody.innerHTML = displayList.length === 0
|
||||
? `<tr><td colspan="5" class="empty-cell">조회된 자산이 없습니다.</td></tr>`
|
||||
: displayList.map(asset => {
|
||||
const serviceType = asset.service_type || '외부';
|
||||
const loc = asset[ASSET_SCHEMA.LOCATION.key] || '';
|
||||
const isWarning = serviceType === '외부' && loc !== 'IDC' && loc !== '미지정';
|
||||
return `
|
||||
<tr class="mini-row ${isWarning ? 'warning' : ''}" data-id="${asset.id}">
|
||||
<td><div class="cell-service-type"><span class="fw-800">${serviceType}</span></div></td>
|
||||
<td title="${asset.asset_purpose || ''}">${asset.asset_purpose || '-'}</td>
|
||||
<td>${asset.manager_primary || '-'}</td>
|
||||
<td>${asset.manager_secondary || '-'}</td>
|
||||
<td>${asset.location_detail || '-'}</td>
|
||||
</tr>`;
|
||||
}).join('');
|
||||
tbody.querySelectorAll('.mini-row').forEach(row => {
|
||||
row.addEventListener('click', () => {
|
||||
tbody.querySelectorAll('.mini-row').forEach(r => r.classList.remove('active'));
|
||||
row.classList.add('active');
|
||||
const asset = fullList.find(a => a.id === (row as HTMLElement).getAttribute('data-id'));
|
||||
if (asset) updateDetailPanel(asset);
|
||||
});
|
||||
if (isPcView) {
|
||||
// PC 뷰일 때: 해당월의 PC 유동 이력을 렌더링하고, 클릭 시 해당 자산 상세를 띄움
|
||||
const recentTbody = document.getElementById('system-status-tbody');
|
||||
if (!recentTbody) return;
|
||||
|
||||
const titleEl = document.getElementById('list-section-title');
|
||||
if (titleEl) {
|
||||
titleEl.textContent = `🔄 PC 유동 이력 (${currentMonthNum}월)`;
|
||||
}
|
||||
|
||||
const logs = state.masterData.logs || [];
|
||||
const flowLogs = logs.filter((log: any) => {
|
||||
const details = log.details || '';
|
||||
if (details.trim().startsWith('{')) {
|
||||
try {
|
||||
const info = JSON.parse(details);
|
||||
return info && (info.type === 'checkout' || info.type === 'return' || info.type === 'move');
|
||||
} catch (e) {}
|
||||
}
|
||||
return details.includes('[불출]') || details.includes('[반납]') || details.includes('[입고]') || details.includes('[이동]') || details.includes('[이관]');
|
||||
});
|
||||
|
||||
// 해당월(currentYearMonth)에 발생한 로그만 필터링
|
||||
const monthlyFlowLogs = flowLogs.filter((log: any) => {
|
||||
const logDate = log.log_date || '';
|
||||
return logDate.startsWith(currentYearMonth);
|
||||
});
|
||||
|
||||
if (monthlyFlowLogs.length === 0) {
|
||||
recentTbody.innerHTML = `<tr><td colspan="7" style="text-align:center; padding:1.5rem; color:#94A3B8;">${currentMonthNum}월 유동 이력이 없습니다.</td></tr>`;
|
||||
} else {
|
||||
recentTbody.innerHTML = monthlyFlowLogs.map((log: any) => {
|
||||
const details = log.details || '';
|
||||
|
||||
let typeDisplay = '-';
|
||||
let userDisplay = '-';
|
||||
let targetUserDisplay = '-';
|
||||
let assetCodeDisplay = '-';
|
||||
let memoDisplay = '-';
|
||||
|
||||
try {
|
||||
const info = JSON.parse(details);
|
||||
if (info.type === 'checkout') typeDisplay = 'checkout';
|
||||
else if (info.type === 'return') typeDisplay = 'return';
|
||||
else if (info.type === 'move') typeDisplay = 'move';
|
||||
|
||||
userDisplay = info.user || '-';
|
||||
targetUserDisplay = info.targetUser || '-';
|
||||
assetCodeDisplay = info.assetCode || '-';
|
||||
memoDisplay = info.memo || '-';
|
||||
} catch (e) {
|
||||
// 하위 호환 파싱 (기존 텍스트형 로그)
|
||||
if (details.includes('[불출]')) typeDisplay = 'checkout';
|
||||
else if (details.includes('[반납]') || details.includes('[입고]')) typeDisplay = 'return';
|
||||
else if (details.includes('[이동]') || details.includes('[이관]')) typeDisplay = 'move';
|
||||
|
||||
const codeMatch = details.match(/PC-\d{6}-\d{4}|HW-PC-\d+/i);
|
||||
if (codeMatch) assetCodeDisplay = codeMatch[0];
|
||||
|
||||
if (details.includes('[불출]')) {
|
||||
const match1 = details.match(/\[불출\]\s*([^\s\(]+)\s*사원/);
|
||||
if (match1) userDisplay = match1[1];
|
||||
else {
|
||||
const match2 = details.match(/\[불출\]\s*([a-zA-Z가-힣]+)/);
|
||||
userDisplay = match2 ? match2[1] : '-';
|
||||
}
|
||||
} else if (details.includes('[반납]') || details.includes('[입고]')) {
|
||||
const match1 = details.match(/\[(?:반납|입고)\]\s*([^\s\(]+)\s*사원/);
|
||||
if (match1) userDisplay = match1[1];
|
||||
else {
|
||||
const match2 = details.match(/\[(?:반납|입고)\]\s*([a-zA-Z가-힣]+)/);
|
||||
userDisplay = match2 ? match2[1] : '-';
|
||||
}
|
||||
} else if (details.includes('[이동]') || details.includes('[이관]')) {
|
||||
const prefixWord = details.includes('[이동]') ? '\\[이동\\]' : '\\[이관\\]';
|
||||
const parts = details.split('➡️');
|
||||
if (parts.length === 2) {
|
||||
const fromMatch = parts[0].match(new RegExp(`${prefixWord}\\s*([a-zA-Z가-힣]+)`));
|
||||
const toMatch = parts[1].match(/\s*([a-zA-Z가-힣]+)/);
|
||||
if (fromMatch && toMatch) {
|
||||
userDisplay = fromMatch[1];
|
||||
targetUserDisplay = toMatch[1];
|
||||
}
|
||||
}
|
||||
if (userDisplay === '-') {
|
||||
const match1 = details.match(new RegExp(`${prefixWord}\\s*([^\s\(]+)\\s*사원`));
|
||||
if (match1) {
|
||||
userDisplay = match1[1];
|
||||
} else {
|
||||
const match2 = details.match(new RegExp(`${prefixWord}\\s*([a-zA-Z가-힣0-9_]+)`));
|
||||
userDisplay = match2 ? match2[1] : '-';
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const cleanDetails = details.replace(/^\[(불출|반납|입고|이동|이관)\]\s*/, '');
|
||||
const memoParts = cleanDetails.split(' - ');
|
||||
if (memoParts.length >= 2) {
|
||||
memoDisplay = memoParts[memoParts.length - 1];
|
||||
} else {
|
||||
if (cleanDetails.includes('지급') || cleanDetails.includes('반납') || cleanDetails.includes('이관')) {
|
||||
memoDisplay = '-';
|
||||
} else {
|
||||
memoDisplay = cleanDetails || '-';
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 구분 뱃지 생성
|
||||
let badgeHtml = '';
|
||||
if (typeDisplay === 'checkout') {
|
||||
badgeHtml = '<span style="background:#E0F2FE;color:#0369A1;padding:2px 6px;border-radius:4px;font-size:11px;font-weight:700;">불출</span>';
|
||||
} else if (typeDisplay === 'return') {
|
||||
badgeHtml = '<span style="background:#DCFCE7;color:#15803D;padding:2px 6px;border-radius:4px;font-size:11px;font-weight:700;">입고</span>';
|
||||
} else if (typeDisplay === 'move') {
|
||||
badgeHtml = '<span style="background:#FEF3C7;color:#B45309;padding:2px 6px;border-radius:4px;font-size:11px;font-weight:700;">이동</span>';
|
||||
} else {
|
||||
badgeHtml = '<span style="background:#F1F5F9;color:#475569;padding:2px 6px;border-radius:4px;font-size:11px;font-weight:700;">기타</span>';
|
||||
}
|
||||
|
||||
return `
|
||||
<tr style="border-bottom: 1px solid var(--border-color);">
|
||||
<td style="padding: 10px 8px; color: #64748B; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; max-width: 120px;">${log.log_date || '-'}</td>
|
||||
<td style="padding: 10px 8px; font-weight: 500; color: #64748B; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; max-width: 100px; text-align: center;" title="${log.log_user || '시스템'}">${log.log_user || '시스템'}</td>
|
||||
<td style="padding: 10px 8px; white-space: nowrap; text-align: center;">${badgeHtml}</td>
|
||||
<td style="padding: 10px 8px; font-weight: 600; color: #1E293B; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; max-width: 90px; text-align: center;" title="${userDisplay}">${userDisplay}</td>
|
||||
<td style="padding: 10px 8px; font-weight: 600; color: #1E293B; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; max-width: 90px; text-align: center;" title="${targetUserDisplay}">${targetUserDisplay}</td>
|
||||
<td style="padding: 10px 8px; font-family: monospace; color: #475569; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; max-width: 160px; text-align: center;" title="${assetCodeDisplay}">${assetCodeDisplay}</td>
|
||||
<td style="padding: 10px 8px; color: #475569; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; max-width: 160px;" title="${memoDisplay}">${memoDisplay}</td>
|
||||
</tr>
|
||||
`;
|
||||
}).join('');
|
||||
}
|
||||
} else {
|
||||
// 기존의 자산 현황 목록 갱신
|
||||
let filtered = selectedLocation
|
||||
? fullList.filter(a => (a[ASSET_SCHEMA.LOCATION.key] || '미지정') === selectedLocation)
|
||||
: fullList;
|
||||
const currentDetailLocs = Array.from(new Set(filtered.map(a => a[ASSET_SCHEMA.LOC_DETAIL.key] || '미지정'))).sort();
|
||||
if (selectedDetailLocation) filtered = filtered.filter(a => (a[ASSET_SCHEMA.LOC_DETAIL.key] || '미지정') === selectedDetailLocation);
|
||||
const finalDisplayList = (!selectedLocation && !selectedDetailLocation) ? filtered.slice(0, 10) : filtered;
|
||||
|
||||
const titleEl = document.getElementById('list-section-title');
|
||||
if (titleEl) titleEl.textContent = selectedLocation ? `${selectedLocation} 자산 현황 (${finalDisplayList.length}대)` : '위치별 자산등록현황 (최근 등록)';
|
||||
const selectEl = document.getElementById('select-detail-loc') as HTMLSelectElement;
|
||||
if (selectEl && !selectedDetailLocation) {
|
||||
selectEl.innerHTML = `<option value="">전체보기</option>` + currentDetailLocs.map(dl => `<option value="${dl}">${dl}</option>`).join('');
|
||||
}
|
||||
|
||||
const tbody = document.getElementById('system-status-tbody');
|
||||
if (tbody) {
|
||||
tbody.innerHTML = finalDisplayList.length === 0
|
||||
? `<tr><td colspan="5" style="padding: 3rem; text-align: center; color: var(--text-muted);">조회된 자산이 없습니다.</td></tr>`
|
||||
: finalDisplayList.map(asset => {
|
||||
const purpose = asset[ASSET_SCHEMA.ASSET_PURPOSE.key] || '';
|
||||
const serviceTypeKey = (ASSET_SCHEMA as any).SERVICE_TYPE?.key || 'service_type';
|
||||
const serviceType = asset[serviceTypeKey] || '외부';
|
||||
const type = asset[ASSET_SCHEMA.ASSET_TYPE.key] || '';
|
||||
const loc = asset[ASSET_SCHEMA.LOCATION.key] || '';
|
||||
|
||||
const labelColor = serviceType === '내부' ? '#94A3B8' : '#35635C';
|
||||
const managerMain = asset[ASSET_SCHEMA.MANAGER_MAIN.key] || '-';
|
||||
const managerSub = asset[ASSET_SCHEMA.MANAGER_SUB.key] || '-';
|
||||
|
||||
// [경고 로직] 외부 운영인데 서버PC이거나 IDC가 아닌 경우
|
||||
const isLocWarning = serviceType === '외부SW' && loc !== 'IDC';
|
||||
const isTypeWarning = serviceType === '외부SW' && type.toLowerCase().replace(/\s/g, '').includes('서버pc');
|
||||
const isWarning = isLocWarning || isTypeWarning;
|
||||
const warningStyle = isWarning ? 'background-color: #FFF1F2; border-left: 3px solid #E11D48;' : '';
|
||||
|
||||
let warningReason = '';
|
||||
if (isLocWarning && isTypeWarning) warningReason = '위치/형식 부적절';
|
||||
else if (isLocWarning) warningReason = '위치 부적절';
|
||||
else if (isTypeWarning) warningReason = '형식 부적절';
|
||||
|
||||
return `
|
||||
<tr style="border-bottom: 1px solid var(--border-color); cursor: pointer; ${warningStyle}" class="mini-row" data-id="${asset.id}">
|
||||
<td style="padding: 10px 0; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; text-align:center;">
|
||||
<div style="display:flex; flex-direction:column; align-items:center; gap:2px;">
|
||||
<span style="color: ${isWarning ? '#E11D48' : labelColor}; font-weight: 800; font-size: 12px;">${serviceType}</span>
|
||||
${isWarning ? `<span style="color: #E11D48; font-size: 9px; font-weight: 700; white-space: nowrap;">${warningReason}</span>` : ''}
|
||||
</div>
|
||||
</td>
|
||||
<td style="padding: 10px 0; font-weight: 600; color: ${isWarning ? '#991B1B' : 'var(--text-main)'}; font-size: 13px; white-space: nowrap; overflow: hidden; text-overflow: ellipsis;" title="${purpose}">${purpose || '-'}</td>
|
||||
<td style="padding: 10px 0; text-align: center; color: var(--text-main); font-size: 12px; white-space: nowrap; overflow: hidden; text-overflow: ellipsis;">${managerMain}</td>
|
||||
<td style="padding: 10px 0; text-align: center; color: var(--text-main); font-size: 12px; white-space: nowrap; overflow: hidden; text-overflow: ellipsis;">${managerSub}</td>
|
||||
<td style="padding: 10px 0; text-align: center; color: var(--text-main); font-size: 12px; white-space: nowrap; overflow: hidden; text-overflow: ellipsis;">${asset[ASSET_SCHEMA.LOC_DETAIL.key] || '-'}</td>
|
||||
</tr>`;
|
||||
}).join('');
|
||||
|
||||
tbody.querySelectorAll('.mini-row').forEach(row => {
|
||||
row.addEventListener('click', () => {
|
||||
tbody.querySelectorAll('.mini-row').forEach(r => {
|
||||
const rIsWarning = (r as HTMLElement).style.borderLeftColor === 'rgb(225, 29, 72)'; // E11D48
|
||||
(r as HTMLElement).style.backgroundColor = rIsWarning ? '#FFF1F2' : 'transparent';
|
||||
});
|
||||
(row as HTMLElement).style.backgroundColor = '#EBF2F1'; // 선택 하이라이트
|
||||
const id = (row as HTMLElement).getAttribute('data-id');
|
||||
const asset = fullList.find(a => a.id === id);
|
||||
if (asset) updateDetailPanel(asset);
|
||||
});
|
||||
row.addEventListener('mouseenter', () => {
|
||||
if ((row as HTMLElement).style.backgroundColor !== 'rgb(235, 242, 241)') {
|
||||
(row as HTMLElement).style.backgroundColor = '#F8FAFA';
|
||||
}
|
||||
});
|
||||
row.addEventListener('mouseleave', () => {
|
||||
const isWarning = (row as HTMLElement).style.borderLeftColor === 'rgb(225, 29, 72)';
|
||||
if ((row as HTMLElement).style.backgroundColor !== 'rgb(235, 242, 241)') {
|
||||
(row as HTMLElement).style.backgroundColor = isWarning ? '#FFF1F2' : 'transparent';
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const updateFlowLogsSection = () => {
|
||||
if (!isPcView) return;
|
||||
|
||||
// 사양 주의 장비 현황 (부족/오버스펙) 계산 및 바인딩
|
||||
const specMismatchTbody = document.getElementById('spec-mismatch-tbody');
|
||||
if (specMismatchTbody) {
|
||||
// fullList 중 개인 PC 관련 장비 필터링
|
||||
const pcs = fullList.filter((a: any) => {
|
||||
const type = a[ASSET_SCHEMA.ASSET_TYPE.key] || '';
|
||||
const job = a[ASSET_SCHEMA.USER_POSITION.key] || '';
|
||||
const status = a[ASSET_SCHEMA.HW_STATUS.key] || '';
|
||||
const user = a[ASSET_SCHEMA.CURRENT_USER.key] || '';
|
||||
|
||||
// 운영 중이고 사용자가 할당되어 있으며, 직무가 재고PC가 아닌 실사용 기기 대상
|
||||
return job !== '재고PC' && status === '운영' && user.trim() !== '';
|
||||
});
|
||||
|
||||
// 직무별 평균 점수 산출
|
||||
const jobScores: Record<string, { totalScore: number; count: number; avg: number }> = {};
|
||||
pcs.forEach((pc: any) => {
|
||||
const job = pc[ASSET_SCHEMA.USER_POSITION.key] || '미분류';
|
||||
const cpu = pc[ASSET_SCHEMA.CPU.key] || '';
|
||||
const ram = pc[ASSET_SCHEMA.RAM.key] || '';
|
||||
const gpu = pc[ASSET_SCHEMA.GPU.key] || '';
|
||||
const pDate = pc[ASSET_SCHEMA.PURCHASE_DATE.key] || '';
|
||||
const score = calculatePcScoreDeductive(cpu, ram, gpu, pDate);
|
||||
pc['_pc_score'] = score;
|
||||
|
||||
if (!jobScores[job]) jobScores[job] = { totalScore: 0, count: 0, avg: 0 };
|
||||
jobScores[job].totalScore += score;
|
||||
jobScores[job].count += 1;
|
||||
});
|
||||
|
||||
Object.keys(jobScores).forEach(job => {
|
||||
jobScores[job].avg = jobScores[job].count > 0 ? jobScores[job].totalScore / jobScores[job].count : 0;
|
||||
});
|
||||
|
||||
// 기준 대비 사양 부족/오버스펙 분류
|
||||
const criticalPcList: any[] = [];
|
||||
pcs.forEach((pc: any) => {
|
||||
const job = pc[ASSET_SCHEMA.USER_POSITION.key] || '미분류';
|
||||
const score = pc['_pc_score'];
|
||||
const avg = jobScores[job].avg;
|
||||
|
||||
if (avg > 0) {
|
||||
if (score < avg * 0.6) {
|
||||
pc['_spec_status'] = '사양 부족';
|
||||
criticalPcList.push(pc);
|
||||
} else if (score > avg * 1.5) {
|
||||
pc['_spec_status'] = '오버스펙';
|
||||
criticalPcList.push(pc);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// 정렬: 직무 평균 대비 사양 부족이 심한 순(비율이 낮은 순)으로 정렬
|
||||
criticalPcList.sort((a: any, b: any) => {
|
||||
const jobA = a[ASSET_SCHEMA.USER_POSITION.key] || '미분류';
|
||||
const jobB = b[ASSET_SCHEMA.USER_POSITION.key] || '미분류';
|
||||
const ratioA = jobScores[jobA].avg > 0 ? a['_pc_score'] / jobScores[jobA].avg : 1;
|
||||
const ratioB = jobScores[jobB].avg > 0 ? b['_pc_score'] / jobScores[jobB].avg : 1;
|
||||
return ratioA - ratioB;
|
||||
});
|
||||
|
||||
if (criticalPcList.length === 0) {
|
||||
specMismatchTbody.innerHTML = '<tr><td colspan="4" style="text-align:center; padding:1.5rem; color:#94A3B8;">사양 주의 자산이 없습니다.</td></tr>';
|
||||
} else {
|
||||
specMismatchTbody.innerHTML = criticalPcList.map((pc: any) => {
|
||||
const user = pc[ASSET_SCHEMA.CURRENT_USER.key] || '-';
|
||||
const dept = pc[ASSET_SCHEMA.CURRENT_DEPT.key] || '-';
|
||||
const job = pc[ASSET_SCHEMA.USER_POSITION.key] || '-';
|
||||
const status = pc['_spec_status'];
|
||||
const assetCode = pc.asset_code || '-';
|
||||
|
||||
const badgeColor = status === '사양 부족'
|
||||
? 'background:#FFF1F2; color:#E11D48; border: 1px solid #FDA4AF;'
|
||||
: 'background:#F0FDF4; color:#16A34A; border: 1px solid #BBF7D0;';
|
||||
|
||||
return `
|
||||
<tr style="border-bottom: 1px solid var(--border-color); cursor: pointer;" class="spec-row" data-id="${pc.id}">
|
||||
<td style="padding: 10px 0; font-weight: 600; color: #334155; white-space: nowrap; overflow: hidden; text-overflow: ellipsis;" title="${user}">${user}</td>
|
||||
<td style="padding: 10px 0; color: #475569; white-space: nowrap; overflow: hidden; text-overflow: ellipsis;" title="${dept} (${job})">${dept} (${job})</td>
|
||||
<td style="padding: 10px 0; white-space: nowrap; text-align: center;">
|
||||
<span style="padding: 2px 6px; border-radius: 4px; font-size: 10px; font-weight: 700; ${badgeColor}">${status}</span>
|
||||
</td>
|
||||
<td style="padding: 10px 0; font-family: monospace; color: #64748B; white-space: nowrap; overflow: hidden; text-overflow: ellipsis;" title="${assetCode}">${assetCode}</td>
|
||||
</tr>
|
||||
`;
|
||||
}).join('');
|
||||
|
||||
// 클릭 시 해당 자산 상세 페이지로 전환
|
||||
specMismatchTbody.querySelectorAll('.spec-row').forEach(row => {
|
||||
row.addEventListener('click', () => {
|
||||
specMismatchTbody.querySelectorAll('.spec-row').forEach(r => {
|
||||
(r as HTMLElement).style.backgroundColor = 'transparent';
|
||||
});
|
||||
(row as HTMLElement).style.backgroundColor = '#EBF2F1'; // 선택 하이라이트
|
||||
|
||||
const id = (row as HTMLElement).getAttribute('data-id');
|
||||
const asset = fullList.find(a => String(a.id) === String(id));
|
||||
if (asset) {
|
||||
updateDetailPanel(asset);
|
||||
}
|
||||
});
|
||||
row.addEventListener('mouseenter', () => {
|
||||
if ((row as HTMLElement).style.backgroundColor !== 'rgb(235, 242, 241)') {
|
||||
(row as HTMLElement).style.backgroundColor = '#F8FAFA';
|
||||
}
|
||||
});
|
||||
row.addEventListener('mouseleave', () => {
|
||||
if ((row as HTMLElement).style.backgroundColor !== 'rgb(235, 242, 241)') {
|
||||
(row as HTMLElement).style.backgroundColor = 'transparent';
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
setTimeout(() => {
|
||||
document.getElementById('select-loc')?.addEventListener('change', (e) => { selectedLocation = (e.target as HTMLSelectElement).value || null; updateTableOnly(); });
|
||||
document.getElementById('select-detail-loc')?.addEventListener('change', (e) => { selectedDetailLocation = (e.target as HTMLSelectElement).value || null; updateTableOnly(); });
|
||||
const selectLoc = document.getElementById('select-loc') as HTMLSelectElement;
|
||||
const selectDetailLoc = document.getElementById('select-detail-loc') as HTMLSelectElement;
|
||||
|
||||
selectLoc?.addEventListener('change', (e) => {
|
||||
selectedLocation = (e.target as HTMLSelectElement).value || null;
|
||||
selectedDetailLocation = null;
|
||||
updateTableOnly();
|
||||
updateFlowLogsSection();
|
||||
});
|
||||
selectDetailLoc?.addEventListener('change', (e) => {
|
||||
selectedDetailLocation = (e.target as HTMLSelectElement).value || null;
|
||||
updateTableOnly();
|
||||
updateFlowLogsSection();
|
||||
});
|
||||
updateTableOnly();
|
||||
updateFlowLogsSection();
|
||||
}, 50);
|
||||
};
|
||||
|
||||
@@ -360,13 +985,47 @@ export function createListView(container: HTMLElement, config: ListViewConfig) {
|
||||
|
||||
toggleWrapper.addEventListener('click', (e) => {
|
||||
const btn = (e.target as HTMLElement).closest('.toggle-btn') as HTMLButtonElement;
|
||||
if (btn) {
|
||||
toggleWrapper.querySelectorAll('.toggle-btn').forEach(b => b.classList.remove('active'));
|
||||
btn.classList.add('active'); (state as any).currentViewMode = btn.getAttribute('data-mode');
|
||||
switchView();
|
||||
if (!btn) return;
|
||||
toggleWrapper.querySelectorAll('.toggle-btn').forEach(b => b.classList.remove('active'));
|
||||
btn.classList.add('active');
|
||||
(state as any).currentViewMode = btn.getAttribute('data-mode') as 'asset' | 'system';
|
||||
switchView();
|
||||
});
|
||||
|
||||
// 필터 바 초기화
|
||||
renderFilterBar(filterBar, {
|
||||
...config.filterOptions,
|
||||
initialFilters: currentFilters,
|
||||
onFilterChange: (filters) => {
|
||||
Object.assign(currentFilters, filters);
|
||||
updateTable();
|
||||
}
|
||||
});
|
||||
|
||||
renderFilterBar(filterBar, { ...config.filterOptions, onFilterChange: (filters) => { currentFilters = { ...currentFilters, ...filters }; updateTable(); } });
|
||||
// 셀렉트 박스 채우기
|
||||
const populateSelect = (selector: string, dataKey: string, initialValue?: string) => {
|
||||
const select = container.querySelector(selector) as HTMLSelectElement;
|
||||
if (select) {
|
||||
const getVal = (a: any) => dataKey === ASSET_SCHEMA.CURRENT_DEPT.key ? (a[dataKey] || a['현사용부서'] || a['현사용조직']) : a[dataKey];
|
||||
const uniqueValues = Array.from(new Set(fullList.map(getVal))).filter(Boolean).sort();
|
||||
uniqueValues.forEach(val => {
|
||||
const opt = document.createElement('option');
|
||||
opt.value = String(val);
|
||||
opt.textContent = String(val);
|
||||
if (initialValue && String(val) === initialValue) {
|
||||
opt.selected = true;
|
||||
}
|
||||
select.appendChild(opt);
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
if (config.filterOptions.showLoc) populateSelect('#filter-loc', ASSET_SCHEMA.LOCATION.key, currentFilters.loc);
|
||||
if (config.filterOptions.showDept) populateSelect('#filter-dept', ASSET_SCHEMA.CURRENT_DEPT.key, currentFilters.dept);
|
||||
if (config.filterOptions.showCorp) populateSelect('#filter-corp', ASSET_SCHEMA.PURCHASE_CORP.key, currentFilters.corp);
|
||||
if (config.filterOptions.showType) populateSelect('#filter-type', ASSET_SCHEMA.ASSET_TYPE.key, currentFilters.type);
|
||||
if (config.filterOptions.showStatus) populateSelect('#filter-status', ASSET_SCHEMA.HW_STATUS.key, currentFilters.status);
|
||||
|
||||
// 초기 실행
|
||||
switchView();
|
||||
}
|
||||
|
||||
66
src/views/List/PartsMasterListView.ts
Normal file
66
src/views/List/PartsMasterListView.ts
Normal file
@@ -0,0 +1,66 @@
|
||||
import { state } from '../../core/state';
|
||||
import { openPartsMasterModal } from '../../components/Modal/PartsMasterModal';
|
||||
import { formatInline } from '../../core/utils';
|
||||
import { createListView } from './ListFactory';
|
||||
|
||||
export function renderPartsMasterList(container: HTMLElement) {
|
||||
createListView(container, {
|
||||
title: '부품 마스터',
|
||||
dataSource: () => state.masterData.partsMaster || [],
|
||||
searchKeys: ['component_name', 'category', 'score_tier'],
|
||||
filterOptions: {
|
||||
keywordLabel: '부품명 / 등급 검색',
|
||||
showLoc: false,
|
||||
showDept: false,
|
||||
showType: false
|
||||
},
|
||||
onRowClick: (component) => openPartsMasterModal(component, 'view'),
|
||||
columns: [
|
||||
{
|
||||
header: 'ID',
|
||||
sortKey: 'id',
|
||||
align: 'center',
|
||||
width: '5%',
|
||||
render: c => c.id.toString()
|
||||
},
|
||||
{
|
||||
header: '분류',
|
||||
sortKey: 'category',
|
||||
align: 'center',
|
||||
width: '15%',
|
||||
render: c => {
|
||||
let badgeClass = 'badge-primary';
|
||||
if (c.category === 'CPU') badgeClass = 'b-primary';
|
||||
else if (c.category === 'GPU') badgeClass = 'b-purple';
|
||||
else if (c.category === 'RAM') badgeClass = 'b-green';
|
||||
return `<span class="badge ${badgeClass}">${c.category}</span>`;
|
||||
}
|
||||
},
|
||||
{
|
||||
header: '부품 표준 명칭',
|
||||
sortKey: 'component_name',
|
||||
render: c => formatInline(c.component_name || '-')
|
||||
},
|
||||
{
|
||||
header: '성능 등급',
|
||||
sortKey: 'score_tier',
|
||||
align: 'center',
|
||||
width: '15%',
|
||||
render: c => c.score_tier || '-'
|
||||
},
|
||||
{
|
||||
header: '감점 점수',
|
||||
sortKey: 'deduction',
|
||||
align: 'center',
|
||||
width: '15%',
|
||||
render: c => {
|
||||
const score = c.deduction || 0;
|
||||
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>`;
|
||||
}
|
||||
}
|
||||
]
|
||||
});
|
||||
}
|
||||
@@ -1,14 +1,102 @@
|
||||
import { state } from '../../core/state';
|
||||
import { openHwModal } from '../../components/Modal/HWModal';
|
||||
import { sortAssets, formatInline } from '../../core/utils';
|
||||
import { sortAssets, formatInline, calculatePcScoreDeductive, getPcGrade } from '../../core/utils';
|
||||
import { ASSET_SCHEMA } from '../../core/schema';
|
||||
import { createListView } from './ListFactory';
|
||||
|
||||
export function renderPcList(container: HTMLElement) {
|
||||
container.innerHTML = `
|
||||
<div style="display: flex; flex-direction: column; align-items: center; justify-content: center; height: 100%; color: var(--text-muted);">
|
||||
<div style="font-size: 1.5rem; font-weight: 600; margin-bottom: 1rem;">PC 관리</div>
|
||||
<p>해당 페이지는 다른 작업자에 의해 개발 중입니다.</p>
|
||||
</div>
|
||||
`;
|
||||
createListView(container, {
|
||||
title: 'PC',
|
||||
dataSource: () => {
|
||||
const list = (state.masterData.pc || []).filter((a: any) => a.asset_type !== '서버PC');
|
||||
list.forEach((a: any) => {
|
||||
a['_pc_score'] = calculatePcScoreDeductive(a[ASSET_SCHEMA.CPU.key], a[ASSET_SCHEMA.RAM.key], a[ASSET_SCHEMA.GPU.key], a.purchase_date);
|
||||
});
|
||||
return sortAssets(list);
|
||||
},
|
||||
searchKeys: ['CURRENT_DEPT', 'CURRENT_USER', 'MODEL_NAME', 'MAC_ADDR', 'MANAGER_MAIN', 'ASSET_TYPE'],
|
||||
filterOptions: {
|
||||
keywordLabel: `통합 검색 (${ASSET_SCHEMA.MODEL_NAME.ui}/${ASSET_SCHEMA.MANAGER_MAIN.ui}/${ASSET_SCHEMA.CURRENT_USER.ui})`,
|
||||
showLoc: true,
|
||||
showDept: true,
|
||||
showType: true,
|
||||
showStatus: true
|
||||
},
|
||||
onRowClick: (asset) => openHwModal(asset, 'view'),
|
||||
columns: [
|
||||
{
|
||||
header: ASSET_SCHEMA.HW_STATUS.ui,
|
||||
sortKey: ASSET_SCHEMA.HW_STATUS.key,
|
||||
align: 'center',
|
||||
width: '8%',
|
||||
render: a => {
|
||||
const status = a[ASSET_SCHEMA.HW_STATUS.key] || '재고';
|
||||
let badgeClass = 'badge-light';
|
||||
if (status === '운영') badgeClass = 'b-green';
|
||||
else if (status === '재고') badgeClass = 'b-yellow';
|
||||
else if (status === '수리') badgeClass = 'b-purple';
|
||||
else if (status === '폐기') badgeClass = 'badge-muted';
|
||||
return `<span class="badge ${badgeClass}">${status}</span>`;
|
||||
}
|
||||
},
|
||||
{ header: ASSET_SCHEMA.CURRENT_USER.ui, sortKey: ASSET_SCHEMA.CURRENT_USER.key, align: 'center', render: a => a[ASSET_SCHEMA.CURRENT_USER.key] || '-' },
|
||||
{ header: ASSET_SCHEMA.USER_POSITION.ui, sortKey: ASSET_SCHEMA.USER_POSITION.key, align: 'center', render: a => a[ASSET_SCHEMA.USER_POSITION.key] || '-' },
|
||||
{ header: ASSET_SCHEMA.ASSET_TYPE.ui, sortKey: ASSET_SCHEMA.ASSET_TYPE.key, align: 'center', width: '10%', render: a => a[ASSET_SCHEMA.ASSET_TYPE.key] || '-' },
|
||||
{ header: ASSET_SCHEMA.CPU.ui, sortKey: ASSET_SCHEMA.CPU.key, align: 'center', render: a => a[ASSET_SCHEMA.CPU.key] || '' },
|
||||
{ header: ASSET_SCHEMA.MAINBOARD.ui, sortKey: ASSET_SCHEMA.MAINBOARD.key, align: 'center', render: a => a[ASSET_SCHEMA.MAINBOARD.key] || '-' },
|
||||
{ header: ASSET_SCHEMA.RAM.ui, sortKey: ASSET_SCHEMA.RAM.key, align: 'center', render: a => a[ASSET_SCHEMA.RAM.key] || '' },
|
||||
{ header: ASSET_SCHEMA.GPU.ui, sortKey: ASSET_SCHEMA.GPU.key, align: 'center', render: a => a[ASSET_SCHEMA.GPU.key] || '-' },
|
||||
{
|
||||
header: 'SSD',
|
||||
align: 'center',
|
||||
width: '8%',
|
||||
render: a => {
|
||||
try {
|
||||
const vols = a.volumes ? (typeof a.volumes === 'string' ? JSON.parse(a.volumes) : a.volumes) : [];
|
||||
if (Array.isArray(vols)) {
|
||||
const ssds = vols.filter((v: any) => v && String(v.type).toUpperCase() === 'SSD');
|
||||
if (ssds.length > 0) {
|
||||
return ssds.map((v: any) => `${v.capacity || ''}${v.unit || 'GB'}`).join(' / ');
|
||||
}
|
||||
}
|
||||
} catch (e) {}
|
||||
return '-';
|
||||
}
|
||||
},
|
||||
{
|
||||
header: 'HDD',
|
||||
align: 'center',
|
||||
width: '12%',
|
||||
render: a => {
|
||||
try {
|
||||
const vols = a.volumes ? (typeof a.volumes === 'string' ? JSON.parse(a.volumes) : a.volumes) : [];
|
||||
if (Array.isArray(vols)) {
|
||||
const hdds = vols.filter((v: any) => v && String(v.type).toUpperCase() === 'HDD');
|
||||
if (hdds.length > 0) {
|
||||
return hdds.map((v: any) => `${v.capacity || ''}${v.unit || 'GB'}`).join(' / ');
|
||||
}
|
||||
}
|
||||
} catch (e) {}
|
||||
return '-';
|
||||
}
|
||||
},
|
||||
{
|
||||
header: ASSET_SCHEMA.MAC_ADDR.ui,
|
||||
sortKey: ASSET_SCHEMA.MAC_ADDR.key,
|
||||
align: 'center',
|
||||
render: a => `<span style="font-family:monospace; font-size:11px;">${a[ASSET_SCHEMA.MAC_ADDR.key] || '-'}</span>`
|
||||
},
|
||||
{
|
||||
header: '성능 등급',
|
||||
sortKey: '_pc_score',
|
||||
align: 'center',
|
||||
width: '8%',
|
||||
render: a => {
|
||||
const score = a._pc_score !== undefined ? a._pc_score : calculatePcScoreDeductive(a[ASSET_SCHEMA.CPU.key], a[ASSET_SCHEMA.RAM.key], a[ASSET_SCHEMA.GPU.key], a.purchase_date);
|
||||
const grade = getPcGrade(score);
|
||||
return `<span class="badge ${grade.class}" title="성능 점수: ${score}점">${grade.name}</span>`;
|
||||
}
|
||||
}
|
||||
]
|
||||
});
|
||||
}
|
||||
|
||||
60
src/views/List/UserListView.ts
Normal file
60
src/views/List/UserListView.ts
Normal file
@@ -0,0 +1,60 @@
|
||||
import { state } from '../../core/state';
|
||||
import { openUserModal } from '../../components/Modal/UserModal';
|
||||
import { formatInline } from '../../core/utils';
|
||||
import { createListView } from './ListFactory';
|
||||
|
||||
export function renderUserList(container: HTMLElement) {
|
||||
createListView(container, {
|
||||
title: '사용자',
|
||||
dataSource: () => state.masterData.users || [],
|
||||
searchKeys: ['emp_no', 'user_name', 'dept_name', 'position', 'status'],
|
||||
filterOptions: {
|
||||
keywordLabel: '사번/이름/부서/직급 검색',
|
||||
showCorp: false,
|
||||
showDept: true,
|
||||
showType: false
|
||||
},
|
||||
onRowClick: (user) => openUserModal(user, 'view'),
|
||||
columns: [
|
||||
{
|
||||
header: '사번',
|
||||
sortKey: 'emp_no',
|
||||
align: 'center',
|
||||
width: '15%',
|
||||
render: u => formatInline(u.emp_no || '-')
|
||||
},
|
||||
{
|
||||
header: '이름',
|
||||
sortKey: 'user_name',
|
||||
align: 'center',
|
||||
width: '15%',
|
||||
render: u => formatInline(u.user_name || '-')
|
||||
},
|
||||
{
|
||||
header: '조직 (부서)',
|
||||
sortKey: 'dept_name',
|
||||
align: 'left',
|
||||
width: '25%',
|
||||
render: u => formatInline(u.dept_name || '-')
|
||||
},
|
||||
{
|
||||
header: '직급 (직무)',
|
||||
sortKey: 'position',
|
||||
align: 'left',
|
||||
width: '25%',
|
||||
render: u => formatInline(u.position || '-')
|
||||
},
|
||||
{
|
||||
header: '상태',
|
||||
sortKey: 'status',
|
||||
align: 'center',
|
||||
width: '10%',
|
||||
render: u => {
|
||||
const status = u.status || '재직';
|
||||
const badgeClass = status === '퇴직' ? 'badge-danger' : 'badge-success';
|
||||
return `<span class="badge ${badgeClass}">${status}</span>`;
|
||||
}
|
||||
}
|
||||
]
|
||||
});
|
||||
}
|
||||
@@ -9,11 +9,13 @@ import { renderCloudList } from './List/CloudListView';
|
||||
import { renderDomainList } from './List/DomainListView';
|
||||
import { renderNetworkList } from './List/NetworkListView';
|
||||
import { renderPcPartList } from './List/PcPartListView';
|
||||
import { renderPartsMasterList } from './List/PartsMasterListView';
|
||||
import { renderSpaceInfoList } from './List/SpaceInfoListView';
|
||||
import { renderGiftList } from './List/GiftListView';
|
||||
import { renderFacilityList } from './List/FacilityListView';
|
||||
import { renderCostList } from './List/CostListView';
|
||||
import { createIcons, Plus, X, LayoutDashboard, Monitor, Server, Database, Laptop, CalendarClock, Key, Cpu, Layers, Users, Paperclip, Edit2, RefreshCcw } from 'lucide';
|
||||
import { renderUserList } from './List/UserListView';
|
||||
import { createIcons, Plus, X, LayoutDashboard, Monitor, Server, Database, Laptop, CalendarClock, Key, Cpu, Layers, Users, Paperclip, Edit2, RefreshCcw, Settings } from 'lucide';
|
||||
|
||||
/**
|
||||
* 자산 목록 테이블 렌더링 통합 허브
|
||||
@@ -36,6 +38,7 @@ export function renderSWTable(mainContent: HTMLElement) {
|
||||
else if (tab === '업무지원장비') renderEquipmentList(container);
|
||||
else if (tab === '네트워크') renderNetworkList(container);
|
||||
else if (tab === 'PC부품') renderPcPartList(container);
|
||||
else if (tab === '부품 마스터') renderPartsMasterList(container);
|
||||
else if (tab === '공간정보장비') renderSpaceInfoList(container);
|
||||
else {
|
||||
container.innerHTML = `<div style="padding:2rem; color:var(--text-muted);">"${tab}" 탭에 대한 하드웨어 리스트 뷰가 정의되지 않았습니다.</div>`;
|
||||
@@ -50,6 +53,7 @@ export function renderSWTable(mainContent: HTMLElement) {
|
||||
if (tab === '도메인') renderDomainList(container);
|
||||
else if (tab === '클라우드') renderCloudList(container);
|
||||
else if (tab === '비용관리') renderCostList(container);
|
||||
else if (tab === '사용자') renderUserList(container);
|
||||
else {
|
||||
container.innerHTML = `<div style="padding:2rem; color:var(--text-muted);">"${tab}" 탭에 대한 운영지원 리스트 뷰가 정의되지 않았습니다.</div>`;
|
||||
}
|
||||
@@ -69,7 +73,7 @@ export function renderSWTable(mainContent: HTMLElement) {
|
||||
|
||||
// 전역 아이콘 초기화 (한 번 더 실행하여 누락 방지)
|
||||
createIcons({
|
||||
icons: { Plus, X, LayoutDashboard, Monitor, Server, Database, Laptop, CalendarClock, Key, Cpu, Layers, Users, Paperclip, Edit2, RefreshCcw }
|
||||
icons: { Plus, X, LayoutDashboard, Monitor, Server, Database, Laptop, CalendarClock, Key, Cpu, Layers, Users, Paperclip, Edit2, RefreshCcw, Settings }
|
||||
});
|
||||
} catch (err: any) {
|
||||
console.error('❌ Error rendering table view:', err);
|
||||
|
||||
Reference in New Issue
Block a user