feat: 자산 관리 시스템 고도화 및 DB 정규화 대응 수정

1. 자산 저장 시 500 에러 해결: V3 정규화 스키마에 맞춰 테이블 매핑 최신화 및 저장 로직 안정화
2. 자산 번호 체계 개편: 구매일자(YYYYMM)와 유형을 기반으로 PREFIX-YYYYMM-NNNN 규칙 적용 (코드 로직 수정 및 기존 데이터 전량 갱신)
3. 구매일자 표준화: 모든 purchase_date를 YYYY-MM-DD 형식으로 통일
4. HWModal 기능 복원: 위치 등록 시 다중 사진 페이지네이션(좌우 버튼) 기능 복구
5. 위치 지도 고도화: HTML 인터랙티브 지도 지원 및 이미지 지도 내 좌석 스내핑 로직 추가
This commit is contained in:
2026-06-12 10:29:42 +09:00
parent 0c1977f707
commit 9186eb50ca
6 changed files with 295 additions and 201 deletions

View File

@@ -268,10 +268,21 @@ class HwAssetModal extends BaseModal {
});
document.getElementById('btn-gen-hw-code')?.addEventListener('click', async () => {
const type = typeSelect.value;
const cat = categorySelect.value;
if (!cat) { alert('구분을 먼저 선택해주세요.'); return; }
const prefix = TYPE_PREFIX_MAP[cat] || 'ETC';
const purchaseDate = (document.getElementById('hw-purchase_date') as HTMLInputElement)?.value || '';
if (!type) { alert('유형을 먼저 선택해주세요.'); return; }
const purchaseDateEl = document.getElementById('hw-purchase_date') as HTMLInputElement;
const purchaseDate = purchaseDateEl?.value || '';
if (!purchaseDate) {
alert('구매일자를 먼저 입력해야 자산번호 생성이 가능합니다.');
purchaseDateEl?.focus();
return;
}
// 유형 기반 매핑 우선, 없으면 구분 기반, 그래도 없으면 ETC
const prefix = TYPE_PREFIX_MAP[type] || TYPE_PREFIX_MAP[cat] || 'ETC';
try {
const res = await fetch(`http://${location.hostname}:3000/api/generate-asset-code?prefix=${prefix}&purchaseDate=${purchaseDate}`);
const data = await res.json();
@@ -690,29 +701,110 @@ class HwAssetModal extends BaseModal {
overlay.className = 'image-picker-overlay';
const renderContent = () => {
const imgPath = imagePaths[currentIdx];
const digitalMap = this.generateDynamicSVG(imgPath);
const isMulti = imagePaths.length > 1;
const isHtmlMap = imgPath.toLowerCase().endsWith('.html');
const digitalMap = isHtmlMap ? '' : this.generateDynamicSVG(imgPath);
overlay.innerHTML = `
<div class="image-picker-header"><h3>${title}</h3><button class="btn-close-picker" style="background:none; border:none; color:white; font-size:24px; cursor:pointer;">&times;</button></div>
<div class="image-picker-content"><div class="layout-map-container" id="picker-container"><img src="${imgPath}" class="layout-map-img" /><div id="picker-marker" class="layout-marker hidden"></div><div class="digital-overlay-layer">${digitalMap}</div></div></div>
<div class="image-picker-header">
<h3>${title} ${isMulti ? `(${currentIdx + 1}/${imagePaths.length})` : ''}</h3>
<button class="btn-close-picker" style="background:none; border:none; color:white; font-size:24px; cursor:pointer;">&times;</button>
</div>
<div class="image-picker-content">
${isMulti ? `
<div class="picker-nav prev ${currentIdx === 0 ? 'disabled' : ''}" style="position: absolute; left: 10px; top: 50%; transform: translateY(-50%); z-index: 100; cursor: pointer; background: rgba(0,0,0,0.5); color: white; padding: 20px 10px; border-radius: 5px; font-size: 24px; user-select: none;">◀</div>
<div class="picker-nav next ${currentIdx === imagePaths.length - 1 ? 'disabled' : ''}" style="position: absolute; right: 10px; top: 50%; transform: translateY(-50%); z-index: 100; cursor: pointer; background: rgba(0,0,0,0.5); color: white; padding: 20px 10px; border-radius: 5px; font-size: 24px; user-select: none;">▶</div>
` : ''}
<div class="layout-map-container" id="picker-container">
${isHtmlMap
? `<iframe src="${imgPath}" style="width:100%; height:100%; border:none; display:block;"></iframe>`
: `<img src="${imgPath}" class="layout-map-img" /><div id="picker-marker" class="layout-marker hidden"></div><div class="digital-overlay-layer">${digitalMap}</div>`
}
</div>
</div>
<div class="image-picker-footer"><button id="btn-picker-cancel" class="btn btn-outline" style="color:white; border-color:white;">취소</button><button id="btn-picker-save" class="btn btn-primary">위치 확정</button></div>`;
let selectedX = ''; let selectedY = '';
const container = overlay.querySelector('#picker-container') as HTMLElement;
const marker = overlay.querySelector('#picker-marker') as HTMLElement;
container.addEventListener('click', (e) => {
const rect = container.getBoundingClientRect();
const x = ((e.clientX - rect.left) / rect.width) * 100;
const y = ((e.clientY - rect.top) / rect.height) * 100;
selectedX = x.toFixed(2); selectedY = y.toFixed(2);
marker.style.left = `${selectedX}%`; marker.style.top = `${selectedY}%`; marker.classList.remove('hidden');
});
overlay.querySelector('.btn-close-picker')?.addEventListener('click', () => overlay.remove());
overlay.querySelector('#btn-picker-cancel')?.addEventListener('click', () => overlay.remove());
overlay.querySelector('#btn-picker-save')?.addEventListener('click', () => {
if (!selectedX || !selectedY) { alert('위치를 선택해주세요.'); return; }
setFieldValue('hw-loc_x', selectedX); setFieldValue('hw-loc_y', selectedY);
setFieldValue('hw-location_photo', imagePaths[currentIdx]);
this.updateMapButtonVisibility(); overlay.remove();
});
if (isMulti) {
overlay.querySelector('.picker-nav.prev')?.addEventListener('click', (e) => { e.stopPropagation(); if (currentIdx > 0) { currentIdx--; renderContent(); } });
overlay.querySelector('.picker-nav.next')?.addEventListener('click', (e) => { e.stopPropagation(); if (currentIdx < imagePaths.length - 1) { currentIdx++; renderContent(); } });
}
if (isHtmlMap) {
// HTML 지도 메시지 리스너
const handleMessage = (e: MessageEvent) => {
if (e.data.type === 'PICK_LOCATION') {
selectedX = e.data.x;
selectedY = e.data.y;
}
};
window.addEventListener('message', handleMessage);
overlay.querySelector('.btn-close-picker')?.addEventListener('click', () => { window.removeEventListener('message', handleMessage); overlay.remove(); });
overlay.querySelector('#btn-picker-cancel')?.addEventListener('click', () => { window.removeEventListener('message', handleMessage); overlay.remove(); });
overlay.querySelector('#btn-picker-save')?.addEventListener('click', () => {
if (!selectedX || !selectedY) { alert('위치를 선택해주세요.'); return; }
setFieldValue('hw-loc_x', selectedX); setFieldValue('hw-loc_y', selectedY);
setFieldValue('hw-location_photo', imagePaths[currentIdx]);
this.updateMapButtonVisibility();
window.removeEventListener('message', handleMessage);
overlay.remove();
});
} else {
const container = overlay.querySelector('#picker-container') as HTMLElement;
const marker = overlay.querySelector('#picker-marker') as HTMLElement;
container.addEventListener('click', (e) => {
const rectBound = container.getBoundingClientRect();
const clickX = ((e.clientX - rectBound.left) / rectBound.width) * 100;
const clickY = ((e.clientY - rectBound.top) / rectBound.height) * 100;
let snapped = false;
overlay.querySelectorAll('rect').forEach(rect => {
const rx = parseFloat(rect.getAttribute('x') || '0');
const ry = parseFloat(rect.getAttribute('y') || '0');
const rw = parseFloat(rect.getAttribute('width') || '0');
const rh = parseFloat(rect.getAttribute('height') || '0');
if (clickX >= rx && clickX <= rx + rw && clickY >= ry && clickY <= ry + rh) {
overlay.querySelectorAll('rect').forEach(r => {
r.style.fill = 'rgba(30,81,73,0.05)';
r.style.stroke = 'rgba(30,81,73,0.2)';
r.style.strokeWidth = '0.2';
});
rect.style.fill = 'rgba(255, 61, 0, 0.4)';
rect.style.stroke = '#FF3D00';
rect.style.strokeWidth = '0.8';
selectedX = rx.toFixed(2);
selectedY = ry.toFixed(2);
marker.style.left = `${rx + rw/2}%`;
marker.style.top = `${ry + rh/2}%`;
marker.classList.remove('hidden');
snapped = true;
}
});
if (!snapped) {
selectedX = '';
selectedY = '';
marker.classList.add('hidden');
overlay.querySelectorAll('rect').forEach(r => {
r.style.fill = 'rgba(30,81,73,0.05)';
r.style.stroke = 'rgba(30,81,73,0.2)';
r.style.strokeWidth = '0.2';
});
}
});
overlay.querySelector('.btn-close-picker')?.addEventListener('click', () => overlay.remove());
overlay.querySelector('#btn-picker-cancel')?.addEventListener('click', () => overlay.remove());
overlay.querySelector('#btn-picker-save')?.addEventListener('click', () => {
if (!selectedX || !selectedY) { alert('위치를 선택해주세요.'); return; }
setFieldValue('hw-loc_x', selectedX); setFieldValue('hw-loc_y', selectedY);
setFieldValue('hw-location_photo', imagePaths[currentIdx]);
this.updateMapButtonVisibility(); overlay.remove();
});
}
};
renderContent(); document.body.appendChild(overlay);
}
@@ -720,13 +812,26 @@ class HwAssetModal extends BaseModal {
private openImagePreview(imagePath: string, title: string, x: string, y: string) {
const overlay = document.createElement('div');
overlay.className = 'image-picker-overlay';
const digitalMap = this.generateDynamicSVG(imagePath);
const isHtmlMap = imagePath.toLowerCase().endsWith('.html');
const digitalMap = isHtmlMap ? '' : this.generateDynamicSVG(imagePath);
// HTML 지도인 경우 좌표를 쿼리 파라미터로 전달
const finalPath = isHtmlMap ? `${imagePath}?markerX=${x}&markerY=${y}` : imagePath;
overlay.innerHTML = `
<div class="image-picker-header"><h3>${title}</h3><button class="btn-close-picker" style="background:none; border:none; color:white; font-size:24px; cursor:pointer;">&times;</button></div>
<div class="image-picker-content"><div class="layout-map-container readonly"><img src="${imagePath}" class="layout-map-img" /><div id="preview-marker" class="layout-marker pulse-marker" style="left:${x}%; top:${y}%;"></div><div class="digital-overlay-layer">${digitalMap}</div></div></div>
<div class="image-picker-content">
<div class="layout-map-container readonly">
${isHtmlMap
? `<iframe src="${finalPath}" style="width:100%; height:100%; border:none; display:block;"></iframe>`
: `<img src="${imagePath}" class="layout-map-img" /><div id="preview-marker" class="layout-marker pulse-marker" style="left:${x}%; top:${y}%;"></div><div class="digital-overlay-layer">${digitalMap}</div>`
}
</div>
</div>
<div class="image-picker-footer"><button id="btn-preview-close" class="btn btn-primary">확인</button></div>`;
document.body.appendChild(overlay);
if (digitalMap) {
if (!isHtmlMap && digitalMap) {
const curX = parseFloat(x || '0'); const curY = parseFloat(y || '0');
overlay.querySelectorAll('rect').forEach(rect => {
const sx = parseFloat(rect.getAttribute('x') || '0');

View File

@@ -30,7 +30,7 @@ export const CATEGORY_TYPE_MAP: Record<string, string[]> = {
// 설치위치 종속성 데이터
export const LOCATION_DATA: Record<string, string[]> = {
'한맥빌딩': ['MDF실', '1층', '2층', '3층', '4층', '5층', '6층', '7층', '파고라'],
'기술개발센터': ['서버실', 'BLUE ZONE', 'GREEN ZONE', 'ORANGE ZONE', '회의실2', '회의실3', '회의실5', '회의실6', '회의실7', '사이니지룸'],
'기술개발센터': ['서버실', '센터내부'],
'유니온빌딩': ['4층', '5층', '6층'],
'뉴코아빌딩': ['4층', '6층', '7층'],
'IDC': ['서관202', '서관203', '서관204', '서관205', '동관53', '동관54']
@@ -60,10 +60,17 @@ export const IMAGE_LOCATIONS: Record<string, Record<string, string[]>> = {
'서버실': [
'img/location_photo/기술개발센터/서버실/서버실_1.png',
'img/location_photo/기술개발센터/서버실/서버실_2.png'
]
],
'센터내부': ['img/location_photo/기술개발센터/센터내부/센터내부.png']
},
'한맥빌딩': {
'7층': ['img/location_photo/한맥빌딩/7층_로비.png'],
'1층': ['img/location_photo/한맥빌딩/1층.png'],
'2층': ['img/location_photo/한맥빌딩/2층.png'],
'3층': ['img/location_photo/한맥빌딩/3층.png'],
'4층': ['img/location_photo/한맥빌딩/4층.png'],
'5층': ['img/location_photo/한맥빌딩/5층.png'],
'6층': ['img/location_photo/한맥빌딩/6층.png'],
'7층': ['img/location_photo/한맥빌딩/7층.png'],
'MDF실': [
'img/location_photo/한맥빌딩/MDF실/MDF_1.png',
'img/location_photo/한맥빌딩/MDF실/MDF_2.png',

View File

@@ -189,66 +189,40 @@ function initRoleSwitcher() {
}
/**
* 로그인 처리 로직
* 앱 초기화 (로그인 과정 없이 즉시 시작)
*/
function handleLogin() {
function initializeAppDirectly() {
const loginContainer = document.getElementById('login-container');
const appLayout = document.getElementById('app-layout');
const roleCards = document.querySelectorAll('.role-card');
const checkbox = document.getElementById('role-toggle-checkbox') as HTMLInputElement;
const userLabel = document.querySelector('.role-label.user');
const adminLabel = document.querySelector('.role-label.admin');
if (!loginContainer || !appLayout || roleCards.length === 0) return;
// 기본 권한 설정: 실무자 (User)
state.currentUserRole = 'user';
state.activeCategory = 'hw';
state.activeSubTab = '서버'; // 실무자 기본 탭
roleCards.forEach(card => {
card.addEventListener('click', () => {
const role = card.getAttribute('data-role');
const checkbox = document.getElementById('role-toggle-checkbox') as HTMLInputElement;
if (role === 'admin') {
console.log('🔓 Entering as Admin');
state.currentUserRole = 'admin';
state.activeCategory = 'hw';
state.activeSubTab = '대시보드'; // 관리자는 대시보드로 진입
if (checkbox) checkbox.checked = true;
if (userLabel) userLabel.classList.remove('active');
if (adminLabel) adminLabel.classList.add('active');
document.body.classList.add('admin-mode');
} else if (role === 'user') {
console.log('🔓 Entering as Practitioner');
state.currentUserRole = 'user';
state.activeCategory = 'hw';
state.activeSubTab = '서버'; // 실무자는 서버 목록으로 진입
if (checkbox) checkbox.checked = false;
if (userLabel) userLabel.classList.add('active');
if (adminLabel) adminLabel.classList.remove('active');
document.body.classList.remove('admin-mode');
} else {
return;
}
// UI 상태 동기화
if (checkbox) checkbox.checked = false;
if (userLabel) userLabel.classList.add('active');
if (adminLabel) adminLabel.classList.remove('active');
document.body.classList.remove('admin-mode');
// UI 전환
loginContainer.style.display = 'none';
appLayout.style.display = 'flex';
// 역할 스위처 및 앱 초기화 시작
initRoleSwitcher();
initApp();
// 로고 클릭 시 초기화면 복귀 로직 (한 번만 등록)
const brand = document.querySelector('.brand') as HTMLElement;
if (brand) {
brand.style.cursor = 'pointer';
brand.onclick = () => {
location.reload(); // 즉시 초기화면으로 복귀
};
}
});
});
// 화면 전환
if (loginContainer) loginContainer.style.display = 'none';
if (appLayout) appLayout.style.display = 'flex';
// 앱 초기화
initRoleSwitcher();
initApp();
// 로고 클릭 시 새로고침 (초기 화면 복귀 효과)
const brand = document.querySelector('.brand') as HTMLElement;
if (brand) {
brand.style.cursor = 'pointer';
brand.onclick = () => location.reload();
}
}
document.addEventListener('DOMContentLoaded', handleLogin);
document.addEventListener('DOMContentLoaded', initializeAppDirectly);

View File

@@ -474,6 +474,7 @@ export function createListView(container: HTMLElement, config: ListViewConfig) {
<div id="detail-photo-wrapper" style="width: 100%; flex: 1; overflow: hidden; display: flex; align-items: center; justify-content: center; position: relative; border: 1px solid var(--border-color); background: #f0f0f0;">
<div class="layout-map-container readonly" style="position: relative; display: flex; align-items: center; justify-content: center; width: 100%; height: 100%;">
<img id="detail-photo" src="" style="display: block; max-width: 100%; max-height: 100%; width: auto; height: auto; object-fit: contain; pointer-events: none;" />
<iframe id="detail-html-map" src="" style="display: none; width: 100%; height: 100%; border: none;"></iframe>
<div id="detail-marker" class="layout-marker pulse-marker" style="display: none; position: absolute; z-index: 20;"></div>
<div id="detail-overlay-layer" class="digital-overlay-layer" style="position: absolute; top: 0; left: 0; width: 100%; height: 100%; pointer-events: none; display: flex; align-items: center; justify-content: center;"></div>
</div>
@@ -529,60 +530,86 @@ export function createListView(container: HTMLElement, config: ListViewConfig) {
const locImgs = IMAGE_LOCATIONS[bldg.trim()]?.[detail.trim()] || null;
const imgPath = (savedImg && locImgs?.includes(savedImg)) ? savedImg : (locImgs ? locImgs[0] : null);
const htmlMap = document.getElementById('detail-html-map') as HTMLIFrameElement;
const isHtmlMap = imgPath?.toLowerCase().endsWith('.html');
// 좌표가 없으면 사진이 있어도 '정보 없음' 상태로 유도 (사용자 요청)
if (imgPath && hasCoords) {
photo.src = imgPath;
photo.style.display = 'block';
if (isHtmlMap) {
// HTML 지도 처리
photo.style.display = 'none';
if (marker) marker.style.display = 'none';
if (overlayLayer) overlayLayer.innerHTML = '';
if (htmlMap) {
htmlMap.src = `${imgPath}?markerX=${x}&markerY=${y}`;
htmlMap.style.display = 'block';
}
} else {
// 일반 이미지 지도 처리
if (htmlMap) {
htmlMap.src = '';
htmlMap.style.display = 'none';
}
photo.src = imgPath;
photo.style.display = 'block';
}
if (noPhoto) noPhoto.style.display = 'none';
photo.onload = () => {
const updateMarkerPos = () => {
const imgW = photo.clientWidth;
const imgH = photo.clientHeight;
if (!isHtmlMap) {
photo.onload = () => {
const updateMarkerPos = () => {
const imgW = photo.clientWidth;
const imgH = photo.clientHeight;
if (marker) {
marker.style.left = `calc(50% - ${imgW/2}px + ${ (parseFloat(x as string) * imgW) / 100 }px)`;
marker.style.top = `calc(50% - ${imgH/2}px + ${ (parseFloat(y as string) * imgH) / 100 }px)`;
marker.style.display = 'block';
}
if (overlayLayer) {
overlayLayer.style.width = `${imgW}px`;
overlayLayer.style.height = `${imgH}px`;
overlayLayer.style.left = `calc(50% - ${imgW/2}px)`;
overlayLayer.style.top = `calc(50% - ${imgH/2}px)`;
const boxes = dynamicMapConfig[imgPath] || [];
if (boxes.length > 0) {
overlayLayer.innerHTML = `
<svg viewBox="0 0 100 100" preserveAspectRatio="none" style="width:100%; height:100%;">
<g class="seat-group">
${boxes.map((b, i) => {
const isSelected = b.x === x && b.y === y;
const fill = isSelected ? 'rgba(255, 61, 0, 0.4)' : 'rgba(30, 81, 73, 0.02)';
const stroke = isSelected ? '#FF3D00' : 'rgba(30, 81, 73, 0.15)';
const strokeWidth = isSelected ? '0.8' : '0.2';
if (isSelected && marker) {
marker.style.left = `calc(50% - ${imgW/2}px + ${ (parseFloat(b.x) + parseFloat(b.w)/2) * imgW / 100 }px)`;
marker.style.top = `calc(50% - ${imgH/2}px + ${ (parseFloat(b.y) + parseFloat(b.h)/2) * imgH / 100 }px)`;
}
return `<rect class="map-seat-obj" x="${b.x}" y="${b.y}" width="${b.w}" height="${b.h}" rx="0.5" style="fill:${fill}; stroke:${stroke}; stroke-width:${strokeWidth};" />`;
}).join('')}
</g>
</svg>
`;
} else {
overlayLayer.innerHTML = '';
if (marker) {
marker.style.left = `calc(50% - ${imgW/2}px + ${ (parseFloat(x as string) * imgW) / 100 }px)`;
marker.style.top = `calc(50% - ${imgH/2}px + ${ (parseFloat(y as string) * imgH) / 100 }px)`;
marker.style.display = 'block';
}
}
if (overlayLayer) {
overlayLayer.style.width = `${imgW}px`;
overlayLayer.style.height = `${imgH}px`;
overlayLayer.style.left = `calc(50% - ${imgW/2}px)`;
overlayLayer.style.top = `calc(50% - ${imgH/2}px)`;
const boxes = dynamicMapConfig[imgPath] || [];
if (boxes.length > 0) {
overlayLayer.innerHTML = `
<svg viewBox="0 0 100 100" preserveAspectRatio="none" style="width:100%; height:100%;">
<g class="seat-group">
${boxes.map((b, i) => {
const isSelected = b.x === x && b.y === y;
const fill = isSelected ? 'rgba(255, 61, 0, 0.4)' : 'rgba(30, 81, 73, 0.02)';
const stroke = isSelected ? '#FF3D00' : 'rgba(30, 81, 73, 0.15)';
const strokeWidth = isSelected ? '0.8' : '0.2';
if (isSelected && marker) {
marker.style.left = `calc(50% - ${imgW/2}px + ${ (parseFloat(b.x) + parseFloat(b.w)/2) * imgW / 100 }px)`;
marker.style.top = `calc(50% - ${imgH/2}px + ${ (parseFloat(b.y) + parseFloat(b.h)/2) * imgH / 100 }px)`;
}
return `<rect class="map-seat-obj" x="${b.x}" y="${b.y}" width="${b.w}" height="${b.h}" rx="0.5" style="fill:${fill}; stroke:${stroke}; stroke-width:${strokeWidth};" />`;
}).join('')}
</g>
</svg>
`;
} else {
overlayLayer.innerHTML = '';
}
}
};
updateMarkerPos();
window.addEventListener('resize', updateMarkerPos);
};
updateMarkerPos();
window.addEventListener('resize', updateMarkerPos);
};
}
} else {
photo.style.display = 'none';
if (htmlMap) {
htmlMap.src = '';
htmlMap.style.display = 'none';
}
if (marker) marker.style.display = 'none';
if (overlayLayer) overlayLayer.innerHTML = '';
if (noPhoto) {