refactor: complete modal class-based architecture, design system integration, and map editor modularization

This commit is contained in:
2026-06-01 14:57:07 +09:00
parent 590ddd0e85
commit 9cd5d59bf8
32 changed files with 1838 additions and 1670 deletions

View File

@@ -3,118 +3,25 @@
<head> <head>
<meta charset="UTF-8"> <meta charset="UTF-8">
<title>ITAM Map Coordinate Editor v3.0</title> <title>ITAM Map Coordinate Editor v3.0</title>
<style>
:root {
--primary: #1E5149;
--bg: #f5f5f5;
}
body { font-family: sans-serif; margin: 0; display: flex; height: 100vh; background: var(--bg); overflow: hidden; }
/* Left Sidebar: File Explorer */
.file-sidebar { width: 260px; background: white; border-right: 1px solid #ddd; display: flex; flex-direction: column; overflow-y: auto; }
.folder-item { padding: 10px 15px; background: #eee; font-weight: bold; font-size: 13px; border-bottom: 1px solid #ddd; color: var(--primary); }
.file-item { padding: 8px 25px; cursor: pointer; font-size: 12px; border-bottom: 1px solid #f9f9f9; transition: background 0.2s; }
.file-item:hover { background: #f0f0f0; }
.file-item.active { background: var(--primary); color: white; font-weight: bold; }
/* Center: Editor Area */
.editor-container { flex: 1; position: relative; overflow: auto; padding: 20px; display: flex; align-items: center; justify-content: center; background: #e0e0e0; }
.img-wrapper { position: relative; display: inline-block; box-shadow: 0 0 30px rgba(0,0,0,0.3); background: white; line-height: 0; }
img {
display: block;
max-width: calc(100vw - 650px); /* 좌우 사이드바 제외 */
max-height: 85vh;
width: auto;
height: auto;
user-select: none;
-webkit-user-drag: none;
}
/* Right Sidebar: Control Panel */
.sidebar { width: 350px; background: white; border-left: 1px solid #ddd; display: flex; flex-direction: column; padding: 20px; box-shadow: -5px 0 15px rgba(0,0,0,0.05); }
h2 { margin-top: 0; color: var(--primary); font-size: 1.2rem; }
p { font-size: 0.85rem; color: #666; line-height: 1.4; margin-bottom: 20px; }
.current-path { font-size: 11px; color: #888; margin-bottom: 10px; word-break: break-all; font-family: monospace; }
.box-list { flex: 1; overflow-y: auto; margin-bottom: 15px; border: 1px solid #eee; border-radius: 4px; padding: 10px; background: #fafafa; }
.box-item { font-family: monospace; font-size: 11px; padding: 6px; border-bottom: 1px solid #eee; display: flex; justify-content: space-between; align-items: center; }
.box-item:hover { background: #fff; }
.btn-del { cursor: pointer; color: #ff4444; border: none; background: none; font-size: 16px; padding: 0 5px; }
.actions { display: flex; flex-direction: column; gap: 8px; }
button { padding: 12px; border-radius: 4px; border: none; cursor: pointer; font-weight: bold; transition: all 0.2s; }
.btn-primary { background: var(--primary); color: white; }
.btn-secondary { background: #f0f0f0; color: #333; border: 1px solid #ccc; }
button:hover { filter: brightness(1.1); }
button:active { transform: scale(0.98); }
button:disabled { background: #ccc; cursor: not-allowed; }
/* Drawing Elements */
.draw-box { position: absolute; border: 2px solid #FF3D00; background: rgba(255, 61, 0, 0.2); pointer-events: none; z-index: 100; }
.placed-box { position: absolute; border: 1.5px solid var(--primary); background: rgba(30, 81, 73, 0.15); cursor: pointer; z-index: 50; }
.placed-box:hover { background: rgba(30, 81, 73, 0.4); border-color: #000; }
.placed-box.selected { border: 2.5px solid #FF3D00; z-index: 60; box-shadow: 0 0 10px rgba(255,61,0,0.5); }
.box-label {
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
font-size: 10px;
font-weight: bold;
color: var(--primary);
pointer-events: none;
white-space: nowrap;
background: rgba(255,255,255,0.7);
padding: 0 2px;
border-radius: 2px;
line-height: 1;
}
.draw-box .box-label {
color: #FF3D00;
background: rgba(255,255,255,0.8);
}
#save-status { margin-top: 8px; font-size: 11px; color: #27ae60; text-align: center; font-weight: bold; height: 14px; }
</style>
</head> </head>
<body> <body>
<!-- Left: File Selector --> <!-- Left: File Selector -->
<div class="file-sidebar" id="file-sidebar"> <div class="file-sidebar" id="file-sidebar">
<div class="folder-item">IDC</div> <!-- Rendered by MapEditor.ts -->
<div class="file-item active" data-path="img/location_photo/IDC/서관202.png">서관202.png</div>
<div class="file-item" data-path="img/location_photo/IDC/서관203.png">서관203.png</div>
<div class="file-item" data-path="img/location_photo/IDC/서관204.png">서관204.png</div>
<div class="file-item" data-path="img/location_photo/IDC/서관205.png">서관205.png</div>
<div class="file-item" data-path="img/location_photo/IDC/동관53.png">동관53.png</div>
<div class="file-item" data-path="img/location_photo/IDC/동관54.png">동관54.png</div>
<div class="folder-item">기술개발센터</div>
<div class="file-item" data-path="img/location_photo/기술개발센터/서버실/서버실_1.png">서버실_1.png</div>
<div class="file-item" data-path="img/location_photo/기술개발센터/서버실/서버실_2.png">서버실_2.png</div>
<div class="folder-item">한맥빌딩</div>
<div class="file-item" data-path="img/location_photo/한맥빌딩/7층_로비.png">7층_배치도(예시)</div>
<div class="file-item" data-path="img/location_photo/한맥빌딩/MDF실/MDF_1.png">MDF_1.png</div>
<div class="file-item" data-path="img/location_photo/한맥빌딩/MDF실/MDF_2.png">MDF_2.png</div>
<div class="file-item" data-path="img/location_photo/한맥빌딩/MDF실/MDF_3.png">MDF_3.png</div>
<div class="file-item" data-path="img/location_photo/한맥빌딩/MDF실/MDF_4.png">MDF_4.png</div>
</div> </div>
<!-- Center: Main Editor --> <!-- Center: Main Editor -->
<div class="editor-container" id="container"> <div class="editor-container" id="container">
<div class="img-wrapper" id="wrapper"> <div class="img-wrapper" id="wrapper">
<img src="img/location_photo/IDC/서관202.png" id="target-img" alt="Map Image"> <img src="" id="target-img" alt="Map Image">
</div> </div>
</div> </div>
<!-- Right: Control Panel --> <!-- Right: Control Panel -->
<div class="sidebar"> <div class="sidebar">
<h2>Map Editor <small style="font-size: 0.6em; color: #888;">v3.0</small></h2> <h2>Map Editor <small style="font-size: 0.6em; color: #888;">v3.0</small></h2>
<div class="current-path" id="current-path">img/location_photo/IDC/서관202.png</div> <div class="current-path" id="current-path">파일을 선택하세요</div>
<p> <p>
드래그하여 구역을 정의하세요. 저장 버튼을 누르면 즉시 시스템에 반영됩니다. 드래그하여 구역을 정의하세요. 저장 버튼을 누르면 즉시 시스템에 반영됩니다.
</p> </p>
@@ -122,186 +29,12 @@
<div class="box-list" id="box-list"></div> <div class="box-list" id="box-list"></div>
<div class="actions"> <div class="actions">
<button class="btn-secondary" onclick="clearAll()">전체 삭제</button> <button class="btn btn-outline" style="height:38px;" onclick="clearAll()">전체 삭제</button>
<button id="btn-save-server" class="btn-primary" onclick="saveToServer()">서버에 즉시 저장</button> <button id="btn-save-server" class="btn btn-primary" style="height:38px;" onclick="saveToServer()">서버에 즉시 저장</button>
<div id="save-status"></div> <div id="save-status"></div>
</div> </div>
</div> </div>
<script> <script type="module" src="/src/map-editor-main.ts"></script>
const wrapper = document.getElementById('wrapper');
const img = document.getElementById('target-img');
const boxListEl = document.getElementById('box-list');
const pathLabel = document.getElementById('current-path');
const fileItems = document.querySelectorAll('.file-item');
const statusEl = document.getElementById('save-status');
const saveBtn = document.getElementById('btn-save-server');
let allMapConfig = {};
let boxes = [];
let isDrawing = false;
let startX, startY;
let currentBox = null;
// 1. 서버에서 기존 설정 로드
async function loadConfig() {
try {
const res = await fetch(`http://${location.hostname}:3000/api/maps`);
allMapConfig = await res.json();
renderCurrentFile();
} catch (err) {
console.error('Failed to load config:', err);
}
}
function renderCurrentFile() {
const activeItem = document.querySelector('.file-item.active');
const activeFile = activeItem.dataset.path;
boxes = allMapConfig[activeFile] || [];
pathLabel.textContent = activeFile;
img.src = activeFile;
render();
}
// File Selection
fileItems.forEach(item => {
item.addEventListener('click', () => {
fileItems.forEach(i => i.classList.remove('active'));
item.classList.add('active');
renderCurrentFile();
});
});
// 2. 서버에 저장
async function saveToServer() {
const activeFile = document.querySelector('.file-item.active').dataset.path;
try {
saveBtn.disabled = true;
saveBtn.textContent = '저장 중...';
const res = await fetch(`http://${location.hostname}:3000/api/maps/save`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ path: activeFile, boxes: boxes })
});
if (res.ok) {
allMapConfig[activeFile] = [...boxes];
statusEl.textContent = '✅ 서버 저장 완료 (' + new Date().toLocaleTimeString() + ')';
setTimeout(() => statusEl.textContent = '', 3000);
} else {
alert('저장 실패!');
}
} catch (err) {
alert('서버 연결 오류!');
} finally {
saveBtn.disabled = false;
saveBtn.textContent = '서버에 즉시 저장';
}
}
wrapper.addEventListener('mousedown', (e) => {
if (e.button !== 0) return;
isDrawing = true;
const rect = wrapper.getBoundingClientRect();
startX = e.clientX - rect.left;
startY = e.clientY - rect.top;
currentBox = document.createElement('div');
currentBox.className = 'draw-box';
currentBox.style.left = startX + 'px';
currentBox.style.top = startY + 'px';
const label = document.createElement('div');
label.className = 'box-label';
label.textContent = '#' + (boxes.length + 1);
currentBox.appendChild(label);
wrapper.appendChild(currentBox);
});
window.addEventListener('mousemove', (e) => {
if (!isDrawing) return;
const rect = wrapper.getBoundingClientRect();
const currentX = Math.max(0, Math.min(e.clientX - rect.left, rect.width));
const currentY = Math.max(0, Math.min(e.clientY - rect.top, rect.height));
const width = currentX - startX;
const height = currentY - startY;
currentBox.style.width = Math.abs(width) + 'px';
currentBox.style.height = Math.abs(height) + 'px';
currentBox.style.left = (width > 0 ? startX : currentX) + 'px';
currentBox.style.top = (height > 0 ? startY : currentY) + 'px';
});
window.addEventListener('mouseup', (e) => {
if (!isDrawing) return;
isDrawing = false;
const width = parseFloat(currentBox.style.width);
const height = parseFloat(currentBox.style.height);
if (width > 3 && height > 3) {
const rect = wrapper.getBoundingClientRect();
const boxData = {
x: (parseFloat(currentBox.style.left) / rect.width * 100).toFixed(2),
y: (parseFloat(currentBox.style.top) / rect.height * 100).toFixed(2),
w: (width / rect.width * 100).toFixed(2),
h: (height / rect.height * 100).toFixed(2)
};
boxes.push(boxData);
render();
}
currentBox.remove();
currentBox = null;
});
function removeBox(index) {
boxes.splice(index, 1);
render();
}
function clearAll() {
if(confirm('모든 박스를 삭제할까요?')) {
boxes = [];
render();
}
}
function render() {
boxListEl.innerHTML = '';
const oldBoxes = wrapper.querySelectorAll('.placed-box');
oldBoxes.forEach(b => b.remove());
boxes.forEach((box, i) => {
const div = document.createElement('div');
div.className = 'placed-box';
div.style.left = box.x + '%';
div.style.top = box.y + '%';
div.style.width = box.w + '%';
div.style.height = box.h + '%';
const label = document.createElement('div');
label.className = 'box-label';
label.textContent = '#' + (i + 1);
div.appendChild(label);
wrapper.appendChild(div);
const item = document.createElement('div');
item.className = 'box-item';
item.innerHTML = `
<span>#${i+1}: [${box.x}, ${box.y}]</span>
<button class="btn-del" onclick="removeBox(${i})">×</button>
`;
boxListEl.appendChild(item);
});
}
loadConfig();
</script>
</body> </body>
</html> </html>

View File

@@ -1,5 +1,106 @@
import { createIcons, X } from 'lucide';
import { setEditLock } from './ModalUtils';
/** /**
* 모든 모달의 공통 기능 (닫기, ESC 처리, 배경 클릭 등)을 관리하는 베이스 모듈입니다. * 모든 모달의 공통 기능을 관리하는 베이스 추상 클래스입니다.
*/
export abstract class BaseModal {
protected idPrefix: string;
protected title: string;
protected currentAsset: any | null = null;
protected isEditMode: boolean = false;
protected modalEl: HTMLElement | null = null;
protected formEl: HTMLFormElement | null = null;
constructor(idPrefix: string, title: string) {
this.idPrefix = idPrefix;
this.title = title;
}
/**
* 모달 초기화: HTML 삽입 및 공통 이벤트 바인딩
*/
public init(onSave: () => void, closeModalsFn: () => void) {
// 1. 프레임 HTML 삽입 (자식 클래스에서 정의한 HTML 사용)
if (!document.getElementById(`${this.idPrefix}-asset-modal`)) {
document.body.insertAdjacentHTML('beforeend', this.renderFrameHTML());
}
this.modalEl = document.getElementById(`${this.idPrefix}-asset-modal`);
this.formEl = document.getElementById(`${this.idPrefix}-asset-form`) as HTMLFormElement;
// 2. 공통 버튼 이벤트 바인딩 (닫기, 취소 등)
const btnCloseHeader = document.getElementById(`btn-close-${this.idPrefix}-modal`);
const btnCancelFooter = document.getElementById(`btn-cancel-${this.idPrefix}-modal`);
const closeAction = () => {
this.close();
closeModalsFn(); // 전역 모달 상태 해제 콜백
};
btnCloseHeader?.addEventListener('click', closeAction);
btnCancelFooter?.addEventListener('click', closeAction);
// 3. 자식 클래스 전용 초기화 로직 실행
this.initChildLogic(onSave, closeModalsFn);
// 4. 아이콘 초기화
createIcons({ icons: { X } });
}
/**
* 모달 열기: 데이터 바인딩 및 모드 설정
*/
public open(asset: any, mode: 'view' | 'edit' | 'add' = 'view') {
this.currentAsset = asset;
this.isEditMode = (mode === 'add' || mode === 'edit');
this.setEditLockMode(mode);
this.fillFormData(asset);
if (this.modalEl) {
this.modalEl.classList.remove('hidden');
}
this.onAfterOpen(asset, mode);
}
/**
* 모달 닫기: 상태 초기화
*/
public close() {
if (this.modalEl) {
this.modalEl.classList.add('hidden');
}
this.isEditMode = false;
this.currentAsset = null;
this.onAfterClose();
}
/**
* 조회/수정 모드에 따른 UI 잠금 및 버튼 제어
*/
protected setEditLockMode(mode: 'view' | 'edit' | 'add') {
setEditLock(`${this.idPrefix}-asset-form`, mode, {
saveBtnId: `btn-save-${this.idPrefix}-asset`,
revertBtnId: `btn-revert-${this.idPrefix}-edit`,
addLogBtnId: `btn-add-${this.idPrefix}-log`
});
}
// --- 추상 메서드: 자식 클래스에서 구현해야 함 ---
protected abstract renderFrameHTML(): string;
protected abstract initChildLogic(onSave: () => void, closeModals: () => void): void;
protected abstract fillFormData(asset: any): void;
protected abstract onAfterOpen(asset: any, mode: string): void;
// --- 훅(Hook) 메서드: 필요 시 오버라이드 ---
protected onAfterClose(): void {}
}
/**
* --- 레거시 호환성을 위한 함수형 익스포트 ---
* 기존 코드들이 참조하고 있는 함수들을 유지합니다.
*/ */
export function closeModals() { export function closeModals() {
const modals = document.querySelectorAll('.modal-overlay'); const modals = document.querySelectorAll('.modal-overlay');
@@ -7,28 +108,14 @@ export function closeModals() {
} }
export function initBaseModal() { export function initBaseModal() {
// ESC 키로 닫기 // ESC 키로 모든 모달 닫기
window.addEventListener('keydown', (e) => { window.addEventListener('keydown', (e) => {
if (e.key === 'Escape') closeModals(); if (e.key === 'Escape') closeModals();
}); });
// 배경(Overlay) 클릭 시 닫기 (요청에 의해 비활성화됨)
/*
document.addEventListener('click', (e) => {
const target = e.target as HTMLElement;
if (target.classList.contains('modal-overlay')) {
closeModals();
}
});
*/
return { closeAllModals: closeModals }; return { closeAllModals: closeModals };
} }
/**
* 특정 모달을 엽니다.
* @param modalId 모달 엘리먼트의 ID
*/
export function openModal(modalId: string) { export function openModal(modalId: string) {
const modal = document.getElementById(modalId); const modal = document.getElementById(modalId);
if (modal) { if (modal) {

View File

@@ -1,121 +1,188 @@
import { state, saveAsset, deleteAsset } from '../../core/state'; import { state, saveAsset, deleteAsset } from '../../core/state';
import { closeModals, openModal } from './BaseModal'; import { BaseModal } from './BaseModal';
import { CORP_LIST } from './SharedData'; import { CORP_LIST } from './SharedData';
import { generateOptionsHTML, setEditLock } from './ModalUtils'; import { generateOptionsHTML, setFieldValue, getFieldValue } from './ModalUtils';
import { createIcons, X, Save, Database, CalendarClock, Edit2 } from 'lucide'; import { createIcons, X, Save, Database, CalendarClock, Edit2, History, Plus } from 'lucide';
import { formatExcelDate } from '../../core/excelHandler'; import { formatExcelDate } from '../../core/excelHandler';
import { UI_TEXT } from '../../core/schema'; import { UI_TEXT } from '../../core/schema';
import { API_BASE_URL } from '../../core/utils';
let currentItem: any = null; class DomainAssetModal extends BaseModal {
constructor() {
const DOMAIN_MODAL_HTML = ` super('domain', '도메인 정보');
... (rest of DOMAIN_MODAL_HTML remains same) ...
`;
export function initDomainModal() {
if (!document.getElementById('domain-asset-modal')) {
document.body.insertAdjacentHTML('beforeend', DOMAIN_MODAL_HTML);
} }
const modal = document.getElementById('domain-asset-modal')!; protected renderFrameHTML(): string {
document.getElementById('btn-close-domain-modal')?.addEventListener('click', () => closeModals()); return `
document.getElementById('btn-cancel-domain')?.addEventListener('click', () => closeModals()); <div id="domain-asset-modal" class="modal-overlay hidden">
<div class="modal-content wide">
<div class="modal-header">
<h2 id="domain-modal-title">${this.title}</h2>
<button id="btn-close-domain-modal" class="btn-icon" aria-label="닫기"><i data-lucide="x"></i></button>
</div>
<div class="modal-body">
<div class="modal-body-split">
<div class="modal-form-area">
<form id="domain-asset-form" class="grid-form">
<input type="hidden" id="domain-id" name="id" />
const saveBtn = document.getElementById('btn-save-domain'); <div class="form-section-title">기본 정보</div>
const revertBtn = document.getElementById('btn-revert-domain'); <div class="form-group">
const deleteBtn = document.getElementById('btn-delete-domain'); <label>구분</label>
const headerEditBtn = document.getElementById('btn-edit-domain-header'); <select id="domain-type" name="type">
<option value="호스팅">호스팅</option>
<option value="도메인">도메인</option>
<option value="기타">기타</option>
</select>
</div>
<div class="form-group">
<label>관리법인</label>
<select id="domain-corp" name="corp">${generateOptionsHTML(CORP_LIST)}</select>
</div>
<div class="form-group full-width">
<label>서비스명</label>
<input type="text" id="domain-service-name" name="service_name" required />
</div>
<div class="form-group full-width">
<label>관리도메인</label>
<input type="text" id="domain-name" name="domain_name" required />
</div>
saveBtn?.addEventListener('click', () => { <div class="form-section-title">계약 및 비용</div>
if (!currentItem) return; <div class="form-group">
if (saveBtn.textContent?.includes('수정')) { <label>계약시작일</label>
setEditLock('domain-asset-form', 'edit', { saveBtnId: 'btn-save-domain', revertBtnId: 'btn-revert-domain' }); <input type="date" id="domain-start-date" name="start_date" />
</div>
<div class="form-group">
<label>만료예정일</label>
<input type="date" id="domain-expiry-date" name="expiry_date" />
</div>
<div class="form-group">
<label>비용 (연간/월간)</label>
<input type="text" id="domain-price" name="price" oninput="this.value = this.value.replace(/[^0-9]/g, '').replace(/\\\\B(?=(\\\\d{3})+(?!\\\\d))/g, ',')" />
</div>
<div class="form-section-title">담당자 및 비고</div>
<div class="form-group">
<label>정담당자</label>
<input type="text" id="domain-manager-main" name="manager_main" />
</div>
<div class="form-group">
<label>부담당자</label>
<input type="text" id="domain-manager-sub" name="manager_sub" />
</div>
<div class="form-group full-width">
<label>비고</label>
<textarea id="domain-remarks" name="remarks" rows="3"></textarea>
</div>
</form>
</div>
<div class="modal-history-area">
<div class="history-header">
<h3><i data-lucide="history" style="width:16px; height:16px;"></i> 변경 이력</h3>
<button type="button" id="btn-add-domain-log" class="btn btn-outline btn-sm">
이력 추가 <i data-lucide="plus" style="width:14px; height:14px;"></i>
</button>
</div>
<div id="domain-history-list" class="history-timeline"></div>
</div>
</div>
</div>
<div class="modal-footer">
<button id="btn-delete-domain-asset" class="btn btn-outline btn-danger">삭제</button>
<div class="footer-actions">
<button id="btn-revert-domain-edit" class="btn btn-outline hidden">수정 취소</button>
<button id="btn-cancel-domain-modal" class="btn btn-outline">닫기</button>
<button id="btn-save-domain-asset" class="btn btn-primary">수정</button>
</div>
</div>
</div>
</div>
`;
}
protected initChildLogic(onSave: () => void, closeModals: () => void): void {
const saveBtn = document.getElementById('btn-save-domain-asset')!;
const revertBtn = document.getElementById('btn-revert-domain-edit')!;
const deleteBtn = document.getElementById('btn-delete-domain-asset')!;
saveBtn.addEventListener('click', async () => {
if (!this.currentAsset) return;
if (!this.isEditMode) {
this.setEditLockMode('edit');
this.isEditMode = true;
return; return;
} }
saveDomain();
});
headerEditBtn?.addEventListener('click', () => { const formData = new FormData(this.formEl!);
setEditLock('domain-asset-form', 'edit', { saveBtnId: 'btn-save-domain', revertBtnId: 'btn-revert-domain' }); const updated = { ...this.currentAsset };
}); formData.forEach((value, key) => { updated[key] = value; });
revertBtn?.addEventListener('click', () => { if (!updated.service_name || !updated.domain_name) {
setEditLock('domain-asset-form', 'view', { saveBtnId: 'btn-save-domain', revertBtnId: 'btn-revert-domain' });
if (currentItem) openDomainModal(currentItem);
});
deleteBtn?.addEventListener('click', async () => {
if (currentItem && confirm(UI_TEXT.MESSAGES.CONFIRM_DELETE)) {
const success = await deleteAsset('domain', currentItem.id);
if (success) {
alert('성공적으로 삭제되었습니다.');
closeModals();
window.dispatchEvent(new CustomEvent('refresh-view'));
}
}
});
}
export function openDomainModal(item: any = null) {
currentItem = item;
const isEdit = !!item;
const mode = isEdit ? 'view' : 'add';
const titleEl = document.getElementById('domain-modal-title');
if (titleEl) titleEl.textContent = isEdit ? '도메인 정보 상세' : '신규 도메인 등록';
setEditLock('domain-asset-form', mode, { saveBtnId: 'btn-save-domain', revertBtnId: 'btn-revert-domain' });
const setVal = (id: string, val: any) => {
const el = document.getElementById(id) as HTMLInputElement | HTMLSelectElement | HTMLTextAreaElement;
if (el) el.value = val || '';
};
setVal('domain-type', item?.type || '호스팅');
setVal('domain-corp', item?.corp || '');
setVal('domain-service-name', item?.service_name || '');
setVal('domain-name', item?.domain_name || '');
setVal('domain-start-date', formatExcelDate(item?.start_date));
setVal('domain-expiry-date', formatExcelDate(item?.expiry_date));
setVal('domain-price', item?.price || '');
setVal('domain-manager-main', item?.manager_main || '');
setVal('domain-manager-sub', item?.manager_sub || '');
setVal('domain-remarks', item?.remarks || '');
const deleteBtn = document.getElementById('btn-delete-domain');
if (deleteBtn) deleteBtn.style.display = isEdit ? 'block' : 'none';
openModal('domain-asset-modal');
createIcons({ icons: { X, Save, Database, CalendarClock, Edit2 } });
}
async function saveDomain() {
const getVal = (id: string) => (document.getElementById(id) as HTMLInputElement)?.value || '';
const newDomain = {
id: currentItem ? currentItem.id : `DOM-${Date.now()}`,
type: getVal('domain-type'),
corp: getVal('domain-corp'),
service_name: getVal('domain-service-name'),
domain_name: getVal('domain-name'),
start_date: getVal('domain-start-date'),
expiry_date: getVal('domain-expiry-date'),
price: getVal('domain-price'),
manager_main: getVal('domain-manager-main'),
manager_sub: getVal('domain-manager-sub'),
remarks: getVal('domain-remarks')
};
if (!newDomain.service_name || !newDomain.domain_name) {
alert('서비스명과 관리도메인은 필수 입력 사항입니다.'); alert('서비스명과 관리도메인은 필수 입력 사항입니다.');
return; return;
} }
const success = await saveAsset('domain', newDomain); if (await saveAsset('domain', updated)) {
if (success) {
alert(UI_TEXT.MESSAGES.SAVE_SUCCESS); alert(UI_TEXT.MESSAGES.SAVE_SUCCESS);
closeModals(); onSave(); this.close(); closeModals();
window.dispatchEvent(new CustomEvent('refresh-view')); }
});
revertBtn.addEventListener('click', () => {
this.setEditLockMode('view');
if (this.currentAsset) this.fillFormData(this.currentAsset);
});
deleteBtn.addEventListener('click', async () => {
if (!this.currentAsset || !confirm(UI_TEXT.MESSAGES.CONFIRM_DELETE)) return;
if (await deleteAsset('domain', this.currentAsset.id)) {
alert('성공적으로 삭제되었습니다.');
onSave(); this.close(); closeModals();
}
});
createIcons({ icons: { History, Plus, Save, CalendarClock, Database } });
}
protected fillFormData(asset: any): void {
setFieldValue('domain-id', asset.id);
setFieldValue('domain-type', asset.type || '호스팅');
setFieldValue('domain-corp', asset.corp || '');
setFieldValue('domain-service-name', asset.service_name || '');
setFieldValue('domain-name', asset.domain_name || '');
setFieldValue('domain-start-date', formatExcelDate(asset.start_date));
setFieldValue('domain-expiry-date', formatExcelDate(asset.expiry_date));
setFieldValue('domain-price', asset.price || '');
setFieldValue('domain-manager-main', asset.manager_main || '');
setFieldValue('domain-manager-sub', asset.manager_sub || '');
setFieldValue('domain-remarks', asset.remarks || '');
this.renderHistory(asset.id);
}
protected onAfterOpen(asset: any, mode: string): void {
const titleEl = document.getElementById('domain-modal-title');
if (titleEl) titleEl.textContent = (mode === 'add') ? '신규 도메인 등록' : '도메인 정보 상세';
const deleteBtn = document.getElementById('btn-delete-domain-asset');
if (deleteBtn) deleteBtn.style.display = (mode === 'add') ? 'none' : 'block';
}
private renderHistory(assetId: string) {
const container = document.getElementById('domain-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('');
} }
} }
export const domainModal = new DomainAssetModal();
export function initDomainModal(onSave: () => void, closeModals: () => void) {
domainModal.init(onSave, closeModals);
}
export function openDomainModal(asset: any, mode: 'view' | 'edit' | 'add' = 'view') {
domainModal.open(asset, mode);
}

View File

@@ -4,81 +4,27 @@ import {
generateOptionsHTML, generateOptionsHTML,
setFieldValue, setFieldValue,
getFieldValue, getFieldValue,
setEditLock,
parseAndSetLocation, parseAndSetLocation,
bindLocationEvents, bindLocationEvents,
getCombinedLocation,
applyDateMask applyDateMask
} from './ModalUtils'; } from './ModalUtils';
import { CORP_LIST, LOCATION_DATA, ORG_LIST, CATEGORY_TYPE_MAP, HW_STATUS_LIST } from './SharedData'; import { CORP_LIST, LOCATION_DATA, CATEGORY_TYPE_MAP, HW_STATUS_LIST, ORG_LIST, IMAGE_LOCATIONS } from './SharedData';
import { BaseModal } from './BaseModal';
import { createIcons, X, History, Plus, Save, Paperclip, Calendar, Monitor, Cpu, Network, ShieldCheck } from 'lucide'; import { createIcons, X, History, Plus, Save, Paperclip, Calendar, Monitor, Cpu, Network, ShieldCheck } from 'lucide';
let currentHwAsset: any | null = null; class HwAssetModal extends BaseModal {
let isEditMode = false; private dynamicMapConfig: Record<string, any[]> = {};
let dynamicMapConfig: Record<string, any[]> = {};
const IMAGE_LOCATIONS: Record<string, Record<string, string[]>> = { constructor() {
'IDC': { super('hw', '자산 상세 정보');
'서관202': ['img/location_photo/IDC/서관202.png'],
'서관203': ['img/location_photo/IDC/서관203.png'],
'서관204': ['img/location_photo/IDC/서관204.png'],
'서관205': ['img/location_photo/IDC/서관205.png'],
'동관53': ['img/location_photo/IDC/동관53.png'],
'동관54': ['img/location_photo/IDC/동관54.png'],
},
'기술개발센터': {
'서버실': [
'img/location_photo/기술개발센터/서버실/서버실_1.png',
'img/location_topic/기술개발센터/서버실/서버실_2.png'
]
},
'한맥빌딩': {
'7층': ['img/location_photo/한맥빌딩/7층_로비.png'],
'MDF실': [
'img/location_photo/한맥빌딩/MDF실/MDF_1.png',
'img/location_photo/한맥빌딩/MDF실/MDF_2.png',
'img/location_photo/한맥빌딩/MDF실/MDF_3.png',
'img/location_photo/한맥빌딩/MDF실/MDF_4.png'
]
} }
};
const getImagesForLocation = (bldg: string, detail: string): string[] | null => { protected renderFrameHTML(): string {
if (!bldg || !detail) return null;
const b = bldg.trim();
const d = detail.trim();
return IMAGE_LOCATIONS[b]?.[d] || null;
};
async function fetchMapConfig() {
try {
const res = await fetch(`http://${location.hostname}:3000/api/maps`);
dynamicMapConfig = await res.json();
} catch (err) {
console.error('Failed to fetch map config:', err);
}
}
function generateDynamicSVG(imagePath: string): string {
const boxes = dynamicMapConfig[imagePath] || [];
if (boxes.length === 0) return '';
return ` return `
<svg viewBox="0 0 100 100" preserveAspectRatio="none" class="digital-map-svg">
<g class="seat-group">
${boxes.map((b, i) => `
<rect class="map-seat-obj" data-id="seat-${i+1}"
x="${b.x}" y="${b.y}" width="${b.w}" height="${b.h}" rx="0.5" />
`).join('')}
</g>
</svg>
`;
}
const HW_MODAL_HTML = `
<div id="hw-asset-modal" class="modal-overlay hidden"> <div id="hw-asset-modal" class="modal-overlay hidden">
<div class="modal-content wide"> <div class="modal-content wide">
<div class="modal-header"> <div class="modal-header">
<h2 id="hw-modal-title">자산 상세 정보</h2> <h2 id="hw-modal-title">${this.title}</h2>
<button id="btn-close-hw-modal" class="btn-icon" aria-label="닫기"><i data-lucide="x"></i></button> <button id="btn-close-hw-modal" class="btn-icon" aria-label="닫기"><i data-lucide="x"></i></button>
</div> </div>
<div class="modal-body"> <div class="modal-body">
@@ -310,327 +256,94 @@ const HW_MODAL_HTML = `
</div> </div>
</div> </div>
</div> </div>
`; `;
/**
* 전역적으로 버튼 가시성을 업데이트하는 공용 함수
*/
function updateMapButtonVisibility(asset?: any) {
const bldgSelect = document.getElementById('hw-bldg-select') as HTMLSelectElement;
const detailSelect = document.getElementById('hw-location_detail') as HTMLSelectElement;
const regLocBtn = document.getElementById('btn-reg-loc-map')!;
const viewLocBtn = document.getElementById('btn-view-loc-map')!;
if (!bldgSelect || !detailSelect) return;
// 인자로 넘어온 asset이 있으면 우선 사용, 없으면 DOM 필드에서 읽음
const bldg = asset ? (asset.location || '') : bldgSelect.value;
const detail = asset ? (asset.location_detail || '') : detailSelect.value;
const x = asset ? (asset.loc_x || '') : getFieldValue('hw-loc_x');
const y = asset ? (asset.loc_y || '') : getFieldValue('hw-loc_y');
const hasCoords = (x !== '' && y !== '' && x !== 'null' && y !== 'null');
const hasImage = !!getImagesForLocation(bldg, detail);
// 위치등록 버튼: 오직 편집 모드일 때만 노출
if (hasImage && isEditMode) {
regLocBtn.classList.remove('hidden');
} else {
regLocBtn.classList.add('hidden');
} }
// 위치보기 버튼: 좌표가 있고 이미지가 있는 위치라면 모드 상관없이 노출 protected initChildLogic(onSave: () => void, closeModals: () => void): void {
if (hasImage && hasCoords) {
viewLocBtn.classList.remove('hidden');
} else {
viewLocBtn.classList.add('hidden');
}
}
export function initHwModal(onSave: () => void, closeModals: () => void) {
if (!document.getElementById('hw-asset-modal')) {
document.body.insertAdjacentHTML('beforeend', HW_MODAL_HTML);
}
fetchMapConfig();
const form = document.getElementById('hw-asset-form') as HTMLFormElement;
const saveBtn = document.getElementById('btn-save-hw-asset')!; const saveBtn = document.getElementById('btn-save-hw-asset')!;
const revertBtn = document.getElementById('btn-revert-hw-edit')!; const revertBtn = document.getElementById('btn-revert-hw-edit')!;
const deleteBtn = document.getElementById('btn-delete-hw-asset')!; const deleteBtn = document.getElementById('btn-delete-hw-asset')!;
const btnCloseHeader = document.getElementById('btn-close-hw-modal')!; const categorySelect = document.getElementById('hw-category') as HTMLSelectElement;
const btnCancelFooter = document.getElementById('btn-cancel-hw-modal')!; 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;
bindLocationEvents('hw-bldg-select', 'hw-location_detail', '', ''); bindLocationEvents('hw-bldg-select', 'hw-location_detail', '', '');
applyDateMask(document.getElementById('hw-purchase_date') as HTMLInputElement); applyDateMask(document.getElementById('hw-purchase_date') as HTMLInputElement);
const categorySelect = document.getElementById('hw-category') as HTMLSelectElement;
const typeSelect = document.getElementById('hw-asset_type') as HTMLSelectElement;
categorySelect.addEventListener('change', () => { categorySelect.addEventListener('change', () => {
const selectedCat = categorySelect.value; const types = CATEGORY_TYPE_MAP[categorySelect.value] || [];
const types = CATEGORY_TYPE_MAP[selectedCat] || [];
typeSelect.innerHTML = types.length > 0 typeSelect.innerHTML = types.length > 0
? generateOptionsHTML(types, '', true) ? generateOptionsHTML(types, '', true)
: '<option value="">구분을 먼저 선택하세요</option>'; : '<option value="">구분을 먼저 선택하세요</option>';
}); });
const closeModalAction = () => { closeModals(); isEditMode = false; }; bldgSelect.addEventListener('change', () => setTimeout(() => this.updateMapButtonVisibility(), 100));
btnCloseHeader.addEventListener('click', closeModalAction); detailSelect.addEventListener('change', () => this.updateMapButtonVisibility());
btnCancelFooter.addEventListener('click', closeModalAction);
const detailSelect = document.getElementById('hw-location_detail') as HTMLSelectElement; document.getElementById('btn-reg-loc-map')?.addEventListener('click', async () => {
const bldgSelect = document.getElementById('hw-bldg-select') as HTMLSelectElement; await this.fetchMapConfig();
const images = this.getImagesForLocation(bldgSelect.value, detailSelect.value);
bldgSelect.addEventListener('change', () => { if (images) this.openImagePicker(images, `${detailSelect.value} 위치 등록`);
setTimeout(() => updateMapButtonVisibility(), 100);
});
detailSelect.addEventListener('change', () => updateMapButtonVisibility());
const regLocBtn = document.getElementById('btn-reg-loc-map')!;
const viewLocBtn = document.getElementById('btn-view-loc-map')!;
regLocBtn.addEventListener('click', async () => {
await fetchMapConfig();
const images = getImagesForLocation(bldgSelect.value, detailSelect.value);
if (images) openImagePicker(images, `${detailSelect.value} 위치 등록`);
}); });
viewLocBtn.addEventListener('click', async () => { document.getElementById('btn-view-loc-map')?.addEventListener('click', async () => {
await fetchMapConfig(); await this.fetchMapConfig();
const bldg = bldgSelect.value; const images = this.getImagesForLocation(bldgSelect.value, detailSelect.value);
const detail = detailSelect.value;
const images = getImagesForLocation(bldg, detail);
const x = getFieldValue('hw-loc_x'); const x = getFieldValue('hw-loc_x');
const y = getFieldValue('hw-loc_y'); const y = getFieldValue('hw-loc_y');
const savedImg = getFieldValue('hw-location_photo'); const savedImg = getFieldValue('hw-location_photo');
if (images) { if (images) {
const imgPath = savedImg && images.includes(savedImg) ? savedImg : images[0]; const imgPath = savedImg && images.includes(savedImg) ? savedImg : images[0];
openImagePreview(imgPath, `${detail} 위치 확인`, x, y); this.openImagePreview(imgPath, `${detailSelect.value} 위치 확인`, x, y);
} }
}); });
function 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 digitalMap = generateDynamicSVG(imgPath);
overlay.innerHTML = `
<div class="image-picker-header">
<h3>${title} ${isMulti ? `(${currentIdx + 1}/${imagePaths.length})` : ''}</h3>
<button class="btn-icon btn-close-picker" style="color:white !important;"><i data-lucide="x"></i></button>
</div>
<div class="image-picker-content">
${isMulti ? `
<div class="picker-nav prev ${currentIdx === 0 ? 'disabled' : ''}">◀</div>
<div class="picker-nav next ${currentIdx === imagePaths.length - 1 ? 'disabled' : ''}">▶</div>
` : ''}
<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-footer">
<p style="color:#ddd; font-size:12px; margin:0; flex:1;">배치도의 네모 칸을 클릭하면 위치가 자동으로 지정됩니다.</p>
<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>
`;
createIcons({ icons: { X } });
let selectedX = '';
let selectedY = '';
const container = overlay.querySelector('#picker-container') as HTMLElement;
const marker = overlay.querySelector('#picker-marker') as HTMLElement;
overlay.querySelectorAll('.map-seat-obj').forEach(seat => {
seat.addEventListener('click', (e) => {
e.stopPropagation();
const target = e.currentTarget as SVGRectElement;
selectedX = target.getAttribute('x') || '';
selectedY = target.getAttribute('y') || '';
const w = target.getAttribute('width') || '0';
const h = target.getAttribute('height') || '0';
marker.style.left = `${parseFloat(selectedX) + parseFloat(w)/2}%`;
marker.style.top = `${parseFloat(selectedY) + parseFloat(h)/2}%`;
marker.classList.remove('hidden');
});
});
if (!digitalMap) {
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());
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(); } });
}
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]);
updateMapButtonVisibility();
overlay.remove();
});
};
renderContent();
document.body.appendChild(overlay);
}
function openImagePreview(imagePath: string, title: string, x: string, y: string) {
const overlay = document.createElement('div');
overlay.className = 'image-picker-overlay';
const digitalMap = generateDynamicSVG(imagePath);
overlay.innerHTML = `
<div class="image-picker-header">
<h3>${title}</h3>
<button class="btn-icon btn-close-picker" style="color:white !important;"><i data-lucide="x"></i></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-footer"><button id="btn-preview-close" class="btn btn-primary">확인</button></div>
`;
document.body.appendChild(overlay);
createIcons({ icons: { X } });
if (digitalMap) {
overlay.querySelectorAll('.map-seat-obj').forEach(seat => {
const sx = seat.getAttribute('x');
const sy = seat.getAttribute('y');
if (sx === x && sy === y) {
(seat as SVGRectElement).style.fill = 'rgba(255, 61, 0, 0.4)';
(seat as SVGRectElement).style.stroke = '#FF3D00';
(seat as SVGRectElement).style.strokeWidth = '0.8';
const marker = overlay.querySelector('#preview-marker') as HTMLElement;
const w = seat.getAttribute('width') || '0';
const h = seat.getAttribute('height') || '0';
marker.style.left = `${parseFloat(sx!) + parseFloat(w)/2}%`;
marker.style.top = `${parseFloat(sy!) + parseFloat(h)/2}%`;
}
});
}
overlay.querySelector('.btn-close-picker')?.addEventListener('click', () => overlay.remove());
overlay.querySelector('#btn-preview-close')?.addEventListener('click', () => overlay.remove());
}
deleteBtn.addEventListener('click', async () => { deleteBtn.addEventListener('click', async () => {
if (!currentHwAsset || !confirm(UI_TEXT.MESSAGES.CONFIRM_DELETE)) return; if (!this.currentAsset || !confirm(UI_TEXT.MESSAGES.CONFIRM_DELETE)) return;
let categoryKey = 'pc'; if (await deleteAsset(this.getCategoryKey(this.currentAsset), this.currentAsset.id)) {
const cat = currentHwAsset.category; alert('성공적으로 삭제되었습니다.'); onSave(); this.close(); closeModals();
const code = currentHwAsset.asset_code || '';
if (currentHwAsset.asset_type === '서버PC') categoryKey = 'pc';
else if (cat === '서버' || code.startsWith('SVR')) categoryKey = 'server';
else if (cat === '스토리지' || code.startsWith('STO')) categoryKey = 'storage';
else if (cat === '네트워크' || code.startsWith('NET')) categoryKey = 'network';
else if (cat === '업무지원장비' || code.startsWith('EQP')) categoryKey = 'equipment';
else if (cat === '공간정보장비') categoryKey = 'survey';
else if (cat === 'PC부품') categoryKey = 'pcParts';
else if (cat === '사무가구' || cat === '사무소모품') categoryKey = 'officeSupplies';
else if (cat === 'PC' || code.startsWith('PC')) categoryKey = 'pc';
if (await deleteAsset(categoryKey, currentHwAsset.id)) {
alert('성공적으로 삭제되었습니다.'); onSave(); closeModalAction();
} }
}); });
revertBtn.addEventListener('click', () => { revertBtn.addEventListener('click', () => {
setEditLock('hw-asset-form', 'view', { saveBtnId: 'btn-save-hw-asset', revertBtnId: 'btn-revert-hw-edit' }); this.setEditLockMode('view');
isEditMode = false; if (this.currentAsset) this.fillFormData(this.currentAsset);
if (currentHwAsset) fillHwFormData(currentHwAsset); this.updateMapButtonVisibility();
updateMapButtonVisibility();
}); });
saveBtn.addEventListener('click', async () => { saveBtn.addEventListener('click', async () => {
if (!currentHwAsset) return; if (!this.currentAsset) return;
if (!isEditMode) { if (!this.isEditMode) {
setEditLock('hw-asset-form', 'edit', { saveBtnId: 'btn-save-hw-asset', revertBtnId: 'btn-revert-hw-edit' }); this.setEditLockMode('edit');
isEditMode = true; this.isEditMode = true;
updateMapButtonVisibility(); this.updateMapButtonVisibility();
return; return;
} }
const formData = new FormData(form);
const updated: any = { ...currentHwAsset }; const formData = new FormData(this.formEl!);
const updated = { ...this.currentAsset };
formData.forEach((value, key) => { if (key !== 'id') updated[key] = value; }); formData.forEach((value, key) => { if (key !== 'id') updated[key] = value; });
updated.location = getFieldValue('hw-bldg-select'); updated.location = getFieldValue('hw-bldg-select');
let categoryKey = 'pc'; if (await saveAsset(this.getCategoryKey(updated), updated)) {
if (updated.asset_type === '서버PC') categoryKey = 'pc';
else if (updated.asset_code?.startsWith('SVR') || updated.category === '서버') categoryKey = 'server';
else if (updated.asset_code?.startsWith('STO') || updated.category === '스토리지') categoryKey = 'storage';
else if (updated.asset_code?.startsWith('EQP') || updated.category === '업무지원장비') categoryKey = 'equipment';
else if (updated.category === '공간정보장비') categoryKey = 'survey';
else if (updated.category === 'PC부품') categoryKey = 'pcParts';
if (await saveAsset(categoryKey, updated)) {
alert(UI_TEXT.MESSAGES.SAVE_SUCCESS); alert(UI_TEXT.MESSAGES.SAVE_SUCCESS);
onSave(); onSave(); this.close(); closeModals();
closeModalAction();
} }
}); });
createIcons({ icons: { X, History, Plus, Save, Paperclip, Calendar, Monitor, Cpu, Network, ShieldCheck } }); createIcons({ icons: { History, Plus, Save, Paperclip, Calendar, Monitor, Cpu, Network, ShieldCheck } });
} }
export function openHwModal(asset: any, mode: 'view' | 'edit' | 'add' = 'view') { protected fillFormData(asset: any): void {
currentHwAsset = asset;
const modal = document.getElementById('hw-asset-modal')!;
setEditLock('hw-asset-form', mode, { saveBtnId: 'btn-save-hw-asset', revertBtnId: 'btn-revert-hw-edit', addLogBtnId: 'btn-add-hw-log' });
isEditMode = (mode === 'add' || mode === 'edit');
fillHwFormData(asset);
// 조회 모드에서도 확실히 버튼을 노출시키기 위해 asset 데이터를 직접 전달
updateMapButtonVisibility(asset);
const isServer = asset.category === '서버' || asset.asset_code?.startsWith('SVR') || asset.asset_type === '서버PC';
const isPc = asset.category === 'PC' || asset.asset_code?.startsWith('PC');
const isVip = asset.category === '선물' || asset.category === 'VIP';
document.querySelectorAll('.server-only').forEach(el => (el as HTMLElement).style.display = isServer ? 'flex' : 'none');
document.querySelectorAll('.non-server').forEach(el => (el as HTMLElement).style.display = !isServer ? 'flex' : 'none');
document.querySelectorAll('.pc-only').forEach(el => (el as HTMLElement).style.display = isPc ? 'flex' : 'none');
document.querySelectorAll('.user-tracking-field').forEach(el => (el as HTMLElement).style.display = (!isServer && !isVip) ? 'flex' : 'none');
modal.classList.remove('hidden');
}
function fillHwFormData(asset: any) {
setFieldValue('hw-id', asset.id); setFieldValue('hw-id', asset.id);
setFieldValue('hw-asset_code', asset.asset_code || ''); setFieldValue('hw-asset_code', asset.asset_code || '');
setFieldValue('hw-purchase_corp', asset.purchase_corp || ''); setFieldValue('hw-purchase_corp', asset.purchase_corp || '');
setFieldValue('hw-category', asset.category || ''); setFieldValue('hw-category', asset.category || '');
const typeSelect = document.getElementById('hw-asset_type') as HTMLSelectElement;
const types = CATEGORY_TYPE_MAP[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>'; 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-asset_type', asset.asset_type || '');
@@ -665,7 +378,10 @@ function fillHwFormData(asset: any) {
setFieldValue('hw-purchase_date', asset.purchase_date || ''); setFieldValue('hw-purchase_date', asset.purchase_date || '');
setFieldValue('hw-purchase_vendor', asset.purchase_vendor || ''); setFieldValue('hw-purchase_vendor', asset.purchase_vendor || '');
setFieldValue('hw-purchase_amount', asset.purchase_amount || ''); setFieldValue('hw-purchase_amount', asset.purchase_amount || '');
(document.getElementById('hw-approval_document_name') as HTMLElement).textContent = asset.approval_document || '';
const docName = document.getElementById('hw-approval_document_name');
if (docName) docName.textContent = asset.approval_document || '';
setFieldValue('hw-memo', asset.memo || ''); setFieldValue('hw-memo', asset.memo || '');
setFieldValue('hw-location_detail', asset.location_detail || ''); setFieldValue('hw-location_detail', asset.location_detail || '');
setFieldValue('hw-loc_x', asset.loc_x || ''); setFieldValue('hw-loc_x', asset.loc_x || '');
@@ -673,13 +389,206 @@ function fillHwFormData(asset: any) {
setFieldValue('hw-location_photo', asset.location_photo || asset.loc_img || ''); setFieldValue('hw-location_photo', asset.location_photo || asset.loc_img || '');
parseAndSetLocation(asset.location || '', asset.location_detail || '', 'hw-bldg-select', 'hw-location_detail'); parseAndSetLocation(asset.location || '', asset.location_detail || '', 'hw-bldg-select', 'hw-location_detail');
renderHwHistory(asset.id); this.renderHistory(asset.id);
} }
function renderHwHistory(assetId: string) { protected onAfterOpen(asset: any, mode: string): void {
this.updateMapButtonVisibility(asset);
const isServer = asset.category === '서버' || asset.asset_code?.startsWith('SVR') || asset.asset_type === '서버PC';
const isPc = asset.category === 'PC' || asset.asset_code?.startsWith('PC');
const isVip = asset.category === '선물' || asset.category === 'VIP';
document.querySelectorAll('.server-only').forEach(el => (el as HTMLElement).style.display = isServer ? 'flex' : 'none');
document.querySelectorAll('.non-server').forEach(el => (el as HTMLElement).style.display = !isServer ? 'flex' : 'none');
document.querySelectorAll('.pc-only').forEach(el => (el as HTMLElement).style.display = isPc ? 'flex' : 'none');
document.querySelectorAll('.user-tracking-field').forEach(el => (el as HTMLElement).style.display = (!isServer && !isVip) ? 'flex' : 'none');
}
private updateMapButtonVisibility(asset?: any) {
const bldg = asset ? (asset.location || '') : getFieldValue('hw-bldg-select');
const detail = asset ? (asset.location_detail || '') : getFieldValue('hw-location_detail');
const x = asset ? (asset.loc_x || '') : getFieldValue('hw-loc_x');
const y = asset ? (asset.loc_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(`http://${location.hostname}:3000/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" class="digital-map-svg">
<g class="seat-group">
${boxes.map((b, i) => `<rect class="map-seat-obj" data-id="seat-${i+1}" x="${b.x}" y="${b.y}" width="${b.w}" height="${b.h}" rx="0.5" />`).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 digitalMap = this.generateDynamicSVG(imgPath);
overlay.innerHTML = `
<div class="image-picker-header">
<h3>${title} ${isMulti ? `(${currentIdx + 1}/${imagePaths.length})` : ''}</h3>
<button class="btn-icon btn-close-picker" style="color:white !important;"><i data-lucide="x"></i></button>
</div>
<div class="image-picker-content">
${isMulti ? `<div class=\"picker-nav prev ${currentIdx === 0 ? 'disabled' : ''}\">◀</div><div class=\"picker-nav next ${currentIdx === imagePaths.length - 1 ? 'disabled' : ''}\">▶</div>` : ''}
<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-footer">
<p style="color:#ddd; font-size:12px; margin:0; flex:1;">배치도의 네모 칸을 클릭하면 위치가 자동으로 지정됩니다.</p>
<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>
`;
createIcons({ icons: { X } });
let selectedX = ''; let selectedY = '';
const container = overlay.querySelector('#picker-container') as HTMLElement;
const marker = overlay.querySelector('#picker-marker') as HTMLElement;
overlay.querySelectorAll('.map-seat-obj').forEach(seat => {
seat.addEventListener('click', (e) => {
e.stopPropagation();
const target = e.currentTarget as SVGRectElement;
selectedX = target.getAttribute('x') || ''; selectedY = target.getAttribute('y') || '';
const w = target.getAttribute('width') || '0'; const h = target.getAttribute('height') || '0';
marker.style.left = `${parseFloat(selectedX) + parseFloat(w)/2}%`;
marker.style.top = `${parseFloat(selectedY) + parseFloat(h)/2}%`;
marker.classList.remove('hidden');
});
});
if (!digitalMap) {
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());
if (isMulti) {
overlay.querySelector('.picker-nav.prev')?.addEventListener('click', (e) => { if (currentIdx > 0) { currentIdx--; renderContent(); } });
overlay.querySelector('.picker-nav.next')?.addEventListener('click', (e) => { if (currentIdx < imagePaths.length - 1) { currentIdx++; renderContent(); } });
}
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 digitalMap = this.generateDynamicSVG(imagePath);
overlay.innerHTML = `
<div class="image-picker-header">
<h3>${title}</h3>
<button class="btn-icon btn-close-picker" style="color:white !important;"><i data-lucide="x"></i></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-footer"><button id="btn-preview-close" class="btn btn-primary">확인</button></div>
`;
document.body.appendChild(overlay);
createIcons({ icons: { X } });
if (digitalMap) {
overlay.querySelectorAll('.map-seat-obj').forEach(seat => {
const sx = seat.getAttribute('x'); const sy = seat.getAttribute('y');
if (sx === x && sy === y) {
(seat as SVGRectElement).style.fill = 'rgba(255, 61, 0, 0.4)';
(seat as SVGRectElement).style.stroke = '#FF3D00'; (seat as SVGRectElement).style.strokeWidth = '0.8';
const marker = overlay.querySelector('#preview-marker') as HTMLElement;
const w = seat.getAttribute('width') || '0'; const h = seat.getAttribute('height') || '0';
marker.style.left = `${parseFloat(sx!) + parseFloat(w)/2}%`; marker.style.top = `${parseFloat(sy!) + parseFloat(h)/2}%`;
}
});
}
overlay.querySelector('.btn-close-picker')?.addEventListener('click', () => overlay.remove());
overlay.querySelector('#btn-preview-close')?.addEventListener('click', () => overlay.remove());
}
private renderHistory(assetId: string) {
const container = document.getElementById('hw-history-list'); const container = document.getElementById('hw-history-list');
if (!container) return; if (!container) return;
const logs = (state.masterData.logs || []).filter(l => l.assetId === assetId); const logs = (state.masterData.logs || []).filter(l => l.assetId === assetId);
if (logs.length === 0) { container.innerHTML = '<div class="empty-history">이력이 없습니다.</div>'; return; } if (logs.length === 0) { container.innerHTML = '<div class="empty-history">이력이 없습니다.</div>'; return; }
container.innerHTML = logs.map(l => `<div class=\"history-item\"><div class=\"history-date\">${l.date}</div><div class=\"history-user\">${l.user}</div><div class=\"history-details\">${l.details}</div></div>`).join(''); container.innerHTML = logs.map(l => `<div class=\"history-item\"><div class=\"history-date\">${l.date}</div><div class=\"history-user\">${l.user}</div><div class=\"history-details\">${l.details}</div></div>`).join('');
}
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';
}
}
// 싱글톤 인스턴스 생성 및 익스포트
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);
} }

View File

@@ -1,7 +1,7 @@
import { state, saveAsset, deleteAsset } from '../../core/state'; import { state, saveAsset, deleteAsset } from '../../core/state';
import { openModal, closeModals } from './BaseModal'; import { BaseModal } from './BaseModal';
import { openSwUserModal } from './SWUserModal'; import { openSwUserModal } from './SWUserModal';
import { createIcons, History, Plus, X, Save, Edit2, RotateCcw, Calendar } from 'lucide'; import { createIcons, History, Plus, X, Save, Edit2, RotateCcw, Calendar, Users } from 'lucide';
import { CORP_LIST } from './SharedData'; import { CORP_LIST } from './SharedData';
import { ASSET_SCHEMA, UI_TEXT } from '../../core/schema'; import { ASSET_SCHEMA, UI_TEXT } from '../../core/schema';
import { API_BASE_URL } from '../../core/utils'; import { API_BASE_URL } from '../../core/utils';
@@ -9,18 +9,20 @@ import {
generateOptionsHTML, generateOptionsHTML,
setFieldValue, setFieldValue,
getFieldValue, getFieldValue,
setEditLock,
applyDateMask applyDateMask
} from './ModalUtils'; } from './ModalUtils';
let currentSwAsset: any | null = null; class SwAssetModal extends BaseModal {
let isEditMode = false; constructor() {
super('sw', '소프트웨어 상세 정보');
}
const SW_MODAL_HTML = ` protected renderFrameHTML(): string {
return `
<div id="sw-asset-modal" class="modal-overlay hidden"> <div id="sw-asset-modal" class="modal-overlay hidden">
<div class="modal-content wide"> <div class="modal-content wide">
<div class="modal-header"> <div class="modal-header">
<h2 id="sw-modal-title">소프트웨어 상세 정보</h2> <h2 id="sw-modal-title">${this.title}</h2>
<button id="btn-close-sw-modal" class="btn-icon" aria-label="닫기"><i data-lucide="x"></i></button> <button id="btn-close-sw-modal" class="btn-icon" aria-label="닫기"><i data-lucide="x"></i></button>
</div> </div>
<div class="modal-body"> <div class="modal-body">
@@ -29,10 +31,9 @@ const SW_MODAL_HTML = `
<form id="sw-asset-form" class="grid-form"> <form id="sw-asset-form" class="grid-form">
<input type="hidden" id="sw-asset-id" name="id" /> <input type="hidden" id="sw-asset-id" name="id" />
<!-- Group 1: 기본 정보 (Identity) -->
<div class="form-section-title">기본 정보 (Identity)</div> <div class="form-section-title">기본 정보 (Identity)</div>
<div class="form-group"> <div class="form-group">
<label for="sw-asset-type">자산 유형</label> <label>자산 유형</label>
<select id="sw-asset-type" name="asset_type" required> <select id="sw-asset-type" name="asset_type" required>
<option value="내부SW">내부SW</option> <option value="내부SW">내부SW</option>
<option value="외부SW">외부SW</option> <option value="외부SW">외부SW</option>
@@ -40,7 +41,7 @@ const SW_MODAL_HTML = `
</select> </select>
</div> </div>
<div class="form-group"> <div class="form-group">
<label for="sw-분야">${ASSET_SCHEMA.SW_FIELD.ui}</label> <label>${ASSET_SCHEMA.SW_FIELD.ui}</label>
<select id="sw-분야" name="sw_field" required> <select id="sw-분야" name="sw_field" required>
<option value="업무공통">업무공통</option> <option value="업무공통">업무공통</option>
<option value="개발S/W">개발S/W</option> <option value="개발S/W">개발S/W</option>
@@ -48,50 +49,47 @@ const SW_MODAL_HTML = `
<option value="설계S/W">설계S/W</option> <option value="설계S/W">설계S/W</option>
</select> </select>
</div> </div>
<div class="form-group"> <div class="form-group">
<label for="sw-법인">${ASSET_SCHEMA.PURCHASE_CORP.ui}</label> <label>${ASSET_SCHEMA.PURCHASE_CORP.ui}</label>
<select id="sw-법인" name="purchase_corp" required>${generateOptionsHTML(CORP_LIST)}</select> <select id="sw-법인" name="purchase_corp" required>${generateOptionsHTML(CORP_LIST)}</select>
</div> </div>
<div class="form-group full-width"> <div class="form-group full-width">
<label for="sw-제품명">${ASSET_SCHEMA.PRODUCT_NAME.ui}</label> <label>${ASSET_SCHEMA.PRODUCT_NAME.ui}</label>
<input type="text" id="sw-제품명" name="product_name" required /> <input type="text" id="sw-제품명" name="product_name" required />
</div> </div>
<div class="form-group cloud-only"> <div class="form-group cloud-only">
<label for="sw-플랫폼명">${ASSET_SCHEMA.DEV_OBJ.ui} / 플랫폼</label> <label>${ASSET_SCHEMA.DEV_OBJ.ui} / 플랫폼</label>
<input type="text" id="sw-플랫폼명" name="dev_objective" placeholder="개발목적 또는 플랫폼명" /> <input type="text" id="sw-플랫폼명" name="dev_objective" placeholder="개발목적 또는 플랫폼명" />
</div> </div>
<div class="form-group"> <div class="form-group">
<label for="sw-부서">${ASSET_SCHEMA.CURRENT_DEPT.ui}</label> <label>${ASSET_SCHEMA.CURRENT_DEPT.ui}</label>
<input type="text" id="sw-부서" name="current_dept" /> <input type="text" id="sw-부서" name="current_dept" />
</div> </div>
<div class="form-group sw-user-tracking"> <div class="form-group sw-user-tracking">
<label for="sw-user-current">${ASSET_SCHEMA.CURRENT_USER.ui}</label> <label>${ASSET_SCHEMA.CURRENT_USER.ui}</label>
<input type="text" id="sw-user-current" name="user_current" /> <input type="text" id="sw-user-current" name="user_current" />
</div> </div>
<div class="form-group sw-user-tracking"> <div class="form-group sw-user-tracking">
<label for="sw-previous-user">${ASSET_SCHEMA.PREV_USER.ui}</label> <label>${ASSET_SCHEMA.PREV_USER.ui}</label>
<input type="text" id="sw-previous-user" name="previous_user" /> <input type="text" id="sw-previous-user" name="previous_user" />
</div> </div>
<!-- Group 2: 라이선스 및 계약 (License/Contract) -->
<div class="form-section-title">라이선스 및 계약 정보</div> <div class="form-section-title">라이선스 및 계약 정보</div>
<div class="form-group sw-standard-field"> <div class="form-group sw-standard-field">
<label for="sw-수량">${ASSET_SCHEMA.ASSET_COUNT.ui}</label> <label>${ASSET_SCHEMA.ASSET_COUNT.ui}</label>
<input type="number" id="sw-수량" name="asset_count" min="0" /> <input type="number" id="sw-수량" name="asset_count" min="0" />
</div> </div>
<div class="form-group sw-standard-field"> <div class="form-group sw-standard-field">
<label for="sw-금액">${ASSET_SCHEMA.PURCHASE_AMOUNT.ui}</label> <label>${ASSET_SCHEMA.PURCHASE_AMOUNT.ui}</label>
<input type="text" id="sw-금액" name="purchase_amount" oninput="this.value = this.value.replace(/[^0-9]/g, '').replace(/\\B(?=(\\d{3})+(?!\\d))/g, ',')" /> <input type="text" id="sw-금액" name="purchase_amount" oninput="this.value = this.value.replace(/[^0-9]/g, '').replace(/\\\\B(?=(\\\\d{3})+(?!\\\\d))/g, ',')" />
</div> </div>
<!-- Group 3: 클라우드 전용 정보 (Cloud Specific) -->
<div class="form-group cloud-only"> <div class="form-group cloud-only">
<label for="sw-계정명">${ASSET_SCHEMA.EMAIL_ACCOUNT.ui}</label> <label>${ASSET_SCHEMA.EMAIL_ACCOUNT.ui}</label>
<input type="text" id="sw-계정명" name="email_account" /> <input type="text" id="sw-계정명" name="email_account" />
</div> </div>
<div class="form-group cloud-only"> <div class="form-group cloud-only">
<label for="sw-결제수단">${ASSET_SCHEMA.PURCHASE_METHOD.ui}</label> <label>${ASSET_SCHEMA.PURCHASE_METHOD.ui}</label>
<select id="sw-결제수단" name="purchase_method"> <select id="sw-결제수단" name="purchase_method">
<option value="">선택안함</option> <option value="">선택안함</option>
<option value="법인카드">법인카드</option> <option value="법인카드">법인카드</option>
@@ -99,10 +97,9 @@ const SW_MODAL_HTML = `
</select> </select>
</div> </div>
<!-- Group 4: 관리 정보 (Management) -->
<div class="form-section-title">관리 및 비고</div> <div class="form-section-title">관리 및 비고</div>
<div class="form-group sw-standard-field"> <div class="form-group sw-standard-field">
<label for="sw-구매일">${ASSET_SCHEMA.PURCHASE_DATE.ui}</label> <label>${ASSET_SCHEMA.PURCHASE_DATE.ui}</label>
<div style="display:flex; gap:0.25rem; align-items:center; position:relative;"> <div style="display:flex; gap:0.25rem; align-items:center; position:relative;">
<input type="text" id="sw-구매일" name="purchase_date" style="flex:1;" /> <input type="text" id="sw-구매일" name="purchase_date" style="flex:1;" />
<button type="button" class="btn-icon" onclick="const p = document.getElementById('sw-구매일-picker'); p.value = document.getElementById('sw-구매일').value; p.showPicker();" style="padding:0.25rem;"> <button type="button" class="btn-icon" onclick="const p = document.getElementById('sw-구매일-picker'); p.value = document.getElementById('sw-구매일').value; p.showPicker();" style="padding:0.25rem;">
@@ -112,23 +109,23 @@ const SW_MODAL_HTML = `
</div> </div>
</div> </div>
<div class="form-group sw-standard-field"> <div class="form-group sw-standard-field">
<label for="sw-납품업체">${ASSET_SCHEMA.PURCHASE_VENDOR.ui}</label> <label>${ASSET_SCHEMA.PURCHASE_VENDOR.ui}</label>
<input type="text" id="sw-납품업체" name="purchase_vendor" /> <input type="text" id="sw-납품업체" name="purchase_vendor" />
</div> </div>
<div class="form-group sw-standard-field"> <div class="form-group sw-standard-field">
<label for="sw-개발담당자">${ASSET_SCHEMA.DEV_MGR.ui}</label> <label>${ASSET_SCHEMA.DEV_MGR.ui}</label>
<input type="text" id="sw-개발담당자" name="dev_manager" /> <input type="text" id="sw-개발담당자" name="dev_manager" />
</div> </div>
<div class="form-group sw-standard-field"> <div class="form-group sw-standard-field">
<label for="sw-기획담당자">${ASSET_SCHEMA.PLANNING_MGR.ui}</label> <label>${ASSET_SCHEMA.PLANNING_MGR.ui}</label>
<input type="text" id="sw-기획담당자" name="planning_manager" /> <input type="text" id="sw-기획담당자" name="planning_manager" />
</div> </div>
<div class="form-group sw-standard-field"> <div class="form-group sw-standard-field">
<label for="sw-영업담당자">${ASSET_SCHEMA.SALES_MGR.ui}</label> <label>${ASSET_SCHEMA.SALES_MGR.ui}</label>
<input type="text" id="sw-영업담당자" name="sales_manager" /> <input type="text" id="sw-영업담당자" name="sales_manager" />
</div> </div>
<div class="form-group sw-standard-field" id="sw-expiry-group"> <div class="form-group sw-standard-field" id="sw-expiry-group">
<label for="sw-만료일">${ASSET_SCHEMA.EXPIRED_DATE.ui}</label> <label>${ASSET_SCHEMA.EXPIRED_DATE.ui}</label>
<div style="display:flex; gap:0.25rem; align-items:center; position:relative;"> <div style="display:flex; gap:0.25rem; align-items:center; position:relative;">
<input type="text" id="sw-만료일" name="expiry_date" style="flex:1;" /> <input type="text" id="sw-만료일" name="expiry_date" style="flex:1;" />
<button type="button" class="btn-icon" onclick="const p = document.getElementById('sw-만료일-picker'); p.value = document.getElementById('sw-만료일').value; p.showPicker();" style="padding:0.25rem;"> <button type="button" class="btn-icon" onclick="const p = document.getElementById('sw-만료일-picker'); p.value = document.getElementById('sw-만료일').value; p.showPicker();" style="padding:0.25rem;">
@@ -138,7 +135,7 @@ const SW_MODAL_HTML = `
</div> </div>
</div> </div>
<div class="form-group full-width"> <div class="form-group full-width">
<label for="sw-비고">${ASSET_SCHEMA.MEMO.ui}</label> <label>${ASSET_SCHEMA.MEMO.ui}</label>
<textarea id="sw-비고" name="memo" rows="2"></textarea> <textarea id="sw-비고" name="memo" rows="2"></textarea>
</div> </div>
</form> </form>
@@ -172,7 +169,7 @@ const SW_MODAL_HTML = `
</div> </div>
</div> </div>
<!-- 계약/유지보수 기간 갱신 및 업데이트 모달 --> <!-- 계약 업데이트 서브 모달 -->
<div id="sw-update-modal" class="modal-overlay hidden" style="z-index: 1100;"> <div id="sw-update-modal" class="modal-overlay hidden" style="z-index: 1100;">
<div class="modal-content" style="max-width: 500px;"> <div class="modal-content" style="max-width: 500px;">
<div class="modal-header"> <div class="modal-header">
@@ -212,9 +209,126 @@ const SW_MODAL_HTML = `
</div> </div>
</div> </div>
</div> </div>
`; `;
}
function applySwTypeUI(type: string) { protected initChildLogic(onSave: () => void, closeModals: () => void): void {
const saveBtn = document.getElementById('btn-save-sw-asset')!;
const revertBtn = document.getElementById('btn-revert-sw-edit')!;
const deleteBtn = document.getElementById('btn-delete-sw-asset')!;
const typeSelect = document.getElementById('sw-asset-type') as HTMLSelectElement;
const userAssignBtn = document.getElementById('btn-open-sw-user')!;
const btnOpenUpdate = document.getElementById('btn-open-sw-update')!;
typeSelect?.addEventListener('change', () => this.applySwTypeUI(typeSelect.value));
['sw-구매일', 'sw-시작일', 'sw-만료일', 'sw-update-start', 'sw-update-end'].forEach(id => {
const el = document.getElementById(id) as HTMLInputElement;
if (el) applyDateMask(el);
});
userAssignBtn.addEventListener('click', () => {
if (this.currentAsset) openSwUserModal(this.currentAsset);
});
// 업데이트 모달 로직
const subModal = document.getElementById('sw-update-modal')!;
const closeUpdate = () => subModal.classList.add('hidden');
document.getElementById('btn-close-sw-update')?.addEventListener('click', closeUpdate);
document.getElementById('btn-cancel-sw-update')?.addEventListener('click', closeUpdate);
btnOpenUpdate?.addEventListener('click', (e) => {
e.preventDefault();
if (!this.isEditMode) { alert('자산을 수정 모드로 변경한 후 업데이트를 진행해주세요.'); return; }
subModal.classList.remove('hidden');
});
document.getElementById('btn-save-sw-update')?.addEventListener('click', async (e) => {
e.preventDefault();
const date = (document.getElementById('sw-update-date') as HTMLInputElement).value;
const start = (document.getElementById('sw-update-start') as HTMLInputElement).value;
const end = (document.getElementById('sw-update-end') as HTMLInputElement).value;
const cost = (document.getElementById('sw-update-cost') as HTMLInputElement).value;
const note = (document.getElementById('sw-update-note') as HTMLInputElement).value;
if (start) setFieldValue('sw-시작일', start);
if (end) setFieldValue('sw-만료일', end);
if (cost) setFieldValue('sw-금액', cost);
const log = { assetId: this.currentAsset.id, date, details: `[계약갱신] ${note} (${start} ~ ${end}, 비용: ${cost})`, user: '관리자' };
await fetch(`${API_BASE_URL}/api/asset/history/batch`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify([...state.masterData.logs, log])
});
closeUpdate(); onSave();
});
revertBtn.addEventListener('click', () => {
this.setEditLockMode('view');
if (this.currentAsset) this.fillFormData(this.currentAsset);
});
saveBtn.addEventListener('click', async () => {
if (!this.currentAsset) return;
if (!this.isEditMode) { this.setEditLockMode('edit'); this.isEditMode = true; return; }
const type = getFieldValue('sw-asset-type');
const formData = new FormData(this.formEl!);
const updated = { ...this.currentAsset };
formData.forEach((value, key) => { updated[key] = value; });
let categoryKey = (type === '내부SW') ? 'swInternal' : (type === '클라우드' ? 'cloud' : 'swExternal');
if (await saveAsset(categoryKey, updated)) { onSave(); this.close(); closeModals(); }
});
deleteBtn.addEventListener('click', async () => {
if (!this.currentAsset || !confirm(UI_TEXT.MESSAGES.CONFIRM_DELETE)) return;
const type = this.currentAsset.asset_type || this.currentAsset.type;
let categoryKey = (type === '내부SW') ? 'swInternal' : (type === '클라우드' ? 'cloud' : 'swExternal');
if (await deleteAsset(categoryKey, this.currentAsset.id)) {
alert('성공적으로 삭제되었습니다.'); onSave(); this.close(); closeModals();
}
});
createIcons({ icons: { History, Plus, Save, Calendar, Users, RotateCcw } });
}
protected fillFormData(asset: any): void {
setFieldValue('sw-asset-id', asset.id);
setFieldValue('sw-asset-type', asset.asset_type || asset.type);
setFieldValue('sw-분야', asset.sw_field || '');
setFieldValue('sw-법인', asset.purchase_corp || '');
setFieldValue('sw-부서', asset.current_dept || '');
setFieldValue('sw-user-current', asset.user_current || '');
setFieldValue('sw-previous-user', asset.previous_user || '');
setFieldValue('sw-제품명', asset.product_name || '');
setFieldValue('sw-수량', asset.asset_count || '');
setFieldValue('sw-금액', asset.purchase_amount || '');
setFieldValue('sw-구매일', asset.purchase_date || '');
setFieldValue('sw-납품업체', asset.purchase_vendor || '');
setFieldValue('sw-개발담당자', asset.dev_manager || '');
setFieldValue('sw-기획담당자', asset.planning_manager || '');
setFieldValue('sw-영업담당자', asset.sales_manager || '');
setFieldValue('sw-비고', asset.memo || '');
if (asset.type === '클라우드' || asset.asset_type === '클라우드') {
setFieldValue('sw-플랫폼명', asset.dev_objective || '');
setFieldValue('sw-계정명', asset.email_account || '');
setFieldValue('sw-결제수단', asset.purchase_method || '');
} else {
setFieldValue('sw-만료일', asset.expiry_date || '');
}
this.renderHistory(asset.id);
}
protected onAfterOpen(asset: any, mode: string): void {
this.applySwTypeUI(asset.asset_type || asset.type);
}
private applySwTypeUI(type: string) {
const cloudFields = document.querySelectorAll('.cloud-only'); const cloudFields = document.querySelectorAll('.cloud-only');
const swFields = document.querySelectorAll('.sw-standard-field'); const swFields = document.querySelectorAll('.sw-standard-field');
const userSection = document.getElementById('sw-user-section'); const userSection = document.getElementById('sw-user-section');
@@ -230,217 +344,28 @@ function applySwTypeUI(type: string) {
cloudFields.forEach(el => (el as HTMLElement).style.display = 'none'); cloudFields.forEach(el => (el as HTMLElement).style.display = 'none');
swFields.forEach(el => (el as HTMLElement).style.display = 'flex'); swFields.forEach(el => (el as HTMLElement).style.display = 'flex');
if (userSection) userSection.style.display = 'block'; if (userSection) userSection.style.display = 'block';
if (type === '외부SW' || type === '내부SW') { if (type === '외부SW' || type === '내부SW') {
if (expiryGroup) expiryGroup.style.display = 'flex'; if (expiryGroup) expiryGroup.style.display = 'flex';
// 외부SW에만 현 사용자/직전 사용자 표시 (내부SW는 user tracking 제외 요청됨)
userTracking.forEach(el => (el as HTMLElement).style.display = (type === '외부SW') ? 'flex' : 'none'); userTracking.forEach(el => (el as HTMLElement).style.display = (type === '외부SW') ? 'flex' : 'none');
} }
} }
}
function fillSwFormData(asset: any) {
setFieldValue('sw-asset-id', asset.id);
setFieldValue('sw-asset-type', asset.asset_type || asset.type);
setFieldValue('sw-분야', asset.sw_field || '');
setFieldValue('sw-법인', asset.purchase_corp || '');
setFieldValue('sw-부서', asset.current_dept || '');
setFieldValue('sw-user-current', asset.user_current || '');
setFieldValue('sw-previous-user', asset.previous_user || '');
setFieldValue('sw-previous_dept', asset.previous_dept || '');
setFieldValue('sw-제품명', asset.product_name || '');
setFieldValue('sw-수량', asset.asset_count || '');
setFieldValue('sw-금액', asset.purchase_amount || '');
setFieldValue('sw-구매일', asset.purchase_date || '');
setFieldValue('sw-시작일', asset.start_date || '');
setFieldValue('sw-납품업체', asset.purchase_vendor || '');
setFieldValue('sw-개발담당자', asset.dev_manager || '');
setFieldValue('sw-기획담당자', asset.planning_manager || '');
setFieldValue('sw-영업담당자', asset.sales_manager || '');
setFieldValue('sw-비고', asset.memo || '');
if (asset.type === '클라우드' || asset.asset_type === '클라우드') {
setFieldValue('sw-플랫폼명', asset.dev_objective || '');
setFieldValue('sw-계정명', asset.email_account || '');
setFieldValue('sw-결제수단', asset.purchase_method || '');
} else {
setFieldValue('sw-만료일', asset.expiry_date || '');
} }
renderSwHistory(asset.id); private renderHistory(swId: string) {
}
function renderSwHistory(swId: string) {
const container = document.getElementById('sw-history-list'); const container = document.getElementById('sw-history-list');
if (!container) return; if (!container) return;
const logs = (state.masterData.logs || []).filter(l => l.assetId === swId); const logs = (state.masterData.logs || []).filter(l => l.assetId === swId);
if (logs.length === 0) { if (logs.length === 0) { container.innerHTML = '<div class="empty-history">수정 이력이 없습니다.</div>'; return; }
container.innerHTML = '<div class="empty-history">수정 이력이 없습니다.</div>'; 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('');
return;
} }
container.innerHTML = logs.map(l => ` }
<div class="history-item">
<div class="history-date">${l.date}</div> export const swModal = new SwAssetModal();
<div class="history-user">${l.user}</div>
<div class="history-details">${l.details}</div> export function initSwModal(onSave: () => void, closeModals: () => void) {
</div> swModal.init(onSave, closeModals);
`).join('');
} }
export function openSwModal(asset: any, mode: 'view' | 'add' | 'edit' = 'view') { export function openSwModal(asset: any, mode: 'view' | 'add' | 'edit' = 'view') {
currentSwAsset = asset; swModal.open(asset, mode);
const modal = document.getElementById('sw-asset-modal')!;
setEditLock('sw-asset-form', mode, {
saveBtnId: 'btn-save-sw-asset',
revertBtnId: 'btn-revert-sw-edit'
});
isEditMode = (mode === 'add' || mode === 'edit');
fillSwFormData(asset);
applySwTypeUI(asset.asset_type || asset.type);
modal.classList.remove('hidden');
createIcons({ icons: { X, History, Plus } });
}
export function initSwModal(onSave: () => void, closeModals: () => void) {
if (!document.getElementById('sw-asset-modal')) {
document.body.insertAdjacentHTML('beforeend', SW_MODAL_HTML);
}
const form = document.getElementById('sw-asset-form') as HTMLFormElement;
const saveBtn = document.getElementById('btn-save-sw-asset')!;
const revertBtn = document.getElementById('btn-revert-sw-edit')!;
const deleteBtn = document.getElementById('btn-delete-sw-asset')!;
const userAssignBtn = document.getElementById('btn-open-sw-user')!;
const btnOpenUpdate = document.getElementById('btn-open-sw-update')!;
const typeSelect = document.getElementById('sw-asset-type') as HTMLSelectElement;
typeSelect?.addEventListener('change', () => {
applySwTypeUI(typeSelect.value);
});
['sw-구매일', 'sw-시작일', 'sw-만료일', 'sw-update-start', 'sw-update-end'].forEach(id => {
applyDateMask(document.getElementById(id) as HTMLInputElement);
});
createIcons({ icons: { Calendar } });
const closeModalAction = () => { closeModals(); isEditMode = false; };
document.getElementById('btn-close-sw-modal')?.addEventListener('click', closeModalAction);
document.getElementById('btn-cancel-sw-modal')?.addEventListener('click', closeModalAction);
revertBtn.addEventListener('click', () => {
setEditLock('sw-asset-form', 'view', {
saveBtnId: 'btn-save-sw-asset',
revertBtnId: 'btn-revert-sw-edit'
});
isEditMode = false;
if (currentSwAsset) fillSwFormData(currentSwAsset);
});
saveBtn.addEventListener('click', async () => {
if (!currentSwAsset) return;
if (!isEditMode) {
setEditLock('sw-asset-form', 'edit', {
saveBtnId: 'btn-save-sw-asset',
revertBtnId: 'btn-revert-sw-edit'
});
isEditMode = true;
return;
}
const type = getFieldValue('sw-asset-type');
const formData = new FormData(form);
const updated: any = { ...currentSwAsset };
formData.forEach((value, key) => {
updated[key] = value;
});
// Mapping for generic saveAsset
let categoryKey = 'swExternal';
if (type === '내부SW') categoryKey = 'swInternal';
else if (type === '클라우드') categoryKey = 'cloud';
const success = await saveAsset(categoryKey, updated);
if (success) {
onSave();
closeModalAction();
}
});
deleteBtn.addEventListener('click', async () => {
if (!currentSwAsset) return;
if (!confirm(UI_TEXT.MESSAGES.CONFIRM_DELETE)) return;
const type = currentSwAsset.asset_type || currentSwAsset.type;
let categoryKey = 'swExternal';
if (type === '내부SW') categoryKey = 'swInternal';
else if (type === '클라우드') categoryKey = 'cloud';
const success = await deleteAsset(categoryKey, currentSwAsset.id);
if (success) {
alert('성공적으로 삭제되었습니다.');
onSave(); // Refresh list
closeModalAction();
}
});
userAssignBtn.addEventListener('click', () => {
if (currentSwAsset) openSwUserModal(currentSwAsset);
});
// 자산 업데이트(계약 갱신) 모달 로직
const subModal = document.getElementById('sw-update-modal')!;
const btnCloseUpdate = document.getElementById('btn-close-sw-update')!;
const btnCancelUpdate = document.getElementById('btn-cancel-sw-update')!;
const btnSaveUpdate = document.getElementById('btn-save-sw-update')!;
const closeUpdateModal = () => subModal.classList.add('hidden');
btnCloseUpdate?.addEventListener('click', closeUpdateModal);
btnCancelUpdate?.addEventListener('click', closeUpdateModal);
btnOpenUpdate?.addEventListener('click', (e) => {
e.preventDefault();
if (!isEditMode) {
alert('자산을 수정 모드로 변경한 후 업데이트를 진행해주세요.');
return;
}
subModal.classList.remove('hidden');
});
btnSaveUpdate?.addEventListener('click', async (e) => {
e.preventDefault();
const date = (document.getElementById('sw-update-date') as HTMLInputElement).value;
const start = (document.getElementById('sw-update-start') as HTMLInputElement).value;
const end = (document.getElementById('sw-update-end') as HTMLInputElement).value;
const cost = (document.getElementById('sw-update-cost') as HTMLInputElement).value;
const note = (document.getElementById('sw-update-note') as HTMLInputElement).value;
if (start) setFieldValue('sw-시작일', start);
if (end) setFieldValue('sw-만료일', end);
if (cost) setFieldValue('sw-금액', cost);
// Save as log
const log = {
assetId: currentSwAsset.id,
date,
details: `[계약갱신] ${note} (${start} ~ ${end}, 비용: ${cost})`,
user: '관리자'
};
// Call generic API for logs (could be added to state.ts)
await fetch(`${API_BASE_URL}/api/asset/history/batch`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify([...state.masterData.logs, log])
});
closeUpdateModal();
onSave();
});
} }

View File

@@ -1,18 +1,22 @@
import { state } from '../../core/state'; import { state } from '../../core/state';
import { SoftwareAsset, SWUser } from '../../core/excelHandler'; import { BaseModal } from './BaseModal';
import { openModal } from './BaseModal'; import { createIcons, Edit2, X, Paperclip, Calendar, Plus } from 'lucide';
import { createIcons, Edit2, X, Paperclip, Calendar } from 'lucide'; import { ORG_LIST } from './SharedData';
import { CORP_LIST, ORG_LIST } from './SharedData';
import { generateOptionsHTML, setFieldValue, getFieldValue, applyDateMask } from './ModalUtils'; import { generateOptionsHTML, setFieldValue, getFieldValue, applyDateMask } from './ModalUtils';
let currentSwUserAsset: SoftwareAsset | null = null; class SwUserModal extends BaseModal {
let tempSwUsers: any[] = []; private tempSwUsers: any[] = [];
const SW_USER_MODAL_HTML = ` constructor() {
<div id="sw-user-modal" class="modal-overlay hidden"> super('sw-user', '소프트웨어 사용자 관리');
}
protected renderFrameHTML(): string {
return `
<div id="sw-user-asset-modal" class="modal-overlay hidden">
<div class="modal-content wide"> <div class="modal-content wide">
<div class="modal-header"> <div class="modal-header">
<h2 id="sw-user-title">소프트웨어 사용자 관리</h2> <h2 id="sw-user-title">${this.title}</h2>
<button id="btn-close-sw-user-modal" class="btn-icon"><i data-lucide="x"></i></button> <button id="btn-close-sw-user-modal" class="btn-icon"><i data-lucide="x"></i></button>
</div> </div>
<div class="modal-body"> <div class="modal-body">
@@ -39,6 +43,8 @@ const SW_USER_MODAL_HTML = `
<tbody id="sw-user-table-body"></tbody> <tbody id="sw-user-table-body"></tbody>
</table> </table>
</div> </div>
<!-- 더미 폼 (BaseModal 필수 요건 충족용) -->
<form id="sw-user-asset-form" class="hidden"></form>
</div> </div>
<div class="modal-footer"> <div class="modal-footer">
<button id="btn-cancel-sw-user" class="btn btn-outline">취소</button> <button id="btn-cancel-sw-user" class="btn btn-outline">취소</button>
@@ -48,8 +54,8 @@ const SW_USER_MODAL_HTML = `
</div> </div>
<!-- 사용자 추가/수정 서브 모달 --> <!-- 사용자 추가/수정 서브 모달 -->
<div id="sw-user-edit-modal" class="modal-overlay hidden" style="z-index:1100;"> <div id="sw-user-edit-modal" class="modal-overlay hidden" style="z-index: 1100;">
<div class="modal-content" style="width:400px;"> <div class="modal-content" style="width: 400px;">
<div class="modal-header"> <div class="modal-header">
<h3 id="sw-user-edit-title">사용자 정보</h3> <h3 id="sw-user-edit-title">사용자 정보</h3>
<button id="btn-close-user-edit" class="btn-icon"><i data-lucide="x"></i></button> <button id="btn-close-user-edit" class="btn-icon"><i data-lucide="x"></i></button>
@@ -105,41 +111,75 @@ const SW_USER_MODAL_HTML = `
</div> </div>
</div> </div>
</div> </div>
`; `;
}
export function openSwUserModal(asset: SoftwareAsset) { protected initChildLogic(onSave: () => void, closeModals: () => void): void {
currentSwUserAsset = asset; const mainSaveBtn = document.getElementById('btn-save-sw-user')!;
const modal = document.getElementById('sw-user-modal')!; const addUserBtn = document.getElementById('btn-open-add-user')!;
const confirmUserBtn = document.getElementById('btn-confirm-user-edit')!;
['new-user-시작일', 'new-user-종료일'].forEach(id => {
const el = document.getElementById(id) as HTMLInputElement;
if (el) applyDateMask(el);
});
addUserBtn.addEventListener('click', () => this.openUserEditSubModal());
confirmUserBtn.addEventListener('click', () => this.saveUserDataToList());
mainSaveBtn.addEventListener('click', () => {
if (!this.currentAsset) return;
const existingIdx = state.masterData.swUsers.findIndex(u => u.sw_id === this.currentAsset!.id);
const newMapping = {
sw_id: this.currentAsset!.id,
userData: this.tempSwUsers.map(u => [u., u., u., u., u., u.])
};
if (existingIdx > -1) state.masterData.swUsers[existingIdx] = newMapping as any;
else state.masterData.swUsers.push(newMapping as any);
onSave(); this.close(); closeModals();
});
// 닫기 이벤트들 (BaseModal의 공통 버튼 외 추가분)
document.getElementById('btn-close-sw-user-modal')?.addEventListener('click', () => this.close());
document.getElementById('btn-cancel-sw-user')?.addEventListener('click', () => this.close());
const subModal = document.getElementById('sw-user-edit-modal')!;
const closeSub = () => subModal.classList.add('hidden');
document.getElementById('btn-close-user-edit')?.addEventListener('click', closeSub);
document.getElementById('btn-close-user-sub')?.addEventListener('click', closeSub);
createIcons({ icons: { X, Plus, Calendar, Edit2, Paperclip } });
}
protected fillFormData(asset: any): void {
const swInfo = document.getElementById('sw-user-sw-info')!; const swInfo = document.getElementById('sw-user-sw-info')!;
swInfo.innerHTML = ` swInfo.innerHTML = `
<div style="background:var(--bg-light); padding:1rem; border-radius:6px; margin-bottom:1.5rem;"> <div style="background:var(--bg-light); padding:1rem; border-radius:6px; margin-bottom:1.5rem;">
<div style="font-size:0.8rem; color:var(--text-muted); margin-bottom:0.25rem;">${asset.}</div> <div style="font-size:0.8rem; color:var(--text-muted); margin-bottom:0.25rem;">${asset.purchase_corp || asset. || ''}</div>
<div style="font-size:1.1rem; font-weight:700; color:var(--primary-color);">${asset.}</div> <div style="font-size:1.1rem; font-weight:700; color:var(--primary-color);">${asset.product_name || asset. || ''}</div>
</div> </div>
`; `;
// 기존 사용자 데이터 복사 (원본 보호를 위해 temp 사용)
const existingMapping = state.masterData.swUsers.find(u => u.sw_id === asset.id); const existingMapping = state.masterData.swUsers.find(u => u.sw_id === asset.id);
tempSwUsers = existingMapping ? (existingMapping.userData || []).map((u: any) => ({ this.tempSwUsers = existingMapping ? (existingMapping.userData || []).map((u: any) => ({
조직: u[0], 부서: u[1], 직위: u[2], 이름: u[3], 사용기간: u[4], 신청서명: u[5] 조직: u[0], 부서: u[1], 직위: u[2], 이름: u[3], 사용기간: u[4], 신청서명: u[5]
})) : []; })) : [];
renderUserList(); this.renderUserList();
modal.classList.remove('hidden'); }
createIcons({ icons: { Edit2, X, Paperclip } });
}
function renderUserList() { protected onAfterOpen(): void {}
private renderUserList() {
const tbody = document.getElementById('sw-user-table-body')!; const tbody = document.getElementById('sw-user-table-body')!;
tbody.innerHTML = ''; tbody.innerHTML = '';
if (this.tempSwUsers.length === 0) {
if (tempSwUsers.length === 0) { tbody.innerHTML = '<tr><td colspan="7" style="text-align:center; padding:2rem; color:var(--text-muted);">할당된 사용자가 없습니다.</td></tr>';
tbody.innerHTML = '<tr><td colspan="6" style="text-align:center; padding:2rem; color:var(--text-muted);">할당된 사용자가 없습니다.</td></tr>';
return; return;
} }
tempSwUsers.forEach((user, idx) => { this.tempSwUsers.forEach((user, idx) => {
const tr = document.createElement('tr'); const tr = document.createElement('tr');
tr.innerHTML = ` tr.innerHTML = `
<td>${user. || ''}</td> <td>${user. || ''}</td>
@@ -153,15 +193,15 @@ function renderUserList() {
<button class="btn btn-outline btn-sm btn-edit-user" data-idx="${idx}">수정</button> <button class="btn btn-outline btn-sm btn-edit-user" data-idx="${idx}">수정</button>
<button class="btn btn-outline btn-sm btn-danger btn-del-user" data-idx="${idx}">삭제</button> <button class="btn btn-outline btn-sm btn-danger btn-del-user" data-idx="${idx}">삭제</button>
</div> </div>
</td>
`; `;
tbody.appendChild(tr); tbody.appendChild(tr);
}); });
// 이벤트 연결
tbody.querySelectorAll('.btn-edit-user').forEach(btn => { tbody.querySelectorAll('.btn-edit-user').forEach(btn => {
btn.addEventListener('click', (e) => { btn.addEventListener('click', (e) => {
const idx = parseInt((e.currentTarget as HTMLElement).getAttribute('data-idx')!); const idx = parseInt((e.currentTarget as HTMLElement).getAttribute('data-idx')!);
openUserEditSubModal(idx); this.openUserEditSubModal(idx);
}); });
}); });
@@ -169,99 +209,37 @@ function renderUserList() {
btn.addEventListener('click', (e) => { btn.addEventListener('click', (e) => {
const idx = parseInt((e.currentTarget as HTMLElement).getAttribute('data-idx')!); const idx = parseInt((e.currentTarget as HTMLElement).getAttribute('data-idx')!);
if (confirm('사용자 할당을 삭제하시겠습니까?')) { if (confirm('사용자 할당을 삭제하시겠습니까?')) {
tempSwUsers.splice(idx, 1); this.tempSwUsers.splice(idx, 1); this.renderUserList();
renderUserList();
} }
}); });
}); });
createIcons({ icons: { Paperclip } }); createIcons({ icons: { Paperclip } });
} }
function openUserEditSubModal(idx: number = -1) { private openUserEditSubModal(idx: number = -1) {
const subModal = document.getElementById('sw-user-edit-modal')!; const subModal = document.getElementById('sw-user-edit-modal')!;
const form = document.getElementById('sw-user-edit-form') as HTMLFormElement; const form = document.getElementById('sw-user-edit-form') as HTMLFormElement;
form.reset(); form.reset();
setFieldValue('edit-user-index', idx); setFieldValue('edit-user-index', idx);
if (idx > -1) { if (idx > -1) {
const user = tempSwUsers[idx]; const user = this.tempSwUsers[idx];
setFieldValue('new-user-조직', user.); setFieldValue('new-user-조직', user.);
setFieldValue('new-user-부서', user.); setFieldValue('new-user-부서', user.);
setFieldValue('new-user-직위', user.); setFieldValue('new-user-직위', user.);
setFieldValue('new-user-이름', user.); setFieldValue('new-user-이름', user.);
// 사용기간 파싱 (yyyy-mm-dd ~ yyyy-mm-dd)
if (user. && user..includes('~')) { if (user. && user..includes('~')) {
const parts = user..split('~'); const parts = user..split('~');
setFieldValue('new-user-시작일', parts[0].trim()); setFieldValue('new-user-시작일', parts[0].trim());
setFieldValue('new-user-종료일', parts[1].trim()); setFieldValue('new-user-종료일', parts[1].trim());
} else {
setFieldValue('new-user-시작일', '');
setFieldValue('new-user-종료일', '');
} }
} }
subModal.classList.remove('hidden'); subModal.classList.remove('hidden');
}
export function initSwUserModal(onSave: () => void, closeModals: () => void) {
if (!document.getElementById('sw-user-modal')) {
document.body.insertAdjacentHTML('beforeend', SW_USER_MODAL_HTML);
} }
const mainSaveBtn = document.getElementById('btn-save-sw-user')!; private saveUserDataToList() {
const addUserBtn = document.getElementById('btn-open-add-user')!;
const confirmUserBtn = document.getElementById('btn-confirm-user-edit')!;
['new-user-시작일', 'new-user-종료일'].forEach(id => {
applyDateMask(document.getElementById(id) as HTMLInputElement);
});
createIcons({ icons: { Calendar } });
addUserBtn.addEventListener('click', () => openUserEditSubModal());
confirmUserBtn.addEventListener('click', () => {
saveUserDataToList();
});
mainSaveBtn.addEventListener('click', () => {
if (!currentSwUserAsset) return;
// 전역 상태 업데이트
const existingIdx = state.masterData.swUsers.findIndex(u => u.sw_id === currentSwUserAsset!.id);
const newMapping = {
sw_id: currentSwUserAsset!.id,
userData: tempSwUsers.map(u => [u., u., u., u., u., u.])
};
if (existingIdx > -1) state.masterData.swUsers[existingIdx] = newMapping as any;
else state.masterData.swUsers.push(newMapping as any);
onSave();
document.getElementById('sw-user-modal')?.classList.add('hidden');
});
document.getElementById('btn-close-sw-user-modal')?.addEventListener('click', () => {
document.getElementById('sw-user-modal')?.classList.add('hidden');
});
document.getElementById('btn-cancel-sw-user')?.addEventListener('click', () => {
document.getElementById('sw-user-modal')?.classList.add('hidden');
});
document.getElementById('btn-close-user-edit')?.addEventListener('click', () => {
document.getElementById('sw-user-edit-modal')?.classList.add('hidden');
});
document.getElementById('btn-close-user-sub')?.addEventListener('click', () => {
document.getElementById('sw-user-edit-modal')?.classList.add('hidden');
});
}
function saveUserDataToList() {
const idx = parseInt(getFieldValue('edit-user-index')); const idx = parseInt(getFieldValue('edit-user-index'));
const Input = document.getElementById('new-user-신청서') as HTMLInputElement; const Input = document.getElementById('new-user-신청서') as HTMLInputElement;
const = Input.files && Input.files.length > 0 ? Input.files[0].name : (idx > -1 ? tempSwUsers[idx]. : ''); const = Input.files && Input.files.length > 0 ? Input.files[0].name : (idx > -1 ? this.tempSwUsers[idx]. : '');
const userData: any = { const userData: any = {
조직: getFieldValue('new-user-조직'), 조직: getFieldValue('new-user-조직'),
@@ -271,10 +249,19 @@ function saveUserDataToList() {
: `${getFieldValue('new-user-시작일')} ~ ${getFieldValue('new-user-종료일')}`, : `${getFieldValue('new-user-시작일')} ~ ${getFieldValue('new-user-종료일')}`,
}; };
if (idx === -1) this.tempSwUsers.push(userData);
if (idx === -1) tempSwUsers.push(userData); else this.tempSwUsers[idx] = userData;
else tempSwUsers[idx] = userData;
document.getElementById('sw-user-edit-modal')?.classList.add('hidden'); document.getElementById('sw-user-edit-modal')?.classList.add('hidden');
renderUserList(); this.renderUserList();
}
}
export const swUserModal = new SwUserModal();
export function initSwUserModal(onSave: () => void, closeModals: () => void) {
swUserModal.init(onSave, closeModals);
}
export function openSwUserModal(asset: any) {
swUserModal.open(asset);
} }

View File

@@ -30,7 +30,7 @@ export const CATEGORY_TYPE_MAP: Record<string, string[]> = {
// 설치위치 종속성 데이터 // 설치위치 종속성 데이터
export const LOCATION_DATA: Record<string, string[]> = { export const LOCATION_DATA: Record<string, string[]> = {
'한맥빌딩': ['MDF실', '1층', '2층', '3층', '4층', '5층', '6층', '7층', '파고라'], '한맥빌딩': ['MDF실', '1층', '2층', '3층', '4층', '5층', '6층', '7층', '파고라'],
'기술개발센터': ['서버실', '1층', '기타'], '기술개발센터': ['서버실', 'BLUE ZONE', 'GREEN ZONE', 'ORANGE ZONE', '회의실2', '회의실3', '회의실5', '회의실6', '회의실7', '사이니지룸'],
'유니온빌딩': ['4층', '5층', '6층'], '유니온빌딩': ['4층', '5층', '6층'],
'뉴코아빌딩': ['4층', '6층', '7층'], '뉴코아빌딩': ['4층', '6층', '7층'],
'IDC': ['서관202', '서관203', '서관204', '서관205', '동관53', '동관54'] 'IDC': ['서관202', '서관203', '서관204', '서관205', '동관53', '동관54']
@@ -38,8 +38,35 @@ export const LOCATION_DATA: Record<string, string[]> = {
// 유형별 자산번호 접두사(Prefix) 매핑 // 유형별 자산번호 접두사(Prefix) 매핑
export const TYPE_PREFIX_MAP: Record<string, string> = { export const TYPE_PREFIX_MAP: Record<string, string> = {
'서버': 'SVR', '개인PC': 'PC', '공용PC': 'PC', '서버PC': 'PC', 'NAS': 'NAS', 'DAS': 'DAS', '스토리지': 'STO', '서버': 'SVR', '워크스테이션': 'SVR', '개인PC': 'PC', '공용PC': 'PC', '서버PC': 'PC', 'NAS': 'NAS', 'DAS': 'DAS', '스토리지': 'STO',
'HDD': 'HDD', 'SSD': 'SSD', '노트북': 'NBK', '태블릿': 'TAB', 'HDD': 'HDD', 'SSD': 'SSD', '노트북': 'NBK', '태블릿': 'TAB',
'드론': 'DRO', '측량장비': 'SUR', '보조기기': 'SUR', '허브': 'NET', '드론': 'DRO', '측량장비': 'SUR', '보조기기': 'SUR', '허브': 'NET',
'구독SW': 'SW', '영구SW': 'SW', '내부' : 'INT' '구독SW': 'SW', '영구SW': 'SW', '내부' : 'INT'
}; };
// 배치도 이미지 매핑 데이터
export const IMAGE_LOCATIONS: Record<string, Record<string, string[]>> = {
'IDC': {
'서관202': ['img/location_photo/IDC/서관202.png'],
'서관203': ['img/location_photo/IDC/서관203.png'],
'서관204': ['img/location_photo/IDC/서관204.png'],
'서관205': ['img/location_photo/IDC/서관205.png'],
'동관53': ['img/location_photo/IDC/동관53.png'],
'동관54': ['img/location_photo/IDC/동관54.png'],
},
'기술개발센터': {
'서버실': [
'img/location_photo/기술개발센터/서버실/서버실_1.png',
'img/location_photo/기술개발센터/서버실/서버실_2.png'
]
},
'한맥빌딩': {
'7층': ['img/location_photo/한맥빌딩/7층_로비.png'],
'MDF실': [
'img/location_photo/한맥빌딩/MDF실/MDF_1.png',
'img/location_photo/한맥빌딩/MDF실/MDF_2.png',
'img/location_photo/한맥빌딩/MDF실/MDF_3.png',
'img/location_photo/한맥빌딩/MDF실/MDF_4.png'
]
}
};

View File

@@ -86,7 +86,7 @@ export function renderNavigation(onTabChange: (tab: string) => void) {
adminTrigger.style.paddingLeft = '1.5rem'; adminTrigger.style.paddingLeft = '1.5rem';
adminTrigger.addEventListener('click', () => { adminTrigger.addEventListener('click', () => {
alert('준비중입니다.'); window.open('/map_editor.html', '_blank');
}); });
adminGroup.appendChild(adminTrigger); adminGroup.appendChild(adminTrigger);

View File

@@ -14,18 +14,26 @@ export interface FilterOptions {
showDept?: boolean; showDept?: boolean;
showLoc?: boolean; showLoc?: boolean;
showField?: boolean; showField?: boolean;
showType?: boolean;
extraHTML?: string; extraHTML?: string;
onFilterChange: (filters: any) => void; onFilterChange: (filters: any) => void;
} }
export function renderFilterBar(container: HTMLElement, options: FilterOptions) { export function renderFilterBar(container: HTMLElement, options: FilterOptions) {
const { keywordLabel = '통합 검색', showCorp = false, showDept = false, showLoc = false, showField = false, extraHTML = '', onFilterChange } = options; const { keywordLabel = '통합 검색', showCorp = false, showDept = false, showLoc = false, showField = false, showType = false, extraHTML = '', onFilterChange } = options;
container.innerHTML = ` container.innerHTML = `
<div class="search-item flex-1"> <div class="search-item flex-1">
<label>${keywordLabel}</label> <label>${keywordLabel}</label>
<input type="text" id="filter-keyword" placeholder="검색어를 입력하세요..." autocomplete="off"> <input type="text" id="filter-keyword" placeholder="검색어를 입력하세요..." autocomplete="off">
</div> </div>
${showType ? `
<div class="search-item">
<label>${ASSET_SCHEMA.ASSET_TYPE.ui}</label>
<select id="filter-type">
<option value="">전체 유형</option>
</select>
</div>` : ''}
${showField ? ` ${showField ? `
<div class="search-item"> <div class="search-item">
<label>${ASSET_SCHEMA.SW_FIELD.ui}</label> <label>${ASSET_SCHEMA.SW_FIELD.ui}</label>
@@ -66,7 +74,8 @@ export function renderFilterBar(container: HTMLElement, options: FilterOptions)
corp: (container.querySelector('#filter-corp') as HTMLSelectElement)?.value || '', corp: (container.querySelector('#filter-corp') as HTMLSelectElement)?.value || '',
dept: (container.querySelector('#filter-dept') as HTMLSelectElement)?.value || '', dept: (container.querySelector('#filter-dept') as HTMLSelectElement)?.value || '',
loc: (container.querySelector('#filter-loc') as HTMLSelectElement)?.value || '', loc: (container.querySelector('#filter-loc') as HTMLSelectElement)?.value || '',
field: (container.querySelector('#filter-field') as HTMLSelectElement)?.value || '' field: (container.querySelector('#filter-field') as HTMLSelectElement)?.value || '',
type: (container.querySelector('#filter-type') as HTMLSelectElement)?.value || ''
}; };
onFilterChange(filters); onFilterChange(filters);
}; };
@@ -76,9 +85,10 @@ export function renderFilterBar(container: HTMLElement, options: FilterOptions)
container.querySelector('#filter-dept')?.addEventListener('change', triggerChange); container.querySelector('#filter-dept')?.addEventListener('change', triggerChange);
container.querySelector('#filter-loc')?.addEventListener('change', triggerChange); container.querySelector('#filter-loc')?.addEventListener('change', triggerChange);
container.querySelector('#filter-field')?.addEventListener('change', triggerChange); container.querySelector('#filter-field')?.addEventListener('change', triggerChange);
container.querySelector('#filter-type')?.addEventListener('change', triggerChange);
container.querySelector('#btn-reset-filters')?.addEventListener('click', () => { container.querySelector('#btn-reset-filters')?.addEventListener('click', () => {
['filter-keyword', 'filter-corp', 'filter-dept', 'filter-loc', 'filter-field'].forEach(id => { ['filter-keyword', 'filter-corp', 'filter-dept', 'filter-loc', 'filter-field', 'filter-type'].forEach(id => {
const el = container.querySelector(`#${id}`); const el = container.querySelector(`#${id}`);
if (el) (el as any).value = ''; if (el) (el as any).value = '';
}); });
@@ -98,7 +108,8 @@ export function applyCommonFilters(list: any[], filters: any, searchKeys: (keyof
const matchDept = !filters.dept || (item[ASSET_SCHEMA.CURRENT_DEPT.key] || item[ASSET_SCHEMA.CURRENT_DEPT.db]) === filters.dept; const matchDept = !filters.dept || (item[ASSET_SCHEMA.CURRENT_DEPT.key] || item[ASSET_SCHEMA.CURRENT_DEPT.db]) === filters.dept;
const matchLoc = !filters.loc || (item[ASSET_SCHEMA.LOCATION.key] || item[ASSET_SCHEMA.LOCATION.db]) === filters.loc; 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 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;
return matchKeyword && matchCorp && matchDept && matchLoc && matchField; return matchKeyword && matchCorp && matchDept && matchLoc && matchField && matchType;
}); });
} }

View File

@@ -21,6 +21,9 @@ export const ASSET_SCHEMA = {
MANAGER_SUB: { key: 'manager_secondary', db: 'manager_secondary', ui: '담당자(부)' }, MANAGER_SUB: { key: 'manager_secondary', db: 'manager_secondary', ui: '담당자(부)' },
LOCATION: { key: 'location', db: 'location', ui: '자산위치' }, LOCATION: { key: 'location', db: 'location', ui: '자산위치' },
LOC_DETAIL: { key: 'location_detail', db: 'location_detail', ui: '상세위치' }, LOC_DETAIL: { key: 'location_detail', db: 'location_detail', ui: '상세위치' },
LOCATION_PHOTO: { key: 'location_photo', db: 'location_photo', ui: '배치도이미지' },
LOC_X: { key: 'loc_x', db: 'loc_x', ui: '위치X' },
LOC_Y: { key: 'loc_y', db: 'loc_y', ui: '위치Y' },
MEMO: { key: 'memo', db: 'memo', ui: '메모' }, MEMO: { key: 'memo', db: 'memo', ui: '메모' },
// ─── 하드웨어 상세 (Hardware) ─── // ─── 하드웨어 상세 (Hardware) ───

View File

@@ -83,15 +83,14 @@ function initApp() {
initHwModal(() => saveAllDataToDB(), closeAllModals); initHwModal(() => saveAllDataToDB(), closeAllModals);
initSwModal(() => saveAllDataToDB(), closeAllModals); initSwModal(() => saveAllDataToDB(), closeAllModals);
initSwUserModal(() => { initSwUserModal(() => {
saveSwUsersToDB().then(() => { saveSwUsersToDB().then(() => {
loadMasterDataFromDB().then(() => refreshView()); loadMasterDataFromDB().then(() => refreshView());
}); });
}, closeAllModals); }, closeAllModals);
initDomainModal(() => saveAllDataToDB(), closeAllModals);
initDashboardDetailModal(); initDashboardDetailModal();
initDomainModal();
initGuide(); initGuide();
loadMasterDataFromDB().then((success) => { loadMasterDataFromDB().then((success) => {

8
src/map-editor-main.ts Normal file
View File

@@ -0,0 +1,8 @@
import './styles/common.css';
import './styles/map-editor.css';
import { MapEditor } from './views/MapEditor';
document.addEventListener('DOMContentLoaded', () => {
const editor = new MapEditor();
editor.init();
});

View File

@@ -41,9 +41,10 @@
--color-yellow-medium: #FFE599; --color-yellow-medium: #FFE599;
--color-orange-medium: #FFD699; --color-orange-medium: #FFD699;
--color-dahong-medium: #FFB199; --color-dahong-medium: #FFB199;
--color-brown-medium: #D9C6BF; --color-dahong: #FF3D00;
--color-iron-medium: #CCCCCC; --color-dahong-light: #FFECE6;
--color-steel-medium: #C3CFD5; --color-dahong-medium: #FFB199;
--color-dahong-dark: #cc3100;
/* --- Primary Brand Levels --- */ /* --- Primary Brand Levels --- */
--primary-lv-0: #E9EEED; --primary-lv-0: #E9EEED;
@@ -57,11 +58,16 @@
--primary-lv-8: #193833; --primary-lv-8: #193833;
--primary-lv-9: #162A27; --primary-lv-9: #162A27;
/* --- Legacy Aliases (Maintained for compatibility) --- */ /* --- Semantic Colors --- */
--primary-color: var(--primary-lv-6); --primary-color: var(--primary-lv-6);
--primary-hover: var(--primary-lv-5); --primary-hover: var(--primary-lv-5);
--primary-light: var(--primary-lv-0); --primary-light: var(--primary-lv-0);
--edit-mode-color: var(--color-dahong);
--edit-mode-light: var(--color-dahong-light);
--edit-mode-focus: var(--color-dahong-medium);
--edit-mode-dark: var(--color-dahong-dark);
--text-main: #111827; --text-main: #111827;
--text-muted: #6B7280; --text-muted: #6B7280;
--border-color: #E5E7EB; --border-color: #E5E7EB;
@@ -70,13 +76,16 @@
--sidebar-bg: #ffffff; --sidebar-bg: #ffffff;
--white: #FFFFFF; --white: #FFFFFF;
--danger: var(--color-red); --danger: var(--color-red);
--info: var(--color-blue);
--success: var(--color-green);
--warning: var(--color-orange);
--dash-primary: #6cc020; --dash-primary: #6cc020;
--dash-light: #f2f9ec; --dash-light: #f2f9ec;
--dash-danger: #cf222e; --dash-danger: #cf222e;
--header-height: 52px; --header-height: 52px;
} }
* { * {
box-sizing: border-box; box-sizing: border-box;
@@ -303,7 +312,7 @@ body {
font-weight: 300; font-weight: 300;
line-height: 1.25rem; line-height: 1.25rem;
letter-spacing: -0.0175rem; letter-spacing: -0.0175rem;
color: #777777; color: var(--text-muted);
user-select: none; user-select: none;
pointer-events: all; pointer-events: all;
-webkit-user-drag: none; -webkit-user-drag: none;

159
src/styles/map-editor.css Normal file
View File

@@ -0,0 +1,159 @@
/* ITAM Map Coordinate Editor Styles */
.file-sidebar {
width: 260px;
background: var(--white);
border-right: 1px solid var(--border-color);
display: flex;
flex-direction: column;
overflow-y: auto;
}
.folder-item {
padding: 10px 15px;
background: var(--bg-light);
font-weight: bold;
font-size: 13px;
border-bottom: 1px solid var(--border-color);
color: var(--primary-color);
}
.file-item {
padding: 8px 25px;
cursor: pointer;
font-size: 12px;
border-bottom: 1px solid var(--bg-color);
transition: background 0.2s;
}
.file-item:hover { background: var(--bg-light); }
.file-item.active { background: var(--primary-color); color: var(--white); font-weight: bold; }
/* Center: Editor Area */
.editor-container {
flex: 1;
position: relative;
overflow: auto;
padding: 20px;
display: flex;
align-items: center;
justify-content: center;
background: #e0e0e0; /* 전용 배경색 유지 */
}
.img-wrapper {
position: relative;
display: inline-block;
box-shadow: 0 0 30px rgba(0,0,0,0.3);
background: var(--white);
line-height: 0;
}
.img-wrapper img {
display: block;
max-width: calc(100vw - 650px);
max-height: 85vh;
width: auto;
height: auto;
user-select: none;
-webkit-user-drag: none;
}
/* Right Sidebar: Control Panel */
.sidebar {
width: 350px;
background: var(--white);
border-left: 1px solid var(--border-color);
display: flex;
flex-direction: column;
padding: 20px;
box-shadow: -5px 0 15px rgba(0,0,0,0.05);
}
.sidebar h2 { margin-top: 0; color: var(--primary-color); font-size: 1.2rem; }
.sidebar p { font-size: 0.85rem; color: var(--text-muted); line-height: 1.4; margin-bottom: 20px; }
.current-path { font-size: 11px; color: var(--text-muted); margin-bottom: 10px; word-break: break-all; font-family: monospace; }
.box-list {
flex: 1;
overflow-y: auto;
margin-bottom: 15px;
border: 1px solid var(--border-color);
border-radius: 4px;
padding: 10px;
background: var(--bg-light);
}
.box-item {
font-family: monospace;
font-size: 11px;
padding: 6px;
border-bottom: 1px solid var(--border-color);
display: flex;
justify-content: space-between;
align-items: center;
}
.box-item:hover { background: var(--white); }
.btn-del { cursor: pointer; color: var(--danger); border: none; background: none; font-size: 16px; padding: 0 5px; }
.actions { display: flex; flex-direction: column; gap: 8px; }
/* Drawing Elements */
.draw-box {
position: absolute;
border: 2px solid var(--edit-mode-color);
background: rgba(255, 61, 0, 0.2);
pointer-events: none;
z-index: 100;
}
.placed-box {
position: absolute;
border: 1.5px solid var(--primary-color);
background: rgba(30, 81, 73, 0.15);
cursor: pointer;
z-index: 50;
}
.placed-box:hover {
background: rgba(30, 81, 73, 0.4);
border-color: #000;
}
.placed-box.selected {
border: 2.5px solid var(--edit-mode-color);
z-index: 60;
box-shadow: 0 0 10px rgba(255,61,0,0.5);
}
.box-label {
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
font-size: 10px;
font-weight: bold;
color: var(--primary-color);
pointer-events: none;
white-space: nowrap;
background: rgba(255,255,255,0.7);
padding: 0 2px;
border-radius: 2px;
line-height: 1;
}
.draw-box .box-label {
color: var(--edit-mode-color);
background: rgba(255,255,255,0.8);
}
#save-status {
margin-top: 8px;
font-size: 11px;
color: var(--success);
text-align: center;
font-weight: bold;
height: 14px;
}

View File

@@ -47,7 +47,7 @@
} }
.modal-header .btn-icon { .modal-header .btn-icon {
color: #FFFFFF !important; color: var(--white) !important;
cursor: pointer; cursor: pointer;
background: none !important; background: none !important;
border: none !important; border: none !important;
@@ -143,7 +143,7 @@
.grid-form.is-edit-mode input, .grid-form.is-edit-mode input,
.grid-form.is-edit-mode select, .grid-form.is-edit-mode select,
.grid-form.is-edit-mode textarea { .grid-form.is-edit-mode textarea {
color: #FF3D00; /* 수정 시 글자색 변경 */ color: var(--edit-mode-color); /* 수정 시 글자색 변경 */
border: 1px solid var(--border-color); border: 1px solid var(--border-color);
} }
@@ -160,8 +160,8 @@
.grid-form.is-edit-mode input:focus, .grid-form.is-edit-mode input:focus,
.grid-form.is-edit-mode select:focus, .grid-form.is-edit-mode select:focus,
.grid-form.is-edit-mode textarea:focus { .grid-form.is-edit-mode textarea:focus {
border-color: #FF3D00; border-color: var(--edit-mode-color);
box-shadow: 0 0 0 2px rgba(255, 61, 0, 0.1); box-shadow: 0 0 0 2px var(--edit-mode-focus);
} }
.form-section-title:first-child { .form-section-title:first-child {

View File

@@ -128,7 +128,7 @@ table {
th, td { th, td {
padding: 0.8rem 1.2rem; padding: 0.8rem 1.2rem;
border-bottom: 1px solid #F3F4F6; border-bottom: 1px solid var(--border-color);
text-align: left; /* 기본은 좌측 정렬 */ text-align: left; /* 기본은 좌측 정렬 */
white-space: nowrap; white-space: nowrap;
} }
@@ -140,7 +140,7 @@ thead {
} }
th { th {
background-color: #FAFAFA !important; background-color: var(--bg-light) !important;
font-size: 13px; font-size: 13px;
font-weight: 600; font-weight: 600;
color: var(--text-muted); color: var(--text-muted);
@@ -158,7 +158,7 @@ td {
} }
tbody tr:hover { tbody tr:hover {
background-color: #F9FAFB; background-color: var(--bg-color);
} }
/* 정렬 클래스 강제 적용 */ /* 정렬 클래스 강제 적용 */

View File

@@ -8,17 +8,19 @@ export function renderCloudList(container: HTMLElement) {
createListView(container, { createListView(container, {
title: '클라우드', title: '클라우드',
dataSource: () => state.masterData.cloud || [], dataSource: () => state.masterData.cloud || [],
searchKeys: ['PRODUCT_NAME', 'ASSET_PURPOSE', 'PURCHASE_VENDOR'], searchKeys: ['PRODUCT_NAME', 'ASSET_PURPOSE', 'PURCHASE_VENDOR', 'ASSET_TYPE'],
filterOptions: { filterOptions: {
keywordLabel: `통합 검색 (${ASSET_SCHEMA.PRODUCT_NAME.ui}/${ASSET_SCHEMA.PURCHASE_VENDOR.ui})`, keywordLabel: `통합 검색 (${ASSET_SCHEMA.PRODUCT_NAME.ui}/${ASSET_SCHEMA.PURCHASE_VENDOR.ui})`,
showCorp: true, showCorp: true,
showDept: true showDept: true,
showType: true
}, },
onRowClick: (asset) => openSwModal(asset, 'view'), onRowClick: (asset) => openSwModal(asset, 'view'),
columns: [ columns: [
{ header: ASSET_SCHEMA.PRODUCT_NAME.ui, sortKey: ASSET_SCHEMA.PRODUCT_NAME.key, render: a => a[ASSET_SCHEMA.PRODUCT_NAME.key] || '' }, { header: ASSET_SCHEMA.PRODUCT_NAME.ui, sortKey: ASSET_SCHEMA.PRODUCT_NAME.key, render: a => a[ASSET_SCHEMA.PRODUCT_NAME.key] || '' },
{ header: ASSET_SCHEMA.ASSET_PURPOSE.ui, sortKey: ASSET_SCHEMA.ASSET_PURPOSE.key, render: a => a[ASSET_SCHEMA.ASSET_PURPOSE.key] || '' }, { header: ASSET_SCHEMA.ASSET_PURPOSE.ui, sortKey: ASSET_SCHEMA.ASSET_PURPOSE.key, render: a => a[ASSET_SCHEMA.ASSET_PURPOSE.key] || '' },
{ header: ASSET_SCHEMA.PURCHASE_VENDOR.ui, sortKey: ASSET_SCHEMA.PURCHASE_VENDOR.key, render: a => a[ASSET_SCHEMA.PURCHASE_VENDOR.key] || '' }, { header: ASSET_SCHEMA.PURCHASE_VENDOR.ui, sortKey: ASSET_SCHEMA.PURCHASE_VENDOR.key, render: a => a[ASSET_SCHEMA.PURCHASE_VENDOR.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.PURCHASE_AMOUNT.ui, header: ASSET_SCHEMA.PURCHASE_AMOUNT.ui,
sortKey: ASSET_SCHEMA.PURCHASE_AMOUNT.key, sortKey: ASSET_SCHEMA.PURCHASE_AMOUNT.key,

View File

@@ -7,15 +7,16 @@ export function renderCostList(container: HTMLElement) {
createListView(container, { createListView(container, {
title: '비용관리', title: '비용관리',
dataSource: () => sortAssets(state.masterData.cloud?.filter((a: any) => a.category === '비용관리') || []), dataSource: () => sortAssets(state.masterData.cloud?.filter((a: any) => a.category === '비용관리') || []),
searchKeys: ['PRODUCT_NAME', 'MANAGER_MAIN', 'EMAIL_ACCOUNT'], searchKeys: ['PRODUCT_NAME', 'MANAGER_MAIN', 'EMAIL_ACCOUNT', 'ASSET_TYPE'],
filterOptions: { filterOptions: {
keywordLabel: `통합 검색 (${ASSET_SCHEMA.PRODUCT_NAME.ui}/${ASSET_SCHEMA.MANAGER_MAIN.ui})`, keywordLabel: `통합 검색 (${ASSET_SCHEMA.PRODUCT_NAME.ui}/${ASSET_SCHEMA.MANAGER_MAIN.ui})`,
showCorp: true, showCorp: true,
showDept: true showDept: true,
showType: true
}, },
onRowClick: () => alert('상세 정보 준비 중입니다.'), onRowClick: () => alert('상세 정보 준비 중입니다.'),
columns: [ columns: [
{ header: ASSET_SCHEMA.ASSET_TYPE.ui, sortKey: ASSET_SCHEMA.ASSET_TYPE.key, align: 'center', render: a => a[ASSET_SCHEMA.ASSET_TYPE.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.ASSET_PURPOSE.ui, sortKey: ASSET_SCHEMA.ASSET_PURPOSE.key, render: a => formatInline(a[ASSET_SCHEMA.ASSET_PURPOSE.key] || '-') }, { header: ASSET_SCHEMA.ASSET_PURPOSE.ui, sortKey: ASSET_SCHEMA.ASSET_PURPOSE.key, render: a => formatInline(a[ASSET_SCHEMA.ASSET_PURPOSE.key] || '-') },
{ header: '현 사용자', sortKey: ASSET_SCHEMA.MANAGER_MAIN.key, align: 'center', render: a => a[ASSET_SCHEMA.MANAGER_MAIN.key] || '-' }, { header: '현 사용자', sortKey: ASSET_SCHEMA.MANAGER_MAIN.key, align: 'center', render: a => a[ASSET_SCHEMA.MANAGER_MAIN.key] || '-' },
{ {

View File

@@ -12,24 +12,20 @@ export function renderDomainList(container: HTMLElement) {
createListView(container, { createListView(container, {
title: '도메인', title: '도메인',
dataSource: () => state.masterData.domain || [], dataSource: () => state.masterData.domain || [],
searchKeys: ['DOMAIN_ADDR', 'ASSET_PURPOSE', 'PRODUCT_NAME'], searchKeys: ['DOMAIN_ADDR', 'ASSET_PURPOSE', 'PRODUCT_NAME', 'ASSET_TYPE'],
persistentSortState, persistentSortState,
emptyMessage: '등록된 도메인 정보가 없습니다.', emptyMessage: '등록된 도메인 정보가 없습니다.',
filterOptions: { filterOptions: {
keywordLabel: `통합 검색 (${ASSET_SCHEMA.DOMAIN_ADDR.ui}/${ASSET_SCHEMA.PRODUCT_NAME.ui})`, keywordLabel: `통합 검색 (${ASSET_SCHEMA.DOMAIN_ADDR.ui}/${ASSET_SCHEMA.PRODUCT_NAME.ui})`,
showCorp: true, showCorp: true,
showDept: true showDept: true,
showType: true
}, },
onRowClick: (item) => openDomainModal(item), onRowClick: (item) => openDomainModal(item),
columns: [ columns: [
{ header: ASSET_SCHEMA.DOMAIN_ADDR.ui, sortKey: ASSET_SCHEMA.DOMAIN_ADDR.key, align: 'left', render: a => a[ASSET_SCHEMA.DOMAIN_ADDR.key] || '' }, { header: ASSET_SCHEMA.DOMAIN_ADDR.ui, sortKey: ASSET_SCHEMA.DOMAIN_ADDR.key, align: 'left', render: a => a[ASSET_SCHEMA.DOMAIN_ADDR.key] || '' },
{ header: ASSET_SCHEMA.ASSET_PURPOSE.ui, sortKey: ASSET_SCHEMA.ASSET_PURPOSE.key, align: 'left', render: a => a[ASSET_SCHEMA.ASSET_PURPOSE.key] || '' }, { header: ASSET_SCHEMA.ASSET_PURPOSE.ui, sortKey: ASSET_SCHEMA.ASSET_PURPOSE.key, align: 'left', render: a => a[ASSET_SCHEMA.ASSET_PURPOSE.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.ASSET_TYPE.ui,
sortKey: ASSET_SCHEMA.ASSET_TYPE.key,
align: 'center',
render: a => `<span class="badge badge-${a[ASSET_SCHEMA.ASSET_TYPE.key] === '관리중' ? 'primary' : 'muted'}">${a[ASSET_SCHEMA.ASSET_TYPE.key] || '-'}</span>`
},
{ header: ASSET_SCHEMA.PURCHASE_CORP.ui, sortKey: ASSET_SCHEMA.PURCHASE_CORP.key, align: 'center', render: a => a[ASSET_SCHEMA.PURCHASE_CORP.key] || '' }, { header: ASSET_SCHEMA.PURCHASE_CORP.ui, sortKey: ASSET_SCHEMA.PURCHASE_CORP.key, align: 'center', render: a => a[ASSET_SCHEMA.PURCHASE_CORP.key] || '' },
{ header: ASSET_SCHEMA.EXPIRED_DATE.ui, sortKey: ASSET_SCHEMA.EXPIRED_DATE.key, align: 'center', render: a => a[ASSET_SCHEMA.EXPIRED_DATE.key] || '' }, { header: ASSET_SCHEMA.EXPIRED_DATE.ui, sortKey: ASSET_SCHEMA.EXPIRED_DATE.key, align: 'center', render: a => a[ASSET_SCHEMA.EXPIRED_DATE.key] || '' },
{ header: ASSET_SCHEMA.MEMO.ui, sortKey: ASSET_SCHEMA.MEMO.key, className: 'col-memo', render: a => formatInline(a[ASSET_SCHEMA.MEMO.key] || '-') } { header: ASSET_SCHEMA.MEMO.ui, sortKey: ASSET_SCHEMA.MEMO.key, className: 'col-memo', render: a => formatInline(a[ASSET_SCHEMA.MEMO.key] || '-') }

View File

@@ -8,11 +8,12 @@ export function renderEquipmentList(container: HTMLElement) {
createListView(container, { createListView(container, {
title: '업무지원장비', title: '업무지원장비',
dataSource: () => sortAssets(state.masterData.equipment || []), dataSource: () => sortAssets(state.masterData.equipment || []),
searchKeys: ['MODEL_NAME', 'CURRENT_USER', 'ASSET_MFR'], searchKeys: ['MODEL_NAME', 'CURRENT_USER', 'ASSET_MFR', 'ASSET_TYPE'],
filterOptions: { filterOptions: {
keywordLabel: `통합 검색 (${ASSET_SCHEMA.MODEL_NAME.ui}/${ASSET_SCHEMA.ASSET_MFR.ui})`, keywordLabel: `통합 검색 (${ASSET_SCHEMA.MODEL_NAME.ui}/${ASSET_SCHEMA.ASSET_MFR.ui})`,
showLoc: true, showLoc: true,
showDept: true showDept: true,
showType: true
}, },
onRowClick: (asset) => openHwModal(asset, 'view'), onRowClick: (asset) => openHwModal(asset, 'view'),
columns: [ columns: [
@@ -23,7 +24,7 @@ export function renderEquipmentList(container: HTMLElement) {
render: a => `<span class="badge badge-${a[ASSET_SCHEMA.HW_STATUS.key] === '대여중' ? 'primary' : 'success'}">${a[ASSET_SCHEMA.HW_STATUS.key] || '보관중'}</span>` render: a => `<span class="badge badge-${a[ASSET_SCHEMA.HW_STATUS.key] === '대여중' ? 'primary' : 'success'}">${a[ASSET_SCHEMA.HW_STATUS.key] || '보관중'}</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.CURRENT_USER.ui, sortKey: ASSET_SCHEMA.CURRENT_USER.key, align: 'center', render: a => a[ASSET_SCHEMA.CURRENT_USER.key] || '-' },
{ header: ASSET_SCHEMA.ASSET_TYPE.ui, sortKey: ASSET_SCHEMA.ASSET_TYPE.key, align: 'center', render: a => a[ASSET_SCHEMA.ASSET_TYPE.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.ASSET_MFR.ui, sortKey: ASSET_SCHEMA.ASSET_MFR.key, align: 'center', render: a => a[ASSET_SCHEMA.ASSET_MFR.key] || '' }, { header: ASSET_SCHEMA.ASSET_MFR.ui, sortKey: ASSET_SCHEMA.ASSET_MFR.key, align: 'center', render: a => a[ASSET_SCHEMA.ASSET_MFR.key] || '' },
{ header: ASSET_SCHEMA.MODEL_NAME.ui, sortKey: ASSET_SCHEMA.MODEL_NAME.key, render: a => formatInline(a[ASSET_SCHEMA.MODEL_NAME.key] || a. || '-') }, { header: ASSET_SCHEMA.MODEL_NAME.ui, sortKey: ASSET_SCHEMA.MODEL_NAME.key, render: a => formatInline(a[ASSET_SCHEMA.MODEL_NAME.key] || a. || '-') },
{ header: ASSET_SCHEMA.ASSET_COUNT.ui, sortKey: ASSET_SCHEMA.ASSET_COUNT.key, align: 'center', render: a => a[ASSET_SCHEMA.ASSET_COUNT.key] || '1' }, { header: ASSET_SCHEMA.ASSET_COUNT.ui, sortKey: ASSET_SCHEMA.ASSET_COUNT.key, align: 'center', render: a => a[ASSET_SCHEMA.ASSET_COUNT.key] || '1' },

View File

@@ -8,11 +8,12 @@ export function renderFacilityList(container: HTMLElement) {
createListView(container, { createListView(container, {
title: '사무가구', title: '사무가구',
dataSource: () => sortAssets(state.masterData.equipment?.filter((a: any) => a.category === '시설자산') || []), dataSource: () => sortAssets(state.masterData.equipment?.filter((a: any) => a.category === '시설자산') || []),
searchKeys: ['MODEL_NAME', 'ASSET_MFR'], searchKeys: ['MODEL_NAME', 'ASSET_MFR', 'ASSET_TYPE'],
filterOptions: { filterOptions: {
keywordLabel: `통합 검색 (${ASSET_SCHEMA.MODEL_NAME.ui})`, keywordLabel: `통합 검색 (${ASSET_SCHEMA.MODEL_NAME.ui})`,
showLoc: true, showLoc: true,
showDept: true showDept: true,
showType: true
}, },
onRowClick: (asset) => openHwModal(asset, 'view'), onRowClick: (asset) => openHwModal(asset, 'view'),
columns: [ columns: [
@@ -22,7 +23,7 @@ export function renderFacilityList(container: HTMLElement) {
align: 'center', align: 'center',
render: a => `<span class="badge badge-success">${a[ASSET_SCHEMA.HW_STATUS.key] || '보관중'}</span>` render: a => `<span class="badge badge-success">${a[ASSET_SCHEMA.HW_STATUS.key] || '보관중'}</span>`
}, },
{ header: ASSET_SCHEMA.ASSET_TYPE.ui, sortKey: ASSET_SCHEMA.ASSET_TYPE.key, align: 'center', render: a => a[ASSET_SCHEMA.ASSET_TYPE.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.ASSET_MFR.ui, sortKey: ASSET_SCHEMA.ASSET_MFR.key, align: 'center', render: a => a[ASSET_SCHEMA.ASSET_MFR.key] || '' }, { header: ASSET_SCHEMA.ASSET_MFR.ui, sortKey: ASSET_SCHEMA.ASSET_MFR.key, align: 'center', render: a => a[ASSET_SCHEMA.ASSET_MFR.key] || '' },
{ header: ASSET_SCHEMA.MODEL_NAME.ui, sortKey: ASSET_SCHEMA.MODEL_NAME.key, render: a => formatInline(a[ASSET_SCHEMA.MODEL_NAME.key] || '-') }, { header: ASSET_SCHEMA.MODEL_NAME.ui, sortKey: ASSET_SCHEMA.MODEL_NAME.key, render: a => formatInline(a[ASSET_SCHEMA.MODEL_NAME.key] || '-') },
{ {

View File

@@ -7,15 +7,17 @@ export function renderGiftList(container: HTMLElement) {
createListView(container, { createListView(container, {
title: '선물', title: '선물',
dataSource: () => sortAssets(state.masterData.equipment?.filter((a: any) => a.category === '선물') || []), dataSource: () => sortAssets(state.masterData.equipment?.filter((a: any) => a.category === '선물') || []),
searchKeys: ['PRODUCT_NAME', 'MODEL_NAME'], searchKeys: ['PRODUCT_NAME', 'MODEL_NAME', 'ASSET_TYPE'],
filterOptions: { filterOptions: {
keywordLabel: `통합 검색 (${ASSET_SCHEMA.PRODUCT_NAME.ui})`, keywordLabel: `통합 검색 (${ASSET_SCHEMA.PRODUCT_NAME.ui})`,
showCorp: true, showCorp: true,
showDept: true showDept: true,
showType: true
}, },
onRowClick: () => alert('상세 정보 준비 중입니다.'), onRowClick: () => alert('상세 정보 준비 중입니다.'),
columns: [ columns: [
{ header: '자산명', sortKey: ASSET_SCHEMA.PRODUCT_NAME.key, render: a => formatInline(a[ASSET_SCHEMA.PRODUCT_NAME.key] || a[ASSET_SCHEMA.MODEL_NAME.key] || '-') }, { header: '자산명', sortKey: ASSET_SCHEMA.PRODUCT_NAME.key, render: a => formatInline(a[ASSET_SCHEMA.PRODUCT_NAME.key] || a[ASSET_SCHEMA.MODEL_NAME.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: '구매연월', sortKey: ASSET_SCHEMA.PURCHASE_DATE.key, align: 'center', render: a => a[ASSET_SCHEMA.PURCHASE_DATE.key] || '' }, { header: '구매연월', sortKey: ASSET_SCHEMA.PURCHASE_DATE.key, align: 'center', render: a => a[ASSET_SCHEMA.PURCHASE_DATE.key] || '' },
{ header: ASSET_SCHEMA.EXPIRED_DATE.ui, sortKey: ASSET_SCHEMA.EXPIRED_DATE.key, align: 'center', render: a => a[ASSET_SCHEMA.EXPIRED_DATE.key] || '' }, { header: ASSET_SCHEMA.EXPIRED_DATE.ui, sortKey: ASSET_SCHEMA.EXPIRED_DATE.key, align: 'center', render: a => a[ASSET_SCHEMA.EXPIRED_DATE.key] || '' },
{ header: ASSET_SCHEMA.ASSET_COUNT.ui, sortKey: ASSET_SCHEMA.ASSET_COUNT.key, align: 'center', render: a => a[ASSET_SCHEMA.ASSET_COUNT.key] || '1' }, { header: ASSET_SCHEMA.ASSET_COUNT.ui, sortKey: ASSET_SCHEMA.ASSET_COUNT.key, align: 'center', render: a => a[ASSET_SCHEMA.ASSET_COUNT.key] || '1' },

View File

@@ -23,6 +23,7 @@ export interface ListViewConfig {
showDept?: boolean; showDept?: boolean;
showLoc?: boolean; showLoc?: boolean;
showField?: boolean; showField?: boolean;
showType?: boolean;
}; };
columns: ColumnDef[]; columns: ColumnDef[];
onRowClick?: (asset: any) => void; onRowClick?: (asset: any) => void;
@@ -37,7 +38,7 @@ export function createListView(container: HTMLElement, config: ListViewConfig) {
let sortState: SortState = config.persistentSortState || { key: '', direction: 'asc' }; let sortState: SortState = config.persistentSortState || { key: '', direction: 'asc' };
// Initialize currentFilters with all possible keys to avoid undefined issues // Initialize currentFilters with all possible keys to avoid undefined issues
let currentFilters: any = { keyword: '', corp: '', dept: '', loc: '', field: '' }; let currentFilters: any = { keyword: '', corp: '', dept: '', loc: '', field: '', type: '' };
const filterBar = document.createElement('div'); const filterBar = document.createElement('div');
filterBar.className = 'search-bar'; filterBar.className = 'search-bar';
@@ -151,6 +152,7 @@ export function createListView(container: HTMLElement, config: ListViewConfig) {
if (config.filterOptions.showLoc) populateSelect('#filter-loc', ASSET_SCHEMA.LOCATION.key); if (config.filterOptions.showLoc) populateSelect('#filter-loc', ASSET_SCHEMA.LOCATION.key);
if (config.filterOptions.showDept) populateSelect('#filter-dept', ASSET_SCHEMA.CURRENT_DEPT.key); if (config.filterOptions.showDept) populateSelect('#filter-dept', ASSET_SCHEMA.CURRENT_DEPT.key);
if (config.filterOptions.showCorp) populateSelect('#filter-corp', ASSET_SCHEMA.PURCHASE_CORP.key); if (config.filterOptions.showCorp) populateSelect('#filter-corp', ASSET_SCHEMA.PURCHASE_CORP.key);
if (config.filterOptions.showType) populateSelect('#filter-type', ASSET_SCHEMA.ASSET_TYPE.key);
// 6. 초기 렌더링 // 6. 초기 렌더링
updateTable(); updateTable();

View File

@@ -8,16 +8,18 @@ export function renderMobileList(container: HTMLElement) {
createListView(container, { createListView(container, {
title: 'PC', // Legacy support title: 'PC', // Legacy support
dataSource: () => sortAssets(state.masterData.mobile || []), dataSource: () => sortAssets(state.masterData.mobile || []),
searchKeys: ['MODEL_NAME'], searchKeys: ['MODEL_NAME', 'ASSET_TYPE'],
filterOptions: { filterOptions: {
keywordLabel: `통합 검색 (${ASSET_SCHEMA.MODEL_NAME.ui})`, keywordLabel: `통합 검색 (${ASSET_SCHEMA.MODEL_NAME.ui})`,
showCorp: true, showCorp: true,
showDept: true showDept: true,
showType: true
}, },
onRowClick: (asset) => openHwModal(asset, 'view'), onRowClick: (asset) => openHwModal(asset, 'view'),
columns: [ columns: [
{ header: ASSET_SCHEMA.HW_STATUS.ui, sortKey: ASSET_SCHEMA.HW_STATUS.key, align: 'center', render: a => a[ASSET_SCHEMA.HW_STATUS.key] || '운영중' }, { header: ASSET_SCHEMA.HW_STATUS.ui, sortKey: ASSET_SCHEMA.HW_STATUS.key, align: 'center', render: a => a[ASSET_SCHEMA.HW_STATUS.key] || '운영중' },
{ header: ASSET_SCHEMA.PURCHASE_CORP.ui, sortKey: ASSET_SCHEMA.PURCHASE_CORP.key, align: 'center', render: a => a[ASSET_SCHEMA.PURCHASE_CORP.key] || '' }, { header: ASSET_SCHEMA.PURCHASE_CORP.ui, sortKey: ASSET_SCHEMA.PURCHASE_CORP.key, align: 'center', render: a => a[ASSET_SCHEMA.PURCHASE_CORP.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.MODEL_NAME.ui, sortKey: ASSET_SCHEMA.MODEL_NAME.key, render: a => a[ASSET_SCHEMA.MODEL_NAME.key] || '' }, { header: ASSET_SCHEMA.MODEL_NAME.ui, sortKey: ASSET_SCHEMA.MODEL_NAME.key, render: a => a[ASSET_SCHEMA.MODEL_NAME.key] || '' },
{ {
header: ASSET_SCHEMA.LOCATION.ui, header: ASSET_SCHEMA.LOCATION.ui,

View File

@@ -8,11 +8,12 @@ export function renderNetworkList(container: HTMLElement) {
createListView(container, { createListView(container, {
title: '네트워크', title: '네트워크',
dataSource: () => sortAssets(state.masterData.network || []), dataSource: () => sortAssets(state.masterData.network || []),
searchKeys: ['MODEL_NAME', 'CURRENT_USER', 'ASSET_MFR'], searchKeys: ['MODEL_NAME', 'CURRENT_USER', 'ASSET_MFR', 'ASSET_TYPE'],
filterOptions: { filterOptions: {
keywordLabel: `통합 검색 (${ASSET_SCHEMA.MODEL_NAME.ui}/${ASSET_SCHEMA.ASSET_MFR.ui})`, keywordLabel: `통합 검색 (${ASSET_SCHEMA.MODEL_NAME.ui}/${ASSET_SCHEMA.ASSET_MFR.ui})`,
showLoc: true, showLoc: true,
showDept: true showDept: true,
showType: true
}, },
onRowClick: (asset) => openHwModal(asset, 'view'), onRowClick: (asset) => openHwModal(asset, 'view'),
columns: [ columns: [
@@ -23,7 +24,7 @@ export function renderNetworkList(container: HTMLElement) {
render: a => `<span class="badge badge-success">${a[ASSET_SCHEMA.HW_STATUS.key] || '운영중'}</span>` render: a => `<span class="badge badge-success">${a[ASSET_SCHEMA.HW_STATUS.key] || '운영중'}</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.CURRENT_USER.ui, sortKey: ASSET_SCHEMA.CURRENT_USER.key, align: 'center', render: a => a[ASSET_SCHEMA.CURRENT_USER.key] || '-' },
{ header: ASSET_SCHEMA.ASSET_TYPE.ui, sortKey: ASSET_SCHEMA.ASSET_TYPE.key, align: 'center', render: a => a[ASSET_SCHEMA.ASSET_TYPE.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.ASSET_MFR.ui, sortKey: ASSET_SCHEMA.ASSET_MFR.key, align: 'center', render: a => a[ASSET_SCHEMA.ASSET_MFR.key] || '' }, { header: ASSET_SCHEMA.ASSET_MFR.ui, sortKey: ASSET_SCHEMA.ASSET_MFR.key, align: 'center', render: a => a[ASSET_SCHEMA.ASSET_MFR.key] || '' },
{ header: ASSET_SCHEMA.MODEL_NAME.ui, sortKey: ASSET_SCHEMA.MODEL_NAME.key, render: a => formatInline(a[ASSET_SCHEMA.MODEL_NAME.key] || '-') }, { header: ASSET_SCHEMA.MODEL_NAME.ui, sortKey: ASSET_SCHEMA.MODEL_NAME.key, render: a => formatInline(a[ASSET_SCHEMA.MODEL_NAME.key] || '-') },
{ header: ASSET_SCHEMA.ASSET_COUNT.ui, sortKey: ASSET_SCHEMA.ASSET_COUNT.key, align: 'center', render: a => a[ASSET_SCHEMA.ASSET_COUNT.key] || '1' }, { header: ASSET_SCHEMA.ASSET_COUNT.ui, sortKey: ASSET_SCHEMA.ASSET_COUNT.key, align: 'center', render: a => a[ASSET_SCHEMA.ASSET_COUNT.key] || '1' },

View File

@@ -8,32 +8,40 @@ export function renderPcList(container: HTMLElement) {
createListView(container, { createListView(container, {
title: 'PC', title: 'PC',
dataSource: () => sortAssets((state.masterData.pc || []).filter((a: any) => a.asset_type !== '서버PC')), dataSource: () => sortAssets((state.masterData.pc || []).filter((a: any) => a.asset_type !== '서버PC')),
searchKeys: ['CURRENT_DEPT', 'CURRENT_USER', 'MODEL_NAME', 'MAC_ADDR', 'MANAGER_MAIN'], searchKeys: ['CURRENT_DEPT', 'CURRENT_USER', 'MODEL_NAME', 'MAC_ADDR', 'MANAGER_MAIN', 'ASSET_TYPE'],
filterOptions: { filterOptions: {
keywordLabel: `통합 검색 (${ASSET_SCHEMA.MODEL_NAME.ui}/${ASSET_SCHEMA.MANAGER_MAIN.ui}/${ASSET_SCHEMA.CURRENT_USER.ui})`, keywordLabel: `통합 검색 (${ASSET_SCHEMA.MODEL_NAME.ui}/${ASSET_SCHEMA.MANAGER_MAIN.ui}/${ASSET_SCHEMA.CURRENT_USER.ui})`,
showLoc: true, showLoc: true,
showDept: true showDept: true,
showType: true
}, },
onRowClick: (asset) => openHwModal(asset, 'view'), onRowClick: (asset) => openHwModal(asset, 'view'),
columns: [ columns: [
{ 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.CURRENT_USER.ui, sortKey: ASSET_SCHEMA.CURRENT_USER.key, align: 'center', render: a => a[ASSET_SCHEMA.CURRENT_USER.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.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.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.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: ASSET_SCHEMA.GPU.ui, sortKey: ASSET_SCHEMA.GPU.key, align: 'center', render: a => a[ASSET_SCHEMA.GPU.key] || '-' },
{ header: 'SSD1', sortKey: ASSET_SCHEMA.SSD1.key, align: 'center', render: a => a[ASSET_SCHEMA.SSD1.key] || '-' }, {
{ header: 'SSD2', sortKey: ASSET_SCHEMA.SSD2.key, align: 'center', render: a => a[ASSET_SCHEMA.SSD2.key] || '-' }, header: 'SSD',
{ header: 'HDD1', sortKey: ASSET_SCHEMA.HDD1.key, align: 'center', render: a => a[ASSET_SCHEMA.HDD1.key] || '-' }, align: 'center',
{ header: 'HDD2', sortKey: ASSET_SCHEMA.HDD2.key, align: 'center', render: a => a[ASSET_SCHEMA.HDD2.key] || '-' }, width: '8%',
{ header: 'HDD3', sortKey: ASSET_SCHEMA.HDD3.key, align: 'center', render: a => a[ASSET_SCHEMA.HDD3.key] || '-' }, render: a => [a[ASSET_SCHEMA.SSD1.key], a[ASSET_SCHEMA.SSD2.key]].filter(Boolean).join(' / ') || '-'
{ header: 'HDD4', sortKey: ASSET_SCHEMA.HDD4.key, align: 'center', render: a => a[ASSET_SCHEMA.HDD4.key] || '-' }, },
{
header: 'HDD',
align: 'center',
width: '12%',
render: a => [a[ASSET_SCHEMA.HDD1.key], a[ASSET_SCHEMA.HDD2.key], a[ASSET_SCHEMA.HDD3.key], a[ASSET_SCHEMA.HDD4.key]].filter(Boolean).join(' / ') || '-'
},
{ {
header: ASSET_SCHEMA.MAC_ADDR.ui, header: ASSET_SCHEMA.MAC_ADDR.ui,
sortKey: ASSET_SCHEMA.MAC_ADDR.key, sortKey: ASSET_SCHEMA.MAC_ADDR.key,
align: 'center', align: 'center',
render: a => `<span style="font-family:monospace; font-size:11px;">${a[ASSET_SCHEMA.MAC_ADDR.key] || '-'}</span>` render: a => `<span style="font-family:monospace; font-size:11px;">${a[ASSET_SCHEMA.MAC_ADDR.key] || '-'}</span>`
}, },
{ header: ASSET_SCHEMA.MEMO.ui, sortKey: ASSET_SCHEMA.MEMO.key, className: 'col-memo', render: a => formatInline(a[ASSET_SCHEMA.MEMO.key] || '-') } { header: ASSET_SCHEMA.MEMO.ui, sortKey: ASSET_SCHEMA.MEMO.key, className: 'col-memo', width: '30%', render: a => formatInline(a[ASSET_SCHEMA.MEMO.key] || '-') }
] ]
}); });
} }

View File

@@ -12,7 +12,8 @@ export function renderPcPartList(container: HTMLElement) {
filterOptions: { filterOptions: {
keywordLabel: `통합 검색 (${ASSET_SCHEMA.MODEL_NAME.ui})`, keywordLabel: `통합 검색 (${ASSET_SCHEMA.MODEL_NAME.ui})`,
showLoc: true, showLoc: true,
showDept: true showDept: true,
showType: true
}, },
onRowClick: (asset) => openHwModal(asset, 'view'), onRowClick: (asset) => openHwModal(asset, 'view'),
columns: [ columns: [
@@ -22,7 +23,7 @@ export function renderPcPartList(container: HTMLElement) {
align: 'center', align: 'center',
render: a => `<span class="badge badge-success">${a[ASSET_SCHEMA.HW_STATUS.key] || '보관중'}</span>` render: a => `<span class="badge badge-success">${a[ASSET_SCHEMA.HW_STATUS.key] || '보관중'}</span>`
}, },
{ header: ASSET_SCHEMA.ASSET_TYPE.ui, sortKey: ASSET_SCHEMA.ASSET_TYPE.key, align: 'center', render: a => a[ASSET_SCHEMA.ASSET_TYPE.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.ASSET_MFR.ui, sortKey: ASSET_SCHEMA.ASSET_MFR.key, align: 'center', render: a => a[ASSET_SCHEMA.ASSET_MFR.key] || '' }, { header: ASSET_SCHEMA.ASSET_MFR.ui, sortKey: ASSET_SCHEMA.ASSET_MFR.key, align: 'center', render: a => a[ASSET_SCHEMA.ASSET_MFR.key] || '' },
{ header: ASSET_SCHEMA.MODEL_NAME.ui, sortKey: ASSET_SCHEMA.MODEL_NAME.key, render: a => formatInline(a[ASSET_SCHEMA.MODEL_NAME.key] || '-') }, { header: ASSET_SCHEMA.MODEL_NAME.ui, sortKey: ASSET_SCHEMA.MODEL_NAME.key, render: a => formatInline(a[ASSET_SCHEMA.MODEL_NAME.key] || '-') },
{ header: ASSET_SCHEMA.VOLUME.ui, sortKey: ASSET_SCHEMA.VOLUME.key, align: 'center', render: a => a[ASSET_SCHEMA.VOLUME.key] || '-' }, { header: ASSET_SCHEMA.VOLUME.ui, sortKey: ASSET_SCHEMA.VOLUME.key, align: 'center', render: a => a[ASSET_SCHEMA.VOLUME.key] || '-' },

View File

@@ -12,11 +12,12 @@ export function renderServerList(container: HTMLElement) {
const serverPcList = (state.masterData.pc || []).filter((a: any) => a.asset_type === '서버PC'); const serverPcList = (state.masterData.pc || []).filter((a: any) => a.asset_type === '서버PC');
return sortAssets([...serverList, ...serverPcList]); return sortAssets([...serverList, ...serverPcList]);
}, },
searchKeys: ['CURRENT_DEPT', 'MODEL_NAME', 'ASSET_PURPOSE'], searchKeys: ['CURRENT_DEPT', 'MODEL_NAME', 'ASSET_PURPOSE', 'ASSET_TYPE'],
filterOptions: { filterOptions: {
keywordLabel: `통합 검색 (${ASSET_SCHEMA.CURRENT_DEPT.ui}/${ASSET_SCHEMA.MODEL_NAME.ui})`, keywordLabel: `통합 검색 (${ASSET_SCHEMA.CURRENT_DEPT.ui}/${ASSET_SCHEMA.MODEL_NAME.ui})`,
showLoc: true, showLoc: true,
showDept: true showDept: true,
showType: true
}, },
onRowClick: (asset) => openHwModal(asset, 'view'), onRowClick: (asset) => openHwModal(asset, 'view'),
columns: [ columns: [

View File

@@ -8,11 +8,12 @@ export function renderSpaceInfoList(container: HTMLElement) {
createListView(container, { createListView(container, {
title: '공간정보장비', title: '공간정보장비',
dataSource: () => sortAssets(state.masterData.equipment?.filter((a: any) => a.category === '공간정보장비') || []), dataSource: () => sortAssets(state.masterData.equipment?.filter((a: any) => a.category === '공간정보장비') || []),
searchKeys: ['MODEL_NAME', 'PRODUCT_NAME', 'CURRENT_USER'], searchKeys: ['MODEL_NAME', 'PRODUCT_NAME', 'CURRENT_USER', 'ASSET_TYPE'],
filterOptions: { filterOptions: {
keywordLabel: `통합 검색 (${ASSET_SCHEMA.MODEL_NAME.ui}/${ASSET_SCHEMA.CURRENT_USER.ui})`, keywordLabel: `통합 검색 (${ASSET_SCHEMA.MODEL_NAME.ui}/${ASSET_SCHEMA.CURRENT_USER.ui})`,
showLoc: true, showLoc: true,
showDept: true showDept: true,
showType: true
}, },
onRowClick: (asset) => openHwModal(asset, 'view'), onRowClick: (asset) => openHwModal(asset, 'view'),
columns: [ columns: [
@@ -24,7 +25,7 @@ export function renderSpaceInfoList(container: HTMLElement) {
}, },
{ 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.CURRENT_USER.ui, sortKey: ASSET_SCHEMA.CURRENT_USER.key, align: 'center', render: a => a[ASSET_SCHEMA.CURRENT_USER.key] || '-' },
{ header: ASSET_SCHEMA.ASSET_NAME.ui, sortKey: ASSET_SCHEMA.ASSET_NAME.key, render: a => formatInline(a[ASSET_SCHEMA.PRODUCT_NAME.key] || a[ASSET_SCHEMA.MODEL_NAME.key] || a[ASSET_SCHEMA.ASSET_NAME.key] || '-') }, { header: ASSET_SCHEMA.ASSET_NAME.ui, sortKey: ASSET_SCHEMA.ASSET_NAME.key, render: a => formatInline(a[ASSET_SCHEMA.PRODUCT_NAME.key] || a[ASSET_SCHEMA.MODEL_NAME.key] || a[ASSET_SCHEMA.ASSET_NAME.key] || '-') },
{ header: ASSET_SCHEMA.ASSET_TYPE.ui, sortKey: ASSET_SCHEMA.ASSET_TYPE.key, align: 'center', render: a => a[ASSET_SCHEMA.ASSET_TYPE.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.LOCATION.ui, header: ASSET_SCHEMA.LOCATION.ui,
sortKey: ASSET_SCHEMA.LOCATION.key, sortKey: ASSET_SCHEMA.LOCATION.key,

View File

@@ -8,17 +8,18 @@ export function renderStorageList(container: HTMLElement) {
createListView(container, { createListView(container, {
title: '스토리지', title: '스토리지',
dataSource: () => sortAssets(state.masterData.storage || []), dataSource: () => sortAssets(state.masterData.storage || []),
searchKeys: ['MODEL_NAME', 'CURRENT_USER', 'SERIAL_NUM'], searchKeys: ['MODEL_NAME', 'CURRENT_USER', 'SERIAL_NUM', 'ASSET_TYPE'],
filterOptions: { filterOptions: {
keywordLabel: `통합 검색 (${ASSET_SCHEMA.MODEL_NAME.ui}/${ASSET_SCHEMA.CURRENT_USER.ui})`, keywordLabel: `통합 검색 (${ASSET_SCHEMA.MODEL_NAME.ui}/${ASSET_SCHEMA.CURRENT_USER.ui})`,
showLoc: true, showLoc: true,
showDept: true showDept: true,
showType: true
}, },
onRowClick: (asset) => openHwModal(asset, 'view'), onRowClick: (asset) => openHwModal(asset, 'view'),
columns: [ columns: [
{ header: ASSET_SCHEMA.HW_STATUS.ui, sortKey: ASSET_SCHEMA.HW_STATUS.key, align: 'center', render: a => a[ASSET_SCHEMA.HW_STATUS.key] || '-' }, { header: ASSET_SCHEMA.HW_STATUS.ui, sortKey: ASSET_SCHEMA.HW_STATUS.key, align: 'center', render: a => a[ASSET_SCHEMA.HW_STATUS.key] || '-' },
{ 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.CURRENT_USER.ui, sortKey: ASSET_SCHEMA.CURRENT_USER.key, align: 'center', render: a => a[ASSET_SCHEMA.CURRENT_USER.key] || '-' },
{ header: ASSET_SCHEMA.ASSET_TYPE.ui, sortKey: ASSET_SCHEMA.ASSET_TYPE.key, align: 'center', render: a => a[ASSET_SCHEMA.ASSET_TYPE.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.VOLUME.ui, sortKey: ASSET_SCHEMA.VOLUME.key, align: 'center', render: a => a[ASSET_SCHEMA.VOLUME.key] || '-' }, { header: ASSET_SCHEMA.VOLUME.ui, sortKey: ASSET_SCHEMA.VOLUME.key, align: 'center', render: a => a[ASSET_SCHEMA.VOLUME.key] || '-' },
{ header: ASSET_SCHEMA.MODEL_NAME.ui, sortKey: ASSET_SCHEMA.MODEL_NAME.key, render: a => formatInline(a[ASSET_SCHEMA.MODEL_NAME.key] || a[ASSET_SCHEMA.ASSET_NAME.key] || '-') }, { header: ASSET_SCHEMA.MODEL_NAME.ui, sortKey: ASSET_SCHEMA.MODEL_NAME.key, render: a => formatInline(a[ASSET_SCHEMA.MODEL_NAME.key] || a[ASSET_SCHEMA.ASSET_NAME.key] || '-') },
{ header: ASSET_SCHEMA.SERIAL_NUM.ui, sortKey: ASSET_SCHEMA.SERIAL_NUM.key, align: 'center', render: a => a[ASSET_SCHEMA.SERIAL_NUM.key] || '-' }, { header: ASSET_SCHEMA.SERIAL_NUM.ui, sortKey: ASSET_SCHEMA.SERIAL_NUM.key, align: 'center', render: a => a[ASSET_SCHEMA.SERIAL_NUM.key] || '-' },

View File

@@ -10,24 +10,26 @@ export function renderSwList(container: HTMLElement) {
createListView(container, { createListView(container, {
title: isInternal ? '내부' : '외부', title: isInternal ? '내부' : '외부',
dataSource: () => sortAssets(isInternal ? state.masterData.swInternal : state.masterData.swExternal), dataSource: () => sortAssets(isInternal ? state.masterData.swInternal : state.masterData.swExternal),
searchKeys: ['PRODUCT_NAME', 'CURRENT_USER', 'CURRENT_DEPT'], searchKeys: ['PRODUCT_NAME', 'CURRENT_USER', 'CURRENT_DEPT', 'ASSET_TYPE'],
emptyMessage: '검색 결과가 없습니다.', emptyMessage: '검색 결과가 없습니다.',
filterOptions: { filterOptions: {
keywordLabel: `통합 검색 (${ASSET_SCHEMA.PRODUCT_NAME.ui}/${ASSET_SCHEMA.CURRENT_DEPT.ui})`, keywordLabel: `통합 검색 (${ASSET_SCHEMA.PRODUCT_NAME.ui}/${ASSET_SCHEMA.CURRENT_DEPT.ui})`,
showField: true, showField: true,
showCorp: true, showCorp: true,
showDept: true showDept: true,
showType: true
}, },
onRowClick: (asset) => openSwModal(asset, 'view'), onRowClick: (asset) => openSwModal(asset, 'view'),
columns: isInternal ? [ columns: isInternal ? [
{ header: ASSET_SCHEMA.SW_FIELD.ui, sortKey: ASSET_SCHEMA.SW_FIELD.key, align: 'center', render: a => a[ASSET_SCHEMA.SW_FIELD.key] || '' }, { header: ASSET_SCHEMA.SW_FIELD.ui, sortKey: ASSET_SCHEMA.SW_FIELD.key, align: 'center', render: a => a[ASSET_SCHEMA.SW_FIELD.key] || '' },
{ header: ASSET_SCHEMA.DEV_OBJ.ui, sortKey: ASSET_SCHEMA.DEV_OBJ.key, align: 'center', render: a => a[ASSET_SCHEMA.DEV_OBJ.key] || '' }, { header: ASSET_SCHEMA.DEV_OBJ.ui, sortKey: ASSET_SCHEMA.DEV_OBJ.key, align: 'center', render: a => a[ASSET_SCHEMA.DEV_OBJ.key] || '' },
{ header: ASSET_SCHEMA.SW_STATUS.ui, sortKey: ASSET_SCHEMA.SW_STATUS.key, align: 'center', render: a => a[ASSET_SCHEMA.SW_STATUS.key] || '보유중' }, { header: ASSET_SCHEMA.SW_STATUS.ui, sortKey: ASSET_SCHEMA.SW_STATUS.key, align: 'center', render: a => a[ASSET_SCHEMA.SW_STATUS.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.SW_TYPE.ui, sortKey: ASSET_SCHEMA.SW_TYPE.key, align: 'center', render: a => a[ASSET_SCHEMA.SW_TYPE.key] || '내부' }, { header: ASSET_SCHEMA.SW_TYPE.ui, sortKey: ASSET_SCHEMA.SW_TYPE.key, align: 'center', render: a => a[ASSET_SCHEMA.SW_TYPE.key] || '내부' },
{ header: ASSET_SCHEMA.MEMO.ui, sortKey: ASSET_SCHEMA.MEMO.key, className: 'col-memo', render: a => formatInline(a[ASSET_SCHEMA.MEMO.key] || '-') } { header: ASSET_SCHEMA.MEMO.ui, sortKey: ASSET_SCHEMA.MEMO.key, className: 'col-memo', render: a => formatInline(a[ASSET_SCHEMA.MEMO.key] || '-') }
] : [ ] : [
{ header: '자산명', sortKey: ASSET_SCHEMA.PRODUCT_NAME.key, render: a => a[ASSET_SCHEMA.PRODUCT_NAME.key] || '' }, { header: '자산명', sortKey: ASSET_SCHEMA.PRODUCT_NAME.key, render: a => a[ASSET_SCHEMA.PRODUCT_NAME.key] || '' },
{ header: '유형', sortKey: ASSET_SCHEMA.ASSET_TYPE.key, align: 'center', render: a => a[ASSET_SCHEMA.ASSET_TYPE.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.SW_STATUS.ui, sortKey: ASSET_SCHEMA.SW_STATUS.key, align: 'center', render: a => a[ASSET_SCHEMA.SW_STATUS.key] || '사용중' }, { header: ASSET_SCHEMA.SW_STATUS.ui, sortKey: ASSET_SCHEMA.SW_STATUS.key, align: 'center', render: a => a[ASSET_SCHEMA.SW_STATUS.key] || '사용중' },
{ header: ASSET_SCHEMA.SW_FIELD.ui, sortKey: ASSET_SCHEMA.SW_FIELD.key, align: 'center', render: a => a[ASSET_SCHEMA.SW_FIELD.key] || '' }, { header: ASSET_SCHEMA.SW_FIELD.ui, sortKey: ASSET_SCHEMA.SW_FIELD.key, align: 'center', render: a => a[ASSET_SCHEMA.SW_FIELD.key] || '' },
{ header: ASSET_SCHEMA.CURRENT_DEPT.ui, sortKey: ASSET_SCHEMA.CURRENT_DEPT.key, align: 'center', render: a => a[ASSET_SCHEMA.CURRENT_DEPT.key] || '' }, { header: ASSET_SCHEMA.CURRENT_DEPT.ui, sortKey: ASSET_SCHEMA.CURRENT_DEPT.key, align: 'center', render: a => a[ASSET_SCHEMA.CURRENT_DEPT.key] || '' },

222
src/views/MapEditor.ts Normal file
View File

@@ -0,0 +1,222 @@
import { IMAGE_LOCATIONS } from '../components/Modal/SharedData';
import { createIcons, X, Save, Trash2, ChevronLeft, ChevronRight } from 'lucide';
export class MapEditor {
private container: HTMLElement;
private wrapper: HTMLElement;
private img: HTMLImageElement;
private boxListEl: HTMLElement;
private pathLabel: HTMLElement;
private statusEl: HTMLElement;
private saveBtn: HTMLButtonElement;
private fileSidebar: HTMLElement;
private allMapConfig: Record<string, any[]> = {};
private boxes: any[] = [];
private isDrawing: boolean = false;
private startX: number = 0;
private startY: number = 0;
private currentBox: HTMLElement | null = null;
private currentPath: string = '';
constructor() {
this.container = document.getElementById('container')!;
this.wrapper = document.getElementById('wrapper')!;
this.img = document.getElementById('target-img') as HTMLImageElement;
this.boxListEl = document.getElementById('box-list')!;
this.pathLabel = document.getElementById('current-path')!;
this.statusEl = document.getElementById('save-status')!;
this.saveBtn = document.getElementById('btn-save-server') as HTMLButtonElement;
this.fileSidebar = document.getElementById('file-sidebar')!;
}
public async init() {
this.renderFileSidebar();
await this.loadConfig();
this.bindEvents();
this.selectFirstFile();
createIcons({ icons: { X, Save, Trash2, ChevronLeft, ChevronRight } });
}
private renderFileSidebar() {
let html = '';
Object.entries(IMAGE_LOCATIONS).forEach(([bldg, details]) => {
html += `<div class="folder-item">${bldg}</div>`;
Object.entries(details).forEach(([detail, paths]) => {
paths.forEach(path => {
const fileName = path.split('/').pop() || path;
html += `<div class="file-item" data-path="${path}">${fileName}</div>`;
});
});
});
this.fileSidebar.innerHTML = html;
this.fileSidebar.querySelectorAll('.file-item').forEach(item => {
item.addEventListener('click', () => {
this.fileSidebar.querySelectorAll('.file-item').forEach(i => i.classList.remove('active'));
item.classList.add('active');
this.renderCurrentFile();
});
});
}
private selectFirstFile() {
const firstItem = this.fileSidebar.querySelector('.file-item') as HTMLElement;
if (firstItem) {
firstItem.classList.add('active');
this.renderCurrentFile();
}
}
private async loadConfig() {
try {
const res = await fetch(`http://${location.hostname}:3000/api/maps`);
this.allMapConfig = await res.json();
} catch (err) {
console.error('Failed to load config:', err);
}
}
private renderCurrentFile() {
const activeItem = this.fileSidebar.querySelector('.file-item.active') as HTMLElement;
if (!activeItem) return;
this.currentPath = activeItem.dataset.path || '';
this.boxes = this.allMapConfig[this.currentPath] || [];
this.pathLabel.textContent = this.currentPath;
this.img.src = this.currentPath;
this.render();
}
private bindEvents() {
this.wrapper.addEventListener('mousedown', (e) => {
if (e.button !== 0) return;
this.isDrawing = true;
const rect = this.wrapper.getBoundingClientRect();
this.startX = e.clientX - rect.left;
this.startY = e.clientY - rect.top;
this.currentBox = document.createElement('div');
this.currentBox.className = 'draw-box';
this.currentBox.style.left = this.startX + 'px';
this.currentBox.style.top = this.startY + 'px';
const label = document.createElement('div');
label.className = 'box-label';
label.textContent = '#' + (this.boxes.length + 1);
this.currentBox.appendChild(label);
this.wrapper.appendChild(this.currentBox);
});
window.addEventListener('mousemove', (e) => {
if (!this.isDrawing || !this.currentBox) return;
const rect = this.wrapper.getBoundingClientRect();
const currentX = Math.max(0, Math.min(e.clientX - rect.left, rect.width));
const currentY = Math.max(0, Math.min(e.clientY - rect.top, rect.height));
const width = currentX - this.startX;
const height = currentY - this.startY;
this.currentBox.style.width = Math.abs(width) + 'px';
this.currentBox.style.height = Math.abs(height) + 'px';
this.currentBox.style.left = (width > 0 ? this.startX : currentX) + 'px';
this.currentBox.style.top = (height > 0 ? this.startY : currentY) + 'px';
});
window.addEventListener('mouseup', () => {
if (!this.isDrawing || !this.currentBox) return;
this.isDrawing = false;
const width = parseFloat(this.currentBox.style.width);
const height = parseFloat(this.currentBox.style.height);
if (width > 3 && height > 3) {
const rect = this.wrapper.getBoundingClientRect();
const boxData = {
x: (parseFloat(this.currentBox.style.left) / rect.width * 100).toFixed(2),
y: (parseFloat(this.currentBox.style.top) / rect.height * 100).toFixed(2),
w: (width / rect.width * 100).toFixed(2),
h: (height / rect.height * 100).toFixed(2)
};
this.boxes.push(boxData);
this.render();
}
this.currentBox.remove();
this.currentBox = null;
});
(window as any).removeBox = (index: number) => {
this.boxes.splice(index, 1);
this.render();
};
(window as any).clearAll = () => {
if(confirm('모든 박스를 삭제할까요?')) {
this.boxes = [];
this.render();
}
};
(window as any).saveToServer = () => this.saveToServer();
}
private async saveToServer() {
if (!this.currentPath) return;
try {
this.saveBtn.disabled = true;
this.saveBtn.textContent = '저장 중...';
const res = await fetch(`http://${location.hostname}:3000/api/maps/save`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ path: this.currentPath, boxes: this.boxes })
});
if (res.ok) {
this.allMapConfig[this.currentPath] = [...this.boxes];
this.statusEl.textContent = '✅ 서버 저장 완료 (' + new Date().toLocaleTimeString() + ')';
setTimeout(() => this.statusEl.textContent = '', 3000);
} else {
alert('저장 실패!');
}
} catch (err) {
alert('서버 연결 오류!');
} finally {
this.saveBtn.disabled = false;
this.saveBtn.textContent = '서버에 즉시 저장';
}
}
private render() {
this.boxListEl.innerHTML = '';
const oldBoxes = this.wrapper.querySelectorAll('.placed-box');
oldBoxes.forEach(b => b.remove());
this.boxes.forEach((box, i) => {
const div = document.createElement('div');
div.className = 'placed-box';
div.style.left = box.x + '%';
div.style.top = box.y + '%';
div.style.width = box.w + '%';
div.style.height = box.h + '%';
const label = document.createElement('div');
label.className = 'box-label';
label.textContent = '#' + (i + 1);
div.appendChild(label);
this.wrapper.appendChild(div);
const item = document.createElement('div');
item.className = 'box-item';
item.innerHTML = `
<span>#${i+1}: [${box.x}, ${box.y}]</span>
<button class="btn-del" onclick="removeBox(${i})">×</button>
`;
this.boxListEl.appendChild(item);
});
}
}