feat/refactor: 자산관리 시스템 기능 고도화 및 UI/UX 개선
1. 컬럼 드래그 너비 조정 버그 수정 및 개선 (ListFactory.ts) - 드래그 완료 시 click 이벤트 전파 차단으로 정렬(sorting) 오작동 방지 - getBoundingClientRect().width 활용한 소수점 정밀 너비 고정 및 레이아웃 시프트 방지 - 마우스 업 시점의 모든 컬럼 너비를 config.columns에 동기화하여 재렌더링 시 너비 영속성 보장 2. PC 자산 모달 필드 잠금 정책 세분화 (HWModal.ts) - 자산 추가(add) 모드에서는 모든 필드(사용자 정보 포함) 입력 허용 - 자산 수정(edit) 모드에서만 사용자/조직 정보 관련 필드(lockedUserFields) 선택적 잠금 적용 - 시스템 사양, 네트워크, 위치, 구매 등 다른 모든 섹션은 수정 가능하도록 복구 및 안내 배너 갱신 3. 관리자 전용 메뉴 단일 페이지 앱(SPA) 통합 (Navigation.ts, main.ts, MapEditor.ts) - 기존의 실사 승인 탭과 독립 실행형 좌표 에디터(MapEditor)를 GNB '관리도구' 하위 메뉴로 통합 - '실사 승인', '위치지정'을 GNB에서 ↳ 화살표 및 11px 폰트의 계층형 탭 스타일로 렌더링 - 내부 서브 탭 바를 삭제하고 메인 영역 전체 높이(calc(100vh - var(--header-height) - 48px))를 확보 - 다른 탭으로 이동 시 MapEditor 인스턴스의 window 이벤트 및 전역 바인딩을 소거하는 destroy() 리사이클 구현 4. 자산 이력(History) 가독성 개선 및 포맷팅 (HWModal.ts, SWModal.ts, DomainModal.ts) - 자산 변경 이력 로그를 일자별로 그룹화하여 타임라인 렌더링 - 최초 등록 데이터에 녹색 '[최초등록]' 배지 추가 - 기존의 생 JSON 이력 데이터를 친절한 한국어 텍스트 포맷으로 가공하여 가독성 극대화
This commit is contained in:
@@ -202,7 +202,57 @@ class DomainAssetModal extends BaseModal {
|
||||
if (logs.length === 0) {
|
||||
container.innerHTML = '<div style="color:var(--mute); padding:1rem; text-align:center;">이력이 없습니다.</div>';
|
||||
} else {
|
||||
container.innerHTML = logs.map(l => `<div class="history-item"><div class="history-date">${l.log_date || ''}</div><div class="history-user">${l.log_user || '시스템'}</div><div class="history-details">${l.details}</div></div>`).join('');
|
||||
const createdDate = this.currentAsset?.created_at ? this.currentAsset.created_at.substring(0, 10) : '';
|
||||
|
||||
const grouped: Record<string, typeof logs> = {};
|
||||
logs.forEach(l => {
|
||||
const date = l.log_date || '날짜 미지정';
|
||||
if (!grouped[date]) grouped[date] = [];
|
||||
grouped[date].push(l);
|
||||
});
|
||||
|
||||
container.innerHTML = Object.entries(grouped).map(([date, dateLogs]) => {
|
||||
const entriesHtml = dateLogs.map((l, idx) => {
|
||||
const isLast = idx === dateLogs.length - 1;
|
||||
const borderStyle = isLast ? '' : 'border-bottom: 1px dashed var(--hairline); padding-bottom: 8px; margin-bottom: 8px;';
|
||||
|
||||
let displayDetails = l.details;
|
||||
if (l.details && l.details.trim().startsWith('{')) {
|
||||
try {
|
||||
const data = JSON.parse(l.details);
|
||||
if (data.type === 'checkout') {
|
||||
displayDetails = `[불출] ${data.user || ''} (${data.dept || ''}) ${data.memo ? `| 메모: ${data.memo}` : ''}`;
|
||||
} else if (data.type === 'return') {
|
||||
displayDetails = `[반납] ${data.user || ''} (${data.dept || ''}) ${data.memo ? `| 메모: ${data.memo}` : ''}`;
|
||||
} else if (data.type === 'move') {
|
||||
displayDetails = `[이동] ${data.user || ''} (${data.dept || ''}) ➔ ${data.targetUser || ''} (${data.targetDept || ''}) ${data.memo ? `| 메모: ${data.memo}` : ''}`;
|
||||
}
|
||||
} catch (e) {}
|
||||
}
|
||||
|
||||
return `
|
||||
<div class="history-entry" style="${borderStyle}">
|
||||
<div style="font-weight: 600; color: var(--primary); opacity: 0.8; margin-bottom: 4px; display: flex; align-items: center; gap: 6px;">
|
||||
<span style="display: inline-block; width: 4px; height: 4px; background-color: var(--primary); border-radius: 50%;"></span>
|
||||
${l.log_user || '시스템'}
|
||||
</div>
|
||||
<div style="color: var(--primary); padding-left: 10px; line-height: 1.5;">${displayDetails}</div>
|
||||
</div>
|
||||
`;
|
||||
}).join('');
|
||||
|
||||
const isInitialReg = date === createdDate;
|
||||
const regBadge = isInitialReg ? `<span class="badge-reg" style="font-size: 10px; padding: 1px 5px; margin-left: 6px; background-color: rgba(16, 185, 129, 0.1); color: #10b981; border: 1px solid rgba(16, 185, 129, 0.2); border-radius: 4px; font-weight: 600;">최초등록</span>` : '';
|
||||
|
||||
return `
|
||||
<div class="history-item">
|
||||
<div class="history-date" style="display: flex; align-items: center;">${date} ${regBadge}</div>
|
||||
<div class="history-details" style="display: flex; flex-direction: column; gap: 4px;">
|
||||
${entriesHtml}
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}).join('');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -98,6 +98,9 @@ class HwAssetModal extends BaseModal {
|
||||
|
||||
<!-- [SECTION 2] 조직 및 사용자 정보 -->
|
||||
<div class="form-section-title">사용자 및 조직 정보</div>
|
||||
<div id="hw-pc-workflow-notice" class="form-group full-width hidden" style="background-color: rgba(59, 130, 246, 0.05); border: 1px solid rgba(59, 130, 246, 0.15); padding: 8px 12px; border-radius: 6px; font-size: 11px; color: var(--primary); line-height: 1.5; margin-bottom: 12px;">
|
||||
💡 PC 자산은 데이터 정합성을 위해 '사용자 및 조직 정보'만 수정이 제한되며, 사양 및 기타 정보는 수정창에서 수정할 수 있습니다.
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>${ASSET_SCHEMA.CURRENT_DEPT.ui}</label>
|
||||
<select id="hw-current_dept" name="current_dept">${generateOptionsHTML(ORG_LIST)}</select>
|
||||
@@ -138,6 +141,10 @@ class HwAssetModal extends BaseModal {
|
||||
<label>${ASSET_SCHEMA.SERIAL_NUM.ui}</label>
|
||||
<input type="text" id="hw-serial_num" name="serial_num" />
|
||||
</div>
|
||||
<div class="form-group mainboard-only">
|
||||
<label>${ASSET_SCHEMA.MAINBOARD.ui}</label>
|
||||
<input type="text" id="hw-mainboard" name="mainboard" />
|
||||
</div>
|
||||
<div class="form-group spec-only">
|
||||
<label>${ASSET_SCHEMA.OS.ui}</label>
|
||||
<input type="text" id="hw-os" name="os" />
|
||||
@@ -309,6 +316,12 @@ class HwAssetModal extends BaseModal {
|
||||
typeSelect.addEventListener('change', () => {
|
||||
this.applyRoleVisibility();
|
||||
this.updateHeaderIdentity(this.currentAsset);
|
||||
|
||||
if (typeSelect.value === '공용PC') {
|
||||
setFieldValue('hw-user_current', '');
|
||||
setFieldValue('hw-emp_no', '');
|
||||
setFieldValue('hw-user_position', '공용PC');
|
||||
}
|
||||
});
|
||||
|
||||
bindLocationEvents('hw-bldg-select', 'hw-location_detail', '', '');
|
||||
@@ -320,9 +333,15 @@ class HwAssetModal extends BaseModal {
|
||||
document.getElementById('btn-gen-hw-code')?.addEventListener('click', async () => {
|
||||
const cat = categorySelect.value;
|
||||
if (!cat) { alert('구분을 먼저 선택해주세요.'); return; }
|
||||
|
||||
const purchaseDate = (document.getElementById('hw-purchase_date') as HTMLInputElement)?.value || '';
|
||||
if (!purchaseDate.trim()) {
|
||||
alert('구매일자를 먼저 입력해 주세요. 구매일자가 없으면 자산번호를 생성할 수 없습니다.');
|
||||
return;
|
||||
}
|
||||
|
||||
const type = (document.getElementById('hw-asset_type') as HTMLSelectElement)?.value || '';
|
||||
const prefix = TYPE_PREFIX_MAP[type] || TYPE_PREFIX_MAP[cat] || 'ETC';
|
||||
const purchaseDate = (document.getElementById('hw-purchase_date') as HTMLInputElement)?.value || '';
|
||||
try {
|
||||
const res = await fetch(`/api/generate-asset-code?prefix=${prefix}&purchaseDate=${purchaseDate}`);
|
||||
const data = await res.json();
|
||||
@@ -749,7 +768,8 @@ class HwAssetModal extends BaseModal {
|
||||
const hasSpec = specCategories.includes(category) || type.includes('서버PC');
|
||||
const noNetCategories = ['저장매체', '네트워크', '공간정보장비', 'PC부품', '사무가구'];
|
||||
const showNet = (isInfra || isPersonal) && !noNetCategories.includes(category);
|
||||
const hasSN = !['사무가구', 'PC부품'].includes(category);
|
||||
const hasSN = ['외부SW', '내부SW'].includes(category);
|
||||
const showMainboard = category === 'PC';
|
||||
const isParts = ['PC부품', '사무가구'].includes(category);
|
||||
const showRemote = category === '서버' || type.includes('서버');
|
||||
const showServiceType = category === '서버' || type === '서버PC';
|
||||
@@ -762,9 +782,83 @@ class HwAssetModal extends BaseModal {
|
||||
document.querySelectorAll('.org-user-section, .org-user-field').forEach(el => (el as HTMLElement).style.display = (isPersonal || isParts || category === '업무지원장비') ? '' : 'none');
|
||||
document.querySelectorAll('.personal-only').forEach(el => (el as HTMLElement).style.display = isPersonal ? '' : 'none');
|
||||
document.querySelectorAll('.sn-only').forEach(el => (el as HTMLElement).style.display = hasSN ? '' : 'none');
|
||||
document.querySelectorAll('.mainboard-only').forEach(el => (el as HTMLElement).style.display = showMainboard ? '' : 'none');
|
||||
document.querySelectorAll('.monitor-only').forEach(el => (el as HTMLElement).style.display = type.includes('모니터') ? '' : 'none');
|
||||
document.querySelectorAll('.parts-only').forEach(el => (el as HTMLElement).style.display = isParts ? '' : 'none');
|
||||
document.querySelectorAll('.hardware-section').forEach(el => (el as HTMLElement).style.display = (hasSpec || isParts) ? '' : 'none');
|
||||
|
||||
// Lock only User and Organization Information for PC category during edit mode
|
||||
const isEditMode = this.currentMode === 'edit';
|
||||
const isPC = category === 'PC';
|
||||
|
||||
const noticeEl = document.getElementById('hw-pc-workflow-notice');
|
||||
if (noticeEl) {
|
||||
if (isPC && isEditMode) {
|
||||
noticeEl.classList.remove('hidden');
|
||||
} else {
|
||||
noticeEl.classList.add('hidden');
|
||||
}
|
||||
}
|
||||
|
||||
const lockedUserFields = [
|
||||
'hw-current_dept',
|
||||
'hw-manager_primary',
|
||||
'hw-manager_secondary',
|
||||
'hw-user_current',
|
||||
'hw-emp_no',
|
||||
'hw-user_position',
|
||||
'hw-previous_user'
|
||||
];
|
||||
|
||||
const allFormControls = this.formEl ? this.formEl.querySelectorAll('input, select, textarea, button') : [];
|
||||
|
||||
allFormControls.forEach(control => {
|
||||
const el = control as HTMLElement;
|
||||
const id = el.id;
|
||||
|
||||
if (el.tagName === 'INPUT' && (el as HTMLInputElement).type === 'hidden') return;
|
||||
if (id === 'hw-asset_code' || id === 'btn-gen-hw-code') return;
|
||||
|
||||
if (isPC && isEditMode && lockedUserFields.includes(id)) {
|
||||
// Lock user information fields for PC in edit mode
|
||||
if (el.tagName === 'SELECT') {
|
||||
el.setAttribute('disabled', 'true');
|
||||
} else if (el.tagName === 'INPUT' || el.tagName === 'TEXTAREA') {
|
||||
el.setAttribute('readonly', 'true');
|
||||
(el as HTMLInputElement).style.backgroundColor = '#f1f5f9';
|
||||
(el as HTMLInputElement).style.cursor = 'not-allowed';
|
||||
} else if (el.tagName === 'BUTTON') {
|
||||
el.setAttribute('disabled', 'true');
|
||||
}
|
||||
} else {
|
||||
// Normal behavior based on modal edit/view mode (includes add mode which has this.isEditMode = true)
|
||||
if (!this.isEditMode) {
|
||||
if (el.tagName === 'SELECT') {
|
||||
el.setAttribute('disabled', 'true');
|
||||
} else if (el.tagName === 'INPUT' || el.tagName === 'TEXTAREA') {
|
||||
el.setAttribute('readonly', 'true');
|
||||
(el as HTMLInputElement).style.backgroundColor = '';
|
||||
(el as HTMLInputElement).style.cursor = '';
|
||||
} else if (el.tagName === 'BUTTON') {
|
||||
if (id !== 'btn-print-hw-qr' && id !== 'btn-close-hw-modal') {
|
||||
el.setAttribute('disabled', 'true');
|
||||
}
|
||||
}
|
||||
} else {
|
||||
if (el.tagName === 'SELECT') {
|
||||
el.removeAttribute('disabled');
|
||||
} else if (el.tagName === 'INPUT' || el.tagName === 'TEXTAREA') {
|
||||
if (id !== 'hw-emp_no') {
|
||||
el.removeAttribute('readonly');
|
||||
(el as HTMLInputElement).style.backgroundColor = '';
|
||||
(el as HTMLInputElement).style.cursor = '';
|
||||
}
|
||||
} else if (el.tagName === 'BUTTON') {
|
||||
el.removeAttribute('disabled');
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
private updateMapButtonVisibility() {
|
||||
@@ -976,6 +1070,8 @@ class HwAssetModal extends BaseModal {
|
||||
|
||||
const showList = (filterText: string = '') => {
|
||||
if (!this.isEditMode) return;
|
||||
const category = (document.getElementById('hw-category') as HTMLSelectElement)?.value || '';
|
||||
if (category === 'PC') return;
|
||||
const users = state.masterData.users || [];
|
||||
const query = filterText.trim().toLowerCase();
|
||||
|
||||
@@ -1053,7 +1149,58 @@ class HwAssetModal extends BaseModal {
|
||||
if (!container) return;
|
||||
const logs = (state.masterData.logs || []).filter(l => l.asset_id === assetId);
|
||||
if (logs.length === 0) { container.innerHTML = '<div class="empty-history">기록된 변동 이력이 없습니다.</div>'; return; }
|
||||
container.innerHTML = logs.map(l => `<div class="history-item"><div class="history-date">${l.log_date || ''}</div><div class="history-user">${l.log_user || '시스템'}</div><div class="history-details">${l.details}</div></div>`).join('');
|
||||
|
||||
const createdDate = this.currentAsset?.created_at ? this.currentAsset.created_at.substring(0, 10) : '';
|
||||
|
||||
const grouped: Record<string, typeof logs> = {};
|
||||
logs.forEach(l => {
|
||||
const date = l.log_date || '날짜 미지정';
|
||||
if (!grouped[date]) grouped[date] = [];
|
||||
grouped[date].push(l);
|
||||
});
|
||||
|
||||
container.innerHTML = Object.entries(grouped).map(([date, dateLogs]) => {
|
||||
const entriesHtml = dateLogs.map((l, idx) => {
|
||||
const isLast = idx === dateLogs.length - 1;
|
||||
const borderStyle = isLast ? '' : 'border-bottom: 1px dashed var(--hairline); padding-bottom: 8px; margin-bottom: 8px;';
|
||||
|
||||
let displayDetails = l.details;
|
||||
if (l.details && l.details.trim().startsWith('{')) {
|
||||
try {
|
||||
const data = JSON.parse(l.details);
|
||||
if (data.type === 'checkout') {
|
||||
displayDetails = `[불출] ${data.user || ''} (${data.dept || ''}) ${data.memo ? `| 메모: ${data.memo}` : ''}`;
|
||||
} else if (data.type === 'return') {
|
||||
displayDetails = `[반납] ${data.user || ''} (${data.dept || ''}) ${data.memo ? `| 메모: ${data.memo}` : ''}`;
|
||||
} else if (data.type === 'move') {
|
||||
displayDetails = `[이동] ${data.user || ''} (${data.dept || ''}) ➔ ${data.targetUser || ''} (${data.targetDept || ''}) ${data.memo ? `| 메모: ${data.memo}` : ''}`;
|
||||
}
|
||||
} catch (e) {}
|
||||
}
|
||||
|
||||
return `
|
||||
<div class="history-entry" style="${borderStyle}">
|
||||
<div style="font-weight: 600; color: var(--primary); opacity: 0.8; margin-bottom: 4px; display: flex; align-items: center; gap: 6px;">
|
||||
<span style="display: inline-block; width: 4px; height: 4px; background-color: var(--primary); border-radius: 50%;"></span>
|
||||
${l.log_user || '시스템'}
|
||||
</div>
|
||||
<div style="color: var(--primary); padding-left: 10px; line-height: 1.5;">${displayDetails}</div>
|
||||
</div>
|
||||
`;
|
||||
}).join('');
|
||||
|
||||
const isInitialReg = date === createdDate;
|
||||
const regBadge = isInitialReg ? `<span class="badge-reg" style="font-size: 10px; padding: 1px 5px; margin-left: 6px; background-color: rgba(16, 185, 129, 0.1); color: #10b981; border: 1px solid rgba(16, 185, 129, 0.2); border-radius: 4px; font-weight: 600;">최초등록</span>` : '';
|
||||
|
||||
return `
|
||||
<div class="history-item">
|
||||
<div class="history-date" style="display: flex; align-items: center;">${date} ${regBadge}</div>
|
||||
<div class="history-details" style="display: flex; flex-direction: column; gap: 4px;">
|
||||
${entriesHtml}
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}).join('');
|
||||
}
|
||||
|
||||
private getCategoryKey(asset: any): string {
|
||||
|
||||
@@ -389,7 +389,58 @@ class SwAssetModal extends BaseModal {
|
||||
if (!container) return;
|
||||
const logs = (state.masterData.logs || []).filter(l => l.asset_id === swId);
|
||||
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.log_date || ''}</div><div class="history-user">${l.log_user || '시스템'}</div><div class="history-details">${l.details}</div></div>`).join('');
|
||||
|
||||
const createdDate = this.currentAsset?.created_at ? this.currentAsset.created_at.substring(0, 10) : '';
|
||||
|
||||
const grouped: Record<string, typeof logs> = {};
|
||||
logs.forEach(l => {
|
||||
const date = l.log_date || '날짜 미지정';
|
||||
if (!grouped[date]) grouped[date] = [];
|
||||
grouped[date].push(l);
|
||||
});
|
||||
|
||||
container.innerHTML = Object.entries(grouped).map(([date, dateLogs]) => {
|
||||
const entriesHtml = dateLogs.map((l, idx) => {
|
||||
const isLast = idx === dateLogs.length - 1;
|
||||
const borderStyle = isLast ? '' : 'border-bottom: 1px dashed var(--hairline); padding-bottom: 8px; margin-bottom: 8px;';
|
||||
|
||||
let displayDetails = l.details;
|
||||
if (l.details && l.details.trim().startsWith('{')) {
|
||||
try {
|
||||
const data = JSON.parse(l.details);
|
||||
if (data.type === 'checkout') {
|
||||
displayDetails = `[불출] ${data.user || ''} (${data.dept || ''}) ${data.memo ? `| 메모: ${data.memo}` : ''}`;
|
||||
} else if (data.type === 'return') {
|
||||
displayDetails = `[반납] ${data.user || ''} (${data.dept || ''}) ${data.memo ? `| 메모: ${data.memo}` : ''}`;
|
||||
} else if (data.type === 'move') {
|
||||
displayDetails = `[이동] ${data.user || ''} (${data.dept || ''}) ➔ ${data.targetUser || ''} (${data.targetDept || ''}) ${data.memo ? `| 메모: ${data.memo}` : ''}`;
|
||||
}
|
||||
} catch (e) {}
|
||||
}
|
||||
|
||||
return `
|
||||
<div class="history-entry" style="${borderStyle}">
|
||||
<div style="font-weight: 600; color: var(--primary); opacity: 0.8; margin-bottom: 4px; display: flex; align-items: center; gap: 6px;">
|
||||
<span style="display: inline-block; width: 4px; height: 4px; background-color: var(--primary); border-radius: 50%;"></span>
|
||||
${l.log_user || '시스템'}
|
||||
</div>
|
||||
<div style="color: var(--primary); padding-left: 10px; line-height: 1.5;">${displayDetails}</div>
|
||||
</div>
|
||||
`;
|
||||
}).join('');
|
||||
|
||||
const isInitialReg = date === createdDate;
|
||||
const regBadge = isInitialReg ? `<span class="badge-reg" style="font-size: 10px; padding: 1px 5px; margin-left: 6px; background-color: rgba(16, 185, 129, 0.1); color: #10b981; border: 1px solid rgba(16, 185, 129, 0.2); border-radius: 4px; font-weight: 600;">최초등록</span>` : '';
|
||||
|
||||
return `
|
||||
<div class="history-item">
|
||||
<div class="history-date" style="display: flex; align-items: center;">${date} ${regBadge}</div>
|
||||
<div class="history-details" style="display: flex; flex-direction: column; gap: 4px;">
|
||||
${entriesHtml}
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}).join('');
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -65,7 +65,7 @@ export function renderNavigation(onTabChange: (tab: string) => void) {
|
||||
});
|
||||
|
||||
if (state.currentUserRole === 'admin' && catKey === 'hw') {
|
||||
visibleTabs = ['대시보드', '실사 승인'];
|
||||
visibleTabs = ['대시보드', '관리도구', '실사 승인', '위치지정'];
|
||||
}
|
||||
|
||||
if (visibleTabs.length === 0) return;
|
||||
@@ -75,29 +75,36 @@ export function renderNavigation(onTabChange: (tab: string) => void) {
|
||||
const item = document.createElement('div');
|
||||
const isActive = state.activeSubTab === tab;
|
||||
item.className = `gnb-trigger ${isActive ? 'active' : ''}`;
|
||||
item.textContent = tab;
|
||||
item.style.fontSize = 'var(--fs-sm)'; // Ensure small but standard font
|
||||
|
||||
const isSubMenu = tab === '실사 승인' || tab === '위치지정';
|
||||
if (isSubMenu) {
|
||||
item.innerHTML = `<span style="opacity: 0.5; margin-right: 3px; font-family: sans-serif;">↳</span>${tab}`;
|
||||
item.style.fontSize = '11px';
|
||||
item.style.fontWeight = '500';
|
||||
item.style.marginLeft = '6px';
|
||||
if (!isActive) {
|
||||
item.style.color = 'var(--mute)';
|
||||
}
|
||||
} else {
|
||||
item.textContent = tab;
|
||||
item.style.fontSize = 'var(--fs-sm)';
|
||||
}
|
||||
|
||||
item.addEventListener('click', (e) => {
|
||||
e.stopPropagation();
|
||||
state.activeCategory = catKey as any;
|
||||
state.activeSubTab = tab;
|
||||
if (tab === '관리도구') {
|
||||
state.activeSubTab = '실사 승인';
|
||||
} else {
|
||||
state.activeSubTab = tab;
|
||||
}
|
||||
render();
|
||||
onTabChange(tab);
|
||||
onTabChange(state.activeSubTab);
|
||||
});
|
||||
navList.appendChild(item);
|
||||
});
|
||||
});
|
||||
|
||||
// 3. 관리자 전용 '관리도구'
|
||||
if (state.currentUserRole === 'admin') {
|
||||
const adminTrigger = document.createElement('div');
|
||||
adminTrigger.className = 'gnb-trigger admin-trigger';
|
||||
adminTrigger.innerHTML = '관리도구';
|
||||
adminTrigger.addEventListener('click', () => window.open('/map_editor.html', '_blank'));
|
||||
navList.appendChild(adminTrigger);
|
||||
}
|
||||
|
||||
// 4. 이벤트 바인딩
|
||||
document.getElementById('btn-home-logo')?.addEventListener('click', () => location.reload());
|
||||
|
||||
|
||||
54
src/main.ts
54
src/main.ts
@@ -6,6 +6,7 @@ import { renderDashboard } from './views/DashboardView';
|
||||
import { renderSWTable } from './views/SW_Table';
|
||||
import { renderLocationView } from './views/LocationView';
|
||||
import { renderAuditApprovalView } from './views/AuditApprovalView';
|
||||
import { MapEditor } from './views/MapEditor';
|
||||
import { initBaseModal } from './components/Modal/BaseModal';
|
||||
import { initHwModal, openHwModal } from './components/Modal/HWModal';
|
||||
import { initSwModal, openSwModal } from './components/Modal/SWModal';
|
||||
@@ -21,11 +22,19 @@ import { pcFlowModal } from './components/Modal/PCFlowModal';
|
||||
import { createIcons, Plus, X, LayoutDashboard, Monitor, Server, Database, Laptop, CalendarClock, Key, Cpu, Layers, Users, Paperclip, Edit2, History, RefreshCcw, BookOpen, Settings } from 'lucide';
|
||||
|
||||
|
||||
let activeMapEditorInstance: MapEditor | null = null;
|
||||
|
||||
// 화면 갱신 통합 핸들러
|
||||
function refreshView(tab?: string) {
|
||||
async function refreshView(tab?: string) {
|
||||
const mainContent = document.getElementById('main-content')!;
|
||||
if (!mainContent) return;
|
||||
|
||||
// Clean up any active MapEditor instance when navigating away
|
||||
if (activeMapEditorInstance) {
|
||||
activeMapEditorInstance.destroy();
|
||||
activeMapEditorInstance = null;
|
||||
}
|
||||
|
||||
const activeTab = tab || state.activeSubTab;
|
||||
|
||||
if (activeTab === '대시보드') {
|
||||
@@ -34,7 +43,48 @@ function refreshView(tab?: string) {
|
||||
}
|
||||
|
||||
if (activeTab === '실사 승인') {
|
||||
renderAuditApprovalView(mainContent);
|
||||
await renderAuditApprovalView(mainContent);
|
||||
return;
|
||||
}
|
||||
|
||||
if (activeTab === '위치지정') {
|
||||
// Render Map Editor directly into main content to maximize working area
|
||||
mainContent.innerHTML = `
|
||||
<div class="map-editor-page-wrapper" style="display: flex; flex: 1; height: calc(100vh - var(--header-height) - 48px); overflow: hidden; width: 100%;">
|
||||
<!-- Left: File Selector -->
|
||||
<div class="file-sidebar" id="file-sidebar"></div>
|
||||
|
||||
<!-- Center: Main Editor -->
|
||||
<div class="editor-container" id="container">
|
||||
<div class="img-wrapper" id="wrapper">
|
||||
<img src="" id="target-img" alt="Map Image">
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Right: Control Panel -->
|
||||
<div class="sidebar">
|
||||
<h2>Map Editor <small class="editor-version">v3.0</small></h2>
|
||||
<div class="current-path" id="current-path">파일을 선택하세요</div>
|
||||
<p>
|
||||
드래그하여 구역을 정의하세요. 저장 버튼을 누르면 즉시 시스템에 반영됩니다.
|
||||
</p>
|
||||
|
||||
<div class="box-list" id="box-list"></div>
|
||||
|
||||
<div class="actions" style="display: flex; flex-direction: column; gap: 0.5rem;">
|
||||
<button id="btn-clear-all" class="btn btn-outline">전체 삭제</button>
|
||||
<button id="btn-print-map-qrs" class="btn btn-outline btn-primary">이 도면 QR 일괄인쇄</button>
|
||||
<button id="btn-save-server" class="btn btn-primary">서버에 즉시 저장</button>
|
||||
<div id="save-status"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
|
||||
// Initialize MapEditor instance
|
||||
const editor = new MapEditor();
|
||||
await editor.init();
|
||||
activeMapEditorInstance = editor;
|
||||
return;
|
||||
}
|
||||
|
||||
|
||||
@@ -708,7 +708,7 @@ export function createListView(container: HTMLElement, config: ListViewConfig) {
|
||||
|
||||
function makeColumnsResizable(tableElement: HTMLTableElement) {
|
||||
const headers = tableElement.querySelectorAll('th');
|
||||
headers.forEach(th => {
|
||||
headers.forEach((th, index) => {
|
||||
const resizer = th.querySelector('.resizer') as HTMLElement;
|
||||
if (!resizer) return;
|
||||
|
||||
@@ -733,28 +733,44 @@ export function createListView(container: HTMLElement, config: ListViewConfig) {
|
||||
resizer.classList.remove('resizing');
|
||||
document.removeEventListener('mousemove', onMouseMove);
|
||||
document.removeEventListener('mouseup', onMouseUp);
|
||||
|
||||
// Save the widths of all columns back to the config so they persist on re-render
|
||||
headers.forEach((hdr, idx) => {
|
||||
if (config.columns[idx]) {
|
||||
config.columns[idx].width = hdr.style.width;
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
resizer.addEventListener('mousedown', (e: MouseEvent) => {
|
||||
// Prevents header click sorting trigger from firing
|
||||
// Prevents header click sorting trigger from firing on mousedown
|
||||
e.stopPropagation();
|
||||
e.preventDefault();
|
||||
|
||||
// Freeze all columns at their current pixel width before dragging
|
||||
// Freeze all columns at their current precise pixel width before dragging
|
||||
headers.forEach(header => {
|
||||
header.style.width = `${header.offsetWidth}px`;
|
||||
header.style.width = `${header.getBoundingClientRect().width}px`;
|
||||
});
|
||||
|
||||
// Freeze the table at its current precise pixel width immediately
|
||||
tableElement.style.width = `${tableElement.getBoundingClientRect().width}px`;
|
||||
|
||||
startX = e.clientX;
|
||||
startWidth = th.offsetWidth;
|
||||
startWidth = th.getBoundingClientRect().width;
|
||||
|
||||
// Capture the initial physical width of the entire table
|
||||
startTableWidth = tableElement.offsetWidth;
|
||||
startTableWidth = tableElement.getBoundingClientRect().width;
|
||||
|
||||
resizer.classList.add('resizing');
|
||||
document.addEventListener('mousemove', onMouseMove);
|
||||
document.addEventListener('mouseup', onMouseUp);
|
||||
});
|
||||
|
||||
// Prevents header click sorting trigger from firing on mouseup/click
|
||||
resizer.addEventListener('click', (e: MouseEvent) => {
|
||||
e.stopPropagation();
|
||||
e.preventDefault();
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { IMAGE_LOCATIONS } from '../components/Modal/SharedData';
|
||||
import { createIcons, X, Save, Trash2, ChevronLeft, ChevronRight } from 'lucide';
|
||||
import { QRPrinter } from '../core/qr_print';
|
||||
import './map-editor.css';
|
||||
|
||||
export class MapEditor {
|
||||
private container: HTMLElement;
|
||||
@@ -114,6 +115,45 @@ export class MapEditor {
|
||||
this.render();
|
||||
}
|
||||
|
||||
private onWindowMouseMove = (e: MouseEvent) => {
|
||||
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';
|
||||
};
|
||||
|
||||
private onWindowMouseUp = () => {
|
||||
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),
|
||||
asset_id: null
|
||||
};
|
||||
this.boxes.push(boxData);
|
||||
this.render();
|
||||
}
|
||||
|
||||
this.currentBox.remove();
|
||||
this.currentBox = null;
|
||||
};
|
||||
|
||||
private bindEvents() {
|
||||
this.wrapper.addEventListener('mousedown', (e) => {
|
||||
if (e.button !== 0) return;
|
||||
@@ -135,44 +175,8 @@ export class MapEditor {
|
||||
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),
|
||||
asset_id: null
|
||||
};
|
||||
this.boxes.push(boxData);
|
||||
this.render();
|
||||
}
|
||||
|
||||
this.currentBox.remove();
|
||||
this.currentBox = null;
|
||||
});
|
||||
window.addEventListener('mousemove', this.onWindowMouseMove);
|
||||
window.addEventListener('mouseup', this.onWindowMouseUp);
|
||||
|
||||
(window as any).removeBox = (index: number) => {
|
||||
this.boxes.splice(index, 1);
|
||||
@@ -341,6 +345,13 @@ export class MapEditor {
|
||||
}]);
|
||||
};
|
||||
}
|
||||
|
||||
public destroy() {
|
||||
window.removeEventListener('mousemove', this.onWindowMouseMove);
|
||||
window.removeEventListener('mouseup', this.onWindowMouseUp);
|
||||
delete (window as any).removeBox;
|
||||
delete (window as any).printBoxQR;
|
||||
}
|
||||
}
|
||||
|
||||
function getCleanMapKey(path: string) {
|
||||
|
||||
Reference in New Issue
Block a user