feat(flow-logs): fix text overlapping, show full asset code, filter by current month and support JSON logs

This commit is contained in:
2026-06-11 11:14:04 +09:00
parent 525dbd77d4
commit 565802f55b
6 changed files with 1408 additions and 107 deletions

View File

@@ -19,6 +19,7 @@ let corpChartInstance4p: any = null;
let totalServerMismatchByPurposeChartInstance4p: any = null;
let serverServiceChartInstance4p: any = null;
let serverStatusChartInstance4p: any = null;
let pcFlowChartInstance: any = null;
// ─── 서버 용도별 카테고리 분류 헬퍼 ───
function categorizePurpose(purpose: string): string {
@@ -563,12 +564,82 @@ function buildServerStatusTableRows(list: any[]): string {
export function renderHwDashboard(container: HTMLElement) {
const allHw = state.masterData.hw || [];
// --- PC FLOW LOGS DATA PREP ---
const logs = state.masterData.logs || [];
const now = new Date();
const currentYearMonth = `${now.getFullYear()}-${String(now.getMonth() + 1).padStart(2, '0')}`;
let currentMonthCheckout = 0;
let currentMonthReturn = 0;
let currentMonthMove = 0;
let totalCheckout = 0;
let totalReturn = 0;
let totalMove = 0;
const flowLogs = logs.filter((log: any) => {
const details = log.details || '';
const isFlow = details.includes('[불출]') || details.includes('[반납]') || details.includes('[입고]') || details.includes('[이동]') || details.includes('[이관]');
if (isFlow) {
const logDate = log.log_date || '';
const isCurrentMonth = logDate.startsWith(currentYearMonth);
if (details.includes('[불출]')) {
totalCheckout++;
if (isCurrentMonth) currentMonthCheckout++;
} else if (details.includes('[반납]') || details.includes('[입고]')) {
totalReturn++;
if (isCurrentMonth) currentMonthReturn++;
} else if (details.includes('[이동]') || details.includes('[이관]')) {
totalMove++;
if (isCurrentMonth) currentMonthMove++;
}
return true;
}
return false;
});
const recentFlowLogs = flowLogs.slice(0, 5);
let recentFlowLogsHtml = '';
if (recentFlowLogs.length === 0) {
recentFlowLogsHtml = '<tr><td colspan="4" style="text-align:center; padding:1.5rem; color:#94A3B8; font-size:12px;">최근 유동 이력이 없습니다.</td></tr>';
} else {
recentFlowLogs.forEach((log: any) => {
const details = log.details || '';
let badgeHtml = '';
if (details.includes('[불출]')) {
badgeHtml = '<span style="background:#E0F2FE;color:#0369A1;padding:2px 6px;border-radius:4px;font-size:11px;font-weight:700;">불출</span>';
} else if (details.includes('[반납]') || details.includes('[입고]')) {
badgeHtml = '<span style="background:#DCFCE7;color:#15803D;padding:2px 6px;border-radius:4px;font-size:11px;font-weight:700;">입고</span>';
} else if (details.includes('[이동]') || details.includes('[이관]')) {
badgeHtml = '<span style="background:#FEF3C7;color:#B45309;padding:2px 6px;border-radius:4px;font-size:11px;font-weight:700;">이동</span>';
}
const cleanDetails = details.replace(/^\[(불출|반납|입고|이동|이관)\]\s*/, '');
recentFlowLogsHtml += `
<tr style="border-bottom: 1px solid #F1F5F9; font-size: 13px;">
<td style="padding: 8px; color: #64748B;">${log.log_date || '-'}</td>
<td style="padding: 8px;">${badgeHtml}</td>
<td style="padding: 8px; font-weight: 600; color: #334155;">${log.log_user || '시스템'}</td>
<td style="padding: 8px; color: #475569;" title="${details}">${cleanDetails}</td>
</tr>
`;
});
}
// --- PC DATA PREP ---
const pcs = allHw.filter(a => {
const cat = a[ASSET_SCHEMA.CATEGORY.key] || '';
const type = a[ASSET_SCHEMA.ASSET_TYPE.key] || '';
const job = a[ASSET_SCHEMA.USER_POSITION.key] || '';
return (cat === 'PC' || type === '개인PC' || type === '노트북' || type === '공용PC') && job !== '재고PC';
const status = a[ASSET_SCHEMA.HW_STATUS.key] || '';
const user = a[ASSET_SCHEMA.CURRENT_USER.key] || '';
return (cat === 'PC' || type === '개인PC' || type === '노트북' || type === '공용PC') &&
job !== '재고PC' &&
status === '사용중' &&
user.trim() !== '';
});
const jobScores: Record<string, { totalScore: number; count: number; avg: number }> = {};
@@ -824,7 +895,7 @@ export function renderHwDashboard(container: HTMLElement) {
'</div>' +
'<div class="slider-controls">' +
'<button id="slider-prev" class="slider-nav-btn" disabled><i data-lucide="chevron-left"></i></button>' +
'<span id="slider-indicator" class="slider-indicator">1 / 4</span>' +
'<span id="slider-indicator" class="slider-indicator">1 / 5</span>' +
'<button id="slider-next" class="slider-nav-btn"><i data-lucide="chevron-right"></i></button>' +
'</div>' +
'</div>' +
@@ -1082,8 +1153,6 @@ export function renderHwDashboard(container: HTMLElement) {
'<div style="flex: 1; min-height: 0; position: relative;"><canvas id="chart-server-status-4p" style="position: absolute; top:0; left:0; width:100%; height:100%;"></canvas></div>' +
'</div>' +
'</div>' +
'</div>' +
'</div>' +
'</div>' +
'</div>' +
@@ -1108,7 +1177,10 @@ export function renderHwDashboard(container: HTMLElement) {
serverServiceGroups,
serverStatusGroups,
purposeServerUnders,
purposeServerOvers
purposeServerOvers,
totalCheckout,
totalReturn,
totalMove
);
// 기획서 보기 버튼 클릭 이벤트 바인딩
@@ -1191,7 +1263,10 @@ function initCharts(
serviceGroups: any,
statusGroups: any,
purposeServerUnders?: any,
purposeServerOvers?: any
purposeServerOvers?: any,
totalCheckout?: number,
totalReturn?: number,
totalMove?: number
) {
// 직무별 점수
const jobCtx = document.getElementById('chart-job-scores') as HTMLCanvasElement;
@@ -1654,4 +1729,39 @@ function initCharts(
}
});
}
// PC 유동 비율 도넛 차트
const flowCtx = document.getElementById('chart-pc-flow-stats') as HTMLCanvasElement;
if (flowCtx && typeof Chart !== 'undefined') {
const tCheckout = totalCheckout || 0;
const tReturn = totalReturn || 0;
const tMove = totalMove || 0;
if (pcFlowChartInstance) {
pcFlowChartInstance.destroy();
pcFlowChartInstance = null;
}
pcFlowChartInstance = new Chart(flowCtx, {
type: 'doughnut',
data: {
labels: ['불출', '입고(반납)', '이동(이관)'],
datasets: [{
data: [tCheckout, tReturn, tMove],
backgroundColor: ['#3B82F6', '#10B981', '#F59E0B'],
borderWidth: 0,
hoverOffset: 8
}]
},
options: {
responsive: true,
maintainAspectRatio: false,
plugins: {
legend: { position: 'bottom', labels: { padding: 10, usePointStyle: true, boxWidth: 10 } }
},
cutout: '75%',
animation: { animateScale: true, animateRotate: true }
}
});
}
}

View File

@@ -5,6 +5,132 @@ import { renderFilterBar, applyCommonFilters } from '../../core/filterHandler';
import { state } from '../../core/state';
import { IMAGE_LOCATIONS } from '../../components/Modal/SharedData';
declare var Chart: any;
let pcFlowChartInstance: any = null;
// ─── 100점 만점 감점형 성능 점수 계산 (CPU + RAM + GPU + 연식) ───
function calculatePcScoreDeductive(cpu: string, ram: string, gpu: string, purchaseDate: string): number {
let score = 100;
if (!cpu) cpu = '';
if (!ram) ram = '';
if (!gpu) gpu = '';
const cpuUpper = cpu.toUpperCase();
const ramUpper = ram.toUpperCase();
const gpuUpper = gpu.toUpperCase();
// 1. CPU 등급 감점 (최대 -30점)
let cpuDeduction = 0;
if (cpuUpper.includes('I9') || cpuUpper.includes('RYZEN 9') || cpuUpper.includes('RYZEN9')) {
cpuDeduction = 0;
} else if (cpuUpper.includes('I7') || cpuUpper.includes('RYZEN 7') || cpuUpper.includes('RYZEN7')) {
cpuDeduction = 5;
} else if (cpuUpper.includes('I5') || cpuUpper.includes('RYZEN 5') || cpuUpper.includes('RYZEN5')) {
cpuDeduction = 15;
} else if (cpuUpper.includes('I3') || cpuUpper.includes('RYZEN 3') || cpuUpper.includes('RYZEN3')) {
cpuDeduction = 25;
} else {
cpuDeduction = 30;
}
score -= cpuDeduction;
// 2. CPU 세대 노후 감점 (최대 -15점)
let genDeduction = 0;
const intelMatch = cpuUpper.match(/I\d-?(\d+)/);
let gen = 0;
if (intelMatch && intelMatch[1]) {
const numStr = intelMatch[1];
if (numStr.length === 5) gen = parseInt(numStr.substring(0, 2), 10);
else if (numStr.length === 4) gen = parseInt(numStr.substring(0, 1), 10);
}
const amdMatch = cpuUpper.match(/RYZEN\s?\d\s?-?(\d+)/);
let amdGen = 0;
if (amdMatch && amdMatch[1] && !intelMatch) {
const numStr = amdMatch[1];
if (numStr.length === 4) amdGen = parseInt(numStr.substring(0, 1), 10);
}
if (intelMatch) {
if (gen >= 12) genDeduction = 0;
else if (gen >= 10) genDeduction = 5;
else if (gen >= 8) genDeduction = 10;
else genDeduction = 15;
} else if (amdMatch) {
if (amdGen >= 5) genDeduction = 0;
else if (amdGen >= 3) genDeduction = 5;
else genDeduction = 10;
} else {
genDeduction = 15;
}
score -= genDeduction;
// 3. RAM 용량 감점 (최대 -25점)
const ramMatch = ramUpper.match(/(\d+)\s*GB/);
let ramDeduction = 25;
if (ramMatch && ramMatch[1]) {
const ramVal = parseInt(ramMatch[1], 10);
if (ramVal >= 32) ramDeduction = 0;
else if (ramVal >= 16) ramDeduction = 10;
else if (ramVal >= 8) ramDeduction = 20;
else ramDeduction = 25;
}
score -= ramDeduction;
// 4. GPU 성능 감점 (최대 -25점)
let gpuDeduction = 25;
if (!gpuUpper || gpuUpper === '-' || gpuUpper.trim() === '') {
gpuDeduction = 25;
} else if (
gpuUpper.includes('RTX 4090') || gpuUpper.includes('RTX 4080') || gpuUpper.includes('RTX 4070') ||
gpuUpper.includes('RTX A5000') || gpuUpper.includes('RTX A6000') || gpuUpper.includes('RTX A4000')
) {
gpuDeduction = 0;
} else if (
gpuUpper.includes('RTX 3070') || gpuUpper.includes('RTX 3060') || gpuUpper.includes('RTX 2060') ||
gpuUpper.includes('RTX A2000') || gpuUpper.includes('RTX A3000') || gpuUpper.includes('QUADRO')
) {
gpuDeduction = 5;
} else if (
gpuUpper.includes('GTX 1660') || gpuUpper.includes('GTX 1080') || gpuUpper.includes('GTX 1070') ||
gpuUpper.includes('GTX 1060') || gpuUpper.includes('RX 6700') || gpuUpper.includes('RX 6600')
) {
gpuDeduction = 15;
} else {
gpuDeduction = 25;
}
score -= gpuDeduction;
// 5. 연식(노후도) 감점 (최대 -15점)
let age = 0;
if (purchaseDate && purchaseDate !== '-') {
let normalized = purchaseDate.replace(/\./g, '-').trim();
if (/^\d{6}$/.test(normalized)) {
normalized = `${normalized.substring(0, 4)}-${normalized.substring(4, 6)}`;
}
const purchase = new Date(normalized);
if (!isNaN(purchase.getTime())) {
// 2026년 5월 31일 기준 경과연수 계산
const mockToday = new Date('2026-05-31');
const diffMs = mockToday.getTime() - purchase.getTime();
age = diffMs / (1000 * 60 * 60 * 24 * 365.25);
age = Math.max(0, parseFloat(age.toFixed(1)));
}
}
let ageDeduction = 0;
if (age < 1) ageDeduction = 0;
else if (age < 2) ageDeduction = 3;
else if (age < 3) ageDeduction = 6;
else if (age < 4) ageDeduction = 9;
else if (age < 5) ageDeduction = 12;
else ageDeduction = 15;
score -= ageDeduction;
return Math.max(10, score);
}
export interface ColumnDef {
header: string;
sortKey?: string;
@@ -47,15 +173,24 @@ export function createListView(container: HTMLElement, config: ListViewConfig) {
// 2. 뷰 전환 토글 버튼 생성 (명칭 변경)
const toggleWrapper = document.createElement('div');
toggleWrapper.className = 'view-toggle-container';
const showPcFlowBtn = config.title === 'PC';
toggleWrapper.innerHTML = `
<div style="display: flex; justify-content: space-between; align-items: center; width: 100%;">
<div class="view-toggle" style="display: flex; gap: 0;">
<button class="toggle-btn ${(state as any).currentViewMode === 'system' ? 'active' : ''}" data-mode="system">자산 현황</button>
<button class="toggle-btn ${(state as any).currentViewMode === 'asset' ? 'active' : ''}" data-mode="asset">자산 목록</button>
</div>
<button id="btn-add-asset" style="padding: 6px 14px; font-size: 12px; font-weight: 700; background: #1E5149; color: white; border: none; border-radius: 4px; cursor: pointer; display: flex; align-items: center; gap: 4px;">
<span style="font-size: 16px; line-height: 1;">+</span> 자산 추가
</button>
<div style="display: flex; gap: 8px;">
${showPcFlowBtn ? `
<button id="btn-pc-flow" style="padding: 6px 14px; font-size: 12px; font-weight: 700; background: white; color: var(--primary-color); border: 1px solid var(--primary-color); border-radius: 4px; cursor: pointer; display: flex; align-items: center; gap: 6px;">
PC 이동/반납
</button>
` : ''}
<button id="btn-add-asset" style="padding: 6px 14px; font-size: 12px; font-weight: 700; background: #1E5149; color: white; border: none; border-radius: 4px; cursor: pointer; display: flex; align-items: center; gap: 4px;">
<span style="font-size: 16px; line-height: 1;">+</span> 자산 추가
</button>
</div>
</div>
`;
container.appendChild(toggleWrapper);
@@ -71,7 +206,7 @@ export function createListView(container: HTMLElement, config: ListViewConfig) {
container.appendChild(contentWrapper);
// --- 내부 상태 ---
let selectedLocation: string | null = '기술개발센터';
let selectedLocation: string | null = null;
let selectedDetailLocation: string | null = null;
let dynamicMapConfig: Record<string, any[]> = {};
@@ -88,6 +223,19 @@ export function createListView(container: HTMLElement, config: ListViewConfig) {
const renderSystemStatus = () => {
const isPcView = config.title === 'PC';
// 실제 보유 자산이 존재하는 위치 목록만 추출 (0대인 곳 제외)
const validLocations = Array.from(new Set(fullList.map(a => a[ASSET_SCHEMA.LOCATION.key] || '미지정')))
.filter(l => {
const count = fullList.filter(a => (a[ASSET_SCHEMA.LOCATION.key] || '미지정') === l).length;
return count > 0;
})
.sort();
// 초기값이나 유효하지 않은 값이 지정되어 있다면 첫 번째 유효 위치로 동적 갱신
if (!selectedLocation || !validLocations.includes(selectedLocation)) {
selectedLocation = validLocations[0] || '';
}
const locationCounts: Record<string, number> = {};
const pcTypeCounts = { public: 0, server: 0, personal: 0 };
@@ -215,29 +363,45 @@ export function createListView(container: HTMLElement, config: ListViewConfig) {
<div style="display: flex; flex: 1; min-height: 0; border-top: 1px solid var(--border-color);">
<!-- 좌측: 자산 현황 목록 (Border-based Separation) -->
<div class="list-section" style="flex: 1.1; display: flex; flex-direction: column; min-height: 0; padding: 1rem 1.5rem 0 0; border-right: 1px solid var(--border-color);">
<div class="list-section" style="flex: 1.3; display: flex; flex-direction: column; min-height: 0; padding: 1rem 1.5rem 0 0; border-right: 1px solid var(--border-color);">
<div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 1rem; flex-shrink: 0;">
<h4 id="list-section-title" style="font-size: 14px; font-weight: 700; color: var(--text-main); margin:0;">자산 현황 목록</h4>
<h4 id="list-section-title" style="font-size: 14px; font-weight: 700; color: var(--text-main); margin:0;">
${isPcView ? `🔄 PC 유동 이력 (${new Date().getMonth() + 1}월)` : '자산 현황 목록'}
</h4>
${!isPcView ? `
<div style="display: flex; align-items: center; gap: 8px;">
<span style="font-size: 11px; font-weight: 600; color: var(--text-muted);">위치:</span>
<select id="select-loc" style="padding: 2px 8px; font-size: 11px; border-radius: 4px; border: 1px solid var(--border-color); outline: none; background: white; cursor:pointer; font-family: 'Pretendard';">
<option value="">전체</option>
${Array.from(new Set(fullList.map(a => a[ASSET_SCHEMA.LOCATION.key] || '미지정'))).sort().map(l => `<option value="${l}" ${l === selectedLocation ? 'selected' : ''}>${l}</option>`).join('')}
${validLocations.map(l => `<option value="${l}" ${l === selectedLocation ? 'selected' : ''}>${l}</option>`).join('')}
</select>
<span style="font-size: 11px; font-weight: 600; color: var(--text-muted);">상세:</span>
<select id="select-detail-loc" style="padding: 2px 8px; font-size: 11px; border-radius: 4px; border: 1px solid var(--border-color); outline: none; background: white; cursor:pointer; font-family: 'Pretendard'; max-width: 120px;"></select>
</div>
` : ''}
</div>
<div style="flex: 1; overflow-y: auto;">
<table style="width: 100%; border-collapse: collapse; table-layout: fixed;">
<thead style="position: sticky; top: 0; background: #fff; z-index: 10;">
<tr style="text-align: left; font-size: 11px; color: var(--text-muted);">
<th style="padding: 10px 0; font-weight: 700; border-bottom: 2px solid var(--border-color); width: 80px; text-align:center; background: #fff;">분류</th>
<th style="padding: 10px 0; font-weight: 700; border-bottom: 2px solid var(--border-color); width: 130px; background: #fff;">용도/자산명</th>
<th style="padding: 10px 0; font-weight: 700; border-bottom: 2px solid var(--border-color); text-align:center; width: 90px; background: #fff;">관리자(정)</th>
<th style="padding: 10px 0; font-weight: 700; border-bottom: 2px solid var(--border-color); text-align:center; width: 90px; background: #fff;">관리자(부)</th>
<th style="padding: 10px 0; text-align: center; font-weight: 700; border-bottom: 2px solid var(--border-color); width: 100px; background: #fff;">상세위치</th>
</tr>
${isPcView ? `
<tr style="text-align: left; font-size: 11px; color: var(--text-muted);">
<th style="padding: 10px 8px; font-weight: 700; border-bottom: 2px solid var(--border-color); width: 120px; background: #fff; white-space: nowrap;">일자</th>
<th style="padding: 10px 8px; font-weight: 700; border-bottom: 2px solid var(--border-color); width: 100px; background: #fff; white-space: nowrap; text-align: center;">담당자</th>
<th style="padding: 10px 8px; font-weight: 700; border-bottom: 2px solid var(--border-color); width: 60px; background: #fff; white-space: nowrap; text-align: center;">구분</th>
<th style="padding: 10px 8px; font-weight: 700; border-bottom: 2px solid var(--border-color); width: 90px; background: #fff; white-space: nowrap; text-align: center;">사용자</th>
<th style="padding: 10px 8px; font-weight: 700; border-bottom: 2px solid var(--border-color); width: 90px; background: #fff; white-space: nowrap; text-align: center;">인수자</th>
<th style="padding: 10px 8px; font-weight: 700; border-bottom: 2px solid var(--border-color); width: 160px; background: #fff; white-space: nowrap; text-align: center;">자산번호</th>
<th style="padding: 10px 8px; font-weight: 700; border-bottom: 2px solid var(--border-color); background: #fff; white-space: nowrap;">상세</th>
</tr>
` : `
<tr style="text-align: left; font-size: 11px; color: var(--text-muted);">
<th style="padding: 10px 0; font-weight: 700; border-bottom: 2px solid var(--border-color); width: 80px; text-align:center; background: #fff;">분류</th>
<th style="padding: 10px 0; font-weight: 700; border-bottom: 2px solid var(--border-color); width: 130px; background: #fff;">용도/자산명</th>
<th style="padding: 10px 0; font-weight: 700; border-bottom: 2px solid var(--border-color); text-align:center; width: 90px; background: #fff;">관리자(정)</th>
<th style="padding: 10px 0; font-weight: 700; border-bottom: 2px solid var(--border-color); text-align:center; width: 90px; background: #fff;">관리자(부)</th>
<th style="padding: 10px 0; text-align: center; font-weight: 700; border-bottom: 2px solid var(--border-color); width: 100px; background: #fff;">상세위치</th>
</tr>
`}
</thead>
<tbody id="system-status-tbody" style="font-size: 12px;"></tbody>
</table>
@@ -245,9 +409,34 @@ export function createListView(container: HTMLElement, config: ListViewConfig) {
</div>
<!-- 우측: 상세 정보 패널 (Box-less, Line-based) -->
<div id="system-detail-panel" style="flex: 0.9; display: flex; flex-direction: column; min-height: 0; padding: 1rem 0 0 1.5rem; overflow: hidden;">
<div id="detail-empty-state" style="height: 100%; display: flex; flex-direction: column; justify-content: center; align-items: center; color: var(--text-muted); text-align: center;">
<p style="font-size: 1.125rem; font-weight: 500; color: #94A3B8;">목록에서 자산을 선택하면<br>상세 정보와 배치도가 표시됩니다.</p>
<div id="system-detail-panel" style="flex: 0.7; display: flex; flex-direction: column; min-height: 0; padding: 1rem 0 0 1.5rem; overflow: hidden;">
<div id="detail-empty-state" style="height: 100%; display: flex; flex-direction: column; color: var(--text-muted); text-align: center; overflow-y: auto; box-sizing: border-box; width: 100%; justify-content: ${isPcView ? 'flex-start' : 'center'}; align-items: ${isPcView ? 'stretch' : 'center'};">
${isPcView ? `
<div style="display: flex; flex-direction: column; min-height: 0; height: 100%; text-align: left;">
<div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 1rem; flex-shrink: 0;">
<h4 style="margin: 0; font-size: 14px; font-weight: 700; color: #E11D48; display: flex; align-items: center; gap: 6px; white-space: nowrap;">
⚠️ 사양 주의 장비 현황 (부족/오버스펙)
</h4>
</div>
<div style="flex: 1; overflow-y: auto;">
<table style="width: 100%; border-collapse: collapse; table-layout: fixed;">
<thead style="position: sticky; top: 0; background: #fff; z-index: 10;">
<tr style="text-align: left; font-size: 11px; color: var(--text-muted);">
<th style="padding: 10px 0; font-weight: 700; border-bottom: 2px solid var(--border-color); width: 65px; background: #fff; white-space: nowrap;">사용자</th>
<th style="padding: 10px 0; font-weight: 700; border-bottom: 2px solid var(--border-color); width: 115px; background: #fff; white-space: nowrap;">부서 (직무)</th>
<th style="padding: 10px 0; font-weight: 700; border-bottom: 2px solid var(--border-color); width: 65px; background: #fff; white-space: nowrap; text-align: center;">상태</th>
<th style="padding: 10px 0; font-weight: 700; border-bottom: 2px solid var(--border-color); background: #fff; white-space: nowrap;">자산코드</th>
</tr>
</thead>
<tbody id="spec-mismatch-tbody" style="font-size: 12px;">
<tr><td colspan="4" style="text-align:center; padding:1.5rem; color:#94A3B8;">사양 주의 자산이 없습니다.</td></tr>
</tbody>
</table>
</div>
</div>
` : `
<p style="font-size: 1.125rem; font-weight: 500; color: #94A3B8;">목록에서 자산을 선택하면<br>상세 정보와 배치도가 표시됩니다.</p>
`}
</div>
<div id="detail-content" style="display: none; height: 100%; flex-direction: column;">
<!-- 상단 요약 정보 (Wrapping 방지 최적화) -->
@@ -266,6 +455,9 @@ export function createListView(container: HTMLElement, config: ListViewConfig) {
<div id="detail-memo" style="font-size: 14px; color: var(--text-main); font-weight: 500; white-space: nowrap; overflow: hidden; text-overflow: ellipsis;"></div>
</div>
</div>
<button id="btn-view-flow-logs" style="flex-shrink: 0; padding: 6px 16px; font-size: 12px; font-weight: 700; background: white; color: var(--primary-color); border: 1px solid var(--primary-color); border-radius: 4px; cursor: pointer; transition: opacity 0.2s; margin-right: 8px;">
${isPcView ? '목록 보기' : '이력 보기'}
</button>
<button id="btn-view-full-detail" style="flex-shrink: 0; padding: 6px 16px; font-size: 12px; font-weight: 700; background: var(--primary-color); color: white; border: none; border-radius: 4px; cursor: pointer; transition: opacity 0.2s;">상세 보기</button>
</div>
@@ -394,86 +586,368 @@ export function createListView(container: HTMLElement, config: ListViewConfig) {
if (msg) msg.textContent = !hasCoords ? '등록된 위치 좌표 정보가 없습니다.' : '등록된 배치도가 없습니다.';
}
}
// 이력 보기 버튼 클릭 이벤트
const flowLogsBtn = document.getElementById('btn-view-flow-logs');
if (flowLogsBtn) {
flowLogsBtn.onclick = () => {
const emptyState = document.getElementById('detail-empty-state');
const content = document.getElementById('detail-content');
if (emptyState && content) {
content.style.display = 'none';
emptyState.style.display = 'flex';
}
const tbody = document.getElementById('system-status-tbody');
if (tbody) {
tbody.querySelectorAll('.mini-row').forEach(r => {
const rIsWarning = (r as HTMLElement).style.borderLeftColor === 'rgb(225, 29, 72)';
(r as HTMLElement).style.backgroundColor = rIsWarning ? '#FFF1F2' : 'transparent';
});
}
updateFlowLogsSection();
};
}
};
const updateTableOnly = () => {
let filtered = selectedLocation
? fullList.filter(a => (a[ASSET_SCHEMA.LOCATION.key] || '미지정') === selectedLocation)
: fullList;
const currentDetailLocs = Array.from(new Set(filtered.map(a => a[ASSET_SCHEMA.LOC_DETAIL.key] || '미지정'))).sort();
if (selectedDetailLocation) filtered = filtered.filter(a => (a[ASSET_SCHEMA.LOC_DETAIL.key] || '미지정') === selectedDetailLocation);
const finalDisplayList = (!selectedLocation && !selectedDetailLocation) ? filtered.slice(0, 10) : filtered;
const now = new Date();
const currentYear = now.getFullYear();
const currentMonthNum = now.getMonth() + 1;
const currentYearMonth = `${currentYear}-${String(currentMonthNum).padStart(2, '0')}`;
const titleEl = document.getElementById('list-section-title');
if (titleEl) titleEl.textContent = selectedLocation ? `${selectedLocation} 자산 현황 (${finalDisplayList.length}대)` : '위치별 자산등록현황 (최근 등록)';
const selectEl = document.getElementById('select-detail-loc') as HTMLSelectElement;
if (selectEl && !selectedDetailLocation) {
selectEl.innerHTML = `<option value="">전체보기</option>` + currentDetailLocs.map(dl => `<option value="${dl}">${dl}</option>`).join('');
}
if (isPcView) {
// PC 뷰일 때: 해당월의 PC 유동 이력을 렌더링하고, 클릭 시 해당 자산 상세를 띄움
const recentTbody = document.getElementById('system-status-tbody');
if (!recentTbody) return;
const tbody = document.getElementById('system-status-tbody');
if (tbody) {
tbody.innerHTML = finalDisplayList.length === 0
? `<tr><td colspan="4" style="padding: 3rem; text-align: center; color: var(--text-muted);">조회된 자산이 없습니다.</td></tr>`
: finalDisplayList.map(asset => {
const purpose = asset[ASSET_SCHEMA.ASSET_PURPOSE.key] || '';
const serviceTypeKey = (ASSET_SCHEMA as any).SERVICE_TYPE?.key || 'service_type';
const serviceType = asset[serviceTypeKey] || '외부';
const type = asset[ASSET_SCHEMA.ASSET_TYPE.key] || '';
const loc = asset[ASSET_SCHEMA.LOCATION.key] || '';
const labelColor = serviceType === '내부' ? '#94A3B8' : '#35635C';
const managerMain = asset[ASSET_SCHEMA.MANAGER_MAIN.key] || '-';
const managerSub = asset[ASSET_SCHEMA.MANAGER_SUB.key] || '-';
// [경고 로직] 외부 운영인데 서버PC이거나 IDC가 아닌 경우
const isLocWarning = serviceType === '외부SW' && loc !== 'IDC';
const isTypeWarning = serviceType === '외부SW' && type.toLowerCase().replace(/\s/g, '').includes('서버pc');
const isWarning = isLocWarning || isTypeWarning;
const warningStyle = isWarning ? 'background-color: #FFF1F2; border-left: 3px solid #E11D48;' : '';
const titleEl = document.getElementById('list-section-title');
if (titleEl) {
titleEl.textContent = `🔄 PC 유동 이력 (${currentMonthNum}월)`;
}
let warningReason = '';
if (isLocWarning && isTypeWarning) warningReason = '위치/형식 부적절';
else if (isLocWarning) warningReason = '위치 부적절';
else if (isTypeWarning) warningReason = '형식 부적절';
return `
<tr style="border-bottom: 1px solid var(--border-color); cursor: pointer; ${warningStyle}" class="mini-row" data-id="${asset.id}">
<td style="padding: 10px 0; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; text-align:center;">
<div style="display:flex; flex-direction:column; align-items:center; gap:2px;">
<span style="color: ${isWarning ? '#E11D48' : labelColor}; font-weight: 800; font-size: 12px;">${serviceType}</span>
${isWarning ? `<span style="color: #E11D48; font-size: 9px; font-weight: 700; white-space: nowrap;">${warningReason}</span>` : ''}
</div>
</td>
<td style="padding: 10px 0; font-weight: 600; color: ${isWarning ? '#991B1B' : 'var(--text-main)'}; font-size: 13px; white-space: nowrap; overflow: hidden; text-overflow: ellipsis;" title="${purpose}">${purpose || '-'}</td>
<td style="padding: 10px 0; text-align: center; color: var(--text-main); font-size: 12px; white-space: nowrap; overflow: hidden; text-overflow: ellipsis;">${managerMain}</td>
<td style="padding: 10px 0; text-align: center; color: var(--text-main); font-size: 12px; white-space: nowrap; overflow: hidden; text-overflow: ellipsis;">${managerSub}</td>
<td style="padding: 10px 0; text-align: center; color: var(--text-main); font-size: 12px; white-space: nowrap; overflow: hidden; text-overflow: ellipsis;">${asset[ASSET_SCHEMA.LOC_DETAIL.key] || '-'}</td>
</tr>`;
}).join('');
tbody.querySelectorAll('.mini-row').forEach(row => {
row.addEventListener('click', () => {
tbody.querySelectorAll('.mini-row').forEach(r => {
const rIsWarning = (r as HTMLElement).style.borderLeftColor === 'rgb(225, 29, 72)'; // E11D48
(r as HTMLElement).style.backgroundColor = rIsWarning ? '#FFF1F2' : 'transparent';
});
(row as HTMLElement).style.backgroundColor = '#EBF2F1'; // 선택 하이라이트
const id = (row as HTMLElement).getAttribute('data-id');
const asset = fullList.find(a => a.id === id);
if (asset) updateDetailPanel(asset);
});
row.addEventListener('mouseenter', () => {
if ((row as HTMLElement).style.backgroundColor !== 'rgb(235, 242, 241)') {
(row as HTMLElement).style.backgroundColor = '#F8FAFA';
}
});
row.addEventListener('mouseleave', () => {
const isWarning = (row as HTMLElement).style.borderLeftColor === 'rgb(225, 29, 72)';
if ((row as HTMLElement).style.backgroundColor !== 'rgb(235, 242, 241)') {
(row as HTMLElement).style.backgroundColor = isWarning ? '#FFF1F2' : 'transparent';
}
});
const logs = state.masterData.logs || [];
const flowLogs = logs.filter((log: any) => {
const details = log.details || '';
if (details.trim().startsWith('{')) {
try {
const info = JSON.parse(details);
return info && (info.type === 'checkout' || info.type === 'return' || info.type === 'move');
} catch (e) {}
}
return details.includes('[불출]') || details.includes('[반납]') || details.includes('[입고]') || details.includes('[이동]') || details.includes('[이관]');
});
// 해당월(currentYearMonth)에 발생한 로그만 필터링
const monthlyFlowLogs = flowLogs.filter((log: any) => {
const logDate = log.log_date || '';
return logDate.startsWith(currentYearMonth);
});
if (monthlyFlowLogs.length === 0) {
recentTbody.innerHTML = `<tr><td colspan="7" style="text-align:center; padding:1.5rem; color:#94A3B8;">${currentMonthNum}월 유동 이력이 없습니다.</td></tr>`;
} 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 = '<span style="background:#E0F2FE;color:#0369A1;padding:2px 6px;border-radius:4px;font-size:11px;font-weight:700;">불출</span>';
} else if (typeDisplay === 'return') {
badgeHtml = '<span style="background:#DCFCE7;color:#15803D;padding:2px 6px;border-radius:4px;font-size:11px;font-weight:700;">입고</span>';
} else if (typeDisplay === 'move') {
badgeHtml = '<span style="background:#FEF3C7;color:#B45309;padding:2px 6px;border-radius:4px;font-size:11px;font-weight:700;">이동</span>';
} else {
badgeHtml = '<span style="background:#F1F5F9;color:#475569;padding:2px 6px;border-radius:4px;font-size:11px;font-weight:700;">기타</span>';
}
return `
<tr style="border-bottom: 1px solid var(--border-color);">
<td style="padding: 10px 8px; color: #64748B; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; max-width: 120px;">${log.log_date || '-'}</td>
<td style="padding: 10px 8px; font-weight: 500; color: #64748B; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; max-width: 100px; text-align: center;" title="${log.log_user || '시스템'}">${log.log_user || '시스템'}</td>
<td style="padding: 10px 8px; white-space: nowrap; text-align: center;">${badgeHtml}</td>
<td style="padding: 10px 8px; font-weight: 600; color: #1E293B; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; max-width: 90px; text-align: center;" title="${userDisplay}">${userDisplay}</td>
<td style="padding: 10px 8px; font-weight: 600; color: #1E293B; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; max-width: 90px; text-align: center;" title="${targetUserDisplay}">${targetUserDisplay}</td>
<td style="padding: 10px 8px; font-family: monospace; color: #475569; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; max-width: 160px; text-align: center;" title="${assetCodeDisplay}">${assetCodeDisplay}</td>
<td style="padding: 10px 8px; color: #475569; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; max-width: 160px;" title="${memoDisplay}">${memoDisplay}</td>
</tr>
`;
}).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 = `<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" style="padding: 3rem; text-align: center; color: var(--text-muted);">조회된 자산이 없습니다.</td></tr>`
: 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 `
<tr style="border-bottom: 1px solid var(--border-color); cursor: pointer; ${warningStyle}" class="mini-row" data-id="${asset.id}">
<td style="padding: 10px 0; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; text-align:center;">
<div style="display:flex; flex-direction:column; align-items:center; gap:2px;">
<span style="color: ${isWarning ? '#E11D48' : labelColor}; font-weight: 800; font-size: 12px;">${serviceType}</span>
${isWarning ? `<span style="color: #E11D48; font-size: 9px; font-weight: 700; white-space: nowrap;">${warningReason}</span>` : ''}
</div>
</td>
<td style="padding: 10px 0; font-weight: 600; color: ${isWarning ? '#991B1B' : 'var(--text-main)'}; font-size: 13px; white-space: nowrap; overflow: hidden; text-overflow: ellipsis;" title="${purpose}">${purpose || '-'}</td>
<td style="padding: 10px 0; text-align: center; color: var(--text-main); font-size: 12px; white-space: nowrap; overflow: hidden; text-overflow: ellipsis;">${managerMain}</td>
<td style="padding: 10px 0; text-align: center; color: var(--text-main); font-size: 12px; white-space: nowrap; overflow: hidden; text-overflow: ellipsis;">${managerSub}</td>
<td style="padding: 10px 0; text-align: center; color: var(--text-main); font-size: 12px; white-space: nowrap; overflow: hidden; text-overflow: ellipsis;">${asset[ASSET_SCHEMA.LOC_DETAIL.key] || '-'}</td>
</tr>`;
}).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<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;
});
// 기준 대비 사양 부족/오버스펙 분류
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 = '<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}</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 id = (row as HTMLElement).getAttribute('data-id');
const asset = fullList.find(a => String(a.id) === String(id));
if (asset) {
updateDetailPanel(asset);
}
});
row.addEventListener('mouseenter', () => {
if ((row as HTMLElement).style.backgroundColor !== 'rgb(235, 242, 241)') {
(row as HTMLElement).style.backgroundColor = '#F8FAFA';
}
});
row.addEventListener('mouseleave', () => {
if ((row as HTMLElement).style.backgroundColor !== 'rgb(235, 242, 241)') {
(row as HTMLElement).style.backgroundColor = 'transparent';
}
});
});
}
}
};
@@ -492,12 +966,15 @@ export function createListView(container: HTMLElement, config: ListViewConfig) {
selectedLocation = (e.target as HTMLSelectElement).value || null;
selectedDetailLocation = null;
updateTableOnly();
updateFlowLogsSection();
});
selectDetailLoc?.addEventListener('change', (e) => {
selectedDetailLocation = (e.target as HTMLSelectElement).value || null;
updateTableOnly();
updateFlowLogsSection();
});
updateTableOnly();
updateFlowLogsSection();
}, 50);
};