feat: DB V3 정규화 및 용도 기반 동적 UI 구현
- 백엔드: asset_core(마스터), asset_spec(사양), asset_volume(스토리지), asset_location(위치), asset_network(네트워크/원격) 5개 테이블로 V3 정규화 완료 - 백엔드: /api/assets/master 단일 엔드포인트로 통합 및 서브쿼리 최적화를 통한 UI 하위 호환성 유지 - 백엔드: 저장 로직(save) V3 스키마 분산 저장 및 cascade 기반 삭제 로직 적용 - 프론트엔드(HWModal): '현 용도(current_role)' 필드 추가 및 서버/개인용에 따른 네트워크/위치 섹션 동적 렌더링 구현 - 프론트엔드(state): 분산된 API 호출을 단일 호출로 통합하여 렌더링 성능 최적화 - 레거시 백업 파일 및 불필요한 구형 테이블 완벽 정리 완료
This commit is contained in:
@@ -42,15 +42,15 @@ export function createListView(container: HTMLElement, config: ListViewConfig) {
|
||||
let currentFilters: any = { keyword: '', corp: '', dept: '', loc: '', field: '', type: '' };
|
||||
|
||||
// 강제로 기본 뷰 모드를 'system' (자산 현황)으로 설정
|
||||
state.currentViewMode = 'system';
|
||||
(state as any).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>
|
||||
<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>
|
||||
`;
|
||||
container.appendChild(toggleWrapper);
|
||||
@@ -82,21 +82,32 @@ export function createListView(container: HTMLElement, config: ListViewConfig) {
|
||||
// [자산 현황] 대시보드 렌더러
|
||||
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;
|
||||
|
||||
// 동적 통계 수집 객체 (Hardcoding 제거)
|
||||
const extStats = { total: 0, locCounts: {} as Record<string, number>, typeCounts: {} as Record<string, number>, locWarning: 0, typeWarning: 0 };
|
||||
const intStats = { total: 0, locCounts: {} as Record<string, number>, typeCounts: {} as Record<string, number> };
|
||||
|
||||
const extTypeCounts: Record<string, number> = {};
|
||||
const intTypeCounts: Record<string, number> = {};
|
||||
let locWarningCount = 0;
|
||||
let typeWarningCount = 0;
|
||||
// 중앙화된 경고 감지 로직
|
||||
const checkAnomaly = (serviceType: string, loc: string, type: string) => {
|
||||
if (serviceType !== '외부') return { isWarning: false, isLocWarning: false, isTypeWarning: false, reason: '' };
|
||||
const isLocWarning = loc !== 'IDC' && loc !== '미지정' && loc !== '';
|
||||
const isTypeWarning = type.toLowerCase().replace(/\s/g, '').includes('서버pc');
|
||||
const isWarning = isLocWarning || isTypeWarning;
|
||||
|
||||
let reason = '';
|
||||
if (isLocWarning && isTypeWarning) reason = '위치/형식 부적절';
|
||||
else if (isLocWarning) reason = '위치 부적절';
|
||||
else if (isTypeWarning) reason = '형식 부적절';
|
||||
|
||||
return { isWarning, isLocWarning, isTypeWarning, reason };
|
||||
};
|
||||
|
||||
fullList.forEach(asset => {
|
||||
const loc = asset[ASSET_SCHEMA.LOCATION.key] || '미지정';
|
||||
const serviceTypeKey = ASSET_SCHEMA.SERVICE_TYPE?.key || 'service_type';
|
||||
const serviceTypeKey = (ASSET_SCHEMA as any).SERVICE_TYPE?.key || 'service_type';
|
||||
const serviceType = asset[serviceTypeKey] || '외부';
|
||||
const type = asset[ASSET_SCHEMA.ASSET_TYPE.key] || '';
|
||||
|
||||
@@ -108,33 +119,39 @@ export function createListView(container: HTMLElement, config: ListViewConfig) {
|
||||
else pcTypeCounts.personal++;
|
||||
}
|
||||
|
||||
if (serviceType === '내부') {
|
||||
internalCount++;
|
||||
if (loc.includes('기술개발')) intSubCounts.tech++;
|
||||
else if (loc.includes('IDC')) intSubCounts.idc++;
|
||||
else if (loc.includes('한맥')) intSubCounts.hm++;
|
||||
if (type) intTypeCounts[type] = (intTypeCounts[type] || 0) + 1;
|
||||
} else {
|
||||
externalCount++;
|
||||
if (loc.includes('기술개발')) extSubCounts.tech++;
|
||||
else if (loc.includes('IDC')) extSubCounts.idc++;
|
||||
else if (loc.includes('한맥')) extSubCounts.hm++;
|
||||
if (type) extTypeCounts[type] = (extTypeCounts[type] || 0) + 1;
|
||||
|
||||
// [경고 로직 세분화] 외부 운영 기준 (공백/대소문자 무시)
|
||||
if (serviceType === '외부') {
|
||||
if (loc !== 'IDC') locWarningCount++;
|
||||
if (type.toLowerCase().replace(/\s/g, '').includes('서버pc')) typeWarningCount++;
|
||||
}
|
||||
const targetStat = serviceType === '내부' ? intStats : extStats;
|
||||
targetStat.total++;
|
||||
if (loc) targetStat.locCounts[loc] = (targetStat.locCounts[loc] || 0) + 1;
|
||||
if (type) targetStat.typeCounts[type] = (targetStat.typeCounts[type] || 0) + 1;
|
||||
|
||||
if (serviceType === '외부') {
|
||||
const anomaly = checkAnomaly(serviceType, loc, type);
|
||||
if (anomaly.isLocWarning) extStats.locWarning++;
|
||||
if (anomaly.isTypeWarning) extStats.typeWarning++;
|
||||
}
|
||||
});
|
||||
|
||||
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]);
|
||||
// 템플릿 제너레이터 함수 (HTML 중복 제거)
|
||||
const generateDetailStatHTML = (title: string, stats: typeof extStats) => `
|
||||
<div style="display: flex; align-items: center; justify-content: space-between; margin-bottom: 0.5rem; gap: 0.5rem;">
|
||||
<span style="font-size: 14px; font-weight: 800; color: var(--text-main); white-space: nowrap;">${title}</span>
|
||||
<div style="display: flex; gap: 4px; flex-wrap: wrap; justify-content: flex-end;">
|
||||
${stats.locWarning ? `<span style="background: #FFF7ED; color: #C2410C; font-size: 10px; font-weight: 800; padding: 2px 6px; border-radius: 4px; border: 1px solid #FFEDD5; white-space: nowrap;">위치부적절: ${stats.locWarning}</span>` : ''}
|
||||
${stats.typeWarning ? `<span style="background: #FFF1F2; color: #E11D48; font-size: 10px; font-weight: 800; padding: 2px 6px; border-radius: 4px; border: 1px solid #FDA4AF; white-space: nowrap;">형식부적절: ${stats.typeWarning}</span>` : ''}
|
||||
</div>
|
||||
</div>
|
||||
<div style="display: flex; flex-direction: column; gap: 0.3rem; font-size: 13px; color: var(--text-muted);">
|
||||
<div style="display: flex; gap: 0.75rem; flex-wrap: wrap;">
|
||||
${Object.entries(stats.locCounts).sort((a, b) => b[1] - a[1]).slice(0, 4).map(([l, c]) => `<span>${l}: <strong style="color:var(--text-main); font-size: 14px;">${c}</strong></span>`).join('')}
|
||||
</div>
|
||||
<div style="display: flex; gap: 0.6rem; flex-wrap: wrap; opacity: 0.9; border-top: 1px dashed var(--border-color); padding-top: 4px; margin-top: 2px;">
|
||||
${Object.entries(stats.typeCounts).sort((a, b) => b[1] - a[1]).slice(0, 6).map(([t, c]) => {
|
||||
const isTypeWarning = title.includes('외부') && t.toLowerCase().replace(/\s/g, '').includes('서버pc');
|
||||
return `<span style="${isTypeWarning ? 'color:#E11D48; font-weight:700;' : ''}; font-size: 13px;">${t}: <strong style="color:var(--text-main); font-size: 14px;">${c}</strong></span>`;
|
||||
}).join('')}
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
|
||||
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;">
|
||||
@@ -145,8 +162,8 @@ export function createListView(container: HTMLElement, config: ListViewConfig) {
|
||||
<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>
|
||||
<div style="display: flex; gap: 0.75rem; font-size: 14px; color: var(--text-muted); margin-top: 0.5rem;">
|
||||
<span>외부: <strong style="color:#35635C; font-size: 18px;">${externalCount}</strong></span>
|
||||
<span>내부: <strong style="color:#94A3B8; font-size: 18px;">${internalCount}</strong></span>
|
||||
<span>외부: <strong style="color:#35635C; font-size: 18px;">${extStats.total}</strong></span>
|
||||
<span>내부: <strong style="color:#94A3B8; font-size: 18px;">${intStats.total}</strong></span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -158,46 +175,11 @@ export function createListView(container: HTMLElement, config: ListViewConfig) {
|
||||
<span>서버: <strong style="color:var(--text-main); font-size: 18px;">${pcTypeCounts.server}</strong></span>
|
||||
<span>개인: <strong style="color:var(--text-main); font-size: 18px;">${pcTypeCounts.personal}</strong></span>
|
||||
</div>
|
||||
` : `
|
||||
<div style="display: flex; align-items: center; justify-content: space-between; margin-bottom: 0.5rem; gap: 0.5rem;">
|
||||
<span style="font-size: 14px; font-weight: 800; color: var(--text-main); white-space: nowrap;">외부 (운영) 상세</span>
|
||||
<div style="display: flex; gap: 4px; flex-wrap: wrap; justify-content: flex-end;">
|
||||
${locWarningCount > 0 ? `<span style="background: #FFF7ED; color: #C2410C; font-size: 10px; font-weight: 800; padding: 2px 6px; border-radius: 4px; border: 1px solid #FFEDD5; white-space: nowrap;">위치부적절: ${locWarningCount}</span>` : ''}
|
||||
${typeWarningCount > 0 ? `<span style="background: #FFF1F2; color: #E11D48; font-size: 10px; font-weight: 800; padding: 2px 6px; border-radius: 4px; border: 1px solid #FDA4AF; white-space: nowrap;">형식부적절: ${typeWarningCount}</span>` : ''}
|
||||
</div>
|
||||
</div>
|
||||
<div style="display: flex; flex-direction: column; gap: 0.3rem; font-size: 13px; color: var(--text-muted);">
|
||||
<div style="display: flex; gap: 0.75rem; flex-wrap: wrap;">
|
||||
<span>기술개발센터: <strong style="color:var(--text-main); font-size: 14px;">${extSubCounts.tech}</strong></span>
|
||||
<span>IDC: <strong style="color:var(--text-main); font-size: 14px;">${extSubCounts.idc}</strong></span>
|
||||
<span>한맥빌딩: <strong style="color:var(--text-main); font-size: 14px;">${extSubCounts.hm}</strong></span>
|
||||
</div>
|
||||
<div style="display: flex; gap: 0.6rem; flex-wrap: wrap; opacity: 0.9; border-top: 1px dashed var(--border-color); padding-top: 4px; margin-top: 2px;">
|
||||
${Object.entries(extTypeCounts).sort((a, b) => b[1] - a[1]).map(([type, count]) => {
|
||||
const isTypeWarning = type.toLowerCase().replace(/\s/g, '').includes('서버pc');
|
||||
return `<span style="${isTypeWarning ? 'color:#E11D48; font-weight:700;' : ''}; font-size: 13px;">${type}: <strong style="color:var(--text-main); font-size: 14px;">${count}</strong></span>`;
|
||||
}).join('')}
|
||||
</div>
|
||||
</div>
|
||||
`}
|
||||
` : generateDetailStatHTML('외부 (운영) 상세', extStats)}
|
||||
</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: 14px; font-weight: 800; color: var(--text-main);">내부 (테스트) 상세</span>
|
||||
</div>
|
||||
<div style="display: flex; flex-direction: column; gap: 0.3rem; font-size: 13px; color: var(--text-muted);">
|
||||
<div style="display: flex; gap: 0.75rem; flex-wrap: wrap;">
|
||||
<span>기술개발센터: <strong style="color:var(--text-main); font-size: 14px;">${intSubCounts.tech}</strong></span>
|
||||
<span>IDC: <strong style="color:var(--text-main); font-size: 14px;">${intSubCounts.idc}</strong></span>
|
||||
<span>한맥빌딩: <strong style="color:var(--text-main); font-size: 14px;">${intSubCounts.hm}</strong></span>
|
||||
</div>
|
||||
<div style="display: flex; gap: 0.6rem; flex-wrap: wrap; opacity: 0.9; border-top: 1px dashed var(--border-color); padding-top: 4px; margin-top: 2px;">
|
||||
${Object.entries(intTypeCounts).sort((a, b) => b[1] - a[1]).map(([type, count]) => `<span style="font-size: 13px;">${type}: <strong style="color:var(--text-main); font-size: 14px;">${count}</strong></span>`).join('')}
|
||||
</div>
|
||||
</div>
|
||||
`}
|
||||
${isPcView ? '' : generateDetailStatHTML('내부 (테스트) 상세', intStats as any)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -405,7 +387,7 @@ export function createListView(container: HTMLElement, config: ListViewConfig) {
|
||||
? `<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.SERVICE_TYPE?.key || 'service_type';
|
||||
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] || '';
|
||||
@@ -501,7 +483,7 @@ export function createListView(container: HTMLElement, config: ListViewConfig) {
|
||||
tableWrapper.appendChild(table);
|
||||
|
||||
const updateTable = () => {
|
||||
if (state.currentViewMode !== 'asset') return;
|
||||
if ((state as any).currentViewMode !== 'asset') return;
|
||||
let filtered = applyCommonFilters(fullList, currentFilters, config.searchKeys as any[]);
|
||||
if (sortState.key) filtered = dynamicSort(filtered, sortState.key, sortState.direction);
|
||||
|
||||
@@ -536,7 +518,7 @@ export function createListView(container: HTMLElement, config: ListViewConfig) {
|
||||
// --- 뷰 전환 로직 ---
|
||||
const switchView = () => {
|
||||
contentWrapper.innerHTML = '';
|
||||
if (state.currentViewMode === 'asset') {
|
||||
if ((state as any).currentViewMode === 'asset') {
|
||||
filterBar.style.display = 'flex';
|
||||
contentWrapper.style.overflowY = 'auto';
|
||||
contentWrapper.appendChild(tableWrapper);
|
||||
@@ -554,7 +536,7 @@ export function createListView(container: HTMLElement, config: ListViewConfig) {
|
||||
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';
|
||||
(state as any).currentViewMode = btn.getAttribute('data-mode') as 'asset' | 'system';
|
||||
switchView();
|
||||
});
|
||||
|
||||
|
||||
Reference in New Issue
Block a user