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:
File diff suppressed because it is too large
Load Diff
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user