feat: PC 맞춤형 대시보드 구현 및 자산 현황 레이아웃 최적화

- PC 자산 관리 화면에 유형별(공용/서버/개인) 통계 및 차트 적용
- 자산 현황 대시보드의 위치 분류 체계 통일 (센터/IDC/한맥빌딩)
- 하단 요약 표의 자산번호 컬럼을 비고(Memo)로 교체 및 말줄임표 적용
- 차트 크기 확대 및 네비게이션 메뉴 레이아웃 안정화
- ListFactory.ts 내 formatInline 미정의 오류 수정
This commit is contained in:
2026-06-05 10:51:29 +09:00
parent 46422e8544
commit eead43837d
2 changed files with 388 additions and 421 deletions

View File

@@ -1,8 +1,12 @@
import { ASSET_SCHEMA, UI_TEXT } from '../../core/schema';
import { dynamicSort, renderPageHeader } from '../../core/utils';
import { dynamicSort, renderPageHeader, calculateAssetAge, formatInline } from '../../core/utils';
import { setupTableSorting, SortState } from '../../core/tableHandler';
import { renderFilterBar, applyCommonFilters } from '../../core/filterHandler';
import { createIcons, RefreshCcw, Plus, Edit2, Trash2, Users, Cloud, CreditCard, DollarSign, Paperclip } from 'lucide';
import { state } from '../../core/state';
import {
createIcons, RefreshCcw, Plus, Edit2, Trash2, Users, Cloud,
CreditCard, DollarSign, Paperclip
} from 'lucide';
export interface ColumnDef {
header: string;
@@ -28,97 +32,340 @@ export interface ListViewConfig {
columns: ColumnDef[];
onRowClick?: (asset: any) => void;
emptyMessage?: string;
persistentSortState?: SortState; // Allow passing external sort state (like DomainListView)
persistentSortState?: SortState;
}
export function createListView(container: HTMLElement, config: ListViewConfig) {
// 1. 컨테이너 초기화 및 헤더 렌더링
container.innerHTML = '';
renderPageHeader(container, config.title);
const fullList = config.dataSource();
let sortState: SortState = config.persistentSortState || { key: '', direction: 'asc' };
// Initialize currentFilters with all possible keys to avoid undefined issues
let currentFilters: any = { keyword: '', corp: '', dept: '', loc: '', field: '', type: '' };
// 강제로 기본 뷰 모드를 'system' (자산 현황)으로 설정
state.currentViewMode = 'system';
// 2. 뷰 전환 토글 버튼 생성 (명칭 변경)
const toggleWrapper = document.createElement('div');
toggleWrapper.className = 'view-toggle-container';
toggleWrapper.innerHTML = `
<div class="view-toggle">
<button class="toggle-btn ${state.currentViewMode === 'system' ? 'active' : ''}" data-mode="system">자산 현황</button>
<button class="toggle-btn ${state.currentViewMode === 'asset' ? 'active' : ''}" data-mode="asset">자산 목록</button>
</div>
`;
container.appendChild(toggleWrapper);
// 3. 필터 바 생성 (자산 목록에서만 사용)
const filterBar = document.createElement('div');
filterBar.className = 'search-bar';
container.appendChild(filterBar);
// 4. 컨텐츠 영역 생성
const contentWrapper = document.createElement('div');
contentWrapper.className = 'view-content-wrapper';
container.appendChild(contentWrapper);
// --- 내부 상태 ---
let selectedLocation: string | null = '기술개발센터';
let selectedDetailLocation: string | null = null;
// [자산 현황] 대시보드 렌더러
const renderSystemStatus = () => {
const isPcView = config.title === 'PC';
const locationCounts: Record<string, number> = {};
const pcTypeCounts = { public: 0, server: 0, personal: 0 };
const extSubCounts = { tech: 0, idc: 0, hm: 0 };
const intSubCounts = { tech: 0, idc: 0, hm: 0 };
let internalCount = 0;
let externalCount = 0;
fullList.forEach(asset => {
const loc = asset[ASSET_SCHEMA.LOCATION.key] || '미지정';
const serviceType = asset[ASSET_SCHEMA.SERVICE_TYPE.key] || '외부';
const type = asset[ASSET_SCHEMA.ASSET_TYPE.key] || '';
locationCounts[loc] = (locationCounts[loc] || 0) + 1;
if (isPcView) {
if (type.includes('공용')) pcTypeCounts.public++;
else if (type.includes('서버')) pcTypeCounts.server++;
else pcTypeCounts.personal++;
}
if (serviceType === '내부') {
internalCount++;
if (loc === '기술개발센터') intSubCounts.tech++;
else if (loc === 'IDC') intSubCounts.idc++;
else if (loc === '한맥빌딩') intSubCounts.hm++;
} else {
externalCount++;
if (loc === '기술개발센터') extSubCounts.tech++;
else if (loc === 'IDC') extSubCounts.idc++;
else if (loc === '한맥빌딩') extSubCounts.hm++;
}
});
const locLabels = Object.keys(locationCounts).sort((a, b) => locationCounts[b] - locationCounts[a]);
const pcLabels = ['공용PC', '서버PC', '개인PC'];
const pcData = [pcTypeCounts.public, pcTypeCounts.server, pcTypeCounts.personal];
const chartLabels = isPcView ? pcLabels : locLabels;
const chartData = isPcView ? pcData : locLabels.map(l => locationCounts[l]);
const chartColors = ['#1E5149', '#4255bd', '#92400E', '#B91C1C', '#6D28D9', '#BE185D', '#0369A1', '#15803D', '#4B5563'];
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 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="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 serviceType = asset[ASSET_SCHEMA.SERVICE_TYPE.key] || '외부';
const labelColor = serviceType === '내부' ? '#94A3B8' : '#35635C';
const memo = asset[ASSET_SCHEMA.MEMO.key] || '';
return `
<tr style="border-bottom: 1px solid var(--border-color); cursor: pointer;" class="mini-row" data-id="${asset.id}">
<td style="padding: 10px 0; white-space: nowrap; overflow: hidden; text-overflow: ellipsis;"><span style="color: ${labelColor}; font-weight: 700; font-size: 12px;">${serviceType}</span></td>
<td style="padding: 10px 0; font-weight: 600; color: var(--text-main); font-size: 13px; white-space: nowrap; overflow: hidden; text-overflow: ellipsis;" title="${purpose}">${purpose || '-'}</td>
<td style="padding: 10px 0; color: var(--text-muted); font-size: 12px; white-space: nowrap; overflow: hidden; text-overflow: ellipsis;" title="${formatInline(memo)}">${formatInline(memo) || '-'}</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', () => {
const id = (row as HTMLElement).getAttribute('data-id');
const asset = fullList.find(a => a.id === id);
if (asset && config.onRowClick) config.onRowClick(asset);
});
row.addEventListener('mouseenter', () => { (row as HTMLElement).style.backgroundColor = '#F8FAFA'; });
row.addEventListener('mouseleave', () => { (row as HTMLElement).style.backgroundColor = 'transparent'; });
});
}
};
contentWrapper.innerHTML = `
<div class="system-dashboard" style="height: calc(100vh - 240px); overflow: hidden; padding: 0.5rem 0; font-family: 'Pretendard', sans-serif; letter-spacing: -0.02em; display: flex; flex-direction: column;">
<!-- [자산 통계 그룹] -->
<div style="border-bottom: 1px solid var(--border-color); padding-bottom: 1.25rem; margin-bottom: 1.5rem; flex-shrink: 0; display: grid; grid-template-columns: 1fr 1.5fr 1.5fr; gap: 2rem;">
<div class="stat-group-item" style="min-width: 0;">
<div style="font-size: 11px; font-weight: 600; color: var(--text-muted); margin-bottom: 0.25rem;">총 보유 자산</div>
<div style="font-size: 28px; font-weight: 800; color: var(--text-main); line-height: 1.1;">${fullList.length}<span style="font-size: 13px; font-weight: 600; margin-left: 4px; color: var(--text-muted);">대</span></div>
${isPcView ? `
<div style="display: flex; gap: 0.75rem; font-size: 11px; color: var(--text-muted); margin-top: 0.5rem;">
<span>공용: <strong style="color:var(--text-main)">${pcTypeCounts.public}</strong></span>
<span>서버: <strong style="color:var(--text-main)">${pcTypeCounts.server}</strong></span>
<span>개인: <strong style="color:var(--text-main)">${pcTypeCounts.personal}</strong></span>
</div>
` : ''}
</div>
<div class="stat-group-item" style="border-left: 1px solid var(--border-color); padding-left: 1.5rem; min-width: 0;">
${isPcView ? '' : `
<div style="display: flex; align-items: flex-end; gap: 0.75rem; margin-bottom: 0.5rem;">
<span style="font-size: 11px; font-weight: 600; color: var(--text-muted);">외부 (운영)</span>
<span style="font-size: 22px; font-weight: 800; color: #35635C; line-height: 1;">${externalCount}</span>
</div>
<div style="display: flex; gap: 0.75rem; font-size: 11px; color: var(--text-muted); flex-wrap: wrap;">
<span>기술개발센터: <strong style="color:var(--text-main)">${extSubCounts.tech}</strong></span>
<span>IDC: <strong style="color:var(--text-main)">${extSubCounts.idc}</strong></span>
<span>한맥빌딩: <strong style="color:var(--text-main)">${extSubCounts.hm}</strong></span>
</div>
`}
</div>
<div class="stat-group-item" style="border-left: 1px solid var(--border-color); padding-left: 1.5rem; min-width: 0;">
${isPcView ? '' : `
<div style="display: flex; align-items: flex-end; gap: 0.75rem; margin-bottom: 0.5rem;">
<span style="font-size: 11px; font-weight: 600; color: var(--text-muted);">내부 (테스트)</span>
<span style="font-size: 22px; font-weight: 800; color: #94A3B8; line-height: 1;">${internalCount}</span>
</div>
<div style="display: flex; gap: 0.75rem; font-size: 11px; color: var(--text-muted); flex-wrap: wrap;">
<span>기술개발센터: <strong style="color:var(--text-main)">${intSubCounts.tech}</strong></span>
<span>IDC: <strong style="color:var(--text-main)">${intSubCounts.idc}</strong></span>
<span>한맥빌딩: <strong style="color:var(--text-main)">${intSubCounts.hm}</strong></span>
</div>
`}
</div>
</div>
<div style="display: grid; grid-template-columns: 1.2fr 1.6fr; gap: 2.5rem; flex: 1; min-height: 0;">
<!-- 차트 구역 -->
<div class="chart-section" style="display: flex; flex-direction: column; min-height: 0; width: 100%;">
<div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 0.75rem; flex-shrink: 0;">
<h4 style="font-size: 14px; font-weight: 700; color: var(--text-main);">${isPcView ? '유형별 분포' : '위치별 분포'}</h4>
${!isPcView && selectedLocation ? `<button id="btn-reset-loc" style="font-size:11px; color:var(--primary-color); background:none; border:none; cursor:pointer; font-weight:700;">초기화 ↺</button>` : ''}
</div>
<div style="display: flex; flex-wrap: wrap; gap: 8px; margin-bottom: 1rem; flex-shrink: 0;">
${chartLabels.map((l, i) => `
<div style="font-size: 11px; display: flex; align-items: center; gap: 5px; cursor:pointer;
color: ${!isPcView && selectedLocation === l ? 'var(--primary-color)' : 'var(--text-main)'};
font-weight: ${!isPcView && selectedLocation === l ? '800' : '500'};"
${!isPcView ? `onclick="window.dispatchLocFilter('${l}')"` : ''}>
<span style="width: 6px; height: 6px; border-radius: 50%; background: ${chartColors[i % chartColors.length]}; opacity: ${!isPcView && selectedLocation && selectedLocation !== l ? 0.2 : 0.8}"></span>
<span>${l}</span>
<span style="color: var(--text-muted); opacity: 0.6;">${chartData[i]}</span>
</div>
`).join('')}
</div>
<div style="flex: 1; position: relative; min-height: 250px; max-height: 320px; display: flex; justify-content: center;">
<canvas id="system-location-chart"></canvas>
</div>
</div>
<div class="list-section" style="display: flex; flex-direction: column; min-height: 0;">
<div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 0.75rem; height: 32px; flex-shrink: 0;">
<h4 id="list-section-title" style="font-size: 14px; font-weight: 700; color: var(--text-main);">자산등록현황</h4>
<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-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';"></select>
</div>
</div>
<div style="flex: 1; overflow-y: auto; border-top: 2px solid var(--text-main);">
<table style="width: 100%; border-collapse: collapse; table-layout: fixed;">
<thead style="position: sticky; top: 0; background: white; z-index: 10;">
<tr style="text-align: left; font-size: 11px;">
<th style="padding: 8px 0; font-weight: 700; border-bottom: 1px solid var(--border-color); width: 50px;">분류</th>
<th style="padding: 8px 0; font-weight: 700; border-bottom: 1px solid var(--border-color); width: 130px;">용도</th>
<th style="padding: 8px 0; font-weight: 700; border-bottom: 1px solid var(--border-color);">비고</th>
<th style="padding: 8px 0; text-align: center; font-weight: 700; border-bottom: 1px solid var(--border-color); width: 90px;">상세위치</th>
</tr>
</thead>
<tbody id="system-status-tbody"></tbody>
</table>
</div>
</div>
</div>
</div>
`;
(window as any).dispatchLocFilter = (loc: string) => {
if (isPcView) return; // PC 뷰에서는 위치 필터링 비활성화 (유형별로 보기 때문)
selectedLocation = loc;
selectedDetailLocation = null;
renderSystemStatus();
};
setTimeout(() => {
const ctx = document.getElementById('system-location-chart') as HTMLCanvasElement;
if (ctx && typeof (window as any).Chart !== 'undefined') {
new (window as any).Chart(ctx, {
type: 'doughnut',
data: { labels: chartLabels, datasets: [{ data: chartData, backgroundColor: chartColors, borderWidth: 0 }] },
options: {
responsive: true, maintainAspectRatio: false, cutout: '70%',
onClick: (evt: any, elements: any[]) => {
if (!isPcView && elements.length > 0) {
selectedLocation = locLabels[elements[0].index];
selectedDetailLocation = null;
renderSystemStatus();
}
},
plugins: { legend: { display: false } }
}
});
}
document.getElementById('btn-reset-loc')?.addEventListener('click', () => {
selectedLocation = null;
selectedDetailLocation = null;
renderSystemStatus();
});
document.getElementById('select-detail-loc')?.addEventListener('change', (e) => {
selectedDetailLocation = (e.target as HTMLSelectElement).value || null;
updateTableOnly();
});
updateTableOnly();
}, 100);
};
// [자산 목록] 테이블 렌더러
const tableWrapper = document.createElement('div');
tableWrapper.className = 'table-container';
const table = document.createElement('table');
// 1. 헤더 생성
const thead = document.createElement('thead');
const trHead = document.createElement('tr');
config.columns.forEach(col => {
const th = document.createElement('th');
th.innerHTML = col.header;
if (col.sortKey) th.setAttribute('data-sort', col.sortKey);
if (col.width) th.style.width = col.width;
if (col.align) th.style.textAlign = col.align;
if (col.className) th.className = col.className;
trHead.appendChild(th);
});
thead.appendChild(trHead);
table.appendChild(thead);
// 2. 본문 생성
const tbody = document.createElement('tbody');
tbody.id = 'dynamic-tbody';
table.appendChild(thead);
table.appendChild(tbody);
tableWrapper.appendChild(table);
container.appendChild(tableWrapper);
// 3. 테이블 업데이트 로직
const updateTable = () => {
if (state.currentViewMode !== 'asset') return;
let filtered = applyCommonFilters(fullList, currentFilters, config.searchKeys as any[]);
if (sortState.key) filtered = dynamicSort(filtered, sortState.key, sortState.direction);
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.align ? `text-align:${col.align};` : ''}"
class="${col.className || ''}">${col.header}</th>`).join('')}</tr>`;
tbody.innerHTML = '';
if (filtered.length === 0) {
const emptyMsg = config.emptyMessage || UI_TEXT.MESSAGES.NO_DATA;
tbody.innerHTML = `<tr><td colspan="${config.columns.length}" class="text-center" style="padding: 3rem; color: var(--text-muted);">${emptyMsg}</td></tr>`;
return;
}
tbody.innerHTML = filtered.length === 0
? `<tr><td colspan="${config.columns.length}" class="text-center" style="padding: 3rem; color: var(--text-muted);">${config.emptyMessage || UI_TEXT.MESSAGES.NO_DATA}</td></tr>`
: filtered.map(asset => `
<tr style="cursor:pointer;" class="asset-row" data-id="${asset.id}">
${config.columns.map(col => `<td style="${col.align ? `text-align:${col.align};` : ''}" class="${col.className || ''}">${col.render(asset)}</td>`).join('')}
</tr>`).join('');
filtered.forEach((asset) => {
const tr = document.createElement('tr');
if (config.onRowClick) {
tr.style.cursor = 'pointer';
tr.addEventListener('click', () => config.onRowClick!(asset));
}
config.columns.forEach(col => {
const td = document.createElement('td');
if (col.align) td.style.textAlign = col.align;
if (col.className) td.className = col.className;
td.innerHTML = col.render(asset);
tr.appendChild(td);
});
tbody.appendChild(tr);
tbody.querySelectorAll('.asset-row').forEach((tr, idx) => {
tr.addEventListener('click', () => config.onRowClick && config.onRowClick(filtered[idx]));
});
setupTableSorting(table, sortState, (key, dir) => {
sortState = { key, direction: dir };
// If external state was provided, sync it back
if (config.persistentSortState) {
config.persistentSortState.key = key;
config.persistentSortState.direction = dir;
config.persistentSortState.key = key;
config.persistentSortState.direction = dir;
}
updateTable();
});
// 모든 가능한 아이콘 로드 (안전하게)
createIcons({ icons: { RefreshCcw, Plus, Edit2, Trash2, Users, Cloud, CreditCard, DollarSign, Paperclip } });
};
// 4. 필터 바 렌더링
// --- 뷰 전환 로직 ---
const switchView = () => {
contentWrapper.innerHTML = '';
if (state.currentViewMode === 'asset') {
filterBar.style.display = 'flex';
contentWrapper.style.overflowY = 'auto';
contentWrapper.appendChild(tableWrapper);
updateTable();
} else {
filterBar.style.display = 'none';
contentWrapper.style.overflowY = 'hidden';
renderSystemStatus();
}
};
// 토글 버튼 이벤트
toggleWrapper.addEventListener('click', (e) => {
const btn = (e.target as HTMLElement).closest('.toggle-btn') as HTMLButtonElement;
if (!btn) return;
toggleWrapper.querySelectorAll('.toggle-btn').forEach(b => b.classList.remove('active'));
btn.classList.add('active');
state.currentViewMode = btn.getAttribute('data-mode') as 'asset' | 'system';
switchView();
});
// 필터 바 초기화
renderFilterBar(filterBar, {
...config.filterOptions,
onFilterChange: (filters) => {
@@ -127,18 +374,11 @@ export function createListView(container: HTMLElement, config: ListViewConfig) {
}
});
// 5. 동적 Select 박스 데이터 채우기
// 셀렉트 박스 채우기
const populateSelect = (selector: string, dataKey: string) => {
const select = container.querySelector(selector) as HTMLSelectElement;
if (select) {
// Handle multiple possible keys for department names due to legacy data
const getVal = (a: any) => {
if (dataKey === ASSET_SCHEMA.CURRENT_DEPT.key) {
return a[dataKey] || a['현사용부서'] || a['현사용조직'];
}
return a[dataKey];
}
const getVal = (a: any) => dataKey === ASSET_SCHEMA.CURRENT_DEPT.key ? (a[dataKey] || a['현사용부서'] || a['현사용조직']) : a[dataKey];
const uniqueValues = Array.from(new Set(fullList.map(getVal))).filter(Boolean).sort();
uniqueValues.forEach(val => {
const opt = document.createElement('option');
@@ -154,6 +394,6 @@ export function createListView(container: HTMLElement, config: ListViewConfig) {
if (config.filterOptions.showCorp) populateSelect('#filter-corp', ASSET_SCHEMA.PURCHASE_CORP.key);
if (config.filterOptions.showType) populateSelect('#filter-type', ASSET_SCHEMA.ASSET_TYPE.key);
// 6. 초기 렌더링
updateTable();
// 초기 실행
switchView();
}