import { ASSET_SCHEMA, UI_TEXT } from '../../core/schema';
import { dynamicSort, renderPageHeader, calculateAssetAge, formatInline, isWindows11Incompatible } 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 3090') || gpuUpper.includes('RTX 3080') ||
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;
showStatus?: 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' };
if (!(state as any).listFilters) {
(state as any).listFilters = {};
}
const filterKey = config.title;
if (!(state as any).listFilters[filterKey]) {
(state as any).listFilters[filterKey] = { keyword: '', corp: '', dept: '', loc: '', field: '', type: '', status: '' };
}
let currentFilters: any = (state as any).listFilters[filterKey];
// 서버 탭이 아닐 경우 '자산 현황' 뷰 진입 방지 및 강제 'asset' 모드 (PC 탭은 자산 현황 숨김)
const isServer = config.title === '서버';
if (!isServer) {
(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);
const htmlMap = document.getElementById('detail-html-map') as HTMLIFrameElement;
const isHtmlMap = imgPath?.toLowerCase().endsWith('.html');
// 좌표가 없으면 사진이 있어도 '정보 없음' 상태로 유도 (사용자 요청)
if (imgPath && hasCoords) {
if (isHtmlMap) {
// HTML 지도 처리
photo.style.display = 'none';
if (marker) marker.style.display = 'none';
if (overlayLayer) overlayLayer.innerHTML = '';
if (htmlMap) {
htmlMap.src = `${imgPath}?markerX=${x}&markerY=${y}`;
htmlMap.style.display = 'block';
}
} else {
// 일반 이미지 지도 처리
if (htmlMap) {
htmlMap.src = '';
htmlMap.style.display = 'none';
}
photo.src = imgPath;
photo.style.display = 'block';
}
if (noPhoto) noPhoto.style.display = 'none';
if (!isHtmlMap) {
photo.onload = () => {
const updateMarkerPos = () => {
const imgW = photo.clientWidth;
const imgH = photo.clientHeight;
if (marker) {
marker.style.left = `calc(50% - ${imgW/2}px + ${ (parseFloat(x as string) * imgW) / 100 }px)`;
marker.style.top = `calc(50% - ${imgH/2}px + ${ (parseFloat(y as string) * imgH) / 100 }px)`;
marker.style.display = 'block';
}
if (overlayLayer) {
overlayLayer.style.width = `${imgW}px`;
overlayLayer.style.height = `${imgH}px`;
overlayLayer.style.left = `calc(50% - ${imgW/2}px)`;
overlayLayer.style.top = `calc(50% - ${imgH/2}px)`;
const boxes = dynamicMapConfig[imgPath] || [];
if (boxes.length > 0) {
overlayLayer.innerHTML = `
`;
} else {
overlayLayer.innerHTML = '';
}
}
};
updateMarkerPos();
window.addEventListener('resize', updateMarkerPos);
};
}
} else {
photo.style.display = 'none';
if (htmlMap) {
htmlMap.src = '';
htmlMap.style.display = 'none';
}
if (marker) marker.style.display = 'none';
if (overlayLayer) overlayLayer.innerHTML = '';
if (noPhoto) {
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;
});
// DB 기준 사양 데이터 맵핑 (state.masterData.jobSpecs 이용)
const jobSpecsMap: Record = {};
if (state.masterData.jobSpecs) {
state.masterData.jobSpecs.forEach((s: any) => {
jobSpecsMap[s.job_name] = s.min_score;
});
}
// 기준 대비 사양 부족/오버스펙 분류
const criticalPcList: any[] = [];
pcs.forEach((pc: any) => {
const job = pc[ASSET_SCHEMA.USER_POSITION.key] || '미분류';
const score = pc['_pc_score'];
const standardScore = jobSpecsMap[job] !== undefined ? jobSpecsMap[job] : (jobScores[job]?.avg || 0);
const cpu = pc[ASSET_SCHEMA.CPU.key] || '';
const ram = pc[ASSET_SCHEMA.RAM.key] || '';
const win11Incompatible = isWindows11Incompatible(cpu, ram);
let isUnder = false;
if (standardScore > 0) {
if (score < standardScore * 0.6) {
isUnder = true;
pc['_spec_status'] = '사양 부족';
} else if (score > standardScore * 1.5 && !win11Incompatible) {
pc['_spec_status'] = '오버스펙';
criticalPcList.push(pc);
} else if (win11Incompatible) {
isUnder = true;
pc['_spec_status'] = '사양 부족';
} else {
pc['_spec_status'] = '적정';
}
} else {
if (win11Incompatible) {
isUnder = true;
pc['_spec_status'] = '사양 부족';
} else {
pc['_spec_status'] = '적정';
}
}
if (isUnder) {
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 stdA = jobSpecsMap[jobA] !== undefined ? jobSpecsMap[jobA] : (jobScores[jobA]?.avg || 0);
const stdB = jobSpecsMap[jobB] !== undefined ? jobSpecsMap[jobB] : (jobScores[jobB]?.avg || 0);
const ratioA = stdA > 0 ? a['_pc_score'] / stdA : 1;
const ratioB = stdB > 0 ? b['_pc_score'] / stdB : 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 === '오버스펙' ? '오버 스펙' : 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,
initialFilters: currentFilters,
onFilterChange: (filters) => {
Object.assign(currentFilters, filters);
updateTable();
}
});
// 셀렉트 박스 채우기
const populateSelect = (selector: string, dataKey: string, initialValue?: 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);
if (initialValue && String(val) === initialValue) {
opt.selected = true;
}
select.appendChild(opt);
});
}
};
if (config.filterOptions.showLoc) populateSelect('#filter-loc', ASSET_SCHEMA.LOCATION.key, currentFilters.loc);
if (config.filterOptions.showDept) populateSelect('#filter-dept', ASSET_SCHEMA.CURRENT_DEPT.key, currentFilters.dept);
if (config.filterOptions.showCorp) populateSelect('#filter-corp', ASSET_SCHEMA.PURCHASE_CORP.key, currentFilters.corp);
if (config.filterOptions.showType) populateSelect('#filter-type', ASSET_SCHEMA.ASSET_TYPE.key, currentFilters.type);
if (config.filterOptions.showStatus) populateSelect('#filter-status', ASSET_SCHEMA.HW_STATUS.key, currentFilters.status);
// 초기 실행
switchView();
}