feat: enhance map editor, refine location view, and update image assets
- Map Editor: Add box numbering (drawing/placed) and set default file - Location View: Refine mouse interaction in view mode (readonly) - Assets: Add MDF room support and update server room directory structure - Backend: Add map configuration API for real-time saving
This commit is contained in:
@@ -12,13 +12,15 @@ export function initBaseModal() {
|
||||
if (e.key === 'Escape') closeModals();
|
||||
});
|
||||
|
||||
// 배경(Overlay) 클릭 시 닫기
|
||||
// 배경(Overlay) 클릭 시 닫기 (요청에 의해 비활성화됨)
|
||||
/*
|
||||
document.addEventListener('click', (e) => {
|
||||
const target = e.target as HTMLElement;
|
||||
if (target.classList.contains('modal-overlay')) {
|
||||
closeModals();
|
||||
}
|
||||
});
|
||||
*/
|
||||
|
||||
return { closeAllModals: closeModals };
|
||||
}
|
||||
|
||||
@@ -15,6 +15,64 @@ import { createIcons, X, History, Plus, Save, Paperclip, Calendar, Monitor, Cpu,
|
||||
|
||||
let currentHwAsset: any | null = null;
|
||||
let isEditMode = false;
|
||||
let dynamicMapConfig: Record<string, any[]> = {};
|
||||
|
||||
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_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 => {
|
||||
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 `
|
||||
<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">
|
||||
@@ -29,7 +87,6 @@ const HW_MODAL_HTML = `
|
||||
<form id="hw-asset-form" class="grid-form">
|
||||
<input type="hidden" id="hw-id" name="id" />
|
||||
|
||||
<!-- Group 1: 기본 및 관리 정보 -->
|
||||
<div class="form-section-title">기본 및 관리 정보</div>
|
||||
<div class="form-group">
|
||||
<label>${ASSET_SCHEMA.ASSET_CODE.ui}</label>
|
||||
@@ -92,7 +149,6 @@ const HW_MODAL_HTML = `
|
||||
<input type="text" id="hw-asset_purpose" name="asset_purpose" placeholder="예: DB서버, 웹서버, 백업용 등" />
|
||||
</div>
|
||||
|
||||
<!-- Group 2: 설치 위치 -->
|
||||
<div class="form-section-title">설치 위치</div>
|
||||
<div class="form-group">
|
||||
<label>건물/위치</label>
|
||||
@@ -100,12 +156,22 @@ const HW_MODAL_HTML = `
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>${ASSET_SCHEMA.LOC_DETAIL.ui}</label>
|
||||
<select id="hw-location_detail" name="location_detail">
|
||||
<option value="">선택</option>
|
||||
</select>
|
||||
<div class="location-detail-container">
|
||||
<select id="hw-location_detail" name="location_detail">
|
||||
<option value="">선택</option>
|
||||
</select>
|
||||
<button type="button" id="btn-reg-loc-map" class="btn-loc-action btn-loc-view hidden" style="background-color: var(--primary-color);">
|
||||
위치등록
|
||||
</button>
|
||||
<button type="button" id="btn-view-loc-map" class="btn-loc-action btn-loc-view hidden">
|
||||
위치보기
|
||||
</button>
|
||||
<input type="hidden" id="hw-loc_x" name="loc_x" />
|
||||
<input type="hidden" id="hw-loc_y" name="loc_y" />
|
||||
<input type="hidden" id="hw-location_photo" name="location_photo" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Group 3: 시스템 사양 -->
|
||||
<div class="form-section-title">시스템 사양</div>
|
||||
<div class="form-group">
|
||||
<label>${ASSET_SCHEMA.MODEL_NAME.ui}</label>
|
||||
@@ -160,7 +226,6 @@ const HW_MODAL_HTML = `
|
||||
<input type="text" id="hw-mac_address" name="mac_address" />
|
||||
</div>
|
||||
|
||||
<!-- Group 4: 네트워크 및 접속 정보 -->
|
||||
<div class="form-section-title">네트워크 및 접속 정보</div>
|
||||
<div class="form-group">
|
||||
<label>${ASSET_SCHEMA.IP_ADDR.ui}</label>
|
||||
@@ -190,7 +255,6 @@ const HW_MODAL_HTML = `
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<!-- Group 5: 구매 정보 -->
|
||||
<div class="form-section-title">구매 및 증빙</div>
|
||||
<div class="form-group">
|
||||
<label>${ASSET_SCHEMA.PURCHASE_DATE.ui}</label>
|
||||
@@ -248,11 +312,49 @@ const HW_MODAL_HTML = `
|
||||
</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');
|
||||
}
|
||||
|
||||
// 위치보기 버튼: 좌표가 있고 이미지가 있는 위치라면 모드 상관없이 노출
|
||||
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 revertBtn = document.getElementById('btn-revert-hw-edit')!;
|
||||
@@ -263,7 +365,6 @@ export function initHwModal(onSave: () => void, closeModals: () => void) {
|
||||
bindLocationEvents('hw-bldg-select', 'hw-location_detail', '', '');
|
||||
applyDateMask(document.getElementById('hw-purchase_date') as HTMLInputElement);
|
||||
|
||||
// Category -> Asset Type Cascading
|
||||
const categorySelect = document.getElementById('hw-category') as HTMLSelectElement;
|
||||
const typeSelect = document.getElementById('hw-asset_type') as HTMLSelectElement;
|
||||
|
||||
@@ -279,16 +380,176 @@ export function initHwModal(onSave: () => void, closeModals: () => void) {
|
||||
btnCloseHeader.addEventListener('click', closeModalAction);
|
||||
btnCancelFooter.addEventListener('click', closeModalAction);
|
||||
|
||||
deleteBtn.addEventListener('click', async () => {
|
||||
if (!currentHwAsset) return;
|
||||
if (!confirm(UI_TEXT.MESSAGES.CONFIRM_DELETE)) return;
|
||||
const detailSelect = document.getElementById('hw-location_detail') as HTMLSelectElement;
|
||||
const bldgSelect = document.getElementById('hw-bldg-select') as HTMLSelectElement;
|
||||
|
||||
bldgSelect.addEventListener('change', () => {
|
||||
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 () => {
|
||||
await fetchMapConfig();
|
||||
const bldg = bldgSelect.value;
|
||||
const detail = detailSelect.value;
|
||||
const images = getImagesForLocation(bldg, detail);
|
||||
const x = getFieldValue('hw-loc_x');
|
||||
const y = getFieldValue('hw-loc_y');
|
||||
const savedImg = getFieldValue('hw-location_photo');
|
||||
|
||||
if (images) {
|
||||
const imgPath = savedImg && images.includes(savedImg) ? savedImg : images[0];
|
||||
openImagePreview(imgPath, `${detail} 위치 확인`, 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 () => {
|
||||
if (!currentHwAsset || !confirm(UI_TEXT.MESSAGES.CONFIRM_DELETE)) return;
|
||||
let categoryKey = 'pc';
|
||||
const cat = currentHwAsset.category;
|
||||
const type = currentHwAsset.asset_type;
|
||||
const code = currentHwAsset.asset_code || '';
|
||||
|
||||
if (type === '서버PC') categoryKey = 'pc';
|
||||
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';
|
||||
@@ -298,11 +559,8 @@ export function initHwModal(onSave: () => void, closeModals: () => void) {
|
||||
else if (cat === '사무가구' || cat === '사무소모품') categoryKey = 'officeSupplies';
|
||||
else if (cat === 'PC' || code.startsWith('PC')) categoryKey = 'pc';
|
||||
|
||||
const success = await deleteAsset(categoryKey, currentHwAsset.id);
|
||||
if (success) {
|
||||
alert('성공적으로 삭제되었습니다.');
|
||||
onSave(); // Refresh list
|
||||
closeModalAction();
|
||||
if (await deleteAsset(categoryKey, currentHwAsset.id)) {
|
||||
alert('성공적으로 삭제되었습니다.'); onSave(); closeModalAction();
|
||||
}
|
||||
});
|
||||
|
||||
@@ -310,49 +568,33 @@ export function initHwModal(onSave: () => void, closeModals: () => void) {
|
||||
setEditLock('hw-asset-form', 'view', { saveBtnId: 'btn-save-hw-asset', revertBtnId: 'btn-revert-hw-edit' });
|
||||
isEditMode = false;
|
||||
if (currentHwAsset) fillHwFormData(currentHwAsset);
|
||||
updateMapButtonVisibility();
|
||||
});
|
||||
|
||||
saveBtn.addEventListener('click', async () => {
|
||||
if (!currentHwAsset) return;
|
||||
if (!isEditMode) {
|
||||
setEditLock('hw-asset-form', 'edit', { saveBtnId: 'btn-save-hw-asset', revertBtnId: 'btn-revert-hw-edit' });
|
||||
isEditMode = true;
|
||||
isEditMode = true;
|
||||
updateMapButtonVisibility();
|
||||
return;
|
||||
}
|
||||
|
||||
const formData = new FormData(form);
|
||||
const updated: any = { ...currentHwAsset };
|
||||
formData.forEach((value, key) => {
|
||||
if (key !== 'id') updated[key] = value;
|
||||
});
|
||||
|
||||
// Handle location columns:
|
||||
// 'location' stores only the building name
|
||||
// 'location_detail' is already handled via the dynamic FormData loop
|
||||
formData.forEach((value, key) => { if (key !== 'id') updated[key] = value; });
|
||||
updated.location = getFieldValue('hw-bldg-select');
|
||||
|
||||
let categoryKey = 'pc';
|
||||
|
||||
// 서버PC인 경우 category는 PC이지만 UI상 서버로 취급되므로,
|
||||
// 저장은 반드시 'pc' 엔드포인트(/api/pc)로 되어야 함.
|
||||
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 (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';
|
||||
|
||||
const success = await saveAsset(categoryKey, updated);
|
||||
if (success) {
|
||||
alert(UI_TEXT.MESSAGES.SAVE_SUCCESS);
|
||||
onSave();
|
||||
if (await saveAsset(categoryKey, updated)) {
|
||||
alert(UI_TEXT.MESSAGES.SAVE_SUCCESS);
|
||||
onSave();
|
||||
closeModalAction();
|
||||
}
|
||||
});
|
||||
@@ -363,31 +605,21 @@ export function initHwModal(onSave: () => void, closeModals: () => void) {
|
||||
export function openHwModal(asset: any, mode: 'view' | 'edit' | 'add' = 'view') {
|
||||
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'
|
||||
});
|
||||
|
||||
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);
|
||||
|
||||
// Show/Hide category specific fields
|
||||
const serverOnly = document.querySelectorAll('.server-only');
|
||||
const nonServer = document.querySelectorAll('.non-server');
|
||||
const pcOnly = document.querySelectorAll('.pc-only');
|
||||
const userFields = document.querySelectorAll('.user-tracking-field');
|
||||
|
||||
// 조회 모드에서도 확실히 버튼을 노출시키기 위해 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';
|
||||
|
||||
serverOnly.forEach(el => (el as HTMLElement).style.display = isServer ? 'flex' : 'none');
|
||||
nonServer.forEach(el => (el as HTMLElement).style.display = !isServer ? 'flex' : 'none');
|
||||
pcOnly.forEach(el => (el as HTMLElement).style.display = isPc ? 'flex' : 'none');
|
||||
userFields.forEach(el => (el as HTMLElement).style.display = (!isServer && !isVip) ? 'flex' : 'none');
|
||||
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');
|
||||
}
|
||||
@@ -397,17 +629,11 @@ function fillHwFormData(asset: any) {
|
||||
setFieldValue('hw-asset_code', asset.asset_code || '');
|
||||
setFieldValue('hw-purchase_corp', asset.purchase_corp || '');
|
||||
setFieldValue('hw-category', asset.category || '');
|
||||
|
||||
// Populate asset_type options based on category
|
||||
const typeSelect = document.getElementById('hw-asset_type') as HTMLSelectElement;
|
||||
const types = CATEGORY_TYPE_MAP[asset.category] || [];
|
||||
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-hw_status', asset.hw_status || '운영');
|
||||
setFieldValue('hw-current_dept', asset.current_dept || '');
|
||||
setFieldValue('hw-previous_dept', asset.previous_dept || '');
|
||||
@@ -417,7 +643,6 @@ function fillHwFormData(asset: any) {
|
||||
setFieldValue('hw-user_position', asset.user_position || '');
|
||||
setFieldValue('hw-previous_user', asset.previous_user || '');
|
||||
setFieldValue('hw-asset_purpose', asset.asset_purpose || '');
|
||||
|
||||
setFieldValue('hw-model_name', asset.model_name || '');
|
||||
setFieldValue('hw-cpu', asset.cpu || '');
|
||||
setFieldValue('hw-ram', asset.ram || '');
|
||||
@@ -431,21 +656,21 @@ function fillHwFormData(asset: any) {
|
||||
setFieldValue('hw-mainboard', asset.mainboard || '');
|
||||
setFieldValue('hw-os', asset.os || '');
|
||||
setFieldValue('hw-mac_address', asset.mac_address || '');
|
||||
|
||||
setFieldValue('hw-ip_address', asset.ip_address || '');
|
||||
setFieldValue('hw-ip_address_2', asset.ip_address_2 || '');
|
||||
setFieldValue('hw-remote_tool', asset.remote_tool || '');
|
||||
setFieldValue('hw-remote_id', asset.remote_id || '');
|
||||
setFieldValue('hw-remote_pw', asset.remote_pw || '');
|
||||
setFieldValue('hw-monitoring', asset.monitoring || '비대상');
|
||||
|
||||
setFieldValue('hw-purchase_date', asset.purchase_date || '');
|
||||
setFieldValue('hw-purchase_vendor', asset.purchase_vendor || '');
|
||||
setFieldValue('hw-purchase_amount', asset.purchase_amount || '');
|
||||
(document.getElementById('hw-approval_document_name') as HTMLElement).textContent = asset.approval_document || '';
|
||||
|
||||
setFieldValue('hw-memo', asset.memo || '');
|
||||
setFieldValue('hw-location_detail', asset.location_detail || '');
|
||||
setFieldValue('hw-loc_x', asset.loc_x || '');
|
||||
setFieldValue('hw-loc_y', asset.loc_y || '');
|
||||
setFieldValue('hw-location_photo', asset.location_photo || asset.loc_img || '');
|
||||
|
||||
parseAndSetLocation(asset.location || '', asset.location_detail || '', 'hw-bldg-select', 'hw-location_detail');
|
||||
renderHwHistory(asset.id);
|
||||
@@ -455,15 +680,6 @@ function renderHwHistory(assetId: string) {
|
||||
const container = document.getElementById('hw-history-list');
|
||||
if (!container) return;
|
||||
const logs = (state.masterData.logs || []).filter(l => l.assetId === assetId);
|
||||
if (logs.length === 0) {
|
||||
container.innerHTML = '<div class="empty-history">이력이 없습니다.</div>';
|
||||
return;
|
||||
}
|
||||
container.innerHTML = logs.map(l => `
|
||||
<div class="history-item">
|
||||
<div class="history-date">${l.date}</div>
|
||||
<div class="history-user">${l.user}</div>
|
||||
<div class="history-details">${l.details}</div>
|
||||
</div>
|
||||
`).join('');
|
||||
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('');
|
||||
}
|
||||
|
||||
@@ -129,7 +129,7 @@
|
||||
display: none !important;
|
||||
}
|
||||
|
||||
.grid-form.is-view-mode button {
|
||||
.grid-form.is-view-mode button:not(.btn-loc-action) {
|
||||
pointer-events: none !important;
|
||||
background: none !important;
|
||||
border: none !important;
|
||||
@@ -508,3 +508,186 @@
|
||||
color: #3b82f6;
|
||||
background: #eff6ff;
|
||||
}
|
||||
|
||||
/* Layout Map & Image Picker Styles */
|
||||
.layout-map-container {
|
||||
position: relative;
|
||||
display: inline-block;
|
||||
cursor: crosshair;
|
||||
background-color: #f0f0f0;
|
||||
border-radius: 4px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.layout-map-container.readonly {
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
.layout-map-container.readonly .map-seat-obj {
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.digital-overlay-layer {
|
||||
position: absolute;
|
||||
top: 0; left: 0; width: 100%; height: 100%;
|
||||
pointer-events: none;
|
||||
z-index: 8;
|
||||
}
|
||||
|
||||
.digital-map-svg {
|
||||
width: 100%; height: 100%;
|
||||
}
|
||||
|
||||
.map-seat-obj {
|
||||
fill: rgba(30, 81, 73, 0.02);
|
||||
stroke: rgba(30, 81, 73, 0.15); /* 평상시에도 아주 연하게 보이게 수정 */
|
||||
stroke-width: 0.2;
|
||||
cursor: pointer;
|
||||
pointer-events: all;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
.map-seat-obj:hover {
|
||||
fill: rgba(30, 81, 73, 0.3);
|
||||
stroke: rgba(30, 81, 73, 0.6);
|
||||
stroke-width: 0.5;
|
||||
}
|
||||
|
||||
.layout-map-img {
|
||||
display: block;
|
||||
max-width: 100%;
|
||||
max-height: 75vh;
|
||||
user-select: none;
|
||||
-webkit-user-drag: none;
|
||||
}
|
||||
|
||||
.layout-marker {
|
||||
position: absolute;
|
||||
width: 14px;
|
||||
height: 14px;
|
||||
background-color: rgba(30, 81, 73, 0.7);
|
||||
border: 2px solid #FFFFFF;
|
||||
border-radius: 50%;
|
||||
transform: translate(-50%, -50%);
|
||||
pointer-events: none;
|
||||
z-index: 10;
|
||||
box-shadow: 0 0 8px rgba(0,0,0,0.4);
|
||||
}
|
||||
|
||||
.pulse-marker {
|
||||
background-color: rgba(255, 61, 0, 0.8) !important;
|
||||
border-color: #FFFFFF !important;
|
||||
animation: marker-pulse 1.2s infinite;
|
||||
}
|
||||
|
||||
@keyframes marker-pulse {
|
||||
0% { transform: translate(-50%, -50%) scale(1); box-shadow: 0 0 0 0 rgba(255, 61, 0, 0.6); }
|
||||
70% { transform: translate(-50%, -50%) scale(1.6); box-shadow: 0 0 0 10px rgba(255, 61, 0, 0); }
|
||||
100% { transform: translate(-50%, -50%) scale(1); box-shadow: 0 0 0 0 rgba(255, 61, 0, 0); }
|
||||
}
|
||||
|
||||
.image-picker-overlay {
|
||||
position: fixed;
|
||||
top: 0; left: 0; right: 0; bottom: 0;
|
||||
background: rgba(0,0,0,0.85);
|
||||
z-index: 2500;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 20px;
|
||||
}
|
||||
|
||||
.image-picker-header {
|
||||
width: 100%;
|
||||
max-width: 900px;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 15px;
|
||||
}
|
||||
|
||||
.image-picker-header h3 {
|
||||
color: white;
|
||||
margin: 0;
|
||||
font-size: 1.25rem;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.image-picker-content {
|
||||
background: white;
|
||||
padding: 8px;
|
||||
border-radius: 8px;
|
||||
max-width: 95vw;
|
||||
max-height: 80vh;
|
||||
overflow: auto;
|
||||
position: relative;
|
||||
box-shadow: 0 20px 50px rgba(0,0,0,0.5);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.picker-nav {
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
transform: translateY(-50%);
|
||||
width: 40px;
|
||||
height: 60px;
|
||||
background: rgba(0,0,0,0.5);
|
||||
color: white;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 24px;
|
||||
cursor: pointer;
|
||||
z-index: 100;
|
||||
user-select: none;
|
||||
transition: background 0.2s;
|
||||
}
|
||||
|
||||
.picker-nav:hover { background: rgba(0,0,0,0.8); }
|
||||
.picker-nav.disabled { opacity: 0.2; cursor: not-allowed; }
|
||||
.picker-nav.prev { left: 10px; border-radius: 0 4px 4px 0; }
|
||||
.picker-nav.next { right: 10px; border-radius: 4px 0 0 4px; }
|
||||
|
||||
.image-picker-footer {
|
||||
margin-top: 20px;
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.btn-loc-action {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 4px;
|
||||
padding: 0 6px;
|
||||
font-size: 10px !important;
|
||||
font-weight: 600;
|
||||
border-radius: 4px;
|
||||
height: 24px;
|
||||
min-width: 52px;
|
||||
white-space: nowrap;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
.btn-loc-view {
|
||||
background-color: var(--primary-color);
|
||||
color: white !important;
|
||||
border: none;
|
||||
}
|
||||
|
||||
.btn-loc-view:hover {
|
||||
background-color: #163d37;
|
||||
}
|
||||
|
||||
.location-detail-container {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.location-detail-container select {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user