style: unify UI styling & restore dashboard logic

- Restored HW/SW Dashboard full features (Chart.js, filters, tables) from main
- Unified Search Bar & Filter Bar across all views (List, Location)
- Integrated asset identity info into all Modal Headers
- Standardized 'Remove Row' buttons as high-visibility circular circles
- Centralized hardcoded inline styles into dedicated CSS files
- Fixed various ReferenceErrors and layout regressions in HWModal
This commit is contained in:
2026-06-17 12:29:26 +09:00
parent b37981506e
commit 89d3ac2e89
16 changed files with 1440 additions and 596 deletions

File diff suppressed because it is too large Load Diff

View File

@@ -1,15 +1,16 @@
import { state } from '../../core/state';
import { openSwUsageDetail } from '../../components/Modal/DashboardDetailModal';
import { openSwDashboardDetail, openSwUsageDetail } from '../../components/Modal/DashboardDetailModal';
import { normalizeDate } from '../../core/utils';
import { ASSET_SCHEMA } from '../../core/schema';
export function renderSwDashboard(container: HTMLElement) {
let extQty = 0, extUsed = 0, extTotal = 0;
let intQty = 0, intUsed = 0, intTotal = 0;
let extQty = 0, extUsed = 0, extExp = 0, extTotal = 0;
let intQty = 0, intUsed = 0, intExp = 0, intTotal = 0;
let extCost2026 = 0;
let intCost2026 = 0;
// 통합 SW 데이터
const allSw = [...state.masterData.swExternal, ...state.masterData.swInternal];
allSw.forEach(sw => {
@@ -20,6 +21,7 @@ export function renderSwDashboard(container: HTMLElement) {
if (sw.asset_type === '외부SW' || sw.type === '외부SW') {
extQty += qty; extUsed += assigned; extTotal++;
if (isSWExpiring(sw)) extExp++;
if (sw[ASSET_SCHEMA.PURCHASE_DATE.key]?.startsWith('2026')) extCost2026 += price;
} else {
intQty += qty; intUsed += assigned; intTotal++;
@@ -31,14 +33,14 @@ export function renderSwDashboard(container: HTMLElement) {
const intPer = intQty > 0 ? Math.round((intUsed/intQty)*100) : 0;
container.innerHTML = `
<div class="view-container">
<div class="view-container bg-soft">
<h3 class="dashboard-section-title">소프트웨어 라이선스 현황</h3>
<div class="dashboard-layout-2col">
<div class="dashboard-layout-2col mb-6">
<div class="dashboard-card clickable" data-action="ext-usage">
<div class="stat-label">외부 소프트웨어 사용율</div>
<div class="stat-sub">${extQty}카피 중 ${extUsed}개 할당</div>
<div class="stat-value"><span>${extPer}</span><span>%</span></div>
<div class="stat-value text-primary">${extPer}%</div>
<div class="stat-progress-bar">
<div class="progress-fill" style="width: ${extPer}%;"></div>
</div>
@@ -46,23 +48,23 @@ export function renderSwDashboard(container: HTMLElement) {
<div class="dashboard-card clickable" data-action="int-usage">
<div class="stat-label">내부 소프트웨어 현황</div>
<div class="stat-sub">등록된 내부 솔루션: ${intTotal}개</div>
<div class="stat-value"><span>${intPer}</span><span>%</span></div>
<div class="stat-progress-bar">
<div class="stat-value text-primary">${intPer}%</div>
<div class="stat-progress-bar">
<div class="progress-fill" style="width: ${intPer}%;"></div>
</div>
</div>
</div>
<h3 class="dashboard-section-title">2026년 누적 도입 비용 분석</h3>
<div class="dashboard-layout-2col">
<div class="dashboard-card">
<div class="stat-label">외부 SW 누적 비용 (2026)</div>
<div class="stat-value"><span>₩ ${extCost2026.toLocaleString()}</span></div>
<div class="stat-value text-primary">₩ ${extCost2026.toLocaleString()}</div>
</div>
<div class="dashboard-card">
<div class="stat-label">내부 SW 누적 비용 (2026)</div>
<div class="stat-value" style="color: var(--color-blue);"><span>₩ ${intCost2026.toLocaleString()}</span></div>
<div class="stat-value text-blue">₩ ${intCost2026.toLocaleString()}</div>
</div>
</div>
</div>
@@ -71,3 +73,11 @@ export function renderSwDashboard(container: HTMLElement) {
container.querySelector('[data-action="ext-usage"]')?.addEventListener('click', () => openSwUsageDetail('외부 소프트웨어 사용 목록', state.masterData.swExternal));
container.querySelector('[data-action="int-usage"]')?.addEventListener('click', () => openSwUsageDetail('내부 소프트웨어 사용 목록', state.masterData.swInternal));
}
function isSWExpiring(sw: any) {
const expiry = sw[ASSET_SCHEMA.EXPIRED_DATE.key];
if (!expiry) return false;
const endMs = new Date(normalizeDate(expiry)).getTime();
const diffDays = (endMs - Date.now()) / (1000 * 60 * 60 * 24);
return diffDays >= 0 && diffDays <= 30;
}

View File

@@ -189,25 +189,10 @@ export function createListView(container: HTMLElement, config: ListViewConfig) {
// 2. 필터 바 생성 (자산 목록에서만 사용)
const filterBar = document.createElement('div');
filterBar.className = 'search-bar';
filterBar.className = 'filter-bar';
// 자산 추가 버튼 및 목록 보기 체크박스 추가 로직
const showPcFlowBtn = config.title === 'PC';
const extraActionHTML = `
<div class="header-action-group flex items-center gap-2" style="margin-left: auto; align-self: flex-end;">
${showPcFlowBtn ? `
<button id="btn-goto-parts-master" class="btn btn-outline btn-sm">
<i data-lucide="settings" class="icon-sm"></i> 부품 마스터
</button>
<button id="btn-pc-flow" class="btn btn-outline btn-sm">
PC 이동/반납
</button>
` : ''}
<button id="btn-add-asset" class="btn btn-primary">
<i data-lucide="plus" class="icon-sm"></i> 자산 추가
</button>
</div>
`;
container.appendChild(filterBar);
container.appendChild(contentWrapper);
@@ -242,15 +227,12 @@ export function createListView(container: HTMLElement, config: ListViewConfig) {
selectedLocation = validLocations[0] || '';
}
const locationCounts: Record<string, number> = {};
const pcTypeCounts = { public: 0, server: 0, personal: 0 };
// 동적 통계 수집 객체 (Hardcoding 제거)
// 동적 통계 수집 객체
const extStats = {
total: 0,
locCounts: {} as Record<string, number>,
typeCounts: {} as Record<string, number>,
typeLocMap: {} as Record<string, Record<string, number>>, // 유형별 위치 분포
typeLocMap: {} as Record<string, Record<string, number>>,
locWarning: 0,
typeWarning: 0
};
@@ -326,10 +308,10 @@ export function createListView(container: HTMLElement, config: ListViewConfig) {
<div class="stat-group-item bordered">${generateDetailStatHTML('내부 (테스트) 상세', intStats)}</div>
</div>
<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.3; display: flex; flex-direction: column; min-height: 0; padding: 1rem 1.5rem 0 0; border-right: 1px solid var(--hairline);">
<div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 1rem; flex-shrink: 0;">
<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>
@@ -344,7 +326,7 @@ export function createListView(container: HTMLElement, config: ListViewConfig) {
</div>
` : ''}
</div>
<div style="flex: 1; overflow-y: auto;">
<div class="flex-1 overflow-y-auto">
<table class="compact-table">
<thead>
${isPcView ? `
@@ -372,17 +354,17 @@ export function createListView(container: HTMLElement, config: ListViewConfig) {
</div>
</div>
<!-- 우측: 상세 정보 패널 (Box-less, Line-based) -->
<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" class="detail-empty-state" style="justify-content: ${isPcView ? 'flex-start' : 'center'}; align-items: ${isPcView ? 'stretch' : 'center'};">
<!-- 우측: 상세 정보 패널 -->
<div id="system-detail-panel" class="detail-panel">
<div id="detail-empty-state" class="detail-empty-state">
${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;">
<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 style="flex: 1; overflow-y: auto;">
<div class="flex-1 overflow-y-auto">
<table class="compact-table">
<thead>
<tr>
@@ -402,8 +384,8 @@ export function createListView(container: HTMLElement, config: ListViewConfig) {
<p class="empty-list-message">목록에서 자산을 선택하면<br>상세 정보와 배치도가 표시됩니다.</p>
`}
</div>
<div id="detail-content" class="detail-content hidden" style="flex: 1; display: flex; flex-direction: column; overflow: hidden;">
<div class="detail-header-actions" style="padding: 1.25rem 1.5rem; border-bottom: 1px solid var(--hairline); background: white;">
<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>
@@ -412,15 +394,15 @@ export function createListView(container: HTMLElement, config: ListViewConfig) {
</div>
<!-- 메인 배치도 영역 -->
<div style="flex: 1; display: flex; flex-direction: column; min-height: 0; overflow: hidden; padding: 1rem;">
<div id="detail-photo-wrapper" style="width: 100%; flex: 1; overflow: hidden; display: flex; align-items: center; justify-content: center; position: relative; border: 1px solid var(--hairline); background: #f0f0f0; border-radius: 8px;">
<div class="layout-map-container readonly" style="position: relative; display: flex; align-items: center; justify-content: center; width: 100%; height: 100%;">
<img id="detail-photo" src="" style="display: block; max-width: 100%; max-height: 100%; width: auto; height: auto; object-fit: contain; pointer-events: none;" />
<iframe id="detail-html-map" src="" style="display: none; width: 100%; height: 100%; border: none;"></iframe>
<div id="detail-marker" class="layout-marker pulse-marker" style="display: none; position: absolute; z-index: 20;"></div>
<div id="detail-overlay-layer" style="position: absolute; pointer-events: none;"></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" style="padding: 3rem; text-align: center; color: var(--mute);">
<div id="detail-no-photo" class="no-photo-state hidden">
<span>등록된 배치도가 없습니다.</span>
</div>
</div>
@@ -438,16 +420,13 @@ export function createListView(container: HTMLElement, config: ListViewConfig) {
emptyState.classList.add('hidden');
content.classList.remove('hidden');
content.style.display = 'flex';
const codeEl = document.getElementById('detail-asset-code');
const typeEl = document.getElementById('detail-asset-type');
const memoEl = document.getElementById('detail-memo');
const viewBtn = document.getElementById('btn-view-full-detail') as HTMLButtonElement;
if (codeEl) codeEl.textContent = asset.asset_code || '미지정';
if (typeEl) typeEl.textContent = asset.asset_type || '-';
if (memoEl) memoEl.textContent = asset.memo || '-';
if (viewBtn) viewBtn.onclick = () => config.onRowClick && config.onRowClick(asset);
const photo = document.getElementById('detail-photo') as HTMLImageElement;
@@ -474,11 +453,13 @@ export function createListView(container: HTMLElement, config: ListViewConfig) {
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;
@@ -545,27 +526,6 @@ export function createListView(container: HTMLElement, config: ListViewConfig) {
if (overlayLayer) overlayLayer.innerHTML = '';
if (noPhoto) { noPhoto.classList.remove('hidden'); noPhoto.style.display = 'flex'; }
}
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 => {
r.classList.remove('active');
});
}
if (isPcView) {
updateTableOnly();
}
};
}
};
// [자산 현황] 테이블 렌더러
@@ -588,19 +548,19 @@ export function createListView(container: HTMLElement, config: ListViewConfig) {
const tbody = document.getElementById('system-status-tbody');
if (tbody) {
tbody.innerHTML = finalDisplayList.length === 0
? `<tr><td colspan="5" class="empty-cell">조회된 자산이 없습니다.</td></tr>`
? `<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 === '외부SW' && (loc !== 'IDC' || type.toLowerCase().includes('서버pc'));
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 ${isWarning ? 'warning' : ''}" data-id="${asset.id}">
<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>
@@ -644,12 +604,10 @@ export function createListView(container: HTMLElement, config: ListViewConfig) {
let filtered = applyCommonFilters(fullList, currentFilters, config.searchKeys as any[]);
if (sortState.key) filtered = dynamicSort(filtered, sortState.key, sortState.direction);
// Headers are naturally centered via CSS now. Only apply specific widths or sorting.
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 => {
// Date columns should remain centered. Everything else defaults to left (via CSS).
const isDateCol = col.header.includes('일') || col.header.includes('날짜') || col.header.includes('연월');
return `<td class="${isDateCol ? 'text-center' : ''}">${col.render(asset)}</td>`;
}).join('')}</tr>`).join('');
@@ -674,9 +632,9 @@ export function createListView(container: HTMLElement, config: ListViewConfig) {
...config.filterOptions,
initialFilters: currentFilters,
extraHTML: isServer ? `
<div class="search-item">
<label class="flex items-center gap-2 cursor-pointer font-semibold" style="color: var(--primary); height: clamp(34px, 4.5vmin, 44px); padding: 0 0.5rem;">
<input type="checkbox" id="chk-list-view" ${(state as any).currentViewMode === 'asset' ? 'checked' : ''} style="width: 16px; height: 16px; cursor: pointer;" />
<div class="filter-group">
<label class="list-view-toggle-label">
<input type="checkbox" id="chk-list-view" ${(state as any).currentViewMode === 'asset' ? 'checked' : ''} />
목록보기
</label>
</div>
@@ -684,24 +642,24 @@ export function createListView(container: HTMLElement, config: ListViewConfig) {
onFilterChange: (filters) => { Object.assign(currentFilters, filters); updateTable(); }
});
// 3. 필터 바 내 액션 버튼 배치 (자산 추가, 부품 마스터 등)
// 3. 필터 바 내 액션 버튼 배치
const actionContainer = filterBar.querySelector('#filter-bar-actions');
if (actionContainer) {
actionContainer.className = "header-action-group flex items-center gap-2 ml-auto self-end";
actionContainer.innerHTML = `
${showPcFlowBtn ? `
<button id="btn-goto-parts-master" class="btn btn-outline">
<i data-lucide="settings" style="width: 18px; height: 18px;"></i> 부품 마스터
<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" style="width: 18px; height: 18px;"></i> 자산 추가
<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);
@@ -717,7 +675,6 @@ export function createListView(container: HTMLElement, config: ListViewConfig) {
// 서버 탭 전용 목록보기 체크박스 이벤트
if (isServer) {
const toggleBtn = filterBar.querySelector('#btn-toggle-list-view');
const chkBox = filterBar.querySelector('#chk-list-view') as HTMLInputElement;
const handleToggle = () => {
@@ -731,10 +688,6 @@ export function createListView(container: HTMLElement, config: ListViewConfig) {
}
window.dispatchEvent(new Event('refresh-view'));
};
toggleBtn?.addEventListener('click', (e) => {
if (e.target !== chkBox) handleToggle();
});
chkBox?.addEventListener('change', handleToggle);
}