- Switched to fixed table layout to ensure fit within viewport - Implemented automatic ellipsis (...) for overflowing text in all cells - Synchronized cell widths and alignment in ListFactory with column definitions - Removed restrictive min-widths that caused horizontal scrolling
839 lines
37 KiB
TypeScript
839 lines
37 KiB
TypeScript
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 = '';
|
|
|
|
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';
|
|
}
|
|
|
|
// 1. 컨텐츠 영역 생성 (먼저 생성하여 참조 가능하게 함)
|
|
const contentWrapper = document.createElement('div');
|
|
contentWrapper.className = 'view-content-wrapper';
|
|
|
|
// 2. 필터 바 생성 (자산 목록에서만 사용)
|
|
const filterBar = document.createElement('div');
|
|
filterBar.className = 'search-bar';
|
|
|
|
// 자산 추가 버튼 및 목록 보기 체크박스 추가 로직
|
|
const showPcFlowBtn = config.title === 'PC';
|
|
|
|
container.appendChild(filterBar);
|
|
container.appendChild(contentWrapper);
|
|
|
|
// --- 내부 상태 ---
|
|
let selectedLocation: string | null = null;
|
|
let selectedDetailLocation: string | null = null;
|
|
let dynamicMapConfig: Record<string, any[]> = {};
|
|
|
|
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 extStats = {
|
|
total: 0,
|
|
locCounts: {} as Record<string, number>,
|
|
typeCounts: {} as Record<string, number>,
|
|
typeLocMap: {} as Record<string, Record<string, number>>,
|
|
locWarning: 0,
|
|
typeWarning: 0
|
|
};
|
|
const intStats = {
|
|
total: 0,
|
|
locCounts: {} as Record<string, number>,
|
|
typeCounts: {} as Record<string, number>,
|
|
typeLocMap: {} as Record<string, Record<string, number>>
|
|
};
|
|
|
|
const checkAnomaly = (serviceType: string, loc: string, type: string) => {
|
|
if (serviceType !== '외부') return { isWarning: false, isLocWarning: false, isTypeWarning: false };
|
|
const isLocWarning = loc !== 'IDC' && loc !== '미지정' && loc !== '';
|
|
const isTypeWarning = type.toLowerCase().replace(/\s/g, '').includes('서버pc');
|
|
return { isWarning: isLocWarning || isTypeWarning, isLocWarning, isTypeWarning };
|
|
};
|
|
|
|
fullList.forEach(asset => {
|
|
const loc = asset[ASSET_SCHEMA.LOCATION.key] || '미지정';
|
|
const serviceType = asset.service_type || '외부';
|
|
const type = asset[ASSET_SCHEMA.ASSET_TYPE.key] || '';
|
|
|
|
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++;
|
|
}
|
|
});
|
|
|
|
const generateDetailStatHTML = (title: string, stats: any) => `
|
|
<div class="detail-stat-header">
|
|
<span class="stat-title">${title}</span>
|
|
<div class="stat-badges">
|
|
${stats.locWarning ? `<span class="warning-badge-orange">위치부적절: ${stats.locWarning}</span>` : ''}
|
|
${stats.typeWarning ? `<span class="warning-badge">형식부적절: ${stats.typeWarning}</span>` : ''}
|
|
</div>
|
|
</div>
|
|
<div class="detail-stat-body">
|
|
<div class="loc-summary">
|
|
${Object.entries(stats.locCounts as Record<string, number>).sort((a, b) => b[1] - a[1]).slice(0, 4).map(([l, c]) => `<span>${l}: <strong>${c}</strong></span>`).join('')}
|
|
</div>
|
|
<div class="type-summary">
|
|
${Object.entries(stats.typeCounts as Record<string, number>).sort((a, b) => b[1] - a[1]).slice(0, 6).map(([t, c]) => {
|
|
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 `<span title="${locHint}">${t}: <strong>${c}</strong></span>`;
|
|
}).join('')}
|
|
</div>
|
|
</div>
|
|
`;
|
|
|
|
contentWrapper.innerHTML = `
|
|
<div class="system-dashboard">
|
|
<div class="dashboard-stats-row">
|
|
<div class="stat-group-item">
|
|
<div class="stat-label">총 보유 자산</div>
|
|
<div class="stat-value">${fullList.length}<span>대</span></div>
|
|
<div class="stat-sub">
|
|
<span>외부: <strong class="text-primary">${extStats.total}</strong></span>
|
|
<span>내부: <strong class="text-muted">${intStats.total}</strong></span>
|
|
</div>
|
|
</div>
|
|
<div class="stat-group-item bordered">${generateDetailStatHTML('외부 (운영) 상세', extStats)}</div>
|
|
<div class="stat-group-item bordered">${generateDetailStatHTML('내부 (테스트) 상세', intStats)}</div>
|
|
</div>
|
|
|
|
<div class="flex flex-1 min-h-0 border-t border-hairline">
|
|
<!-- 좌측: 자산 현황 목록 -->
|
|
<div class="list-section">
|
|
<div class="flex justify-between items-center mb-4 flex-shrink-0">
|
|
<h4 id="list-section-title" class="sidebar-title">
|
|
${isPcView ? `🔄 PC 유동 이력 (${new Date().getMonth() + 1}월)` : '자산 현황 목록'}
|
|
</h4>
|
|
${!isPcView ? `
|
|
<div class="filter-row">
|
|
<span class="detail-label-sm">위치:</span>
|
|
<select id="select-loc" class="form-select-sm">
|
|
<option value="">전체</option>
|
|
${validLocations.map(l => `<option value="${l}" ${l === selectedLocation ? 'selected' : ''}>${l}</option>`).join('')}
|
|
</select>
|
|
<select id="select-detail-loc" class="form-select-sm"></select>
|
|
</div>
|
|
` : ''}
|
|
</div>
|
|
<div class="flex-1 overflow-y-auto">
|
|
<table class="compact-table">
|
|
<thead>
|
|
${isPcView ? `
|
|
<tr>
|
|
<th class="text-center" style="width: 120px;">일자</th>
|
|
<th class="text-center" style="width: 100px;">담당자</th>
|
|
<th class="text-center" style="width: 60px;">구분</th>
|
|
<th class="text-center" style="width: 90px;">사용자</th>
|
|
<th class="text-center" style="width: 90px;">인수자</th>
|
|
<th class="text-center" style="width: 160px;">자산번호</th>
|
|
<th class="text-center">상세</th>
|
|
</tr>
|
|
` : `
|
|
<tr>
|
|
<th class="text-center" style="width: 80px;">분류</th>
|
|
<th class="text-center">용도/자산명</th>
|
|
<th class="text-center" style="width: 90px;">관리자(정)</th>
|
|
<th class="text-center" style="width: 90px;">관리자(부)</th>
|
|
<th class="text-center" style="width: 100px;">상세위치</th>
|
|
</tr>
|
|
`}
|
|
</thead>
|
|
<tbody id="system-status-tbody"></tbody>
|
|
</table>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- 우측: 상세 정보 패널 -->
|
|
<div id="system-detail-panel" class="detail-panel">
|
|
<div id="detail-empty-state" class="detail-empty-state">
|
|
${isPcView ? `
|
|
<div class="flex-col h-full text-left">
|
|
<div class="flex justify-between items-center mb-4 flex-shrink-0">
|
|
<h4 class="sidebar-title text-danger">
|
|
⚠️ 사양 주의 장비 현황 (부족/오버스펙)
|
|
</h4>
|
|
</div>
|
|
<div class="flex-1 overflow-y-auto">
|
|
<table class="compact-table">
|
|
<thead>
|
|
<tr>
|
|
<th style="width: 65px;">사용자</th>
|
|
<th style="width: 115px;">부서 (직무)</th>
|
|
<th class="text-center" style="width: 65px;">상태</th>
|
|
<th>자산코드</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody id="spec-mismatch-tbody">
|
|
<tr><td colspan="4" class="empty-cell">사양 주의 자산이 없습니다.</td></tr>
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
</div>
|
|
` : `
|
|
<p class="empty-list-message">목록에서 자산을 선택하면<br>상세 정보와 배치도가 표시됩니다.</p>
|
|
`}
|
|
</div>
|
|
<div id="detail-content" class="detail-content hidden flex-col flex-1 overflow-hidden">
|
|
<div class="detail-header-actions bg-canvas p-4 border-b border-hairline">
|
|
<div class="header-identity">
|
|
<span class="asset-code-title" id="detail-asset-code"></span>
|
|
<span class="asset-type-label" id="detail-asset-type"></span>
|
|
</div>
|
|
<button id="btn-view-full-detail" class="btn btn-primary btn-sm">상세 보기</button>
|
|
</div>
|
|
|
|
<!-- 메인 배치도 영역 -->
|
|
<div class="flex-col flex-1 overflow-hidden p-4">
|
|
<div id="detail-photo-wrapper" class="detail-photo-wrapper">
|
|
<div class="layout-map-container readonly w-full h-full justify-center">
|
|
<img id="detail-photo" src="" class="layout-map-img pointer-events-none" />
|
|
<iframe id="detail-html-map" src="" class="hidden w-full h-full border-none"></iframe>
|
|
<div id="detail-marker" class="layout-marker pulse-marker hidden absolute z-20"></div>
|
|
<div id="detail-overlay-layer" class="absolute pointer-events-none"></div>
|
|
</div>
|
|
<div id="detail-no-photo" class="no-photo-state hidden">
|
|
<span>등록된 배치도가 없습니다.</span>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
`;
|
|
|
|
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<string, { totalScore: number; count: number; avg: number }> = {};
|
|
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<string, number> = {};
|
|
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 = '<tr><td colspan="4" style="text-align:center; padding:1.5rem; color:#94A3B8;">사양 주의 자산이 없습니다.</td></tr>';
|
|
} 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 `
|
|
<tr style="border-bottom: 1px solid var(--border-color); cursor: pointer;" class="spec-row" data-id="${pc.id}">
|
|
<td style="padding: 10px 0; font-weight: 600; color: #334155; white-space: nowrap; overflow: hidden; text-overflow: ellipsis;" title="${user}">${user}</td>
|
|
<td style="padding: 10px 0; color: #475569; white-space: nowrap; overflow: hidden; text-overflow: ellipsis;" title="${dept} (${job})">${dept} (${job})</td>
|
|
<td style="padding: 10px 0; white-space: nowrap; text-align: center;">
|
|
<span style="padding: 2px 6px; border-radius: 4px; font-size: 10px; font-weight: 700; ${badgeColor}">${status === '오버스펙' ? '오버 스펙' : status}</span>
|
|
</td>
|
|
<td style="padding: 10px 0; font-family: monospace; color: #64748B; white-space: nowrap; overflow: hidden; text-overflow: ellipsis;" title="${assetCode}">${assetCode}</td>
|
|
</tr>
|
|
`;
|
|
}).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 assetId = row.getAttribute('data-id');
|
|
const found = fullList.find(a => String(a.id) === String(assetId));
|
|
if (found) {
|
|
updateDetailPanel(found);
|
|
}
|
|
});
|
|
});
|
|
}
|
|
}
|
|
};
|
|
|
|
const updateDetailPanel = (asset: any) => {
|
|
const emptyState = document.getElementById('detail-empty-state');
|
|
const content = document.getElementById('detail-content');
|
|
if (!emptyState || !content) return;
|
|
|
|
emptyState.classList.add('hidden');
|
|
content.classList.remove('hidden');
|
|
|
|
const codeEl = document.getElementById('detail-asset-code');
|
|
const typeEl = document.getElementById('detail-asset-type');
|
|
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 (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 bldg = asset.location || '';
|
|
const detail = asset.location_detail || '';
|
|
const x = asset.loc_x; const y = asset.loc_y;
|
|
const hasCoords = (x !== null && x !== undefined && x !== '' && x !== '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) {
|
|
photo.style.display = 'none';
|
|
if (marker) marker.style.display = 'none';
|
|
if (overlayLayer) overlayLayer.innerHTML = '';
|
|
if (htmlMap) {
|
|
htmlMap.src = `${imgPath}?markerX=${x}&markerY=${y}`;
|
|
htmlMap.classList.remove('hidden');
|
|
htmlMap.style.display = 'block';
|
|
}
|
|
} else {
|
|
if (htmlMap) {
|
|
htmlMap.src = '';
|
|
htmlMap.classList.add('hidden');
|
|
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 = `
|
|
<svg viewBox="0 0 100 100" preserveAspectRatio="none" style="width:100%; height:100%;">
|
|
<g class="seat-group">
|
|
${boxes.map((b, i) => {
|
|
const isSelected = b.x === x && b.y === y;
|
|
const fill = isSelected ? 'rgba(255, 61, 0, 0.4)' : 'rgba(30, 81, 73, 0.02)';
|
|
const stroke = isSelected ? '#FF3D00' : 'rgba(30, 81, 73, 0.15)';
|
|
const strokeWidth = isSelected ? '0.8' : '0.2';
|
|
|
|
if (isSelected && marker) {
|
|
marker.style.left = `calc(50% - ${imgW/2}px + ${ (parseFloat(b.x) + parseFloat(b.w)/2) * imgW / 100 }px)`;
|
|
marker.style.top = `calc(50% - ${imgH/2}px + ${ (parseFloat(b.y) + parseFloat(b.h)/2) * imgH / 100 }px)`;
|
|
}
|
|
|
|
return `<rect class="map-seat-obj" x="${b.x}" y="${b.y}" width="${b.w}" height="${b.h}" rx="0.5" style="fill:${fill}; stroke:${stroke}; stroke-width:${strokeWidth};" />`;
|
|
}).join('')}
|
|
</g>
|
|
</svg>
|
|
`;
|
|
} else {
|
|
overlayLayer.innerHTML = '';
|
|
}
|
|
}
|
|
};
|
|
updateMarkerPos();
|
|
window.addEventListener('resize', updateMarkerPos);
|
|
};
|
|
}
|
|
} 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.classList.remove('hidden'); noPhoto.style.display = 'flex'; }
|
|
}
|
|
};
|
|
|
|
// [자산 현황] 테이블 렌더러
|
|
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, 20) : 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 = `<option value="">전체보기</option>` + currentDetailLocs.map(dl => `<option value="${dl}">${dl}</option>`).join('');
|
|
}
|
|
|
|
const tbody = document.getElementById('system-status-tbody');
|
|
if (tbody) {
|
|
tbody.innerHTML = finalDisplayList.length === 0
|
|
? `<tr><td colspan="5" class="empty-cell text-center">조회된 자산이 없습니다.</td></tr>`
|
|
: finalDisplayList.map(asset => {
|
|
const purpose = asset[ASSET_SCHEMA.ASSET_PURPOSE.key] || '';
|
|
const serviceType = asset.service_type || '외부';
|
|
const type = asset[ASSET_SCHEMA.ASSET_TYPE.key] || '';
|
|
const loc = asset[ASSET_SCHEMA.LOCATION.key] || '';
|
|
|
|
const isWarning = serviceType === '외부' && (loc !== 'IDC' || type.toLowerCase().includes('서버pc'));
|
|
const managerMain = asset[ASSET_SCHEMA.MANAGER_MAIN.key] || '-';
|
|
const managerSub = asset[ASSET_SCHEMA.MANAGER_SUB.key] || '-';
|
|
|
|
return `
|
|
<tr class="mini-row clickable-row ${isWarning ? 'warning' : ''}" data-id="${asset.id}">
|
|
<td class="text-center">
|
|
<span class="badge ${isWarning ? 'badge-danger' : 'badge-primary'}">${serviceType}</span>
|
|
</td>
|
|
<td class="font-bold">${purpose || '-'}</td>
|
|
<td class="text-center">${managerMain}</td>
|
|
<td class="text-center">${managerSub}</td>
|
|
<td class="text-center">${asset[ASSET_SCHEMA.LOC_DETAIL.key] || '-'}</td>
|
|
</tr>`;
|
|
}).join('');
|
|
|
|
tbody.querySelectorAll('.mini-row').forEach(row => {
|
|
row.addEventListener('click', () => {
|
|
tbody.querySelectorAll('.mini-row').forEach(r => r.classList.remove('active'));
|
|
row.classList.add('active');
|
|
const asset = fullList.find(a => a.id === row.getAttribute('data-id'));
|
|
if (asset) updateDetailPanel(asset);
|
|
});
|
|
});
|
|
}
|
|
};
|
|
|
|
setTimeout(() => {
|
|
document.getElementById('select-loc')?.addEventListener('change', (e) => {
|
|
selectedLocation = (e.target as HTMLSelectElement).value || null; selectedDetailLocation = null; updateTableOnly();
|
|
});
|
|
document.getElementById('select-detail-loc')?.addEventListener('change', (e) => {
|
|
selectedDetailLocation = (e.target as HTMLSelectElement).value || null; updateTableOnly();
|
|
});
|
|
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');
|
|
table.appendChild(thead); table.appendChild(tbody); tableWrapper.appendChild(table);
|
|
|
|
const updateTable = () => {
|
|
let filtered = applyCommonFilters(fullList, currentFilters, config.searchKeys as any[]);
|
|
if (sortState.key) filtered = dynamicSort(filtered, sortState.key, sortState.direction);
|
|
|
|
thead.innerHTML = `<tr>${config.columns.map(col => `<th ${col.sortKey ? `data-sort="${col.sortKey}"` : ''} style="${col.width ? `width:${col.width};` : ''}">${col.header}</th>`).join('')}</tr>`;
|
|
|
|
tbody.innerHTML = filtered.length === 0 ? `<tr><td colspan="${config.columns.length}" class="text-center empty-cell">${UI_TEXT.MESSAGES.NO_DATA}</td></tr>`
|
|
: filtered.map(asset => `<tr class="asset-row clickable" data-id="${asset.id}">${config.columns.map(col => {
|
|
const isDateCol = col.header.includes('일') || col.header.includes('날짜') || col.header.includes('연월');
|
|
const alignmentClass = col.align ? `text-${col.align}` : (isDateCol ? 'text-center' : '');
|
|
const customClass = col.className || '';
|
|
return `<td class="${alignmentClass} ${customClass}" style="${col.width ? `width:${col.width};` : ''}">${col.render(asset)}</td>`;
|
|
}).join('')}</tr>`).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 }; updateTable(); });
|
|
};
|
|
|
|
const switchView = () => {
|
|
contentWrapper.innerHTML = '';
|
|
if ((state as any).currentViewMode === 'asset') {
|
|
filterBar.style.display = 'flex'; contentWrapper.style.overflowY = 'hidden';
|
|
contentWrapper.appendChild(tableWrapper); updateTable();
|
|
} else {
|
|
filterBar.style.display = 'none'; contentWrapper.style.overflowY = 'hidden';
|
|
renderSystemStatus();
|
|
}
|
|
};
|
|
|
|
// 2. 필터 바 렌더링
|
|
renderFilterBar(filterBar, {
|
|
...config.filterOptions,
|
|
initialFilters: currentFilters,
|
|
fullList: fullList, // Added for dynamic options
|
|
extraHTML: isServer ? `
|
|
<div class="search-item">
|
|
<label class="list-view-toggle-label">
|
|
<input type="checkbox" id="chk-list-view" ${(state as any).currentViewMode === 'asset' ? 'checked' : ''} />
|
|
목록보기
|
|
</label>
|
|
</div>
|
|
` : '',
|
|
onFilterChange: (filters) => { Object.assign(currentFilters, filters); updateTable(); }
|
|
});
|
|
|
|
// 3. 필터 바 내 액션 버튼 배치
|
|
const actionContainer = filterBar.querySelector('#filter-bar-actions');
|
|
if (actionContainer) {
|
|
actionContainer.className = "header-action-group";
|
|
actionContainer.innerHTML = `
|
|
${showPcFlowBtn ? `
|
|
<button id="btn-goto-parts-master" class="btn btn-outline">
|
|
<i data-lucide="settings" class="icon-sm"></i> 부품 마스터
|
|
</button>
|
|
<button id="btn-pc-flow" class="btn btn-outline">
|
|
PC 이동/반납
|
|
</button>
|
|
` : ''}
|
|
<button id="btn-add-asset" class="btn btn-primary">
|
|
<i data-lucide="plus" class="icon-sm"></i> 자산 추가
|
|
</button>
|
|
`;
|
|
|
|
actionContainer.querySelector('#btn-add-asset')?.addEventListener('click', () => {
|
|
const dummyAsset = { id: '', category: config.title };
|
|
config.onRowClick && config.onRowClick(dummyAsset);
|
|
});
|
|
actionContainer.querySelector('#btn-pc-flow')?.addEventListener('click', () => {
|
|
window.dispatchEvent(new CustomEvent('open-pc-flow'));
|
|
});
|
|
actionContainer.querySelector('#btn-goto-parts-master')?.addEventListener('click', () => {
|
|
state.activeSubTab = '부품 마스터';
|
|
window.dispatchEvent(new Event('refresh-view'));
|
|
});
|
|
}
|
|
|
|
// 서버 탭 전용 목록보기 체크박스 이벤트
|
|
if (isServer) {
|
|
const chkBox = filterBar.querySelector('#chk-list-view') as HTMLInputElement;
|
|
|
|
const handleToggle = () => {
|
|
const isListMode = (state as any).currentViewMode === 'asset';
|
|
if (isListMode) {
|
|
state.viewMode = 'location';
|
|
(state as any).currentViewMode = 'location';
|
|
} else {
|
|
state.viewMode = 'list';
|
|
(state as any).currentViewMode = 'asset';
|
|
}
|
|
window.dispatchEvent(new Event('refresh-view'));
|
|
};
|
|
chkBox?.addEventListener('change', handleToggle);
|
|
}
|
|
|
|
switchView();
|
|
}
|