import { ASSET_SCHEMA, UI_TEXT } from '../../core/schema'; import { dynamicSort, renderPageHeader, calculateAssetAge, formatInline } from '../../core/utils'; import { setupTableSorting, SortState } from '../../core/tableHandler'; 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; width?: string; align?: 'left' | 'center' | 'right'; className?: string; render: (asset: any) => string; } export interface ListViewConfig { title: string; dataSource: () => any[]; searchKeys: string[]; filterOptions: { keywordLabel: string; showCorp?: boolean; showDept?: boolean; showLoc?: boolean; showField?: boolean; showType?: boolean; }; columns: ColumnDef[]; onRowClick?: (asset: any) => void; emptyMessage?: string; persistentSortState?: SortState; } export function createListView(container: HTMLElement, config: ListViewConfig) { // 1. 컨테이너 초기화 및 헤더 렌더링 container.innerHTML = ''; renderPageHeader(container, config.title); const fullList = config.dataSource(); let sortState: SortState = config.persistentSortState || { key: '', direction: 'asc' }; let currentFilters: any = { keyword: '', corp: '', dept: '', loc: '', field: '', type: '' }; // 서버 및 PC 탭이 아닐 경우 '자산 현황' 뷰 진입 방지 및 강제 'asset' 모드 const isServerOrPc = config.title === '서버' || config.title === 'PC'; if (!isServerOrPc) { (state as any).currentViewMode = 'asset'; } else if (!(state as any).currentViewMode) { (state as any).currentViewMode = 'system'; } // 2. 뷰 전환 토글 버튼 생성 (명칭 변경) const toggleWrapper = document.createElement('div'); toggleWrapper.className = 'view-toggle-container'; const showPcFlowBtn = config.title === 'PC'; toggleWrapper.innerHTML = `
${showPcFlowBtn ? ` ` : ''}
`; container.appendChild(toggleWrapper); // 3. 필터 바 생성 (자산 목록에서만 사용) const filterBar = document.createElement('div'); filterBar.className = 'search-bar'; container.appendChild(filterBar); // 4. 컨텐츠 영역 생성 const contentWrapper = document.createElement('div'); contentWrapper.className = 'view-content-wrapper'; container.appendChild(contentWrapper); // --- 내부 상태 --- let selectedLocation: string | null = null; let selectedDetailLocation: string | null = null; let dynamicMapConfig: Record = {}; // 맵 설정 미리 로드 const fetchMapConfig = async () => { try { const res = await fetch(`http://${location.hostname}:3000/api/maps`); dynamicMapConfig = await res.json(); } catch (err) { console.error('Failed to fetch map config:', err); } }; fetchMapConfig(); // [자산 현황] 대시보드 렌더러 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 }; // 동적 통계 수집 객체 (Hardcoding 제거) const extStats = { total: 0, locCounts: {} as Record, typeCounts: {} as Record, typeLocMap: {} as Record>, // 유형별 위치 분포 locWarning: 0, typeWarning: 0 }; const intStats = { total: 0, locCounts: {} as Record, typeCounts: {} as Record, typeLocMap: {} as Record> }; // 중앙화된 경고 감지 로직 const checkAnomaly = (serviceType: string, loc: string, type: string) => { if (serviceType !== '외부') return { isWarning: false, isLocWarning: false, isTypeWarning: false, reason: '' }; const isLocWarning = loc !== 'IDC' && loc !== '미지정' && loc !== ''; const isTypeWarning = type.toLowerCase().replace(/\s/g, '').includes('서버pc'); const isWarning = isLocWarning || isTypeWarning; let reason = ''; if (isLocWarning && isTypeWarning) reason = '위치/형식 부적절'; else if (isLocWarning) reason = '위치 부적절'; else if (isTypeWarning) reason = '형식 부적절'; return { isWarning, isLocWarning, isTypeWarning, reason }; }; fullList.forEach(asset => { const loc = asset[ASSET_SCHEMA.LOCATION.key] || '미지정'; const serviceTypeKey = (ASSET_SCHEMA as any).SERVICE_TYPE?.key || 'service_type'; const serviceType = asset[serviceTypeKey] || '외부'; const type = asset[ASSET_SCHEMA.ASSET_TYPE.key] || ''; locationCounts[loc] = (locationCounts[loc] || 0) + 1; if (isPcView) { if (type.includes('공용')) pcTypeCounts.public++; else if (type.includes('서버')) pcTypeCounts.server++; else pcTypeCounts.personal++; } const targetStat = serviceType === '내부' ? intStats : extStats; targetStat.total++; if (loc) targetStat.locCounts[loc] = (targetStat.locCounts[loc] || 0) + 1; if (type) { targetStat.typeCounts[type] = (targetStat.typeCounts[type] || 0) + 1; // 유형별 위치 분포 수집 if (!targetStat.typeLocMap[type]) targetStat.typeLocMap[type] = {}; targetStat.typeLocMap[type][loc] = (targetStat.typeLocMap[type][loc] || 0) + 1; } if (serviceType === '외부') { const anomaly = checkAnomaly(serviceType, loc, type); if (anomaly.isLocWarning) extStats.locWarning++; if (anomaly.isTypeWarning) extStats.typeWarning++; } }); // 템플릿 제너레이터 함수 (HTML 중복 제거) const generateDetailStatHTML = (title: string, stats: any) => `
${title}
${stats.locWarning ? `위치부적절: ${stats.locWarning}` : ''} ${stats.typeWarning ? `형식부적절: ${stats.typeWarning}` : ''}
${Object.entries(stats.locCounts as Record).sort((a, b) => b[1] - a[1]).slice(0, 4).map(([l, c]) => `${l}: ${c}`).join('')}
${Object.entries(stats.typeCounts as Record).sort((a, b) => b[1] - a[1]).slice(0, 6).map(([t, c]) => { const isTypeWarning = title.includes('외부') && t.toLowerCase().replace(/\s/g, '').includes('서버pc'); // 위치별 상세 정보 생성 (툴팁용) const locDist = stats.typeLocMap[t] || {}; const locHint = Object.entries(locDist) .sort((a: any, b: any) => b[1] - a[1]) .map(([l, count]) => `${l}: ${count}대`) .join('\n'); return `${t}: ${c}`; }).join('')}
`; contentWrapper.innerHTML = `
총 보유 자산
${fullList.length}
외부: ${extStats.total} 내부: ${intStats.total}
${isPcView ? `
PC 유형별 현황
공용: ${pcTypeCounts.public} 서버: ${pcTypeCounts.server} 개인: ${pcTypeCounts.personal}
` : generateDetailStatHTML('외부 (운영) 상세', extStats)}
${isPcView ? '' : generateDetailStatHTML('내부 (테스트) 상세', intStats as any)}

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

${!isPcView ? `
위치: 상세:
` : ''}
${isPcView ? ` ` : ` `}
일자 담당자 구분 사용자 인수자 자산번호 상세
분류 용도/자산명 관리자(정) 관리자(부) 상세위치
${isPcView ? `

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

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

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

`}
`; // 상세 정보 패널 업데이트 함수 const updateDetailPanel = (asset: any) => { const emptyState = document.getElementById('detail-empty-state'); const content = document.getElementById('detail-content'); if (!emptyState || !content) return; emptyState.style.display = 'none'; content.style.display = 'flex'; // 텍스트 정보 업데이트 const codeEl = document.getElementById('detail-asset-code'); const typeEl = document.getElementById('detail-asset-type'); const memoEl = document.getElementById('detail-memo'); const viewBtn = document.getElementById('btn-view-full-detail') as HTMLButtonElement; if (codeEl) codeEl.textContent = asset.asset_code || '미지정'; if (typeEl) typeEl.textContent = asset.asset_type || '-'; if (memoEl) memoEl.textContent = asset.memo || '-'; if (viewBtn) { viewBtn.onclick = () => config.onRowClick && config.onRowClick(asset); } // 위치 및 사진 정보 업데이트 const photo = document.getElementById('detail-photo') as HTMLImageElement; const marker = document.getElementById('detail-marker'); const overlayLayer = document.getElementById('detail-overlay-layer'); const noPhoto = document.getElementById('detail-no-photo'); const photoWrapper = document.getElementById('detail-photo-wrapper'); const bldg = asset.location || ''; const detail = asset.location_detail || ''; // 숫자 0도 유효한 좌표이므로 정확한 체크 필요 const x = asset.loc_x; const y = asset.loc_y; const hasCoords = (x !== null && x !== undefined && x !== '' && x !== 'null') && (y !== null && y !== undefined && y !== '' && y !== 'null'); const savedImg = asset.location_photo || asset.loc_img; const locImgs = IMAGE_LOCATIONS[bldg.trim()]?.[detail.trim()] || null; const imgPath = (savedImg && locImgs?.includes(savedImg)) ? savedImg : (locImgs ? locImgs[0] : null); // 좌표가 없으면 사진이 있어도 '정보 없음' 상태로 유도 (사용자 요청) if (imgPath && hasCoords) { 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 (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 = ` ${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 ``; }).join('')} `; } else { overlayLayer.innerHTML = ''; } } }; updateMarkerPos(); window.addEventListener('resize', updateMarkerPos); }; } else { photo.style.display = 'none'; if (marker) marker.style.display = 'none'; if (overlayLayer) overlayLayer.innerHTML = ''; if (noPhoto) { noPhoto.style.display = 'flex'; const msg = noPhoto.querySelector('span'); 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 = () => { const now = new Date(); const currentYear = now.getFullYear(); const currentMonthNum = now.getMonth() + 1; const currentYearMonth = `${currentYear}-${String(currentMonthNum).padStart(2, '0')}`; if (isPcView) { // PC 뷰일 때: 해당월의 PC 유동 이력을 렌더링하고, 클릭 시 해당 자산 상세를 띄움 const recentTbody = document.getElementById('system-status-tbody'); if (!recentTbody) return; const titleEl = document.getElementById('list-section-title'); if (titleEl) { titleEl.textContent = `🔄 PC 유동 이력 (${currentMonthNum}월)`; } 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'; } }); }); } } }; (window as any).dispatchLocFilter = (loc: string) => { if (isPcView) return; selectedLocation = loc; selectedDetailLocation = null; renderSystemStatus(); }; setTimeout(() => { const selectLoc = document.getElementById('select-loc') as HTMLSelectElement; const selectDetailLoc = document.getElementById('select-detail-loc') as HTMLSelectElement; selectLoc?.addEventListener('change', (e) => { 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); }; // [자산 목록] 테이블 렌더러 const tableWrapper = document.createElement('div'); tableWrapper.className = 'table-container'; const table = document.createElement('table'); const thead = document.createElement('thead'); const tbody = document.createElement('tbody'); tbody.id = 'dynamic-tbody'; table.appendChild(thead); table.appendChild(tbody); tableWrapper.appendChild(table); const updateTable = () => { if ((state as any).currentViewMode !== 'asset') return; let filtered = applyCommonFilters(fullList, currentFilters, config.searchKeys as any[]); if (sortState.key) filtered = dynamicSort(filtered, sortState.key, sortState.direction); thead.innerHTML = `${config.columns.map(col => ` ${col.header}`).join('')}`; tbody.innerHTML = filtered.length === 0 ? `${config.emptyMessage || UI_TEXT.MESSAGES.NO_DATA}` : filtered.map(asset => ` ${config.columns.map(col => `${col.render(asset)}`).join('')} `).join(''); tbody.querySelectorAll('.asset-row').forEach((tr, idx) => { tr.addEventListener('click', () => config.onRowClick && config.onRowClick(filtered[idx])); }); setupTableSorting(table, sortState, (key, dir) => { sortState = { key, direction: dir }; if (config.persistentSortState) { config.persistentSortState.key = key; config.persistentSortState.direction = dir; } updateTable(); }); // createIcons call removed as icons are no longer needed in dashboard }; // --- 뷰 전환 로직 --- const switchView = () => { contentWrapper.innerHTML = ''; if ((state as any).currentViewMode === 'asset') { filterBar.style.display = 'flex'; contentWrapper.style.overflowY = 'auto'; contentWrapper.appendChild(tableWrapper); updateTable(); } else { filterBar.style.display = 'none'; contentWrapper.style.overflowY = 'hidden'; renderSystemStatus(); } }; // 토글 버튼 이벤트 toggleWrapper.addEventListener('click', (e) => { const btn = (e.target as HTMLElement).closest('.toggle-btn') as HTMLButtonElement; if (!btn) return; toggleWrapper.querySelectorAll('.toggle-btn').forEach(b => b.classList.remove('active')); btn.classList.add('active'); (state as any).currentViewMode = btn.getAttribute('data-mode') as 'asset' | 'system'; switchView(); }); // 필터 바 초기화 renderFilterBar(filterBar, { ...config.filterOptions, onFilterChange: (filters) => { currentFilters = { ...currentFilters, ...filters }; updateTable(); } }); // 셀렉트 박스 채우기 const populateSelect = (selector: string, dataKey: string) => { const select = container.querySelector(selector) as HTMLSelectElement; if (select) { const getVal = (a: any) => dataKey === ASSET_SCHEMA.CURRENT_DEPT.key ? (a[dataKey] || a['현사용부서'] || a['현사용조직']) : a[dataKey]; const uniqueValues = Array.from(new Set(fullList.map(getVal))).filter(Boolean).sort(); uniqueValues.forEach(val => { const opt = document.createElement('option'); opt.value = String(val); opt.textContent = String(val); select.appendChild(opt); }); } }; if (config.filterOptions.showLoc) populateSelect('#filter-loc', ASSET_SCHEMA.LOCATION.key); if (config.filterOptions.showDept) populateSelect('#filter-dept', ASSET_SCHEMA.CURRENT_DEPT.key); if (config.filterOptions.showCorp) populateSelect('#filter-corp', ASSET_SCHEMA.PURCHASE_CORP.key); if (config.filterOptions.showType) populateSelect('#filter-type', ASSET_SCHEMA.ASSET_TYPE.key); // 초기 실행 switchView(); }