style: 레이아웃 비율 복구 및 타이포그래피 전역 표준화 (16px Base)

- 주요 변경 사항:
  1. 레이아웃 안정화: 서버 위치도 뷰의 2:1 비율 복원 및 가변형(Adaptive) 레이아웃 적용
  2. 타이포그래피 표준화: 전역 폰트 스케일 도입 및 기본 폰트 사이즈 상향 (15px -> 16px)
  3. 3-Way 토글 통합: [자산 위치] [운영 현황] [자산 목록] 간의 전환 오류 수정 및 UI 통일
  4. 하드코딩 제거: 인라인 스타일을 CSS 클래스 및 변수 체계로 전면 리팩토링
  5. 가이드 업데이트: 변경된 디자인 정책을 design_rule.md에 반영
This commit is contained in:
2026-06-15 14:21:54 +09:00
parent b169176d57
commit 119c799d1d
9 changed files with 1196 additions and 1052 deletions

View File

@@ -161,9 +161,11 @@ export interface ListViewConfig {
}
export function createListView(container: HTMLElement, config: ListViewConfig) {
// 1. 컨테이너 초기화 및 헤더 렌더링
// 1. 컨테이너 초기화 및 헤더 렌더링 (서버 탭은 상단 공간 확보를 위해 헤더 생략)
container.innerHTML = '';
renderPageHeader(container, config.title);
if (config.title !== '서버') {
renderPageHeader(container, config.title);
}
const fullList = config.dataSource();
let sortState: SortState = config.persistentSortState || { key: '', direction: 'asc' };
@@ -185,30 +187,29 @@ export function createListView(container: HTMLElement, config: ListViewConfig) {
(state as any).currentViewMode = 'system';
}
// 2. 뷰 전환 토글 버튼 생성
// 2. 뷰 전환 토글 생성 (Unified Header Style)
const toggleWrapper = document.createElement('div');
toggleWrapper.className = 'view-toggle-container';
toggleWrapper.className = 'location-filter-bar'; // Use unified class for the bar
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: ${isServerOrPc ? 'flex' : 'none'}; 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>
<div style="display: flex; gap: 8px;">
${showPcFlowBtn ? `
<button id="btn-goto-parts-master" style="padding: 6px 14px !important; font-size: 12px !important; font-weight: 700 !important; background: white !important; color: var(--primary-color) !important; border: 1px solid var(--primary-color) !important; border-radius: 4px !important; cursor: pointer !important; display: flex !important; align-items: center !important; justify-content: center !important; gap: 6px !important; width: fit-content !important; min-width: 0 !important; white-space: nowrap !important;">
<i data-lucide="settings" style="width: 14px; height: 14px;"></i> 부품 마스터
</button>
<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> 자산 추가
<div class="view-toggle" style="display: ${isServerOrPc ? 'inline-flex' : 'none'};">
${config.title === '서버' ? `<button class="toggle-btn ${state.viewMode === 'location' ? 'active' : ''}" data-mode="location">자산 위치</button>` : ''}
<button class="toggle-btn ${(state as any).currentViewMode === 'system' && state.viewMode === 'list' ? 'active' : ''}" data-mode="system">${config.title === '서버' ? '운영 현황' : '자산 현황'}</button>
<button class="toggle-btn ${(state as any).currentViewMode === 'asset' && state.viewMode === 'list' ? 'active' : ''}" data-mode="asset">자산 목록</button>
</div>
<div class="header-action-group" style="display: flex; gap: 8px;">
${showPcFlowBtn ? `
<button id="btn-goto-parts-master" class="btn btn-outline btn-sm">
<i data-lucide="settings" style="width: 14px; height: 14px;"></i> 부품 마스터
</button>
</div>
<button id="btn-pc-flow" class="btn btn-outline btn-sm">
PC 이동/반납
</button>
` : ''}
<button id="btn-add-asset" class="btn btn-primary btn-sm">
<span style="font-size: 16px; line-height: 1;">+</span> 자산 추가
</button>
</div>
`;
container.appendChild(toggleWrapper);
@@ -486,10 +487,8 @@ export function createListView(container: HTMLElement, config: ListViewConfig) {
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 = '';
@@ -498,7 +497,6 @@ export function createListView(container: HTMLElement, config: ListViewConfig) {
htmlMap.style.display = 'block';
}
} else {
// 일반 이미지 지도 처리
if (htmlMap) {
htmlMap.src = '';
htmlMap.style.display = 'none';
@@ -568,7 +566,6 @@ export function createListView(container: HTMLElement, config: ListViewConfig) {
if (noPhoto) { noPhoto.classList.remove('hidden'); noPhoto.style.display = 'flex'; }
}
// 이력 보기 버튼 클릭 이벤트
const flowLogsBtn = document.getElementById('btn-view-flow-logs');
if (flowLogsBtn) {
flowLogsBtn.onclick = () => {
@@ -597,15 +594,10 @@ export function createListView(container: HTMLElement, config: ListViewConfig) {
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}월)`;
}
if (titleEl) titleEl.textContent = `🔄 PC 유동 이력 (${currentMonthNum}월)`;
const logs = state.masterData.logs || [];
const flowLogs = logs.filter((log: any) => {
const details = log.details || '';
@@ -617,338 +609,88 @@ export function createListView(container: HTMLElement, config: ListViewConfig) {
}
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);
});
const monthlyFlowLogs = flowLogs.filter((log: any) => (log.log_date || '').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 = '-';
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 || '-';
typeDisplay = info.type; 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 || '-';
}
}
const codeMatch = details.match(/PC-\d{6}-\d{4}|HW-PC-\d+/i); if (codeMatch) assetCodeDisplay = codeMatch[0];
}
// 구분 뱃지 생성
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>';
}
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; font-weight: 500; color: #64748B; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; max-width: 100px; text-align: center;">${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>
`;
<td style="padding: 10px 8px; font-weight: 600; color: #1E293B; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; max-width: 90px; text-align: center;">${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;">${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;">${assetCodeDisplay}</td>
<td style="padding: 10px 8px; color: #475569; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; max-width: 160px;">${memoDisplay}</td>
</tr>`;
}).join('');
}
} else {
// 기존의 자산 현황 목록 갱신
let filtered = selectedLocation
? fullList.filter(a => (a[ASSET_SCHEMA.LOCATION.key] || '미지정') === selectedLocation)
: fullList;
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>`
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 serviceType = asset.service_type || '외부';
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 = '형식 부적절';
const isWarning = serviceType === '외부SW' && (loc !== 'IDC' || type.toLowerCase().includes('서버pc'));
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 style="border-bottom: 1px solid var(--border-color); cursor: pointer; ${isWarning ? 'background-color:#FFF1F2; border-left:3px solid #E11D48;' : ''}" class="mini-row" data-id="${asset.id}">
<td style="padding: 10px 0; text-align:center;"><span style="font-weight:800; font-size:12px; color:${isWarning ? '#E11D48' : '#35635C'}">${serviceType}</span></td>
<td style="padding: 10px 0; font-weight: 600; font-size: 13px;">${purpose || '-'}</td>
<td style="padding: 10px 0; text-align: center; font-size: 12px;">${asset[ASSET_SCHEMA.MANAGER_MAIN.key] || '-'}</td>
<td style="padding: 10px 0; text-align: center; font-size: 12px;">${asset[ASSET_SCHEMA.MANAGER_SUB.key] || '-'}</td>
<td style="padding: 10px 0; text-align: center; font-size: 12px;">${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);
tbody.querySelectorAll('.mini-row').forEach(r => (r as HTMLElement).style.backgroundColor = (r as HTMLElement).style.borderLeftColor === 'rgb(225, 29, 72)' ? '#FFF1F2' : 'transparent');
(row as HTMLElement).style.backgroundColor = '#EBF2F1';
const asset = fullList.find(a => a.id === (row as HTMLElement).getAttribute('data-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.6) {
pc['_spec_status'] = '사양 부족';
criticalPcList.push(pc);
} else if (score > avg * 1.5) {
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';
}
});
});
}
}
};
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();
document.getElementById('select-loc')?.addEventListener('change', (e) => {
selectedLocation = (e.target as HTMLSelectElement).value || null; selectedDetailLocation = null; updateTableOnly();
});
selectDetailLoc?.addEventListener('change', (e) => {
selectedDetailLocation = (e.target as HTMLSelectElement).value || null;
updateTableOnly();
updateFlowLogsSection();
document.getElementById('select-detail-loc')?.addEventListener('change', (e) => {
selectedDetailLocation = (e.target as HTMLSelectElement).value || null; updateTableOnly();
});
updateTableOnly();
updateFlowLogsSection();
}, 50);
};
@@ -957,17 +699,14 @@ export function createListView(container: HTMLElement, config: ListViewConfig) {
const table = document.createElement('table');
const thead = document.createElement('thead');
const tbody = document.createElement('tbody');
table.appendChild(thead); table.appendChild(tbody);
tableWrapper.appendChild(table);
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};` : ''}" class="${col.align ? `text-${col.align}` : ''}">${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 => `<td class="${col.align ? `text-${col.align}` : ''}">${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(); });
};
@@ -986,35 +725,24 @@ export function createListView(container: HTMLElement, config: ListViewConfig) {
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();
const mode = btn.getAttribute('data-mode');
if (mode === 'location') state.viewMode = 'location';
else { state.viewMode = 'list'; (state as any).currentViewMode = mode; }
window.dispatchEvent(new Event('refresh-view'));
});
// 필터 바 초기화
renderFilterBar(filterBar, {
...config.filterOptions,
initialFilters: currentFilters,
onFilterChange: (filters) => {
Object.assign(currentFilters, filters);
updateTable();
}
...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();
const uniqueValues = Array.from(new Set(fullList.map(a => a[dataKey]))).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;
}
const opt = document.createElement('option'); opt.value = String(val); opt.textContent = String(val);
if (initialValue && String(val) === initialValue) opt.selected = true;
select.appendChild(opt);
});
}
@@ -1026,6 +754,5 @@ export function createListView(container: HTMLElement, config: ListViewConfig) {
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();
}