1. 컬럼 드래그 너비 조정 버그 수정 및 개선 (ListFactory.ts) - 드래그 완료 시 click 이벤트 전파 차단으로 정렬(sorting) 오작동 방지 - getBoundingClientRect().width 활용한 소수점 정밀 너비 고정 및 레이아웃 시프트 방지 - 마우스 업 시점의 모든 컬럼 너비를 config.columns에 동기화하여 재렌더링 시 너비 영속성 보장 2. PC 자산 모달 필드 잠금 정책 세분화 (HWModal.ts) - 자산 추가(add) 모드에서는 모든 필드(사용자 정보 포함) 입력 허용 - 자산 수정(edit) 모드에서만 사용자/조직 정보 관련 필드(lockedUserFields) 선택적 잠금 적용 - 시스템 사양, 네트워크, 위치, 구매 등 다른 모든 섹션은 수정 가능하도록 복구 및 안내 배너 갱신 3. 관리자 전용 메뉴 단일 페이지 앱(SPA) 통합 (Navigation.ts, main.ts, MapEditor.ts) - 기존의 실사 승인 탭과 독립 실행형 좌표 에디터(MapEditor)를 GNB '관리도구' 하위 메뉴로 통합 - '실사 승인', '위치지정'을 GNB에서 ↳ 화살표 및 11px 폰트의 계층형 탭 스타일로 렌더링 - 내부 서브 탭 바를 삭제하고 메인 영역 전체 높이(calc(100vh - var(--header-height) - 48px))를 확보 - 다른 탭으로 이동 시 MapEditor 인스턴스의 window 이벤트 및 전역 바인딩을 소거하는 destroy() 리사이클 구현 4. 자산 이력(History) 가독성 개선 및 포맷팅 (HWModal.ts, SWModal.ts, DomainModal.ts) - 자산 변경 이력 로그를 일자별로 그룹화하여 타임라인 렌더링 - 최초 등록 데이터에 녹색 '[최초등록]' 배지 추가 - 기존의 생 JSON 이력 데이터를 친절한 한국어 텍스트 포맷으로 가공하여 가독성 극대화
1281 lines
65 KiB
TypeScript
1281 lines
65 KiB
TypeScript
import { state, saveAsset, deleteAsset } from '../../core/state';
|
|
import { ASSET_SCHEMA, UI_TEXT } from '../../core/schema';
|
|
import { calculatePcScoreDeductive, getPcGrade, API_BASE_URL } from '../../core/utils';
|
|
import {
|
|
generateOptionsHTML,
|
|
setFieldValue,
|
|
getFieldValue,
|
|
parseAndSetLocation,
|
|
bindLocationEvents,
|
|
applyDateMask
|
|
} from './ModalUtils';
|
|
import { CORP_LIST, LOCATION_DATA, CATEGORY_TYPE_MAP, HW_STATUS_LIST, ORG_LIST, IMAGE_LOCATIONS, TYPE_PREFIX_MAP } from './SharedData';
|
|
import { BaseModal } from './BaseModal';
|
|
import { QRPrinter } from '../../core/qr_print';
|
|
|
|
/**
|
|
* 하드웨어 자산 상세 모달 (Styled Main Edition)
|
|
* - 내용/순서는 main 버전 준수
|
|
* - 스타일은 ux_setting의 Vercel 디자인 준수
|
|
*/
|
|
class HwAssetModal extends BaseModal {
|
|
private dynamicMapConfig: Record<string, any[]> = {};
|
|
private masterComponents: any[] = [];
|
|
|
|
constructor() {
|
|
super('hw', '자산 상세 정보');
|
|
}
|
|
|
|
protected renderFrameHTML(): string {
|
|
return `
|
|
<div id="hw-asset-modal" class="modal-overlay hidden">
|
|
<div class="modal-content wide">
|
|
<div class="modal-header">
|
|
<div class="header-left" style="display: flex; align-items: center; gap: 0.75rem; flex-wrap: wrap;">
|
|
<h2 id="hw-modal-title" class="modal-title" style="display: none;">${this.title}</h2>
|
|
<div id="hw-header-identity" class="header-identity" style="display: inline-flex; gap: 0.5rem; align-items: center;"></div>
|
|
<button id="btn-print-hw-qr" class="btn btn-outline btn-primary hidden" style="padding: 2px 8px; font-size: 11px; height: 22px; margin: 0; line-height: 1; display: inline-flex; align-items: center; justify-content: center; cursor: pointer;">QR 인쇄</button>
|
|
<span id="hw-modal-audit-approved-badge" style="display: none; align-items: center; background-color: rgba(16, 185, 129, 0.08); color: #059669; border: 1px solid rgba(16, 185, 129, 0.18); padding: 1px 6px; border-radius: 4px; font-size: 10px; font-weight: 600; height: 20px; line-height: 1; vertical-align: middle; white-space: nowrap; margin-left: 4px;">승인완료</span>
|
|
</div>
|
|
<button id="btn-close-hw-modal" class="btn-icon" aria-label="닫기">×</button>
|
|
</div>
|
|
<div class="modal-body">
|
|
<div class="modal-body-split">
|
|
<div class="modal-form-area">
|
|
<form id="hw-asset-form" class="grid-form">
|
|
<input type="hidden" id="hw-id" name="id" />
|
|
<input type="hidden" id="hw-remotes-data" name="remotes" />
|
|
<input type="hidden" id="hw-volumes-data" name="volumes" />
|
|
|
|
<!-- [SECTION 1] 기본 관리 정보 -->
|
|
<div class="form-section-title">기본 관리 정보</div>
|
|
<div class="form-group">
|
|
<label>${ASSET_SCHEMA.ASSET_CODE.ui}</label>
|
|
<div class="input-with-btn">
|
|
<input type="text" id="hw-asset_code" name="asset_code" placeholder="자동 생성" readonly />
|
|
<button type="button" id="btn-gen-hw-code" class="btn btn-outline">생성</button>
|
|
</div>
|
|
</div>
|
|
<div class="form-group">
|
|
<label>${ASSET_SCHEMA.PURCHASE_CORP.ui}</label>
|
|
<select id="hw-purchase_corp" name="purchase_corp">${generateOptionsHTML(CORP_LIST)}</select>
|
|
</div>
|
|
<div class="form-group">
|
|
<label>${ASSET_SCHEMA.CATEGORY.ui}</label>
|
|
<select id="hw-category" name="category">
|
|
<option value="">선택</option>
|
|
${generateOptionsHTML(Object.keys(CATEGORY_TYPE_MAP), '', false)}
|
|
</select>
|
|
</div>
|
|
<div class="form-group">
|
|
<label>${ASSET_SCHEMA.ASSET_TYPE.ui}</label>
|
|
<select id="hw-asset_type" name="asset_type">
|
|
<option value="">구분을 먼저 선택하세요</option>
|
|
</select>
|
|
</div>
|
|
<div class="form-group">
|
|
<label>${ASSET_SCHEMA.HW_STATUS.ui}</label>
|
|
<select id="hw-hw_status" name="hw_status">${generateOptionsHTML(HW_STATUS_LIST)}</select>
|
|
</div>
|
|
<div class="form-group service-type-field">
|
|
<label>${ASSET_SCHEMA.SERVICE_TYPE.ui}</label>
|
|
<select id="hw-service_type" name="service_type">
|
|
<option value="외부">외부</option>
|
|
<option value="내부">내부</option>
|
|
</select>
|
|
</div>
|
|
<div class="form-group full-width">
|
|
<label>${ASSET_SCHEMA.ASSET_PURPOSE.ui}</label>
|
|
<input type="text" id="hw-asset_purpose" name="asset_purpose" placeholder="자산의 용도를 입력하세요" />
|
|
</div>
|
|
<div class="form-group infra-only monitoring-field">
|
|
<label>${ASSET_SCHEMA.MONITORING.ui}</label>
|
|
<select id="hw-monitoring" name="monitoring">
|
|
<option value="비대상">비대상</option>
|
|
<option value="대상">대상</option>
|
|
</select>
|
|
</div>
|
|
|
|
<!-- [SECTION 2] 조직 및 사용자 정보 -->
|
|
<div class="form-section-title">사용자 및 조직 정보</div>
|
|
<div id="hw-pc-workflow-notice" class="form-group full-width hidden" style="background-color: rgba(59, 130, 246, 0.05); border: 1px solid rgba(59, 130, 246, 0.15); padding: 8px 12px; border-radius: 6px; font-size: 11px; color: var(--primary); line-height: 1.5; margin-bottom: 12px;">
|
|
💡 PC 자산은 데이터 정합성을 위해 '사용자 및 조직 정보'만 수정이 제한되며, 사양 및 기타 정보는 수정창에서 수정할 수 있습니다.
|
|
</div>
|
|
<div class="form-group">
|
|
<label>${ASSET_SCHEMA.CURRENT_DEPT.ui}</label>
|
|
<select id="hw-current_dept" name="current_dept">${generateOptionsHTML(ORG_LIST)}</select>
|
|
</div>
|
|
<div class="form-group">
|
|
<label>${ASSET_SCHEMA.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" />
|
|
</div>
|
|
<div class="form-group personal-only relative">
|
|
<label>${ASSET_SCHEMA.CURRENT_USER.ui}</label>
|
|
<input type="text" id="hw-user_current" name="user_current" autocomplete="off" />
|
|
<div id="hw-user-current-list" class="autocomplete-list hidden"></div>
|
|
</div>
|
|
<div class="form-group personal-only">
|
|
<label>${ASSET_SCHEMA.EMP_NO.ui}</label>
|
|
<input type="text" id="hw-emp_no" name="emp_no" readonly style="background-color: #f1f5f9; cursor: not-allowed;" />
|
|
</div>
|
|
<div class="form-group personal-only">
|
|
<label>${ASSET_SCHEMA.USER_POSITION.ui}</label>
|
|
<input type="text" id="hw-user_position" name="user_position" />
|
|
</div>
|
|
<div class="form-group personal-only">
|
|
<label>${ASSET_SCHEMA.PREV_USER.ui}</label>
|
|
<input type="text" id="hw-previous_user" name="previous_user" />
|
|
</div>
|
|
|
|
<!-- [SECTION 3] 하드웨어 사양 -->
|
|
<div class="form-section-title hardware-section">시스템 사양 정보</div>
|
|
<div class="form-group">
|
|
<label>${ASSET_SCHEMA.MODEL_NAME.ui}</label>
|
|
<input type="text" id="hw-model_name" name="model_name" />
|
|
</div>
|
|
<div class="form-group sn-only">
|
|
<label>${ASSET_SCHEMA.SERIAL_NUM.ui}</label>
|
|
<input type="text" id="hw-serial_num" name="serial_num" />
|
|
</div>
|
|
<div class="form-group mainboard-only">
|
|
<label>${ASSET_SCHEMA.MAINBOARD.ui}</label>
|
|
<input type="text" id="hw-mainboard" name="mainboard" />
|
|
</div>
|
|
<div class="form-group spec-only">
|
|
<label>${ASSET_SCHEMA.OS.ui}</label>
|
|
<input type="text" id="hw-os" name="os" />
|
|
</div>
|
|
<div class="form-group spec-only relative">
|
|
<label>${ASSET_SCHEMA.CPU.ui}</label>
|
|
<input type="text" id="hw-cpu" name="cpu" autocomplete="off" />
|
|
<div id="hw-cpu-list" class="autocomplete-list hidden"></div>
|
|
</div>
|
|
<div class="form-group spec-only relative">
|
|
<label>${ASSET_SCHEMA.RAM.ui}</label>
|
|
<input type="text" id="hw-ram" name="ram" autocomplete="off" />
|
|
<div id="hw-ram-list" class="autocomplete-list hidden"></div>
|
|
</div>
|
|
<div class="form-group spec-only relative">
|
|
<label>${ASSET_SCHEMA.GPU.ui}</label>
|
|
<input type="text" id="hw-gpu" name="gpu" autocomplete="off" />
|
|
<div id="hw-gpu-list" class="autocomplete-list hidden"></div>
|
|
</div>
|
|
<div class="form-group spec-only">
|
|
<label>적정성 등급</label>
|
|
<div id="hw-pc-grade-badge" class="badge">정보 부족</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" />
|
|
</div>
|
|
<div class="form-group spec-only">
|
|
<label>${ASSET_SCHEMA.ASSET_MFR.ui}</label>
|
|
<input type="text" id="hw-asset_mfr" name="asset_mfr" />
|
|
</div>
|
|
|
|
<!-- 동적 볼륨 정보 -->
|
|
<div class="form-group full-width spec-only">
|
|
<label>디스크 구성 (Volume)</label>
|
|
<div id="hw-volume-container" class="dynamic-row-container"></div>
|
|
<button type="button" id="btn-add-volume" class="btn btn-outline btn-sm">+ 볼륨 추가</button>
|
|
</div>
|
|
|
|
<!-- [SECTION 4] 네트워크 및 원격 정보 -->
|
|
<div class="form-section-title net-only">네트워크 및 원격 관리</div>
|
|
<div class="form-group full-width net-only">
|
|
<label>접속 정보 (IP / MAC / Remote)</label>
|
|
<div id="hw-remote-info-container" class="dynamic-row-container"></div>
|
|
<button type="button" id="btn-add-remote-info" class="btn btn-outline btn-sm">+ 접속 정보 추가</button>
|
|
</div>
|
|
|
|
<!-- [SECTION 5] 위치 정보 -->
|
|
<div class="form-section-title location-section">물리적 위치 정보</div>
|
|
<div class="form-group location-field">
|
|
<label>건물/층</label>
|
|
<select id="hw-bldg-select">${generateOptionsHTML(Object.keys(LOCATION_DATA))}</select>
|
|
</div>
|
|
<div class="form-group location-field">
|
|
<label>상세 위치</label>
|
|
<div class="input-with-btn">
|
|
<select id="hw-location_detail" name="location_detail">
|
|
<option value="">층을 먼저 선택하세요</option>
|
|
</select>
|
|
<button type="button" id="btn-reg-loc-map" class="btn btn-outline hidden">위치 등록</button>
|
|
<button type="button" id="btn-view-loc-map" class="btn btn-outline hidden">위치 확인</button>
|
|
</div>
|
|
</div>
|
|
<!-- 숨김 처리된 좌표 필드 -->
|
|
<input type="hidden" id="hw-loc_x" name="loc_x" />
|
|
<input type="hidden" id="hw-loc_y" name="loc_y" />
|
|
<input type="hidden" id="hw-location_photo" name="location_photo" />
|
|
|
|
<!-- [SECTION 6] 구매 및 기타 정보 -->
|
|
<div class="form-section-title">구매 및 기타 정보</div>
|
|
<div class="form-group">
|
|
<label>${ASSET_SCHEMA.PURCHASE_DATE.ui}</label>
|
|
<input type="text" id="hw-purchase_date" name="purchase_date" placeholder="YYYY-MM-DD" />
|
|
</div>
|
|
<div class="form-group">
|
|
<label>${ASSET_SCHEMA.PURCHASE_VENDOR.ui}</label>
|
|
<input type="text" id="hw-purchase_vendor" name="purchase_vendor" />
|
|
</div>
|
|
<div class="form-group">
|
|
<label>${ASSET_SCHEMA.PURCHASE_AMOUNT.ui}</label>
|
|
<input type="text" id="hw-purchase_amount" name="purchase_amount" />
|
|
</div>
|
|
<div class="form-group">
|
|
<label>${ASSET_SCHEMA.APPROVAL_DOC.ui}</label>
|
|
<div class="input-with-btn">
|
|
<input type="hidden" id="hw-approval_document" name="approval_document" />
|
|
<div id="hw-file-name-display" class="file-upload-display">파일 선택...</div>
|
|
<div id="hw-file-link-container"></div>
|
|
<input type="file" id="hw-approval_document_file" class="hidden" />
|
|
<button type="button" id="btn-file-select" class="btn btn-outline">파일 찾기</button>
|
|
</div>
|
|
</div>
|
|
<div class="form-group full-width">
|
|
<label>${ASSET_SCHEMA.MEMO.ui}</label>
|
|
<textarea id="hw-memo" name="memo" rows="3" placeholder="기타 참고 사항을 입력하세요"></textarea>
|
|
</div>
|
|
</form>
|
|
</div>
|
|
|
|
<div class="modal-history-area">
|
|
<div class="history-header">
|
|
<h3>자산 변동 이력</h3>
|
|
</div>
|
|
<div id="hw-history-list" class="history-timeline"></div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
<div class="modal-footer">
|
|
<button id="btn-delete-hw-asset" class="btn btn-outline btn-danger">삭제</button>
|
|
<div class="footer-actions">
|
|
<button id="btn-revert-hw-edit" class="btn btn-outline hidden">수정 취소</button>
|
|
<button id="btn-cancel-hw-modal" class="btn btn-outline">닫기</button>
|
|
<button id="btn-save-hw-asset" class="btn btn-primary">저장</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
<style>
|
|
.hidden {
|
|
display: none !important;
|
|
}
|
|
</style>
|
|
`;
|
|
}
|
|
|
|
protected initChildLogic(onSave: () => void, closeModals: () => void): void {
|
|
const saveBtn = document.getElementById('btn-save-hw-asset')!;
|
|
const deleteBtn = document.getElementById('btn-delete-hw-asset')!;
|
|
const categorySelect = document.getElementById('hw-category') as HTMLSelectElement;
|
|
const typeSelect = document.getElementById('hw-asset_type') as HTMLSelectElement;
|
|
const bldgSelect = document.getElementById('hw-bldg-select') as HTMLSelectElement;
|
|
const detailSelect = document.getElementById('hw-location_detail') as HTMLSelectElement;
|
|
|
|
this.fetchMapConfig();
|
|
|
|
const qrPrintBtn = document.getElementById('btn-print-hw-qr');
|
|
qrPrintBtn?.addEventListener('click', () => {
|
|
if (this.currentAsset && this.currentAsset.asset_code) {
|
|
QRPrinter.print([{
|
|
type: 'asset',
|
|
code: this.currentAsset.asset_code,
|
|
title: '[ HM IT ASSET ]',
|
|
subtitle: this.currentAsset.model_name || this.currentAsset.asset_purpose || this.currentAsset.category || 'IT 자산',
|
|
dept: this.currentAsset.current_dept || '-',
|
|
user: this.currentAsset.user_current || '-'
|
|
}]);
|
|
}
|
|
});
|
|
|
|
this.fetchMasterComponents().then(() => {
|
|
this.bindAutocomplete('hw-cpu', 'hw-cpu-list', 'CPU');
|
|
this.bindAutocomplete('hw-ram', 'hw-ram-list', 'RAM');
|
|
this.bindAutocomplete('hw-gpu', 'hw-gpu-list', 'GPU');
|
|
this.bindUserAutocomplete();
|
|
});
|
|
|
|
categorySelect.addEventListener('change', () => {
|
|
const cat = categorySelect.value;
|
|
const types = CATEGORY_TYPE_MAP[cat] || [];
|
|
typeSelect.innerHTML = types.length > 0 ? generateOptionsHTML(types, '', true) : '<option value="">구분을 먼저 선택하세요</option>';
|
|
this.applyRoleVisibility();
|
|
|
|
const identityContainer = document.getElementById('hw-header-identity');
|
|
if (identityContainer) {
|
|
identityContainer.innerHTML = cat ? `<span class="service-type-badge">${cat}</span>` : '';
|
|
}
|
|
});
|
|
|
|
typeSelect.addEventListener('change', () => {
|
|
this.applyRoleVisibility();
|
|
this.updateHeaderIdentity(this.currentAsset);
|
|
|
|
if (typeSelect.value === '공용PC') {
|
|
setFieldValue('hw-user_current', '');
|
|
setFieldValue('hw-emp_no', '');
|
|
setFieldValue('hw-user_position', '공용PC');
|
|
}
|
|
});
|
|
|
|
bindLocationEvents('hw-bldg-select', 'hw-location_detail', '', '');
|
|
bldgSelect.addEventListener('change', () => setTimeout(() => this.updateMapButtonVisibility(), 100));
|
|
detailSelect.addEventListener('change', () => this.updateMapButtonVisibility());
|
|
|
|
applyDateMask(document.getElementById('hw-purchase_date') as HTMLInputElement);
|
|
|
|
document.getElementById('btn-gen-hw-code')?.addEventListener('click', async () => {
|
|
const cat = categorySelect.value;
|
|
if (!cat) { alert('구분을 먼저 선택해주세요.'); return; }
|
|
|
|
const purchaseDate = (document.getElementById('hw-purchase_date') as HTMLInputElement)?.value || '';
|
|
if (!purchaseDate.trim()) {
|
|
alert('구매일자를 먼저 입력해 주세요. 구매일자가 없으면 자산번호를 생성할 수 없습니다.');
|
|
return;
|
|
}
|
|
|
|
const type = (document.getElementById('hw-asset_type') as HTMLSelectElement)?.value || '';
|
|
const prefix = TYPE_PREFIX_MAP[type] || TYPE_PREFIX_MAP[cat] || 'ETC';
|
|
try {
|
|
const res = await fetch(`/api/generate-asset-code?prefix=${prefix}&purchaseDate=${purchaseDate}`);
|
|
const data = await res.json();
|
|
if (data.nextCode) setFieldValue('hw-asset_code', data.nextCode);
|
|
} catch (err) { console.error('코드 생성 실패:', err); }
|
|
});
|
|
|
|
const fileSelectBtn = document.getElementById('btn-file-select');
|
|
const fileInput = document.getElementById('hw-approval_document_file') as HTMLInputElement;
|
|
fileSelectBtn?.addEventListener('click', () => fileInput.click());
|
|
fileInput?.addEventListener('change', async (e) => {
|
|
const file = (e.target as HTMLInputElement).files?.[0];
|
|
if (!file) return;
|
|
const fileNameDisplay = document.getElementById('hw-file-name-display');
|
|
const fileLinkContainer = document.getElementById('hw-file-link-container');
|
|
if (fileNameDisplay) fileNameDisplay.textContent = file.name;
|
|
const reader = new FileReader();
|
|
reader.onload = async () => {
|
|
try {
|
|
const res = await fetch('/api/upload', {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({ fileName: file.name, fileData: reader.result })
|
|
});
|
|
const data = await res.json();
|
|
if (data.success) {
|
|
setFieldValue('hw-approval_document', data.filePath);
|
|
if (fileLinkContainer) {
|
|
fileLinkContainer.innerHTML = `<a href="${data.filePath}" target="_blank" class="btn btn-outline btn-sm">[파일 보기]</a>`;
|
|
}
|
|
}
|
|
} catch (err) { console.error('파일 업로드 실패:', err); alert('파일 업로드 중 오류가 발생했습니다.'); }
|
|
};
|
|
reader.readAsDataURL(file);
|
|
});
|
|
|
|
document.getElementById('btn-reg-loc-map')?.addEventListener('click', async (e) => {
|
|
e.preventDefault(); e.stopPropagation();
|
|
const bldg = bldgSelect.value;
|
|
const detail = detailSelect.value;
|
|
await this.fetchMapConfig();
|
|
const images = this.getImagesForLocation(bldg, detail);
|
|
if (images) this.openImagePicker(images, `${detail} 위치 등록`);
|
|
});
|
|
|
|
document.getElementById('btn-view-loc-map')?.addEventListener('click', async (e) => {
|
|
e.preventDefault(); e.stopPropagation();
|
|
await this.fetchMapConfig();
|
|
const x = getFieldValue('hw-loc_x');
|
|
const y = getFieldValue('hw-loc_y');
|
|
const savedImg = getFieldValue('hw-location_photo');
|
|
const bldg = bldgSelect.value;
|
|
const detail = detailSelect.value;
|
|
const images = this.getImagesForLocation(bldg, detail);
|
|
if (images) {
|
|
const imgPath = savedImg && images.includes(savedImg) ? savedImg : images[0];
|
|
this.openImagePreview(imgPath, `${detail} 위치 확인`, x, y);
|
|
}
|
|
});
|
|
|
|
document.getElementById('btn-add-volume')?.addEventListener('click', () => this.addVolumeRow());
|
|
document.getElementById('btn-add-remote-info')?.addEventListener('click', () => this.addRemoteInfoRow());
|
|
|
|
deleteBtn.addEventListener('click', async () => {
|
|
if (!this.currentAsset || !confirm(UI_TEXT.MESSAGES.CONFIRM_DELETE)) return;
|
|
if (await deleteAsset(this.getCategoryKey(this.currentAsset), this.currentAsset.id)) {
|
|
alert('성공적으로 삭제되었습니다.'); onSave(); this.close(); closeModals();
|
|
}
|
|
});
|
|
|
|
saveBtn.addEventListener('click', async () => {
|
|
if (!this.currentAsset) return;
|
|
|
|
// [추가] 조회 모드인 경우 수정 모드로 전환
|
|
if (!this.isEditMode) {
|
|
this.open(this.currentAsset, 'edit');
|
|
return;
|
|
}
|
|
|
|
// 자산코드가 비어있는 경우 자동 생성 처리
|
|
let assetCode = getFieldValue('hw-asset_code').trim();
|
|
if (!assetCode) {
|
|
const cat = categorySelect.value;
|
|
if (!cat) { alert('구분을 먼저 선택해주세요.'); return; }
|
|
const type = (document.getElementById('hw-asset_type') as HTMLSelectElement)?.value || '';
|
|
const prefix = TYPE_PREFIX_MAP[type] || TYPE_PREFIX_MAP[cat] || 'ETC';
|
|
const purchaseDate = (document.getElementById('hw-purchase_date') as HTMLInputElement)?.value || '';
|
|
try {
|
|
const res = await fetch(`/api/generate-asset-code?prefix=${prefix}&purchaseDate=${purchaseDate}`);
|
|
const data = await res.json();
|
|
if (data.nextCode) {
|
|
setFieldValue('hw-asset_code', data.nextCode);
|
|
assetCode = data.nextCode;
|
|
} else {
|
|
alert('자산코드 자동 생성에 실패했습니다. 수동으로 생성 버튼을 눌러주세요.');
|
|
return;
|
|
}
|
|
} catch (err) {
|
|
console.error('코드 자동 생성 실패:', err);
|
|
alert('자산코드 자동 생성 중 오류가 발생했습니다.');
|
|
return;
|
|
}
|
|
}
|
|
|
|
// 동적 볼륨 데이터 수집
|
|
const vols: any[] = [];
|
|
document.querySelectorAll('#hw-volume-container .volume-row').forEach((row, idx) => {
|
|
const type = (row.querySelector('.vol-type') as HTMLSelectElement).value;
|
|
const cap = (row.querySelector('.vol-cap') as HTMLInputElement).value;
|
|
const unit = (row.querySelector('.vol-unit') as HTMLSelectElement).value;
|
|
if (cap) vols.push({ type, capacity: parseFloat(cap), unit, slot: idx + 1 });
|
|
});
|
|
setFieldValue('hw-volumes-data', JSON.stringify(vols));
|
|
|
|
// 동적 네트워크/원격 데이터 수집
|
|
const nets: any[] = [];
|
|
document.querySelectorAll('#hw-remote-info-container .remote-info-row').forEach(row => {
|
|
const type = (row.querySelector('.ri-type') as HTMLSelectElement).value;
|
|
const val1 = (row.querySelector('.ri-val1') as HTMLInputElement).value;
|
|
|
|
if (type === 'IP' && val1) {
|
|
const tool = (row.querySelector('.ri-tool') as HTMLSelectElement)?.value || '';
|
|
const id = (row.querySelector('.ri-id') as HTMLInputElement)?.value || '';
|
|
const pw = (row.querySelector('.ri-pw') as HTMLInputElement)?.value || '';
|
|
const val2Str = (id || pw) ? JSON.stringify({ id, pw }) : '';
|
|
nets.push({ type: 'IP', name: tool, val1: val1, val2: val2Str });
|
|
} else if (type === 'MAC' && val1) {
|
|
nets.push({ type: 'MAC', name: 'MAC 주소', val1: val1, val2: '' });
|
|
}
|
|
});
|
|
setFieldValue('hw-remotes-data', JSON.stringify(nets));
|
|
|
|
const formData = new FormData(this.formEl!);
|
|
const updated = { ...this.currentAsset };
|
|
formData.forEach((value, key) => { if (key !== 'id') updated[key] = value; });
|
|
updated.location = bldgSelect.value;
|
|
|
|
// 부품 마스터 기준 정합성 검증 (CPU, GPU, RAM)
|
|
const checkFields = [
|
|
{ name: 'cpu', label: 'CPU', category: 'CPU' },
|
|
{ name: 'gpu', label: 'GPU', category: 'GPU' },
|
|
{ name: 'ram', label: 'RAM', category: 'RAM' }
|
|
];
|
|
|
|
for (const field of checkFields) {
|
|
const value = String(updated[field.name] || '').trim();
|
|
if (value) {
|
|
const isExists = this.masterComponents.some(c =>
|
|
c.category.toUpperCase() === field.category &&
|
|
c.component_name.trim().toLowerCase() === value.toLowerCase()
|
|
);
|
|
if (!isExists) {
|
|
alert(`입력하신 ${field.label} "${value}"은(는) 부품 마스터에 등록되지 않은 규격입니다. 자동완성 목록에서 선택하거나 부품마스터에 먼저 등록해 주세요.`);
|
|
return;
|
|
}
|
|
}
|
|
}
|
|
|
|
if (await saveAsset(this.getCategoryKey(updated), updated)) {
|
|
alert(UI_TEXT.MESSAGES.SAVE_SUCCESS);
|
|
onSave(); this.close(); closeModals();
|
|
}
|
|
});
|
|
}
|
|
|
|
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 items-center';
|
|
row.innerHTML = `
|
|
<select class="vol-type" style="width: 80px; flex-shrink: 0;" ${!this.isEditMode ? 'disabled' : ''}>
|
|
<option value="SSD" ${vol.type === 'SSD' ? 'selected' : ''}>SSD</option>
|
|
<option value="HDD" ${vol.type === 'HDD' ? 'selected' : ''}>HDD</option>
|
|
<option value="NVMe" ${vol.type === 'NVMe' ? 'selected' : ''}>NVMe</option>
|
|
</select>
|
|
<input type="number" class="vol-cap" value="${vol.capacity || ''}" placeholder="용량" style="flex: 1; min-width: 0;" ${!this.isEditMode ? 'readonly' : ''} />
|
|
<select class="vol-unit" style="width: 70px; flex-shrink: 0;" ${!this.isEditMode ? 'disabled' : ''}>
|
|
<option value="GB" ${vol.unit === 'GB' ? 'selected' : ''}>GB</option>
|
|
<option value="TB" ${vol.unit === 'TB' ? 'selected' : ''}>TB</option>
|
|
</select>
|
|
<button type="button" class="btn-circle-remove edit-only-btn" style="display: ${this.isEditMode ? 'inline-flex' : 'none'};">×</button>
|
|
`;
|
|
row.querySelector('.btn-circle-remove')?.addEventListener('click', () => row.remove());
|
|
container.appendChild(row);
|
|
}
|
|
|
|
private addRemoteInfoRow(info: any = { type: 'IP', name: '원격접속', val1: '', val2: '' }) {
|
|
const container = document.getElementById('hw-remote-info-container');
|
|
if (!container) return;
|
|
|
|
let parsedId = '';
|
|
let parsedPw = '';
|
|
if (info.type === 'IP' && info.val2) {
|
|
try {
|
|
const parsed = typeof info.val2 === 'string' ? JSON.parse(info.val2) : info.val2;
|
|
parsedId = parsed.id || '';
|
|
parsedPw = parsed.pw || '';
|
|
} catch (e) {
|
|
parsedId = info.val2;
|
|
}
|
|
}
|
|
|
|
const row = document.createElement('div');
|
|
row.className = 'remote-info-row w-full';
|
|
|
|
const line1 = document.createElement('div');
|
|
line1.className = 'ri-line items-center';
|
|
line1.innerHTML = `
|
|
<select class="ri-type" ${!this.isEditMode ? 'disabled' : ''} style="width: 75px; flex-shrink: 0; font-size: var(--fs-xs); padding: 0 6px;">
|
|
<option value="IP" ${info.type === 'IP' ? 'selected' : ''}>IP 주소</option>
|
|
<option value="MAC" ${info.type === 'MAC' ? 'selected' : ''}>MAC 주소</option>
|
|
</select>
|
|
<input type="text" class="ri-val1" value="${info.val1 || ''}" placeholder="주소 입력" ${!this.isEditMode ? 'readonly' : ''} style="flex: 1; min-width: 0; font-size: var(--fs-xs); padding: 0 6px;" />
|
|
<button type="button" class="btn-circle-remove edit-only-btn" style="display: ${this.isEditMode ? 'inline-flex' : 'none'};">×</button>
|
|
`;
|
|
|
|
const line2 = document.createElement('div');
|
|
line2.className = 'ri-line ri-cred-line items-center';
|
|
if (info.type !== 'IP') line2.classList.add('hidden');
|
|
|
|
line2.innerHTML = `
|
|
<div class="ri-connector" style="width: 16px; height: 16px; margin-top: -12px;"></div>
|
|
<select class="ri-tool" ${!this.isEditMode ? 'disabled' : ''} style="width: 85px; flex-shrink: 0; font-size: var(--fs-xs); padding: 0 6px;">
|
|
<option value="원격접속" ${info.name === '원격접속' ? 'selected' : ''}>원격접속</option>
|
|
<option value="리눅스" ${info.name === '리눅스' ? 'selected' : ''}>리눅스</option>
|
|
<option value="기타" ${info.name === '기타' ? 'selected' : ''}>기타</option>
|
|
</select>
|
|
<input type="text" class="ri-id" value="${parsedId}" placeholder="원격 ID" ${!this.isEditMode ? 'readonly' : ''} style="flex: 1; min-width: 0; font-size: var(--fs-xs); padding: 0 6px;" />
|
|
<input type="text" class="ri-pw" value="${parsedPw}" placeholder="원격 PW" ${!this.isEditMode ? 'readonly' : ''} style="flex: 1; min-width: 0; font-size: var(--fs-xs); padding: 0 6px;" />
|
|
`;
|
|
|
|
row.appendChild(line1);
|
|
row.appendChild(line2);
|
|
|
|
const typeSelect = row.querySelector('.ri-type') as HTMLSelectElement;
|
|
typeSelect.addEventListener('change', (e) => {
|
|
const isIP = (e.target as HTMLSelectElement).value === 'IP';
|
|
line2.classList.toggle('hidden', !isIP);
|
|
if (!isIP) {
|
|
(row.querySelector('.ri-id') as HTMLInputElement).value = '';
|
|
(row.querySelector('.ri-pw') as HTMLInputElement).value = '';
|
|
}
|
|
});
|
|
|
|
row.querySelector('.btn-circle-remove')?.addEventListener('click', () => row.remove());
|
|
container.appendChild(row);
|
|
}
|
|
|
|
private toggleEditOnlyBtns(isEdit: boolean) {
|
|
['btn-add-volume', 'btn-add-remote-info'].forEach(id => {
|
|
const btn = document.getElementById(id);
|
|
if (btn) btn.classList.toggle('hidden', !isEdit);
|
|
});
|
|
document.querySelectorAll('.edit-only-btn').forEach(btn => {
|
|
(btn as HTMLElement).style.display = isEdit ? 'inline-flex' : 'none';
|
|
});
|
|
|
|
const containers = ['#hw-volume-container', '#hw-remote-info-container'];
|
|
containers.forEach(selector => {
|
|
document.querySelectorAll(`${selector} input`).forEach(input => {
|
|
if (isEdit) input.removeAttribute('readonly');
|
|
else input.setAttribute('readonly', 'true');
|
|
});
|
|
document.querySelectorAll(`${selector} select`).forEach(select => {
|
|
if (isEdit) select.removeAttribute('disabled');
|
|
else select.setAttribute('disabled', 'true');
|
|
});
|
|
});
|
|
}
|
|
|
|
protected fillFormData(asset: any): void {
|
|
setFieldValue('hw-id', asset.id);
|
|
setFieldValue('hw-asset_code', asset.asset_code || '');
|
|
setFieldValue('hw-purchase_corp', asset.purchase_corp || '');
|
|
setFieldValue('hw-category', asset.category || '');
|
|
const types = CATEGORY_TYPE_MAP[asset.category] || [];
|
|
const typeSelect = document.getElementById('hw-asset_type') as HTMLSelectElement;
|
|
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 || '');
|
|
setFieldValue('hw-user_current', asset.user_current || '');
|
|
setFieldValue('hw-emp_no', asset.emp_no || '');
|
|
setFieldValue('hw-user_position', asset.user_position || '');
|
|
setFieldValue('hw-previous_user', asset.previous_user || '');
|
|
setFieldValue('hw-model_name', asset.model_name || '');
|
|
setFieldValue('hw-asset_mfr', asset.asset_mfr || '');
|
|
setFieldValue('hw-os', asset.os || '');
|
|
setFieldValue('hw-cpu', asset.cpu || '');
|
|
setFieldValue('hw-ram', asset.ram || '');
|
|
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));
|
|
|
|
// 통합 원격 접속 정보 렌더링 초기화 및 생성
|
|
const remoteInfoContainer = document.getElementById('hw-remote-info-container');
|
|
if (remoteInfoContainer) {
|
|
remoteInfoContainer.innerHTML = '';
|
|
let nets = [];
|
|
try {
|
|
nets = asset.remotes ? (typeof asset.remotes === 'string' ? JSON.parse(asset.remotes) : asset.remotes) : [];
|
|
} catch(e) {}
|
|
|
|
// Fallback for legacy data
|
|
if (nets.length === 0 && (asset.ip_address || asset.mac_address || asset.remote_tool || asset.remote_id)) {
|
|
if (asset.ip_address) {
|
|
const tool = asset.remote_tool || '원격접속';
|
|
const creds = (asset.remote_id || asset.remote_pw) ? JSON.stringify({ id: asset.remote_id || '', pw: asset.remote_pw || '' }) : '';
|
|
nets.push({ type: 'IP', name: tool, val1: asset.ip_address, val2: creds });
|
|
}
|
|
if (asset.mac_address) {
|
|
nets.push({ type: 'MAC', name: 'MAC 주소', val1: asset.mac_address, val2: '' });
|
|
}
|
|
if (!asset.ip_address && (asset.remote_tool || asset.remote_id)) {
|
|
const creds = JSON.stringify({ id: asset.remote_id || '', pw: asset.remote_pw || '' });
|
|
nets.push({ type: 'IP', name: asset.remote_tool || '기타', val1: '', val2: creds });
|
|
}
|
|
}
|
|
nets.forEach((n: any) => this.addRemoteInfoRow(n));
|
|
}
|
|
|
|
setFieldValue('hw-monitoring', asset.monitoring || '비대상');
|
|
setFieldValue('hw-serial_num', asset.serial_num || '');
|
|
setFieldValue('hw-monitor_inch', asset.monitor_inch || '');
|
|
setFieldValue('hw-volume', asset.volume || '');
|
|
setFieldValue('hw-asset_count', asset.asset_count || '');
|
|
setFieldValue('hw-purchase_date', asset.purchase_date || '');
|
|
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');
|
|
if (fileLinkContainer && asset.approval_document) {
|
|
fileLinkContainer.innerHTML = `<a href="${asset.approval_document}" target="_blank" class="btn btn-outline btn-sm">[파일 보기]</a>`;
|
|
} else if (fileLinkContainer) {
|
|
fileLinkContainer.innerHTML = '';
|
|
}
|
|
|
|
setFieldValue('hw-memo', asset.memo || '');
|
|
setFieldValue('hw-location_detail', asset.location_detail || '');
|
|
setFieldValue('hw-loc_x', asset.loc_x || '');
|
|
setFieldValue('hw-loc_y', asset.loc_y || '');
|
|
setFieldValue('hw-location_photo', asset.location_photo || asset.loc_img || '');
|
|
parseAndSetLocation(asset.location || '', asset.location_detail || '', 'hw-bldg-select', 'hw-location_detail');
|
|
|
|
this.renderHistory(asset.id);
|
|
this.applyRoleVisibility();
|
|
this.updatePcGradeBadge();
|
|
this.updateHeaderIdentity(asset);
|
|
}
|
|
|
|
protected onAfterOpen(asset: any, mode: string): void {
|
|
const genBtn = document.getElementById('btn-gen-hw-code');
|
|
if (genBtn) genBtn.style.display = (mode === 'add') ? 'inline-flex' : 'none';
|
|
|
|
const qrBtn = document.getElementById('btn-print-hw-qr');
|
|
if (qrBtn) {
|
|
const hasCode = asset && asset.asset_code && asset.asset_code.trim() !== '';
|
|
qrBtn.classList.toggle('hidden', mode !== 'view' || !hasCode);
|
|
}
|
|
|
|
const approvedBadge = document.getElementById('hw-modal-audit-approved-badge');
|
|
if (approvedBadge) {
|
|
const isApproved = asset && asset.is_audit_approved;
|
|
approvedBadge.style.display = (mode === 'view' && isApproved) ? 'inline-flex' : 'none';
|
|
}
|
|
|
|
this.toggleFileUploadUI(mode !== 'view');
|
|
this.toggleEditOnlyBtns(mode !== 'view');
|
|
this.updateMapButtonVisibility();
|
|
this.applyRoleVisibility();
|
|
this.updateHeaderIdentity(asset);
|
|
}
|
|
|
|
private updateHeaderIdentity(asset: any) {
|
|
const container = document.getElementById('hw-header-identity');
|
|
if (!container) return;
|
|
|
|
if (this.currentMode === 'add') {
|
|
container.innerHTML = '<span class="badge badge-primary">신규 등록</span>';
|
|
return;
|
|
}
|
|
|
|
const category = (document.getElementById('hw-category') as HTMLSelectElement)?.value || asset.category || '';
|
|
const type = (document.getElementById('hw-asset_type') as HTMLSelectElement)?.value || asset.asset_type || '';
|
|
const code = (document.getElementById('hw-asset_code') as HTMLInputElement)?.value || asset.asset_code || '미부여';
|
|
const serviceType = (document.getElementById('hw-service_type') as HTMLSelectElement)?.value || asset.service_type || '외부';
|
|
|
|
container.innerHTML = `
|
|
<span class="asset-code-title">${code}</span>
|
|
<span class="service-type-badge">${serviceType}</span>
|
|
<span class="service-type-badge" style="background: var(--canvas-soft-2); color: var(--primary); border: 1px solid var(--hairline);">${category}</span>
|
|
<span class="asset-type-label">${type}</span>
|
|
`;
|
|
}
|
|
|
|
private toggleFileUploadUI(showUpload: boolean) {
|
|
const fileBtn = document.getElementById('btn-file-select') as HTMLElement;
|
|
if (fileBtn) fileBtn.style.display = showUpload ? 'inline-flex' : 'none';
|
|
}
|
|
|
|
private applyRoleVisibility(): void {
|
|
const category = (document.getElementById('hw-category') as HTMLSelectElement)?.value || '';
|
|
const type = (document.getElementById('hw-asset_type') as HTMLSelectElement)?.value || '';
|
|
|
|
const infraCategories = ['서버', '저장매체', '네트워크', '보안장비', '공간정보장비'];
|
|
const isInfra = infraCategories.includes(category) || type.includes('서버') || type.includes('저장시스템');
|
|
const personalCategories = ['PC', '노트북', '모바일', '태블릿'];
|
|
const isPersonal = (personalCategories.includes(category) || type.includes('개인PC') || type.includes('노트북')) && !type.includes('서버PC');
|
|
const specCategories = ['PC', '서버', '노트북', '스토리지', '워크스테이션'];
|
|
const hasSpec = specCategories.includes(category) || type.includes('서버PC');
|
|
const noNetCategories = ['저장매체', '네트워크', '공간정보장비', 'PC부품', '사무가구'];
|
|
const showNet = (isInfra || isPersonal) && !noNetCategories.includes(category);
|
|
const hasSN = ['외부SW', '내부SW'].includes(category);
|
|
const showMainboard = category === 'PC';
|
|
const isParts = ['PC부품', '사무가구'].includes(category);
|
|
const showRemote = category === '서버' || type.includes('서버');
|
|
const showServiceType = category === '서버' || type === '서버PC';
|
|
|
|
document.querySelectorAll('.remote-section, .remote-field, .monitoring-field').forEach(el => (el as HTMLElement).style.display = showRemote ? '' : 'none');
|
|
document.querySelectorAll('.service-type-field').forEach(el => (el as HTMLElement).style.display = showServiceType ? '' : 'none');
|
|
document.querySelectorAll('.net-only').forEach(el => (el as HTMLElement).style.display = showNet ? '' : 'none');
|
|
document.querySelectorAll('.spec-only').forEach(el => (el as HTMLElement).style.display = hasSpec ? '' : 'none');
|
|
document.querySelectorAll('.location-section, .location-field').forEach(el => (el as HTMLElement).style.display = (isInfra || category === '공간정보장비') ? '' : 'none');
|
|
document.querySelectorAll('.org-user-section, .org-user-field').forEach(el => (el as HTMLElement).style.display = (isPersonal || isParts || category === '업무지원장비') ? '' : 'none');
|
|
document.querySelectorAll('.personal-only').forEach(el => (el as HTMLElement).style.display = isPersonal ? '' : 'none');
|
|
document.querySelectorAll('.sn-only').forEach(el => (el as HTMLElement).style.display = hasSN ? '' : 'none');
|
|
document.querySelectorAll('.mainboard-only').forEach(el => (el as HTMLElement).style.display = showMainboard ? '' : 'none');
|
|
document.querySelectorAll('.monitor-only').forEach(el => (el as HTMLElement).style.display = type.includes('모니터') ? '' : 'none');
|
|
document.querySelectorAll('.parts-only').forEach(el => (el as HTMLElement).style.display = isParts ? '' : 'none');
|
|
document.querySelectorAll('.hardware-section').forEach(el => (el as HTMLElement).style.display = (hasSpec || isParts) ? '' : 'none');
|
|
|
|
// Lock only User and Organization Information for PC category during edit mode
|
|
const isEditMode = this.currentMode === 'edit';
|
|
const isPC = category === 'PC';
|
|
|
|
const noticeEl = document.getElementById('hw-pc-workflow-notice');
|
|
if (noticeEl) {
|
|
if (isPC && isEditMode) {
|
|
noticeEl.classList.remove('hidden');
|
|
} else {
|
|
noticeEl.classList.add('hidden');
|
|
}
|
|
}
|
|
|
|
const lockedUserFields = [
|
|
'hw-current_dept',
|
|
'hw-manager_primary',
|
|
'hw-manager_secondary',
|
|
'hw-user_current',
|
|
'hw-emp_no',
|
|
'hw-user_position',
|
|
'hw-previous_user'
|
|
];
|
|
|
|
const allFormControls = this.formEl ? this.formEl.querySelectorAll('input, select, textarea, button') : [];
|
|
|
|
allFormControls.forEach(control => {
|
|
const el = control as HTMLElement;
|
|
const id = el.id;
|
|
|
|
if (el.tagName === 'INPUT' && (el as HTMLInputElement).type === 'hidden') return;
|
|
if (id === 'hw-asset_code' || id === 'btn-gen-hw-code') return;
|
|
|
|
if (isPC && isEditMode && lockedUserFields.includes(id)) {
|
|
// Lock user information fields for PC in edit mode
|
|
if (el.tagName === 'SELECT') {
|
|
el.setAttribute('disabled', 'true');
|
|
} else if (el.tagName === 'INPUT' || el.tagName === 'TEXTAREA') {
|
|
el.setAttribute('readonly', 'true');
|
|
(el as HTMLInputElement).style.backgroundColor = '#f1f5f9';
|
|
(el as HTMLInputElement).style.cursor = 'not-allowed';
|
|
} else if (el.tagName === 'BUTTON') {
|
|
el.setAttribute('disabled', 'true');
|
|
}
|
|
} else {
|
|
// Normal behavior based on modal edit/view mode (includes add mode which has this.isEditMode = true)
|
|
if (!this.isEditMode) {
|
|
if (el.tagName === 'SELECT') {
|
|
el.setAttribute('disabled', 'true');
|
|
} else if (el.tagName === 'INPUT' || el.tagName === 'TEXTAREA') {
|
|
el.setAttribute('readonly', 'true');
|
|
(el as HTMLInputElement).style.backgroundColor = '';
|
|
(el as HTMLInputElement).style.cursor = '';
|
|
} else if (el.tagName === 'BUTTON') {
|
|
if (id !== 'btn-print-hw-qr' && id !== 'btn-close-hw-modal') {
|
|
el.setAttribute('disabled', 'true');
|
|
}
|
|
}
|
|
} else {
|
|
if (el.tagName === 'SELECT') {
|
|
el.removeAttribute('disabled');
|
|
} else if (el.tagName === 'INPUT' || el.tagName === 'TEXTAREA') {
|
|
if (id !== 'hw-emp_no') {
|
|
el.removeAttribute('readonly');
|
|
(el as HTMLInputElement).style.backgroundColor = '';
|
|
(el as HTMLInputElement).style.cursor = '';
|
|
}
|
|
} else if (el.tagName === 'BUTTON') {
|
|
el.removeAttribute('disabled');
|
|
}
|
|
}
|
|
}
|
|
});
|
|
}
|
|
|
|
private updateMapButtonVisibility() {
|
|
const bldg = getFieldValue('hw-bldg-select');
|
|
const detail = getFieldValue('hw-location_detail');
|
|
const x = getFieldValue('hw-loc_x');
|
|
const y = getFieldValue('hw-loc_y');
|
|
const hasCoords = (x !== '' && y !== '' && x !== 'null' && y !== 'null');
|
|
const hasImage = !!this.getImagesForLocation(bldg, detail);
|
|
const regLocBtn = document.getElementById('btn-reg-loc-map')!;
|
|
const viewLocBtn = document.getElementById('btn-view-loc-map')!;
|
|
|
|
if (hasImage && this.isEditMode) regLocBtn.classList.remove('hidden');
|
|
else regLocBtn.classList.add('hidden');
|
|
|
|
if (hasImage && hasCoords) viewLocBtn.classList.remove('hidden');
|
|
else viewLocBtn.classList.add('hidden');
|
|
}
|
|
|
|
private getImagesForLocation(bldg: string, detail: string): string[] | null {
|
|
if (!bldg || !detail) return null;
|
|
return IMAGE_LOCATIONS[bldg.trim()]?.[detail.trim()] || null;
|
|
}
|
|
|
|
private async fetchMapConfig() {
|
|
try {
|
|
const res = await fetch(`${API_BASE_URL}/api/maps`);
|
|
this.dynamicMapConfig = await res.json();
|
|
} catch (err) { console.error('Failed to fetch map config:', err); }
|
|
}
|
|
|
|
private generateDynamicSVG(imagePath: string): string {
|
|
const boxes = this.dynamicMapConfig[imagePath] || [];
|
|
if (boxes.length === 0) return '';
|
|
return `<svg viewBox="0 0 100 100" preserveAspectRatio="none" style="width:100%; height:100%; position:absolute; top:0; left:0; pointer-events:none;"><g>${boxes.map((b) => `<rect x="${b.x}" y="${b.y}" width="${b.w}" height="${b.h}" rx="0.5" style="fill:rgba(30,81,73,0.05); stroke:rgba(30,81,73,0.2); stroke-width:0.2;" />`).join('')}</g></svg>`;
|
|
}
|
|
|
|
private openImagePicker(imagePaths: string[], title: string) {
|
|
let currentIdx = 0;
|
|
const overlay = document.createElement('div');
|
|
overlay.className = 'image-picker-overlay';
|
|
const renderContent = () => {
|
|
const imgPath = imagePaths[currentIdx];
|
|
const isMulti = imagePaths.length > 1;
|
|
const isHtmlMap = imgPath.toLowerCase().endsWith('.html');
|
|
const digitalMap = isHtmlMap ? '' : this.generateDynamicSVG(imgPath);
|
|
|
|
overlay.innerHTML = `
|
|
<div class="image-picker-window">
|
|
<div class="image-picker-header">
|
|
<h3>${title} ${isMulti ? `(${currentIdx + 1}/${imagePaths.length})` : ''}</h3>
|
|
<button class="btn-icon btn-close-picker" aria-label="닫기">×</button>
|
|
</div>
|
|
<div class="image-picker-content">
|
|
${isMulti ? `
|
|
<div class="picker-nav prev ${currentIdx === 0 ? 'disabled' : ''}" style="position: absolute; left: 10px; top: 50%; transform: translateY(-50%); z-index: 100; cursor: pointer; background: rgba(0,0,0,0.5); color: white; padding: 20px 10px; border-radius: 5px; font-size: 24px; user-select: none;">❮</div>
|
|
<div class="picker-nav next ${currentIdx === imagePaths.length - 1 ? 'disabled' : ''}" style="position: absolute; right: 10px; top: 50%; transform: translateY(-50%); z-index: 100; cursor: pointer; background: rgba(0,0,0,0.5); color: white; padding: 20px 10px; border-radius: 5px; font-size: 24px; user-select: none;">❯</div>
|
|
` : ''}
|
|
<div class="layout-map-container" id="picker-container">
|
|
${isHtmlMap
|
|
? `<iframe src="${imgPath}" style="width:100%; height:100%; border:none; display:block;"></iframe>`
|
|
: `<div class="image-marker-wrapper">
|
|
<img src="${imgPath}" class="layout-map-img" />
|
|
<div id="picker-marker" class="layout-marker hidden"></div>
|
|
<div class="digital-overlay-layer">${digitalMap}</div>
|
|
</div>`
|
|
}
|
|
</div>
|
|
</div>
|
|
<div class="image-picker-footer"><button id="btn-picker-cancel" class="btn btn-outline">취소</button><button id="btn-picker-save" class="btn btn-primary">위치 확정</button></div>
|
|
</div>`;
|
|
|
|
let selectedX = ''; let selectedY = '';
|
|
|
|
if (isMulti) {
|
|
overlay.querySelector('.picker-nav.prev')?.addEventListener('click', (e) => { e.stopPropagation(); if (currentIdx > 0) { currentIdx--; renderContent(); } });
|
|
overlay.querySelector('.picker-nav.next')?.addEventListener('click', (e) => { e.stopPropagation(); if (currentIdx < imagePaths.length - 1) { currentIdx++; renderContent(); } });
|
|
}
|
|
|
|
if (isHtmlMap) {
|
|
const handleMessage = (e: MessageEvent) => {
|
|
if (e.data.type === 'PICK_LOCATION') {
|
|
selectedX = e.data.x;
|
|
selectedY = e.data.y;
|
|
}
|
|
};
|
|
window.addEventListener('message', handleMessage);
|
|
overlay.querySelector('.btn-close-picker')?.addEventListener('click', () => { window.removeEventListener('message', handleMessage); overlay.remove(); });
|
|
overlay.querySelector('#btn-picker-cancel')?.addEventListener('click', () => { window.removeEventListener('message', handleMessage); overlay.remove(); });
|
|
overlay.querySelector('#btn-picker-save')?.addEventListener('click', () => {
|
|
if (!selectedX || !selectedY) { alert('위치를 선택해주세요.'); return; }
|
|
setFieldValue('hw-loc_x', selectedX); setFieldValue('hw-loc_y', selectedY);
|
|
setFieldValue('hw-location_photo', imagePaths[currentIdx]);
|
|
this.updateMapButtonVisibility();
|
|
window.removeEventListener('message', handleMessage);
|
|
overlay.remove();
|
|
});
|
|
} else {
|
|
const container = overlay.querySelector('#picker-container') as HTMLElement;
|
|
const marker = overlay.querySelector('#picker-marker') as HTMLElement;
|
|
container.addEventListener('click', (e) => {
|
|
const rectBound = container.getBoundingClientRect();
|
|
const clickX = ((e.clientX - rectBound.left) / rectBound.width) * 100;
|
|
const clickY = ((e.clientY - rectBound.top) / rectBound.height) * 100;
|
|
|
|
let snapped = false;
|
|
overlay.querySelectorAll('rect').forEach(rect => {
|
|
const rx = parseFloat(rect.getAttribute('x') || '0');
|
|
const ry = parseFloat(rect.getAttribute('y') || '0');
|
|
const rw = parseFloat(rect.getAttribute('width') || '0');
|
|
const rh = parseFloat(rect.getAttribute('height') || '0');
|
|
|
|
if (clickX >= rx && clickX <= rx + rw && clickY >= ry && clickY <= ry + rh) {
|
|
overlay.querySelectorAll('rect').forEach(r => {
|
|
r.style.fill = 'rgba(30,81,73,0.05)';
|
|
r.style.stroke = 'rgba(30,81,73,0.2)';
|
|
r.style.strokeWidth = '0.2';
|
|
});
|
|
rect.style.fill = 'rgba(255, 61, 0, 0.4)';
|
|
rect.style.stroke = '#FF3D00';
|
|
rect.style.strokeWidth = '0.8';
|
|
|
|
selectedX = rx.toFixed(2);
|
|
selectedY = ry.toFixed(2);
|
|
|
|
marker.style.left = `${rx + rw/2}%`;
|
|
marker.style.top = `${ry + rh/2}%`;
|
|
marker.classList.remove('hidden');
|
|
snapped = true;
|
|
}
|
|
});
|
|
|
|
if (!snapped) {
|
|
selectedX = '';
|
|
selectedY = '';
|
|
marker.classList.add('hidden');
|
|
overlay.querySelectorAll('rect').forEach(r => {
|
|
r.style.fill = 'rgba(30,81,73,0.05)';
|
|
r.style.stroke = 'rgba(30,81,73,0.2)';
|
|
r.style.strokeWidth = '0.2';
|
|
});
|
|
}
|
|
});
|
|
overlay.querySelector('.btn-close-picker')?.addEventListener('click', () => overlay.remove());
|
|
overlay.querySelector('#btn-picker-cancel')?.addEventListener('click', () => overlay.remove());
|
|
overlay.querySelector('#btn-picker-save')?.addEventListener('click', () => {
|
|
if (!selectedX || !selectedY) { alert('위치를 선택해주세요.'); return; }
|
|
setFieldValue('hw-loc_x', selectedX); setFieldValue('hw-loc_y', selectedY);
|
|
setFieldValue('hw-location_photo', imagePaths[currentIdx]);
|
|
this.updateMapButtonVisibility(); overlay.remove();
|
|
});
|
|
}
|
|
};
|
|
renderContent(); document.body.appendChild(overlay);
|
|
}
|
|
|
|
private openImagePreview(imagePath: string, title: string, x: string, y: string) {
|
|
const overlay = document.createElement('div');
|
|
overlay.className = 'image-picker-overlay';
|
|
const isHtmlMap = imagePath.toLowerCase().endsWith('.html');
|
|
const digitalMap = isHtmlMap ? '' : this.generateDynamicSVG(imagePath);
|
|
const finalPath = isHtmlMap ? `${imagePath}?markerX=${x}&markerY=${y}` : imagePath;
|
|
|
|
overlay.innerHTML = `
|
|
<div class="image-picker-window">
|
|
<div class="image-picker-header"><h3>${title}</h3><button class="btn-icon btn-close-picker" aria-label="닫기">×</button></div>
|
|
<div class="image-picker-content">
|
|
<div class="layout-map-container readonly">
|
|
${isHtmlMap
|
|
? `<iframe src="${finalPath}" style="width:100%; height:100%; border:none; display:block;"></iframe>`
|
|
: `<div class="image-marker-wrapper">
|
|
<img src="${imagePath}" class="layout-map-img" />
|
|
<div id="preview-marker" class="layout-marker pulse-marker" style="left:${x}%; top:${y}%;"></div>
|
|
<div class="digital-overlay-layer">${digitalMap}</div>
|
|
</div>`
|
|
}
|
|
</div>
|
|
</div>
|
|
<div class="image-picker-footer"><button id="btn-preview-close" class="btn btn-primary">확인</button></div>
|
|
</div>`;
|
|
|
|
document.body.appendChild(overlay);
|
|
if (!isHtmlMap && digitalMap) {
|
|
const curX = parseFloat(x || '0'); const curY = parseFloat(y || '0');
|
|
overlay.querySelectorAll('rect').forEach(rect => {
|
|
const sx = parseFloat(rect.getAttribute('x') || '0');
|
|
const sy = parseFloat(rect.getAttribute('y') || '0');
|
|
if (Math.abs(sx - curX) < 0.01 && Math.abs(sy - curY) < 0.01) {
|
|
rect.style.fill = 'rgba(255, 61, 0, 0.4)'; rect.style.stroke = '#FF3D00'; rect.style.strokeWidth = '0.8';
|
|
const w = parseFloat(rect.getAttribute('width') || '0');
|
|
const h = parseFloat(rect.getAttribute('height') || '0');
|
|
const marker = overlay.querySelector('#preview-marker') as HTMLElement;
|
|
if (marker) { marker.style.left = `${sx + w/2}%`; marker.style.top = `${sy + h/2}%`; }
|
|
}
|
|
});
|
|
}
|
|
overlay.querySelector('.btn-close-picker')?.addEventListener('click', () => overlay.remove());
|
|
overlay.querySelector('#btn-preview-close')?.addEventListener('click', () => overlay.remove());
|
|
}
|
|
|
|
private bindUserAutocomplete() {
|
|
const input = document.getElementById('hw-user_current') as HTMLInputElement;
|
|
const list = document.getElementById('hw-user-current-list') as HTMLDivElement;
|
|
const deptSelect = document.getElementById('hw-current_dept') as HTMLSelectElement;
|
|
const positionInput = document.getElementById('hw-user_position') as HTMLInputElement;
|
|
const empNoInput = document.getElementById('hw-emp_no') as HTMLInputElement;
|
|
|
|
if (!input || !list) return;
|
|
|
|
const showList = (filterText: string = '') => {
|
|
if (!this.isEditMode) return;
|
|
const category = (document.getElementById('hw-category') as HTMLSelectElement)?.value || '';
|
|
if (category === 'PC') return;
|
|
const users = state.masterData.users || [];
|
|
const query = filterText.trim().toLowerCase();
|
|
|
|
const filtered = query
|
|
? users.filter((u: any) =>
|
|
u.user_name.toLowerCase().includes(query) ||
|
|
(u.dept_name && u.dept_name.toLowerCase().includes(query)) ||
|
|
(u.emp_no && u.emp_no.toLowerCase().includes(query))
|
|
)
|
|
: users;
|
|
|
|
if (filtered.length === 0) {
|
|
list.innerHTML = '<div class="autocomplete-item" style="color: #94a3b8; cursor: default;">일치하는 사원 없음</div>';
|
|
} else {
|
|
const seen = new Set();
|
|
const uniqueFiltered = filtered.filter((u: any) => {
|
|
const key = `${u.user_name}-${u.dept_name}-${u.emp_no}`;
|
|
if (seen.has(key)) return false;
|
|
seen.add(key);
|
|
return true;
|
|
}).slice(0, 15);
|
|
|
|
list.innerHTML = uniqueFiltered.map((u: any) => `
|
|
<div class="autocomplete-item user-suggestion-item"
|
|
data-name="${u.user_name}"
|
|
data-dept="${u.dept_name || ''}"
|
|
data-pos="${u.position || ''}"
|
|
data-emp="${u.emp_no || ''}">
|
|
<div style="font-weight: 600; color: #1e293b;">${u.user_name}</div>
|
|
<div style="font-size: 0.75rem; color: #64748b; margin-top: 2px;">
|
|
${u.dept_name || '부서 없음'} / 사번: ${u.emp_no || '-'} / ${u.position || '직급 없음'}
|
|
</div>
|
|
</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('.user-suggestion-item');
|
|
if (item) {
|
|
const name = item.getAttribute('data-name') || '';
|
|
const dept = item.getAttribute('data-dept') || '';
|
|
const pos = item.getAttribute('data-pos') || '';
|
|
const emp = item.getAttribute('data-emp') || '';
|
|
|
|
input.value = name;
|
|
if (positionInput) positionInput.value = pos;
|
|
if (empNoInput) empNoInput.value = emp;
|
|
|
|
if (deptSelect && dept) {
|
|
for (let i = 0; i < deptSelect.options.length; i++) {
|
|
if (deptSelect.options[i].value === dept) {
|
|
deptSelect.selectedIndex = i;
|
|
break;
|
|
}
|
|
}
|
|
}
|
|
list.classList.add('hidden');
|
|
}
|
|
});
|
|
|
|
document.addEventListener('mousedown', (e) => {
|
|
if (e.target !== input && !list.contains(e.target as Node)) {
|
|
list.classList.add('hidden');
|
|
}
|
|
});
|
|
}
|
|
|
|
private renderHistory(assetId: string) {
|
|
const container = document.getElementById('hw-history-list');
|
|
if (!container) return;
|
|
const logs = (state.masterData.logs || []).filter(l => l.asset_id === assetId);
|
|
if (logs.length === 0) { container.innerHTML = '<div class="empty-history">기록된 변동 이력이 없습니다.</div>'; return; }
|
|
|
|
const createdDate = this.currentAsset?.created_at ? this.currentAsset.created_at.substring(0, 10) : '';
|
|
|
|
const grouped: Record<string, typeof logs> = {};
|
|
logs.forEach(l => {
|
|
const date = l.log_date || '날짜 미지정';
|
|
if (!grouped[date]) grouped[date] = [];
|
|
grouped[date].push(l);
|
|
});
|
|
|
|
container.innerHTML = Object.entries(grouped).map(([date, dateLogs]) => {
|
|
const entriesHtml = dateLogs.map((l, idx) => {
|
|
const isLast = idx === dateLogs.length - 1;
|
|
const borderStyle = isLast ? '' : 'border-bottom: 1px dashed var(--hairline); padding-bottom: 8px; margin-bottom: 8px;';
|
|
|
|
let displayDetails = l.details;
|
|
if (l.details && l.details.trim().startsWith('{')) {
|
|
try {
|
|
const data = JSON.parse(l.details);
|
|
if (data.type === 'checkout') {
|
|
displayDetails = `[불출] ${data.user || ''} (${data.dept || ''}) ${data.memo ? `| 메모: ${data.memo}` : ''}`;
|
|
} else if (data.type === 'return') {
|
|
displayDetails = `[반납] ${data.user || ''} (${data.dept || ''}) ${data.memo ? `| 메모: ${data.memo}` : ''}`;
|
|
} else if (data.type === 'move') {
|
|
displayDetails = `[이동] ${data.user || ''} (${data.dept || ''}) ➔ ${data.targetUser || ''} (${data.targetDept || ''}) ${data.memo ? `| 메모: ${data.memo}` : ''}`;
|
|
}
|
|
} catch (e) {}
|
|
}
|
|
|
|
return `
|
|
<div class="history-entry" style="${borderStyle}">
|
|
<div style="font-weight: 600; color: var(--primary); opacity: 0.8; margin-bottom: 4px; display: flex; align-items: center; gap: 6px;">
|
|
<span style="display: inline-block; width: 4px; height: 4px; background-color: var(--primary); border-radius: 50%;"></span>
|
|
${l.log_user || '시스템'}
|
|
</div>
|
|
<div style="color: var(--primary); padding-left: 10px; line-height: 1.5;">${displayDetails}</div>
|
|
</div>
|
|
`;
|
|
}).join('');
|
|
|
|
const isInitialReg = date === createdDate;
|
|
const regBadge = isInitialReg ? `<span class="badge-reg" style="font-size: 10px; padding: 1px 5px; margin-left: 6px; background-color: rgba(16, 185, 129, 0.1); color: #10b981; border: 1px solid rgba(16, 185, 129, 0.2); border-radius: 4px; font-weight: 600;">최초등록</span>` : '';
|
|
|
|
return `
|
|
<div class="history-item">
|
|
<div class="history-date" style="display: flex; align-items: center;">${date} ${regBadge}</div>
|
|
<div class="history-details" style="display: flex; flex-direction: column; gap: 4px;">
|
|
${entriesHtml}
|
|
</div>
|
|
</div>
|
|
`;
|
|
}).join('');
|
|
}
|
|
|
|
private getCategoryKey(asset: any): string {
|
|
const cat = asset.category;
|
|
const code = asset.asset_code || '';
|
|
if (asset.asset_type === '서버PC') return 'pc';
|
|
if (cat === '서버' || code.startsWith('SVR')) return 'server';
|
|
if (cat === '스토리지' || code.startsWith('STO')) return 'storage';
|
|
if (cat === '네트워크' || code.startsWith('NET')) return 'network';
|
|
if (cat === '업무지원장비' || code.startsWith('EQP')) return 'equipment';
|
|
if (cat === '공간정보장비') return 'survey';
|
|
if (cat === 'PC부품') return 'pcParts';
|
|
return (cat === 'PC' || code.startsWith('PC')) ? 'pc' : 'officeSupplies';
|
|
}
|
|
|
|
private async fetchMasterComponents(): Promise<void> {
|
|
try {
|
|
const res = await fetch(`${API_BASE_URL}/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();
|
|
export function initHwModal(onSave: () => void, closeModals: () => void) { hwModal.init(onSave, closeModals); }
|
|
export function openHwModal(asset: any, mode: 'view' | 'edit' | 'add' = 'view') { hwModal.open(asset, mode); }
|