diff --git a/server.js b/server.js index 1dacc03..eaf8c23 100644 --- a/server.js +++ b/server.js @@ -47,7 +47,7 @@ const CATEGORY_TABLE_MAP = { swInternal: 'sw_internal', swExternal: 'sw_external', cloud: 'asset_cloud', - users: 'user_master', + users: 'system_users', swUsers: 'sw_assignment', logs: 'asset_history' }; @@ -62,6 +62,7 @@ const ASSET_TABLES = [ // 1. Generic Batch Save (Dynamic Table Detection) app.post('/api/:table/batch', async (req, res) => { const { table } = req.params; + const dbTable = CATEGORY_TABLE_MAP[table] || table; const data = req.body; if (!Array.isArray(data)) return res.status(400).json({ error: 'Data must be an array' }); @@ -70,14 +71,14 @@ app.post('/api/:table/batch', async (req, res) => { connection = await pool.getConnection(); await connection.beginTransaction(); - const [columns] = await connection.query(`DESCRIBE ${table}`); + const [columns] = await connection.query(`DESCRIBE ${dbTable}`); const validFields = columns.map(c => c.Field); - await connection.query(`DELETE FROM ${table}`); + await connection.query(`DELETE FROM ${dbTable}`); if (data.length > 0) { const placeholders = validFields.map(() => '?').join(', '); - const sql = `INSERT INTO ${table} (${validFields.join(', ')}) VALUES (${placeholders})`; + const sql = `INSERT INTO ${dbTable} (${validFields.join(', ')}) VALUES (${placeholders})`; for (const item of data) { const values = validFields.map(field => { @@ -245,6 +246,71 @@ app.post('/api/asset/:category/save', async (req, res) => { } }); +// 3.6 PC Flow Transaction (Checkout, Return, Move) +app.post('/api/pc/flow', async (req, res) => { + const { action, assetId, userName, dept, empNo, position, date, details, manager } = req.body; + let connection; + try { + connection = await pool.getConnection(); + await connection.beginTransaction(); + + if (action === 'checkout') { + await connection.query( + `UPDATE asset_core + SET user_current = ?, emp_no = ?, current_dept = ?, user_position = ? + WHERE id = ?`, + [userName, empNo, dept, position, assetId] + ); + await connection.query( + `UPDATE asset_spec SET hw_status = '사용중' WHERE asset_id = ?`, + [assetId] + ); + } else if (action === 'return') { + await connection.query( + `UPDATE asset_core + SET previous_user = user_current, previous_dept = current_dept, + user_current = '', emp_no = '', current_dept = '재고창고', user_position = '' + WHERE id = ?`, + [assetId] + ); + await connection.query( + `UPDATE asset_spec SET hw_status = '대기' WHERE asset_id = ?`, + [assetId] + ); + } else if (action === 'move') { + await connection.query( + `UPDATE asset_core + SET previous_user = user_current, previous_dept = current_dept, + user_current = ?, emp_no = ?, current_dept = ?, user_position = ? + WHERE id = ?`, + [userName, empNo, dept, position, assetId] + ); + await connection.query( + `UPDATE asset_spec SET hw_status = '사용중' WHERE asset_id = ?`, + [assetId] + ); + } else { + throw new Error('Invalid action type'); + } + + // Insert into asset_history + await connection.query( + `INSERT INTO asset_history (asset_id, log_date, log_user, details) + VALUES (?, ?, ?, ?)`, + [assetId, date || new Date().toISOString().split('T')[0], manager || 'system', details] + ); + + await connection.commit(); + console.log(`💾 [PC FLOW TRANSACTION] Action: ${action}, Asset ID: ${assetId}`); + res.json({ success: true }); + } catch (err) { + if (connection) await connection.rollback(); + handleError(res, err, 'PC FLOW TRANSACTION'); + } finally { + if (connection) connection.release(); + } +}); + // 4. Asset Delete app.delete('/api/asset/:category/:id', async (req, res) => { const { category, id } = req.params; diff --git a/src/components/Modal/PCFlowModal.ts b/src/components/Modal/PCFlowModal.ts new file mode 100644 index 0000000..d304088 --- /dev/null +++ b/src/components/Modal/PCFlowModal.ts @@ -0,0 +1,625 @@ +import { state, loadMasterDataFromDB } from '../../core/state'; +import { createIcons, Search, Monitor, RefreshCw } from 'lucide'; +import { API_BASE_URL } from '../../core/utils'; + +export class PCFlowModal { + private static instance: PCFlowModal | null = null; + + private modalEl: HTMLElement | null = null; + private currentFlowType: 'checkout' | 'return' | 'move' = 'checkout'; + + // Selected state + private selectedUser: any = null; + private selectedTargetUser: any = null; + private selectedPC: any = null; + + private constructor() {} + + public static getInstance(): PCFlowModal { + if (!PCFlowModal.instance) { + PCFlowModal.instance = new PCFlowModal(); + } + return PCFlowModal.instance; + } + + public init(onSave: () => void) { + if (document.getElementById('pc-flow-modal')) return; + + // Inject HTML + document.body.insertAdjacentHTML('beforeend', this.renderHTML()); + + this.modalEl = document.getElementById('pc-flow-modal'); + this.setupEventListeners(onSave); + + // Set default date to today + const dateInput = document.getElementById('pc-flow-date') as HTMLInputElement; + if (dateInput) { + dateInput.value = new Date().toISOString().split('T')[0]; + } + + createIcons({ icons: { Search, Monitor, RefreshCw } }); + } + + public open() { + this.resetState(); + if (this.modalEl) { + this.modalEl.classList.remove('hidden'); + } + this.updateUI(); + } + + public close() { + if (this.modalEl) { + this.modalEl.classList.add('hidden'); + } + } + + private resetState() { + this.selectedUser = null; + this.selectedTargetUser = null; + this.selectedPC = null; + this.currentFlowType = 'checkout'; + + const radioCheckout = document.querySelector('input[name="flow-type"][value="checkout"]') as HTMLInputElement; + if (radioCheckout) radioCheckout.checked = true; + + // Reset text fields + const userSearch = document.getElementById('pc-flow-user-search') as HTMLInputElement; + if (userSearch) userSearch.value = ''; + + const targetUserSearch = document.getElementById('pc-flow-target-user-search') as HTMLInputElement; + if (targetUserSearch) targetUserSearch.value = ''; + + const stockSearch = document.getElementById('pc-flow-stock-search') as HTMLInputElement; + if (stockSearch) stockSearch.value = ''; + + const details = document.getElementById('pc-flow-details') as HTMLTextAreaElement; + if (details) details.value = ''; + } + + private setupEventListeners(onSave: () => void) { + const btnClose = document.getElementById('btn-close-pc-flow-modal'); + const btnCancel = document.getElementById('btn-cancel-pc-flow-modal'); + const btnSubmit = document.getElementById('btn-submit-pc-flow'); + + btnClose?.addEventListener('click', () => this.close()); + btnCancel?.addEventListener('click', () => this.close()); + + // Flow Type Radio Buttons + const labels = document.querySelectorAll('.flow-type-label'); + labels.forEach(label => { + const radio = label.querySelector('input[name="flow-type"]') as HTMLInputElement; + label.addEventListener('click', () => { + labels.forEach(l => l.classList.remove('active')); + label.classList.add('active'); + radio.checked = true; + this.currentFlowType = radio.value as any; + + // Reset selected PC when switching flow types + this.selectedPC = null; + this.updateUI(); + }); + }); + + // 1. Source User Autocomplete Search + const userSearch = document.getElementById('pc-flow-user-search') as HTMLInputElement; + const userSuggestions = document.getElementById('pc-flow-user-suggestions')!; + + userSearch?.addEventListener('input', () => { + const query = userSearch.value.trim().toLowerCase(); + if (!query) { + userSuggestions.classList.add('hidden'); + return; + } + + const users = state.masterData.users || []; + const filtered = users.filter((u: any) => + (u.user_name && u.user_name.toLowerCase().includes(query)) || + (u.dept_name && u.dept_name.toLowerCase().includes(query)) || + (u.emp_no && u.emp_no.toString().includes(query)) + ); + + const uniqueFiltered: any[] = []; + const seen = new Set(); + filtered.forEach((u: any) => { + const key = u.emp_no || u.user_name; + if (!seen.has(key)) { + seen.add(key); + uniqueFiltered.push(u); + } + }); + + this.renderUserSuggestions(uniqueFiltered, userSuggestions, (user) => { + this.selectedUser = user; + userSearch.value = `${user.user_name} (${user.dept_name} / 사번:${user.emp_no || '-'})`; + userSuggestions.classList.add('hidden'); + + // Automatically populate details if return or move + if (this.currentFlowType === 'return' || this.currentFlowType === 'move') { + this.selectedPC = null; // Reset selection + } + this.updateUI(); + }); + }); + + // Close suggestion overlays on clicking outside + document.addEventListener('click', (e) => { + const target = e.target as HTMLElement; + if (!target.closest('#pc-flow-user-search') && !target.closest('#pc-flow-user-suggestions')) { + userSuggestions.classList.add('hidden'); + } + if (!target.closest('#pc-flow-target-user-search') && !target.closest('#pc-flow-target-user-suggestions')) { + const targetSuggestions = document.getElementById('pc-flow-target-user-suggestions'); + targetSuggestions?.classList.add('hidden'); + } + if (!target.closest('#pc-flow-stock-search') && !target.closest('#pc-flow-stock-suggestions')) { + const stockSuggestions = document.getElementById('pc-flow-stock-suggestions'); + stockSuggestions?.classList.add('hidden'); + } + }); + + // 2. Target User Autocomplete Search (For Moves) + const targetUserSearch = document.getElementById('pc-flow-target-user-search') as HTMLInputElement; + const targetSuggestions = document.getElementById('pc-flow-target-user-suggestions')!; + + targetUserSearch?.addEventListener('input', () => { + const query = targetUserSearch.value.trim().toLowerCase(); + if (!query) { + targetSuggestions.classList.add('hidden'); + return; + } + + const users = state.masterData.users || []; + const filtered = users.filter((u: any) => + (u.user_name && u.user_name.toLowerCase().includes(query)) || + (u.dept_name && u.dept_name.toLowerCase().includes(query)) || + (u.emp_no && u.emp_no.toString().includes(query)) + ); + + const uniqueFiltered: any[] = []; + const seen = new Set(); + filtered.forEach((u: any) => { + const key = u.emp_no || u.user_name; + if (!seen.has(key)) { + seen.add(key); + uniqueFiltered.push(u); + } + }); + + this.renderUserSuggestions(uniqueFiltered, targetSuggestions, (user) => { + this.selectedTargetUser = user; + targetUserSearch.value = `${user.user_name} (${user.dept_name} / 사번:${user.emp_no || '-'})`; + targetSuggestions.classList.add('hidden'); + this.updateUI(); + }); + }); + + // 3. Stock PC Autocomplete Search (For Checkout) + const stockSearch = document.getElementById('pc-flow-stock-search') as HTMLInputElement; + const stockSuggestions = document.getElementById('pc-flow-stock-suggestions')!; + + const showStockSuggestions = () => { + const query = stockSearch.value.trim().toLowerCase(); + + // Filter available PCs (category PC, status '대기' or '재고창고') + const pcs = state.masterData.pc || []; + const filtered = pcs.filter((p: any) => { + const status = (p.hw_status || '').trim(); + const matchesQuery = !query || + (p.asset_code && p.asset_code.toLowerCase().includes(query)) || + (p.model_name && p.model_name.toLowerCase().includes(query)) || + (p.cpu && p.cpu.toLowerCase().includes(query)); + + return (status === '대기' || status === '재고창고' || status === '미할당') && matchesQuery; + }); + + this.renderPCSuggestions(filtered, stockSuggestions, (pc) => { + this.selectedPC = pc; + stockSearch.value = `${pc.asset_code} - ${pc.model_name}`; + stockSuggestions.classList.add('hidden'); + this.updateUI(); + }); + }; + + stockSearch?.addEventListener('input', showStockSuggestions); + stockSearch?.addEventListener('focus', showStockSuggestions); + stockSearch?.addEventListener('click', showStockSuggestions); + + // 4. Submit Transaction + btnSubmit?.addEventListener('click', async () => { + if (!this.validateInputs()) return; + + const dateVal = (document.getElementById('pc-flow-date') as HTMLInputElement).value; + const detailsVal = (document.getElementById('pc-flow-details') as HTMLTextAreaElement).value.trim(); + const loginUser = state.currentUserRole === 'admin' ? '관리자' : '실무담당자'; + + // Build Details Message as JSON + const logData = { + type: this.currentFlowType, + user: this.selectedUser ? this.selectedUser.user_name : '', + dept: this.selectedUser ? this.selectedUser.dept_name : '', + targetUser: this.selectedTargetUser ? this.selectedTargetUser.user_name : '', + targetDept: this.selectedTargetUser ? this.selectedTargetUser.dept_name : '', + assetCode: this.selectedPC ? this.selectedPC.asset_code : '', + memo: detailsVal + }; + const finalDetails = JSON.stringify(logData); + + const payload: any = { + action: this.currentFlowType, + assetId: this.selectedPC.id, + date: dateVal, + details: finalDetails, + manager: loginUser + }; + + if (this.currentFlowType === 'checkout') { + payload.userName = this.selectedUser.user_name; + payload.dept = this.selectedUser.dept_name; + payload.empNo = this.selectedUser.emp_no; + payload.position = this.selectedUser.position || '사원'; + } else if (this.currentFlowType === 'move') { + payload.userName = this.selectedTargetUser.user_name; + payload.dept = this.selectedTargetUser.dept_name; + payload.empNo = this.selectedTargetUser.emp_no; + payload.position = this.selectedTargetUser.position || '사원'; + } + + try { + const response = await fetch(`${API_BASE_URL}/api/pc/flow`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(payload) + }); + + if (response.ok) { + alert('PC 이동/반납 처리가 완료되었습니다.'); + this.close(); + onSave(); // Refresh views + } else { + const errData = await response.json(); + alert(`오류 발생: ${errData.error || '처리 실패'}`); + } + } catch (err) { + console.error('API Error:', err); + alert('서버 전송 중 오류가 발생했습니다.'); + } + }); + } + + private validateInputs(): boolean { + if (this.currentFlowType === 'checkout') { + if (!this.selectedUser) { alert('대상 사원을 선택해주세요.'); return false; } + if (!this.selectedPC) { alert('불출할 재고 PC를 선택해주세요.'); return false; } + } else if (this.currentFlowType === 'return') { + if (!this.selectedUser) { alert('반납 대상 사원을 선택해주세요.'); return false; } + if (!this.selectedPC) { alert('반납할 PC 자산을 선택해주세요.'); return false; } + } else if (this.currentFlowType === 'move') { + if (!this.selectedUser) { alert('인계 사원을 선택해주세요.'); return false; } + if (!this.selectedPC) { alert('이동할 PC 자산을 선택해주세요.'); return false; } + if (!this.selectedTargetUser) { alert('인수 사원을 선택해주세요.'); return false; } + if (this.selectedUser.emp_no === this.selectedTargetUser.emp_no) { + alert('인계자와 인수자는 동일할 수 없습니다.'); + return false; + } + } + return true; + } + + private renderUserSuggestions(users: any[], container: HTMLElement, onSelect: (user: any) => void) { + container.innerHTML = ''; + if (users.length === 0) { + container.innerHTML = '
일치하는 사원이 없습니다.
'; + container.classList.remove('hidden'); + return; + } + + users.forEach(u => { + const item = document.createElement('div'); + item.style.padding = '8px 12px'; + item.style.cursor = 'pointer'; + item.style.fontSize = '13px'; + item.style.borderBottom = '1px solid #F3F4F6'; + item.className = 'suggestion-item'; + item.innerHTML = ` +
${u.user_name}
+
+ 부서: ${u.dept_name} + | + 사번: ${u.emp_no || '-'} +
+ `; + item.addEventListener('click', () => onSelect(u)); + container.appendChild(item); + }); + container.classList.remove('hidden'); + } + + private renderPCSuggestions(pcs: any[], container: HTMLElement, onSelect: (pc: any) => void) { + container.innerHTML = ''; + if (pcs.length === 0) { + container.innerHTML = '
불출 가능한 대기 PC 재고가 없습니다.
'; + container.classList.remove('hidden'); + return; + } + + pcs.forEach(p => { + const item = document.createElement('div'); + item.style.padding = '8px 12px'; + item.style.cursor = 'pointer'; + item.style.fontSize = '13px'; + item.style.borderBottom = '1px solid #F3F4F6'; + item.className = 'suggestion-item'; + item.innerHTML = ` +
${p.asset_code} (${p.model_name || '모델명 없음'})
+
+ 사양: CPU ${p.cpu || '-'} / RAM ${p.ram || '-'} / 위치: ${p.location || '-'} +
+ `; + item.addEventListener('click', () => onSelect(p)); + container.appendChild(item); + }); + container.classList.remove('hidden'); + } + + private updateUI() { + // 1. Hide/Show dynamic sections based on flow type + const stockContainer = document.getElementById('stock-pc-search-container')!; + const targetUserContainer = document.getElementById('target-user-search-container')!; + const userPcsContainer = document.getElementById('user-pcs-container')!; + const labelStep2 = document.getElementById('user-search-label')!; + + if (this.currentFlowType === 'checkout') { + stockContainer.classList.remove('hidden'); + targetUserContainer.classList.add('hidden'); + userPcsContainer.classList.add('hidden'); + labelStep2.textContent = '2. 불출 대상 사원 검색'; + } else if (this.currentFlowType === 'return') { + stockContainer.classList.add('hidden'); + targetUserContainer.classList.add('hidden'); + userPcsContainer.classList.remove('hidden'); + labelStep2.textContent = '2. 반납 대상 사원 검색'; + } else if (this.currentFlowType === 'move') { + stockContainer.classList.add('hidden'); + targetUserContainer.classList.remove('hidden'); + userPcsContainer.classList.remove('hidden'); + labelStep2.textContent = '2. 인계 사원 검색'; + } + + // 2. Update summary panels on the right + const summaryUserName = document.getElementById('summary-user-name')!; + const summaryUserDept = document.getElementById('summary-user-dept')!; + if (this.selectedUser) { + summaryUserName.textContent = this.selectedUser.user_name; + summaryUserDept.textContent = `${this.selectedUser.dept_name} / 사번: ${this.selectedUser.emp_no || '-'}`; + } else { + summaryUserName.textContent = '선택된 사원 없음'; + summaryUserDept.textContent = '-'; + } + + const summaryTargetCard = document.getElementById('summary-target-user-card')!; + const summaryTargetUserName = document.getElementById('summary-target-user-name')!; + const summaryTargetUserDept = document.getElementById('summary-target-user-dept')!; + if (this.currentFlowType === 'move') { + summaryTargetCard.classList.remove('hidden'); + if (this.selectedTargetUser) { + summaryTargetUserName.textContent = this.selectedTargetUser.user_name; + summaryTargetUserDept.textContent = `${this.selectedTargetUser.dept_name} / 사번: ${this.selectedTargetUser.emp_no || '-'}`; + } else { + summaryTargetUserName.textContent = '선택된 사원 없음'; + summaryTargetUserDept.textContent = '-'; + } + } else { + summaryTargetCard.classList.add('hidden'); + } + + const summaryPcCode = document.getElementById('summary-pc-code')!; + const summaryPcModel = document.getElementById('summary-pc-model')!; + if (this.selectedPC) { + summaryPcCode.textContent = this.selectedPC.asset_code; + summaryPcModel.textContent = `${this.selectedPC.model_name || '모델명 없음'} (${this.selectedPC.cpu || '-'} / ${this.selectedPC.ram || '-'})`; + } else { + summaryPcCode.textContent = '선택된 PC 없음'; + summaryPcModel.textContent = '-'; + } + + // 3. Render user's active PCs list on the right (For Return & Move) + const userPcsList = document.getElementById('user-pcs-list')!; + if (this.selectedUser && (this.currentFlowType === 'return' || this.currentFlowType === 'move')) { + const allPcs = state.masterData.pc || []; + const userPcs = allPcs.filter((p: any) => + (p.emp_no && p.emp_no.toString() === this.selectedUser.emp_no?.toString()) || + (p.user_current && p.user_current === this.selectedUser.user_name) + ); + + if (userPcs.length === 0) { + userPcsList.innerHTML = '
이 사용자가 소유한 PC 자산이 없습니다.
'; + } else { + userPcsList.innerHTML = userPcs.map(p => { + const isSelected = this.selectedPC && this.selectedPC.id === p.id; + return ` +
+
${p.asset_code}
+
+ ${p.model_name || '모델명 없음'} | CPU: ${p.cpu || '-'} | RAM: ${p.ram || '-'} +
+
+ `; + }).join(''); + + // Bind clicks to list items + userPcsList.querySelectorAll('.user-pc-item').forEach(item => { + item.addEventListener('click', () => { + const pcId = item.getAttribute('data-id'); + const foundPC = userPcs.find(p => p.id === pcId); + if (foundPC) { + this.selectedPC = foundPC; + this.updateUI(); + } + }); + }); + } + } else { + userPcsList.innerHTML = ''; + } + } + + private renderHTML(): string { + const overlayStyle = ` + position: fixed; top: 0; left: 0; right: 0; bottom: 0; + background: rgba(0, 0, 0, 0.4); display: flex; align-items: center; justify-content: center; + z-index: 1000; transition: opacity 0.3s; + `; + const contentStyle = ` + background: white; border-radius: 12px; box-shadow: 0 10px 25px rgba(0, 0, 0, 0.15); + overflow: hidden; max-height: 90vh; width: 950px; display: flex; flex-direction: column; + `; + const labelStyle = 'display: block; font-size: 13px; font-weight: 700; color: var(--text-muted); margin-bottom: 8px;'; + const inputStyle = 'width: 100%; height: 38px; padding: 0 12px; border: 1px solid var(--border-color); border-radius: 4px; font-size: 13px; outline: none; box-sizing: border-box;'; + const inputWithIconStyle = 'width: 100%; height: 38px; padding: 0 12px 0 36px; border: 1px solid var(--border-color); border-radius: 4px; font-size: 13px; outline: none; box-sizing: border-box;'; + + return ` + + + + `; + } +} + +export const pcFlowModal = PCFlowModal.getInstance(); diff --git a/src/core/state.ts b/src/core/state.ts index 717b270..ced6c95 100644 --- a/src/core/state.ts +++ b/src/core/state.ts @@ -73,7 +73,15 @@ export async function loadMasterDataFromDB() { // 전역 상태 업데이트 state.masterData = { ...state.masterData, - ...data + ...data, + logs: (data.logs || []).map((l: any) => ({ + ...l, + assetId: l.asset_id || l.assetId, + date: l.log_date || l.date, + user: l.log_user || l.user, + log_date: l.log_date || l.date, + log_user: l.log_user || l.user + })) }; // Mapping for backward compatibility diff --git a/src/main.ts b/src/main.ts index ba87660..900d4c6 100644 --- a/src/main.ts +++ b/src/main.ts @@ -9,12 +9,18 @@ import { initSwUserModal } from './components/Modal/SWUserModal'; import { initDomainModal, openDomainModal } from './components/Modal/DomainModal'; import { initDashboardDetailModal } from './components/Modal/DashboardDetailModal'; import { initGuide } from './components/Guide'; +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'; -// --- DB 저장을 위한 세분화된 헬퍼 함수들 --- async function apiBatchSave(url: string, data: any[], label: string) { try { - console.log(`✅ ${label} DB 저장 완료 (Dummy Mode: ${data?.length || 0} items)`); + const response = await fetch(url, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(data) + }); + if (!response.ok) throw new Error(`${label} DB 저장 실패`); + console.log(`✅ ${label} DB 저장 완료`); } catch (err) { console.error(`❌ ${label} DB 저장 오류:`, err); alert(`${label} 저장 중 오류가 발생했습니다: ${(err as any).message}`); @@ -26,11 +32,11 @@ const saveServerToDB = () => apiBatchSave(`http://${location.hostname}:3000/api/ const saveStorageToDB = () => apiBatchSave(`http://${location.hostname}:3000/api/storage/batch`, state.masterData.storage, '스토리지'); const saveNetworkToDB = () => apiBatchSave(`http://${location.hostname}:3000/api/network/batch`, state.masterData.network, '네트워크'); const saveEquipToDB = () => apiBatchSave(`http://${location.hostname}:3000/api/equipment/batch`, state.masterData.equipment, '업무지원장비'); -const saveSwInternalToDB = () => apiBatchSave(`http://${location.hostname}:3000/api/sw/internal/batch`, state.masterData.swInternal, '내부SW'); -const saveSwExternalToDB = () => apiBatchSave(`http://${location.hostname}:3000/api/sw/external/batch`, state.masterData.swExternal, '외부SW'); +const saveSwInternalToDB = () => apiBatchSave(`http://${location.hostname}:3000/api/swInternal/batch`, state.masterData.swInternal, '내부SW'); +const saveSwExternalToDB = () => apiBatchSave(`http://${location.hostname}:3000/api/swExternal/batch`, state.masterData.swExternal, '외부SW'); const saveCloudToDB = () => apiBatchSave(`http://${location.hostname}:3000/api/cloud/batch`, state.masterData.cloud, '클라우드'); -const saveSwUsersToDB = () => apiBatchSave(`http://${location.hostname}:3000/api/asset/software/assignment/batch`, state.masterData.swUsers, 'SW사용자'); -const saveLogsToDB = () => apiBatchSave(`http://${location.hostname}:3000/api/asset/history/batch`, state.masterData.logs, '자산 로그'); +const saveSwUsersToDB = () => apiBatchSave(`http://${location.hostname}:3000/api/swUsers/batch`, state.masterData.swUsers, 'SW사용자'); +const saveLogsToDB = () => apiBatchSave(`http://${location.hostname}:3000/api/logs/batch`, state.masterData.logs, '자산 로그'); const saveUsersToDB = () => apiBatchSave(`http://${location.hostname}:3000/api/users/batch`, state.masterData.users, '사용자마스터'); // 화면 갱신 통합 핸들러 @@ -83,6 +89,9 @@ function initApp() { initDashboardDetailModal(); initGuide(); + pcFlowModal.init(() => { + loadMasterDataFromDB().then(() => refreshView()); + }); loadMasterDataFromDB().then((success) => { if (success) { @@ -119,6 +128,12 @@ function initApp() { } return; } + + // PC 이동/반납 모달 열기 + if (target.closest('#btn-pc-flow')) { + pcFlowModal.open(); + return; + } }); createIcons({ diff --git a/src/views/Dashboard/HwDashboard.ts b/src/views/Dashboard/HwDashboard.ts index 932203f..6d567dc 100644 --- a/src/views/Dashboard/HwDashboard.ts +++ b/src/views/Dashboard/HwDashboard.ts @@ -19,6 +19,7 @@ let corpChartInstance4p: any = null; let totalServerMismatchByPurposeChartInstance4p: any = null; let serverServiceChartInstance4p: any = null; let serverStatusChartInstance4p: any = null; +let pcFlowChartInstance: any = null; // ─── 서버 용도별 카테고리 분류 헬퍼 ─── function categorizePurpose(purpose: string): string { @@ -563,12 +564,82 @@ function buildServerStatusTableRows(list: any[]): string { export function renderHwDashboard(container: HTMLElement) { const allHw = state.masterData.hw || []; + // --- PC FLOW LOGS DATA PREP --- + const logs = state.masterData.logs || []; + const now = new Date(); + const currentYearMonth = `${now.getFullYear()}-${String(now.getMonth() + 1).padStart(2, '0')}`; + + let currentMonthCheckout = 0; + let currentMonthReturn = 0; + let currentMonthMove = 0; + + let totalCheckout = 0; + let totalReturn = 0; + let totalMove = 0; + + const flowLogs = logs.filter((log: any) => { + const details = log.details || ''; + const isFlow = details.includes('[불출]') || details.includes('[반납]') || details.includes('[입고]') || details.includes('[이동]') || details.includes('[이관]'); + + if (isFlow) { + const logDate = log.log_date || ''; + const isCurrentMonth = logDate.startsWith(currentYearMonth); + + if (details.includes('[불출]')) { + totalCheckout++; + if (isCurrentMonth) currentMonthCheckout++; + } else if (details.includes('[반납]') || details.includes('[입고]')) { + totalReturn++; + if (isCurrentMonth) currentMonthReturn++; + } else if (details.includes('[이동]') || details.includes('[이관]')) { + totalMove++; + if (isCurrentMonth) currentMonthMove++; + } + return true; + } + return false; + }); + + const recentFlowLogs = flowLogs.slice(0, 5); + let recentFlowLogsHtml = ''; + if (recentFlowLogs.length === 0) { + recentFlowLogsHtml = '최근 유동 이력이 없습니다.'; + } else { + recentFlowLogs.forEach((log: any) => { + const details = log.details || ''; + let badgeHtml = ''; + if (details.includes('[불출]')) { + badgeHtml = '불출'; + } else if (details.includes('[반납]') || details.includes('[입고]')) { + badgeHtml = '입고'; + } else if (details.includes('[이동]') || details.includes('[이관]')) { + badgeHtml = '이동'; + } + + const cleanDetails = details.replace(/^\[(불출|반납|입고|이동|이관)\]\s*/, ''); + + recentFlowLogsHtml += ` + + ${log.log_date || '-'} + ${badgeHtml} + ${log.log_user || '시스템'} + ${cleanDetails} + + `; + }); + } + // --- PC DATA PREP --- const pcs = allHw.filter(a => { const cat = a[ASSET_SCHEMA.CATEGORY.key] || ''; const type = a[ASSET_SCHEMA.ASSET_TYPE.key] || ''; const job = a[ASSET_SCHEMA.USER_POSITION.key] || ''; - return (cat === 'PC' || type === '개인PC' || type === '노트북' || type === '공용PC') && job !== '재고PC'; + const status = a[ASSET_SCHEMA.HW_STATUS.key] || ''; + const user = a[ASSET_SCHEMA.CURRENT_USER.key] || ''; + return (cat === 'PC' || type === '개인PC' || type === '노트북' || type === '공용PC') && + job !== '재고PC' && + status === '사용중' && + user.trim() !== ''; }); const jobScores: Record = {}; @@ -824,7 +895,7 @@ export function renderHwDashboard(container: HTMLElement) { '' + '
' + '' + - '1 / 4' + + '1 / 5' + '' + '
' + '' + @@ -1082,8 +1153,6 @@ export function renderHwDashboard(container: HTMLElement) { '
' + '' + '' + - '' + - '' + '' + '' + @@ -1108,7 +1177,10 @@ export function renderHwDashboard(container: HTMLElement) { serverServiceGroups, serverStatusGroups, purposeServerUnders, - purposeServerOvers + purposeServerOvers, + totalCheckout, + totalReturn, + totalMove ); // 기획서 보기 버튼 클릭 이벤트 바인딩 @@ -1191,7 +1263,10 @@ function initCharts( serviceGroups: any, statusGroups: any, purposeServerUnders?: any, - purposeServerOvers?: any + purposeServerOvers?: any, + totalCheckout?: number, + totalReturn?: number, + totalMove?: number ) { // 직무별 점수 const jobCtx = document.getElementById('chart-job-scores') as HTMLCanvasElement; @@ -1654,4 +1729,39 @@ function initCharts( } }); } + + // PC 유동 비율 도넛 차트 + const flowCtx = document.getElementById('chart-pc-flow-stats') as HTMLCanvasElement; + if (flowCtx && typeof Chart !== 'undefined') { + const tCheckout = totalCheckout || 0; + const tReturn = totalReturn || 0; + const tMove = totalMove || 0; + + if (pcFlowChartInstance) { + pcFlowChartInstance.destroy(); + pcFlowChartInstance = null; + } + + pcFlowChartInstance = new Chart(flowCtx, { + type: 'doughnut', + data: { + labels: ['불출', '입고(반납)', '이동(이관)'], + datasets: [{ + data: [tCheckout, tReturn, tMove], + backgroundColor: ['#3B82F6', '#10B981', '#F59E0B'], + borderWidth: 0, + hoverOffset: 8 + }] + }, + options: { + responsive: true, + maintainAspectRatio: false, + plugins: { + legend: { position: 'bottom', labels: { padding: 10, usePointStyle: true, boxWidth: 10 } } + }, + cutout: '75%', + animation: { animateScale: true, animateRotate: true } + } + }); + } } diff --git a/src/views/List/ListFactory.ts b/src/views/List/ListFactory.ts index fa6abef..4580972 100644 --- a/src/views/List/ListFactory.ts +++ b/src/views/List/ListFactory.ts @@ -5,6 +5,132 @@ import { renderFilterBar, applyCommonFilters } from '../../core/filterHandler'; import { state } from '../../core/state'; import { IMAGE_LOCATIONS } from '../../components/Modal/SharedData'; +declare var Chart: any; +let pcFlowChartInstance: any = null; + +// ─── 100점 만점 감점형 성능 점수 계산 (CPU + RAM + GPU + 연식) ─── +function calculatePcScoreDeductive(cpu: string, ram: string, gpu: string, purchaseDate: string): number { + let score = 100; + if (!cpu) cpu = ''; + if (!ram) ram = ''; + if (!gpu) gpu = ''; + + const cpuUpper = cpu.toUpperCase(); + const ramUpper = ram.toUpperCase(); + const gpuUpper = gpu.toUpperCase(); + + // 1. CPU 등급 감점 (최대 -30점) + let cpuDeduction = 0; + if (cpuUpper.includes('I9') || cpuUpper.includes('RYZEN 9') || cpuUpper.includes('RYZEN9')) { + cpuDeduction = 0; + } else if (cpuUpper.includes('I7') || cpuUpper.includes('RYZEN 7') || cpuUpper.includes('RYZEN7')) { + cpuDeduction = 5; + } else if (cpuUpper.includes('I5') || cpuUpper.includes('RYZEN 5') || cpuUpper.includes('RYZEN5')) { + cpuDeduction = 15; + } else if (cpuUpper.includes('I3') || cpuUpper.includes('RYZEN 3') || cpuUpper.includes('RYZEN3')) { + cpuDeduction = 25; + } else { + cpuDeduction = 30; + } + score -= cpuDeduction; + + // 2. CPU 세대 노후 감점 (최대 -15점) + let genDeduction = 0; + const intelMatch = cpuUpper.match(/I\d-?(\d+)/); + let gen = 0; + if (intelMatch && intelMatch[1]) { + const numStr = intelMatch[1]; + if (numStr.length === 5) gen = parseInt(numStr.substring(0, 2), 10); + else if (numStr.length === 4) gen = parseInt(numStr.substring(0, 1), 10); + } + + const amdMatch = cpuUpper.match(/RYZEN\s?\d\s?-?(\d+)/); + let amdGen = 0; + if (amdMatch && amdMatch[1] && !intelMatch) { + const numStr = amdMatch[1]; + if (numStr.length === 4) amdGen = parseInt(numStr.substring(0, 1), 10); + } + + if (intelMatch) { + if (gen >= 12) genDeduction = 0; + else if (gen >= 10) genDeduction = 5; + else if (gen >= 8) genDeduction = 10; + else genDeduction = 15; + } else if (amdMatch) { + if (amdGen >= 5) genDeduction = 0; + else if (amdGen >= 3) genDeduction = 5; + else genDeduction = 10; + } else { + genDeduction = 15; + } + score -= genDeduction; + + // 3. RAM 용량 감점 (최대 -25점) + const ramMatch = ramUpper.match(/(\d+)\s*GB/); + let ramDeduction = 25; + if (ramMatch && ramMatch[1]) { + const ramVal = parseInt(ramMatch[1], 10); + if (ramVal >= 32) ramDeduction = 0; + else if (ramVal >= 16) ramDeduction = 10; + else if (ramVal >= 8) ramDeduction = 20; + else ramDeduction = 25; + } + score -= ramDeduction; + + // 4. GPU 성능 감점 (최대 -25점) + let gpuDeduction = 25; + if (!gpuUpper || gpuUpper === '-' || gpuUpper.trim() === '') { + gpuDeduction = 25; + } else if ( + gpuUpper.includes('RTX 4090') || gpuUpper.includes('RTX 4080') || gpuUpper.includes('RTX 4070') || + gpuUpper.includes('RTX A5000') || gpuUpper.includes('RTX A6000') || gpuUpper.includes('RTX A4000') + ) { + gpuDeduction = 0; + } else if ( + gpuUpper.includes('RTX 3070') || gpuUpper.includes('RTX 3060') || gpuUpper.includes('RTX 2060') || + gpuUpper.includes('RTX A2000') || gpuUpper.includes('RTX A3000') || gpuUpper.includes('QUADRO') + ) { + gpuDeduction = 5; + } else if ( + gpuUpper.includes('GTX 1660') || gpuUpper.includes('GTX 1080') || gpuUpper.includes('GTX 1070') || + gpuUpper.includes('GTX 1060') || gpuUpper.includes('RX 6700') || gpuUpper.includes('RX 6600') + ) { + gpuDeduction = 15; + } else { + gpuDeduction = 25; + } + score -= gpuDeduction; + + // 5. 연식(노후도) 감점 (최대 -15점) + let age = 0; + if (purchaseDate && purchaseDate !== '-') { + let normalized = purchaseDate.replace(/\./g, '-').trim(); + if (/^\d{6}$/.test(normalized)) { + normalized = `${normalized.substring(0, 4)}-${normalized.substring(4, 6)}`; + } + const purchase = new Date(normalized); + if (!isNaN(purchase.getTime())) { + // 2026년 5월 31일 기준 경과연수 계산 + const mockToday = new Date('2026-05-31'); + const diffMs = mockToday.getTime() - purchase.getTime(); + age = diffMs / (1000 * 60 * 60 * 24 * 365.25); + age = Math.max(0, parseFloat(age.toFixed(1))); + } + } + + let ageDeduction = 0; + if (age < 1) ageDeduction = 0; + else if (age < 2) ageDeduction = 3; + else if (age < 3) ageDeduction = 6; + else if (age < 4) ageDeduction = 9; + else if (age < 5) ageDeduction = 12; + else ageDeduction = 15; + + score -= ageDeduction; + + return Math.max(10, score); +} + export interface ColumnDef { header: string; sortKey?: string; @@ -47,15 +173,24 @@ export function createListView(container: HTMLElement, config: ListViewConfig) { // 2. 뷰 전환 토글 버튼 생성 (명칭 변경) const toggleWrapper = document.createElement('div'); toggleWrapper.className = 'view-toggle-container'; + + const showPcFlowBtn = config.title === 'PC'; toggleWrapper.innerHTML = `
- +
+ ${showPcFlowBtn ? ` + + ` : ''} + +
`; container.appendChild(toggleWrapper); @@ -71,7 +206,7 @@ export function createListView(container: HTMLElement, config: ListViewConfig) { container.appendChild(contentWrapper); // --- 내부 상태 --- - let selectedLocation: string | null = '기술개발센터'; + let selectedLocation: string | null = null; let selectedDetailLocation: string | null = null; let dynamicMapConfig: Record = {}; @@ -88,6 +223,19 @@ export function createListView(container: HTMLElement, config: ListViewConfig) { const renderSystemStatus = () => { const isPcView = config.title === 'PC'; + // 실제 보유 자산이 존재하는 위치 목록만 추출 (0대인 곳 제외) + const validLocations = Array.from(new Set(fullList.map(a => a[ASSET_SCHEMA.LOCATION.key] || '미지정'))) + .filter(l => { + const count = fullList.filter(a => (a[ASSET_SCHEMA.LOCATION.key] || '미지정') === l).length; + return count > 0; + }) + .sort(); + + // 초기값이나 유효하지 않은 값이 지정되어 있다면 첫 번째 유효 위치로 동적 갱신 + if (!selectedLocation || !validLocations.includes(selectedLocation)) { + selectedLocation = validLocations[0] || ''; + } + const locationCounts: Record = {}; const pcTypeCounts = { public: 0, server: 0, personal: 0 }; @@ -215,29 +363,45 @@ export function createListView(container: HTMLElement, config: ListViewConfig) {
-
+
-

자산 현황 목록

+

+ ${isPcView ? `🔄 PC 유동 이력 (${new Date().getMonth() + 1}월)` : '자산 현황 목록'} +

+ ${!isPcView ? `
위치: 상세:
+ ` : ''}
- - - - - - - + ${isPcView ? ` + + + + + + + + + + ` : ` + + + + + + + + `}
분류용도/자산명관리자(정)관리자(부)상세위치
일자담당자구분사용자인수자자산번호상세
분류용도/자산명관리자(정)관리자(부)상세위치
@@ -245,9 +409,34 @@ export function createListView(container: HTMLElement, config: ListViewConfig) {
-
-
-

목록에서 자산을 선택하면
상세 정보와 배치도가 표시됩니다.

+
+
+ ${isPcView ? ` +
+
+

+ ⚠️ 사양 주의 장비 현황 (부족/오버스펙) +

+
+
+ + + + + + + + + + + + +
사용자부서 (직무)상태자산코드
사양 주의 자산이 없습니다.
+
+
+ ` : ` +

목록에서 자산을 선택하면
상세 정보와 배치도가 표시됩니다.

+ `}
+
@@ -394,86 +586,368 @@ export function createListView(container: HTMLElement, config: ListViewConfig) { if (msg) msg.textContent = !hasCoords ? '등록된 위치 좌표 정보가 없습니다.' : '등록된 배치도가 없습니다.'; } } + + // 이력 보기 버튼 클릭 이벤트 + const flowLogsBtn = document.getElementById('btn-view-flow-logs'); + if (flowLogsBtn) { + flowLogsBtn.onclick = () => { + const emptyState = document.getElementById('detail-empty-state'); + const content = document.getElementById('detail-content'); + if (emptyState && content) { + content.style.display = 'none'; + emptyState.style.display = 'flex'; + } + const tbody = document.getElementById('system-status-tbody'); + if (tbody) { + tbody.querySelectorAll('.mini-row').forEach(r => { + const rIsWarning = (r as HTMLElement).style.borderLeftColor === 'rgb(225, 29, 72)'; + (r as HTMLElement).style.backgroundColor = rIsWarning ? '#FFF1F2' : 'transparent'; + }); + } + updateFlowLogsSection(); + }; + } }; const updateTableOnly = () => { - let filtered = selectedLocation - ? fullList.filter(a => (a[ASSET_SCHEMA.LOCATION.key] || '미지정') === selectedLocation) - : fullList; - const currentDetailLocs = Array.from(new Set(filtered.map(a => a[ASSET_SCHEMA.LOC_DETAIL.key] || '미지정'))).sort(); - if (selectedDetailLocation) filtered = filtered.filter(a => (a[ASSET_SCHEMA.LOC_DETAIL.key] || '미지정') === selectedDetailLocation); - const finalDisplayList = (!selectedLocation && !selectedDetailLocation) ? filtered.slice(0, 10) : filtered; + const now = new Date(); + const currentYear = now.getFullYear(); + const currentMonthNum = now.getMonth() + 1; + const currentYearMonth = `${currentYear}-${String(currentMonthNum).padStart(2, '0')}`; - const titleEl = document.getElementById('list-section-title'); - if (titleEl) titleEl.textContent = selectedLocation ? `${selectedLocation} 자산 현황 (${finalDisplayList.length}대)` : '위치별 자산등록현황 (최근 등록)'; - const selectEl = document.getElementById('select-detail-loc') as HTMLSelectElement; - if (selectEl && !selectedDetailLocation) { - selectEl.innerHTML = `` + currentDetailLocs.map(dl => ``).join(''); - } + if (isPcView) { + // PC 뷰일 때: 해당월의 PC 유동 이력을 렌더링하고, 클릭 시 해당 자산 상세를 띄움 + const recentTbody = document.getElementById('system-status-tbody'); + if (!recentTbody) return; - const tbody = document.getElementById('system-status-tbody'); - if (tbody) { - tbody.innerHTML = finalDisplayList.length === 0 - ? `조회된 자산이 없습니다.` - : finalDisplayList.map(asset => { - const purpose = asset[ASSET_SCHEMA.ASSET_PURPOSE.key] || ''; - const serviceTypeKey = (ASSET_SCHEMA as any).SERVICE_TYPE?.key || 'service_type'; - const serviceType = asset[serviceTypeKey] || '외부'; - const type = asset[ASSET_SCHEMA.ASSET_TYPE.key] || ''; - const loc = asset[ASSET_SCHEMA.LOCATION.key] || ''; - - const labelColor = serviceType === '내부' ? '#94A3B8' : '#35635C'; - const managerMain = asset[ASSET_SCHEMA.MANAGER_MAIN.key] || '-'; - const managerSub = asset[ASSET_SCHEMA.MANAGER_SUB.key] || '-'; - - // [경고 로직] 외부 운영인데 서버PC이거나 IDC가 아닌 경우 - const isLocWarning = serviceType === '외부SW' && loc !== 'IDC'; - const isTypeWarning = serviceType === '외부SW' && type.toLowerCase().replace(/\s/g, '').includes('서버pc'); - const isWarning = isLocWarning || isTypeWarning; - const warningStyle = isWarning ? 'background-color: #FFF1F2; border-left: 3px solid #E11D48;' : ''; + const titleEl = document.getElementById('list-section-title'); + if (titleEl) { + titleEl.textContent = `🔄 PC 유동 이력 (${currentMonthNum}월)`; + } - let warningReason = ''; - if (isLocWarning && isTypeWarning) warningReason = '위치/형식 부적절'; - else if (isLocWarning) warningReason = '위치 부적절'; - else if (isTypeWarning) warningReason = '형식 부적절'; - - return ` - - -
- ${serviceType} - ${isWarning ? `${warningReason}` : ''} -
- - ${purpose || '-'} - ${managerMain} - ${managerSub} - ${asset[ASSET_SCHEMA.LOC_DETAIL.key] || '-'} - `; - }).join(''); - tbody.querySelectorAll('.mini-row').forEach(row => { - row.addEventListener('click', () => { - tbody.querySelectorAll('.mini-row').forEach(r => { - const rIsWarning = (r as HTMLElement).style.borderLeftColor === 'rgb(225, 29, 72)'; // E11D48 - (r as HTMLElement).style.backgroundColor = rIsWarning ? '#FFF1F2' : 'transparent'; - }); - (row as HTMLElement).style.backgroundColor = '#EBF2F1'; // 선택 하이라이트 - const id = (row as HTMLElement).getAttribute('data-id'); - const asset = fullList.find(a => a.id === id); - if (asset) updateDetailPanel(asset); - }); - row.addEventListener('mouseenter', () => { - if ((row as HTMLElement).style.backgroundColor !== 'rgb(235, 242, 241)') { - (row as HTMLElement).style.backgroundColor = '#F8FAFA'; - } - }); - row.addEventListener('mouseleave', () => { - const isWarning = (row as HTMLElement).style.borderLeftColor === 'rgb(225, 29, 72)'; - if ((row as HTMLElement).style.backgroundColor !== 'rgb(235, 242, 241)') { - (row as HTMLElement).style.backgroundColor = isWarning ? '#FFF1F2' : 'transparent'; - } - }); + const logs = state.masterData.logs || []; + const flowLogs = logs.filter((log: any) => { + const details = log.details || ''; + if (details.trim().startsWith('{')) { + try { + const info = JSON.parse(details); + return info && (info.type === 'checkout' || info.type === 'return' || info.type === 'move'); + } catch (e) {} + } + return details.includes('[불출]') || details.includes('[반납]') || details.includes('[입고]') || details.includes('[이동]') || details.includes('[이관]'); }); + + // 해당월(currentYearMonth)에 발생한 로그만 필터링 + const monthlyFlowLogs = flowLogs.filter((log: any) => { + const logDate = log.log_date || ''; + return logDate.startsWith(currentYearMonth); + }); + + if (monthlyFlowLogs.length === 0) { + recentTbody.innerHTML = `${currentMonthNum}월 유동 이력이 없습니다.`; + } else { + recentTbody.innerHTML = monthlyFlowLogs.map((log: any) => { + const details = log.details || ''; + + let typeDisplay = '-'; + let userDisplay = '-'; + let targetUserDisplay = '-'; + let assetCodeDisplay = '-'; + let memoDisplay = '-'; + + try { + const info = JSON.parse(details); + if (info.type === 'checkout') typeDisplay = 'checkout'; + else if (info.type === 'return') typeDisplay = 'return'; + else if (info.type === 'move') typeDisplay = 'move'; + + userDisplay = info.user || '-'; + targetUserDisplay = info.targetUser || '-'; + assetCodeDisplay = info.assetCode || '-'; + memoDisplay = info.memo || '-'; + } catch (e) { + // 하위 호환 파싱 (기존 텍스트형 로그) + if (details.includes('[불출]')) typeDisplay = 'checkout'; + else if (details.includes('[반납]') || details.includes('[입고]')) typeDisplay = 'return'; + else if (details.includes('[이동]') || details.includes('[이관]')) typeDisplay = 'move'; + + const codeMatch = details.match(/PC-\d{6}-\d{4}|HW-PC-\d+/i); + if (codeMatch) assetCodeDisplay = codeMatch[0]; + + if (details.includes('[불출]')) { + const match1 = details.match(/\[불출\]\s*([^\s\(]+)\s*사원/); + if (match1) userDisplay = match1[1]; + else { + const match2 = details.match(/\[불출\]\s*([a-zA-Z가-힣]+)/); + userDisplay = match2 ? match2[1] : '-'; + } + } else if (details.includes('[반납]') || details.includes('[입고]')) { + const match1 = details.match(/\[(?:반납|입고)\]\s*([^\s\(]+)\s*사원/); + if (match1) userDisplay = match1[1]; + else { + const match2 = details.match(/\[(?:반납|입고)\]\s*([a-zA-Z가-힣]+)/); + userDisplay = match2 ? match2[1] : '-'; + } + } else if (details.includes('[이동]') || details.includes('[이관]')) { + const prefixWord = details.includes('[이동]') ? '\\[이동\\]' : '\\[이관\\]'; + const parts = details.split('➡️'); + if (parts.length === 2) { + const fromMatch = parts[0].match(new RegExp(`${prefixWord}\\s*([a-zA-Z가-힣]+)`)); + const toMatch = parts[1].match(/\s*([a-zA-Z가-힣]+)/); + if (fromMatch && toMatch) { + userDisplay = fromMatch[1]; + targetUserDisplay = toMatch[1]; + } + } + if (userDisplay === '-') { + const match1 = details.match(new RegExp(`${prefixWord}\\s*([^\s\(]+)\\s*사원`)); + if (match1) { + userDisplay = match1[1]; + } else { + const match2 = details.match(new RegExp(`${prefixWord}\\s*([a-zA-Z가-힣0-9_]+)`)); + userDisplay = match2 ? match2[1] : '-'; + } + } + } + + const cleanDetails = details.replace(/^\[(불출|반납|입고|이동|이관)\]\s*/, ''); + const memoParts = cleanDetails.split(' - '); + if (memoParts.length >= 2) { + memoDisplay = memoParts[memoParts.length - 1]; + } else { + if (cleanDetails.includes('지급') || cleanDetails.includes('반납') || cleanDetails.includes('이관')) { + memoDisplay = '-'; + } else { + memoDisplay = cleanDetails || '-'; + } + } + } + + // 구분 뱃지 생성 + let badgeHtml = ''; + if (typeDisplay === 'checkout') { + badgeHtml = '불출'; + } else if (typeDisplay === 'return') { + badgeHtml = '입고'; + } else if (typeDisplay === 'move') { + badgeHtml = '이동'; + } else { + badgeHtml = '기타'; + } + + return ` + + ${log.log_date || '-'} + ${log.log_user || '시스템'} + ${badgeHtml} + ${userDisplay} + ${targetUserDisplay} + ${assetCodeDisplay} + ${memoDisplay} + + `; + }).join(''); + } + } else { + // 기존의 자산 현황 목록 갱신 + let filtered = selectedLocation + ? fullList.filter(a => (a[ASSET_SCHEMA.LOCATION.key] || '미지정') === selectedLocation) + : fullList; + const currentDetailLocs = Array.from(new Set(filtered.map(a => a[ASSET_SCHEMA.LOC_DETAIL.key] || '미지정'))).sort(); + if (selectedDetailLocation) filtered = filtered.filter(a => (a[ASSET_SCHEMA.LOC_DETAIL.key] || '미지정') === selectedDetailLocation); + const finalDisplayList = (!selectedLocation && !selectedDetailLocation) ? filtered.slice(0, 10) : filtered; + + const titleEl = document.getElementById('list-section-title'); + if (titleEl) titleEl.textContent = selectedLocation ? `${selectedLocation} 자산 현황 (${finalDisplayList.length}대)` : '위치별 자산등록현황 (최근 등록)'; + const selectEl = document.getElementById('select-detail-loc') as HTMLSelectElement; + if (selectEl && !selectedDetailLocation) { + selectEl.innerHTML = `` + currentDetailLocs.map(dl => ``).join(''); + } + + const tbody = document.getElementById('system-status-tbody'); + if (tbody) { + tbody.innerHTML = finalDisplayList.length === 0 + ? `조회된 자산이 없습니다.` + : finalDisplayList.map(asset => { + const purpose = asset[ASSET_SCHEMA.ASSET_PURPOSE.key] || ''; + const serviceTypeKey = (ASSET_SCHEMA as any).SERVICE_TYPE?.key || 'service_type'; + const serviceType = asset[serviceTypeKey] || '외부'; + const type = asset[ASSET_SCHEMA.ASSET_TYPE.key] || ''; + const loc = asset[ASSET_SCHEMA.LOCATION.key] || ''; + + const labelColor = serviceType === '내부' ? '#94A3B8' : '#35635C'; + const managerMain = asset[ASSET_SCHEMA.MANAGER_MAIN.key] || '-'; + const managerSub = asset[ASSET_SCHEMA.MANAGER_SUB.key] || '-'; + + // [경고 로직] 외부 운영인데 서버PC이거나 IDC가 아닌 경우 + const isLocWarning = serviceType === '외부SW' && loc !== 'IDC'; + const isTypeWarning = serviceType === '외부SW' && type.toLowerCase().replace(/\s/g, '').includes('서버pc'); + const isWarning = isLocWarning || isTypeWarning; + const warningStyle = isWarning ? 'background-color: #FFF1F2; border-left: 3px solid #E11D48;' : ''; + + let warningReason = ''; + if (isLocWarning && isTypeWarning) warningReason = '위치/형식 부적절'; + else if (isLocWarning) warningReason = '위치 부적절'; + else if (isTypeWarning) warningReason = '형식 부적절'; + + return ` + + +
+ ${serviceType} + ${isWarning ? `${warningReason}` : ''} +
+ + ${purpose || '-'} + ${managerMain} + ${managerSub} + ${asset[ASSET_SCHEMA.LOC_DETAIL.key] || '-'} + `; + }).join(''); + + tbody.querySelectorAll('.mini-row').forEach(row => { + row.addEventListener('click', () => { + tbody.querySelectorAll('.mini-row').forEach(r => { + const rIsWarning = (r as HTMLElement).style.borderLeftColor === 'rgb(225, 29, 72)'; // E11D48 + (r as HTMLElement).style.backgroundColor = rIsWarning ? '#FFF1F2' : 'transparent'; + }); + (row as HTMLElement).style.backgroundColor = '#EBF2F1'; // 선택 하이라이트 + const id = (row as HTMLElement).getAttribute('data-id'); + const asset = fullList.find(a => a.id === id); + if (asset) updateDetailPanel(asset); + }); + row.addEventListener('mouseenter', () => { + if ((row as HTMLElement).style.backgroundColor !== 'rgb(235, 242, 241)') { + (row as HTMLElement).style.backgroundColor = '#F8FAFA'; + } + }); + row.addEventListener('mouseleave', () => { + const isWarning = (row as HTMLElement).style.borderLeftColor === 'rgb(225, 29, 72)'; + if ((row as HTMLElement).style.backgroundColor !== 'rgb(235, 242, 241)') { + (row as HTMLElement).style.backgroundColor = isWarning ? '#FFF1F2' : 'transparent'; + } + }); + }); + } + } + }; + + const updateFlowLogsSection = () => { + if (!isPcView) return; + + // 사양 주의 장비 현황 (부족/오버스펙) 계산 및 바인딩 + const specMismatchTbody = document.getElementById('spec-mismatch-tbody'); + if (specMismatchTbody) { + // fullList 중 개인 PC 관련 장비 필터링 + const pcs = fullList.filter((a: any) => { + const type = a[ASSET_SCHEMA.ASSET_TYPE.key] || ''; + const job = a[ASSET_SCHEMA.USER_POSITION.key] || ''; + const status = a[ASSET_SCHEMA.HW_STATUS.key] || ''; + const user = a[ASSET_SCHEMA.CURRENT_USER.key] || ''; + + // 사용 중이고 사용자가 할당되어 있으며, 직무가 재고PC가 아닌 실사용 기기 대상 + return job !== '재고PC' && status === '사용중' && user.trim() !== ''; + }); + + // 직무별 평균 점수 산출 + const jobScores: Record = {}; + pcs.forEach((pc: any) => { + const job = pc[ASSET_SCHEMA.USER_POSITION.key] || '미분류'; + const cpu = pc[ASSET_SCHEMA.CPU.key] || ''; + const ram = pc[ASSET_SCHEMA.RAM.key] || ''; + const gpu = pc[ASSET_SCHEMA.GPU.key] || ''; + const pDate = pc[ASSET_SCHEMA.PURCHASE_DATE.key] || ''; + const score = calculatePcScoreDeductive(cpu, ram, gpu, pDate); + pc['_pc_score'] = score; + + if (!jobScores[job]) jobScores[job] = { totalScore: 0, count: 0, avg: 0 }; + jobScores[job].totalScore += score; + jobScores[job].count += 1; + }); + + Object.keys(jobScores).forEach(job => { + jobScores[job].avg = jobScores[job].count > 0 ? jobScores[job].totalScore / jobScores[job].count : 0; + }); + + // 기준 대비 사양 부족/오버스펙 분류 + const criticalPcList: any[] = []; + pcs.forEach((pc: any) => { + const job = pc[ASSET_SCHEMA.USER_POSITION.key] || '미분류'; + const score = pc['_pc_score']; + const avg = jobScores[job].avg; + + if (avg > 0) { + if (score < avg * 0.8) { + pc['_spec_status'] = '사양 부족'; + criticalPcList.push(pc); + } else if (score > avg * 1.3) { + pc['_spec_status'] = '오버스펙'; + criticalPcList.push(pc); + } + } + }); + + // 정렬: 직무 평균 대비 사양 부족이 심한 순(비율이 낮은 순)으로 정렬 + criticalPcList.sort((a: any, b: any) => { + const jobA = a[ASSET_SCHEMA.USER_POSITION.key] || '미분류'; + const jobB = b[ASSET_SCHEMA.USER_POSITION.key] || '미분류'; + const ratioA = jobScores[jobA].avg > 0 ? a['_pc_score'] / jobScores[jobA].avg : 1; + const ratioB = jobScores[jobB].avg > 0 ? b['_pc_score'] / jobScores[jobB].avg : 1; + return ratioA - ratioB; + }); + + if (criticalPcList.length === 0) { + specMismatchTbody.innerHTML = '사양 주의 자산이 없습니다.'; + } else { + specMismatchTbody.innerHTML = criticalPcList.map((pc: any) => { + const user = pc[ASSET_SCHEMA.CURRENT_USER.key] || '-'; + const dept = pc[ASSET_SCHEMA.CURRENT_DEPT.key] || '-'; + const job = pc[ASSET_SCHEMA.USER_POSITION.key] || '-'; + const status = pc['_spec_status']; + const assetCode = pc.asset_code || '-'; + + const badgeColor = status === '사양 부족' + ? 'background:#FFF1F2; color:#E11D48; border: 1px solid #FDA4AF;' + : 'background:#F0FDF4; color:#16A34A; border: 1px solid #BBF7D0;'; + + return ` + + ${user} + ${dept} (${job}) + + ${status} + + ${assetCode} + + `; + }).join(''); + + // 클릭 시 해당 자산 상세 페이지로 전환 + specMismatchTbody.querySelectorAll('.spec-row').forEach(row => { + row.addEventListener('click', () => { + specMismatchTbody.querySelectorAll('.spec-row').forEach(r => { + (r as HTMLElement).style.backgroundColor = 'transparent'; + }); + (row as HTMLElement).style.backgroundColor = '#EBF2F1'; // 선택 하이라이트 + + const id = (row as HTMLElement).getAttribute('data-id'); + const asset = fullList.find(a => String(a.id) === String(id)); + if (asset) { + updateDetailPanel(asset); + } + }); + row.addEventListener('mouseenter', () => { + if ((row as HTMLElement).style.backgroundColor !== 'rgb(235, 242, 241)') { + (row as HTMLElement).style.backgroundColor = '#F8FAFA'; + } + }); + row.addEventListener('mouseleave', () => { + if ((row as HTMLElement).style.backgroundColor !== 'rgb(235, 242, 241)') { + (row as HTMLElement).style.backgroundColor = 'transparent'; + } + }); + }); + } } }; @@ -492,12 +966,15 @@ export function createListView(container: HTMLElement, config: ListViewConfig) { selectedLocation = (e.target as HTMLSelectElement).value || null; selectedDetailLocation = null; updateTableOnly(); + updateFlowLogsSection(); }); selectDetailLoc?.addEventListener('change', (e) => { selectedDetailLocation = (e.target as HTMLSelectElement).value || null; updateTableOnly(); + updateFlowLogsSection(); }); updateTableOnly(); + updateFlowLogsSection(); }, 50); };