Merge remote-tracking branch 'origin/main' into ux_setting
This commit is contained in:
File diff suppressed because it is too large
Load Diff
@@ -37,34 +37,34 @@ export function renderSwDashboard(container: HTMLElement) {
|
||||
<h3 class="dashboard-section-title">소프트웨어 라이선스 현황</h3>
|
||||
|
||||
<div class="dashboard-layout-2col" style="margin-bottom: 1.5rem;">
|
||||
<div class="dashboard-card dashboard-card-mini clickable-card" data-action="ext-usage">
|
||||
<h4>외부 소프트웨어 사용율</h4>
|
||||
<div class="description">${extQty}카피 중 ${extUsed}개 할당</div>
|
||||
<div class="big-value">${extPer}%</div>
|
||||
<div class="progress-container">
|
||||
<div class="progress-fill" style="width: ${extPer}%;"></div>
|
||||
<div class="dashboard-card" data-action="ext-usage" style="cursor:pointer; min-height:auto;">
|
||||
<span style="font-size:1.21rem; font-weight:700; color:var(--text-main);">외부 소프트웨어 사용율</span>
|
||||
<div style="font-size: 1.02rem; color:var(--text-muted); margin-bottom: 1rem;">${extQty}카피 중 ${extUsed}개 할당</div>
|
||||
<div style="font-size: 2.21rem; font-weight:700; color:var(--dash-primary);">${extPer}%</div>
|
||||
<div style="width: 100%; height: 4px; background-color: var(--border-color); border-radius: 2px; overflow: hidden; margin-top: 0.5rem;">
|
||||
<div style="width: ${extPer}%; height: 100%; background-color: var(--dash-primary);"></div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="dashboard-card dashboard-card-mini clickable-card" data-action="int-usage">
|
||||
<h4>내부 소프트웨어 현황</h4>
|
||||
<div class="description">등록된 내부 솔루션: ${intTotal}개</div>
|
||||
<div class="big-value">${intPer}%</div>
|
||||
<div class="progress-container">
|
||||
<div class="progress-fill" style="width: ${intPer}%;"></div>
|
||||
<div class="dashboard-card" data-action="int-usage" style="cursor:pointer; min-height:auto;">
|
||||
<span style="font-size:1.21rem; font-weight:700; color:var(--text-main);">내부 소프트웨어 현황</span>
|
||||
<div style="font-size: 1.02rem; color:var(--text-muted); margin-bottom: 1rem;">등록된 내부 솔루션: ${intTotal}개</div>
|
||||
<div style="font-size: 2.21rem; font-weight:700; color:var(--dash-primary);">${intPer}%</div>
|
||||
<div style="width: 100%; height: 4px; background-color: var(--border-color); border-radius: 2px; overflow: hidden; margin-top: 0.5rem;">
|
||||
<div style="width: ${intPer}%; height: 100%; background-color: var(--dash-primary);"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<h3 class="dashboard-section-title">2026년 누적 도입 비용 분석</h3>
|
||||
|
||||
<div class="dashboard-layout-2col">
|
||||
<div class="dashboard-card dashboard-card-mini">
|
||||
<h4>외부 SW 누적 비용 (2026)</h4>
|
||||
<div class="big-value">₩ ${extCost2026.toLocaleString()}</div>
|
||||
<div style="display:grid; grid-template-columns: repeat(2, 1fr); gap:1.5rem; margin-bottom:1.5rem;">
|
||||
<div class="dashboard-card" style="min-height:auto;">
|
||||
<span style="font-size:1.21rem; font-weight:700; color:var(--text-main);">외부 SW 누적 비용 (2026)</span>
|
||||
<div style="font-size: 2.21rem; font-weight:700; color:var(--dash-primary);">₩ ${extCost2026.toLocaleString()}</div>
|
||||
</div>
|
||||
<div class="dashboard-card dashboard-card-mini">
|
||||
<h4>내부 SW 누적 비용 (2026)</h4>
|
||||
<div class="big-value" style="color:#3b82f6;">₩ ${intCost2026.toLocaleString()}</div>
|
||||
<div class="dashboard-card" style="min-height:auto;">
|
||||
<span style="font-size:1.21rem; font-weight:700; color:var(--text-main);">내부 SW 누적 비용 (2026)</span>
|
||||
<div style="font-size: 2.21rem; font-weight:700; color:#3b82f6;">₩ ${intCost2026.toLocaleString()}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -5,6 +5,133 @@ import { renderFilterBar, applyCommonFilters } from '../../core/filterHandler';
|
||||
import { state } from '../../core/state';
|
||||
import { IMAGE_LOCATIONS } from '../../components/Modal/SharedData';
|
||||
|
||||
declare var Chart: any;
|
||||
let pcFlowChartInstance: any = null;
|
||||
|
||||
// ─── 100점 만점 감점형 성능 점수 계산 (CPU + RAM + GPU + 연식) ───
|
||||
function calculatePcScoreDeductive(cpu: string, ram: string, gpu: string, purchaseDate: string): number {
|
||||
let score = 100;
|
||||
if (!cpu) cpu = '';
|
||||
if (!ram) ram = '';
|
||||
if (!gpu) gpu = '';
|
||||
|
||||
const cpuUpper = cpu.toUpperCase();
|
||||
const ramUpper = ram.toUpperCase();
|
||||
const gpuUpper = gpu.toUpperCase();
|
||||
|
||||
// 1. CPU 등급 감점 (최대 -30점)
|
||||
let cpuDeduction = 0;
|
||||
if (cpuUpper.includes('I9') || cpuUpper.includes('RYZEN 9') || cpuUpper.includes('RYZEN9')) {
|
||||
cpuDeduction = 0;
|
||||
} else if (cpuUpper.includes('I7') || cpuUpper.includes('RYZEN 7') || cpuUpper.includes('RYZEN7')) {
|
||||
cpuDeduction = 5;
|
||||
} else if (cpuUpper.includes('I5') || cpuUpper.includes('RYZEN 5') || cpuUpper.includes('RYZEN5')) {
|
||||
cpuDeduction = 15;
|
||||
} else if (cpuUpper.includes('I3') || cpuUpper.includes('RYZEN 3') || cpuUpper.includes('RYZEN3')) {
|
||||
cpuDeduction = 25;
|
||||
} else {
|
||||
cpuDeduction = 30;
|
||||
}
|
||||
score -= cpuDeduction;
|
||||
|
||||
// 2. CPU 세대 노후 감점 (최대 -15점)
|
||||
let genDeduction = 0;
|
||||
const intelMatch = cpuUpper.match(/I\d-?(\d+)/);
|
||||
let gen = 0;
|
||||
if (intelMatch && intelMatch[1]) {
|
||||
const numStr = intelMatch[1];
|
||||
if (numStr.length === 5) gen = parseInt(numStr.substring(0, 2), 10);
|
||||
else if (numStr.length === 4) gen = parseInt(numStr.substring(0, 1), 10);
|
||||
}
|
||||
|
||||
const amdMatch = cpuUpper.match(/RYZEN\s?\d\s?-?(\d+)/);
|
||||
let amdGen = 0;
|
||||
if (amdMatch && amdMatch[1] && !intelMatch) {
|
||||
const numStr = amdMatch[1];
|
||||
if (numStr.length === 4) amdGen = parseInt(numStr.substring(0, 1), 10);
|
||||
}
|
||||
|
||||
if (intelMatch) {
|
||||
if (gen >= 12) genDeduction = 0;
|
||||
else if (gen >= 10) genDeduction = 5;
|
||||
else if (gen >= 8) genDeduction = 10;
|
||||
else genDeduction = 15;
|
||||
} else if (amdMatch) {
|
||||
if (amdGen >= 5) genDeduction = 0;
|
||||
else if (amdGen >= 3) genDeduction = 5;
|
||||
else genDeduction = 10;
|
||||
} else {
|
||||
genDeduction = 15;
|
||||
}
|
||||
score -= genDeduction;
|
||||
|
||||
// 3. RAM 용량 감점 (최대 -25점)
|
||||
const ramMatch = ramUpper.match(/(\d+)\s*GB/);
|
||||
let ramDeduction = 25;
|
||||
if (ramMatch && ramMatch[1]) {
|
||||
const ramVal = parseInt(ramMatch[1], 10);
|
||||
if (ramVal >= 32) ramDeduction = 0;
|
||||
else if (ramVal >= 16) ramDeduction = 10;
|
||||
else if (ramVal >= 8) ramDeduction = 20;
|
||||
else ramDeduction = 25;
|
||||
}
|
||||
score -= ramDeduction;
|
||||
|
||||
// 4. GPU 성능 감점 (최대 -25점)
|
||||
let gpuDeduction = 25;
|
||||
if (!gpuUpper || gpuUpper === '-' || gpuUpper.trim() === '') {
|
||||
gpuDeduction = 25;
|
||||
} else if (
|
||||
gpuUpper.includes('RTX 4090') || gpuUpper.includes('RTX 4080') || gpuUpper.includes('RTX 4070') ||
|
||||
gpuUpper.includes('RTX 3090') || gpuUpper.includes('RTX 3080') ||
|
||||
gpuUpper.includes('RTX A5000') || gpuUpper.includes('RTX A6000') || gpuUpper.includes('RTX A4000')
|
||||
) {
|
||||
gpuDeduction = 0;
|
||||
} else if (
|
||||
gpuUpper.includes('RTX 3070') || gpuUpper.includes('RTX 3060') || gpuUpper.includes('RTX 2060') ||
|
||||
gpuUpper.includes('RTX A2000') || gpuUpper.includes('RTX A3000') || gpuUpper.includes('QUADRO')
|
||||
) {
|
||||
gpuDeduction = 5;
|
||||
} else if (
|
||||
gpuUpper.includes('GTX 1660') || gpuUpper.includes('GTX 1080') || gpuUpper.includes('GTX 1070') ||
|
||||
gpuUpper.includes('GTX 1060') || gpuUpper.includes('RX 6700') || gpuUpper.includes('RX 6600')
|
||||
) {
|
||||
gpuDeduction = 15;
|
||||
} else {
|
||||
gpuDeduction = 25;
|
||||
}
|
||||
score -= gpuDeduction;
|
||||
|
||||
// 5. 연식(노후도) 감점 (최대 -15점)
|
||||
let age = 0;
|
||||
if (purchaseDate && purchaseDate !== '-') {
|
||||
let normalized = purchaseDate.replace(/\./g, '-').trim();
|
||||
if (/^\d{6}$/.test(normalized)) {
|
||||
normalized = `${normalized.substring(0, 4)}-${normalized.substring(4, 6)}`;
|
||||
}
|
||||
const purchase = new Date(normalized);
|
||||
if (!isNaN(purchase.getTime())) {
|
||||
// 2026년 5월 31일 기준 경과연수 계산
|
||||
const mockToday = new Date('2026-05-31');
|
||||
const diffMs = mockToday.getTime() - purchase.getTime();
|
||||
age = diffMs / (1000 * 60 * 60 * 24 * 365.25);
|
||||
age = Math.max(0, parseFloat(age.toFixed(1)));
|
||||
}
|
||||
}
|
||||
|
||||
let ageDeduction = 0;
|
||||
if (age < 1) ageDeduction = 0;
|
||||
else if (age < 2) ageDeduction = 3;
|
||||
else if (age < 3) ageDeduction = 6;
|
||||
else if (age < 4) ageDeduction = 9;
|
||||
else if (age < 5) ageDeduction = 12;
|
||||
else ageDeduction = 15;
|
||||
|
||||
score -= ageDeduction;
|
||||
|
||||
return Math.max(10, score);
|
||||
}
|
||||
|
||||
export interface ColumnDef {
|
||||
header: string;
|
||||
sortKey?: string;
|
||||
@@ -25,6 +152,7 @@ export interface ListViewConfig {
|
||||
showLoc?: boolean;
|
||||
showField?: boolean;
|
||||
showType?: boolean;
|
||||
showStatus?: boolean;
|
||||
};
|
||||
columns: ColumnDef[];
|
||||
onRowClick?: (asset: any) => void;
|
||||
@@ -39,10 +167,19 @@ export function createListView(container: HTMLElement, config: ListViewConfig) {
|
||||
|
||||
const fullList = config.dataSource();
|
||||
let sortState: SortState = config.persistentSortState || { key: '', direction: 'asc' };
|
||||
let currentFilters: any = { keyword: '', corp: '', dept: '', loc: '', field: '', type: '' };
|
||||
|
||||
const isServer = config.title === '서버';
|
||||
if (!isServer) {
|
||||
if (!(state as any).listFilters) {
|
||||
(state as any).listFilters = {};
|
||||
}
|
||||
const filterKey = config.title;
|
||||
if (!(state as any).listFilters[filterKey]) {
|
||||
(state as any).listFilters[filterKey] = { keyword: '', corp: '', dept: '', loc: '', field: '', type: '', status: '' };
|
||||
}
|
||||
let currentFilters: any = (state as any).listFilters[filterKey];
|
||||
|
||||
// 서버 및 PC 탭이 아닐 경우 '자산 현황' 뷰 진입 방지 및 강제 'asset' 모드
|
||||
const isServerOrPc = config.title === '서버' || config.title === 'PC';
|
||||
if (!isServerOrPc) {
|
||||
(state as any).currentViewMode = 'asset';
|
||||
} else if (!(state as any).currentViewMode) {
|
||||
(state as any).currentViewMode = 'system';
|
||||
@@ -51,15 +188,27 @@ export function createListView(container: HTMLElement, config: ListViewConfig) {
|
||||
// 2. 뷰 전환 토글 버튼 생성
|
||||
const toggleWrapper = document.createElement('div');
|
||||
toggleWrapper.className = 'view-toggle-container';
|
||||
|
||||
const showPcFlowBtn = config.title === 'PC';
|
||||
toggleWrapper.innerHTML = `
|
||||
<div class="view-toggle-flex-row">
|
||||
<div class="view-toggle" style="display: ${isServer ? 'flex' : 'none'};">
|
||||
<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>
|
||||
<button id="btn-add-asset" class="btn btn-primary btn-add-new">
|
||||
<span class="plus-icon">+</span> 자산 추가
|
||||
</button>
|
||||
<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> 자산 추가
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
container.appendChild(toggleWrapper);
|
||||
@@ -75,7 +224,7 @@ export function createListView(container: HTMLElement, config: ListViewConfig) {
|
||||
container.appendChild(contentWrapper);
|
||||
|
||||
// --- 내부 상태 ---
|
||||
let selectedLocation: string | null = '기술개발센터';
|
||||
let selectedLocation: string | null = null;
|
||||
let selectedDetailLocation: string | null = null;
|
||||
let dynamicMapConfig: Record<string, any[]> = {};
|
||||
|
||||
@@ -91,8 +240,37 @@ export function createListView(container: HTMLElement, config: ListViewConfig) {
|
||||
const renderSystemStatus = () => {
|
||||
const isPcView = config.title === 'PC';
|
||||
|
||||
const extStats = { total: 0, locCounts: {} as Record<string, number>, typeCounts: {} as Record<string, number>, typeLocMap: {} as Record<string, Record<string, number>>, locWarning: 0, typeWarning: 0 };
|
||||
const intStats = { total: 0, locCounts: {} as Record<string, number>, typeCounts: {} as Record<string, number>, typeLocMap: {} as Record<string, Record<string, number>> };
|
||||
// 실제 보유 자산이 존재하는 위치 목록만 추출 (0대인 곳 제외)
|
||||
const validLocations = Array.from(new Set(fullList.map(a => a[ASSET_SCHEMA.LOCATION.key] || '미지정')))
|
||||
.filter(l => {
|
||||
const count = fullList.filter(a => (a[ASSET_SCHEMA.LOCATION.key] || '미지정') === l).length;
|
||||
return count > 0;
|
||||
})
|
||||
.sort();
|
||||
|
||||
// 초기값이나 유효하지 않은 값이 지정되어 있다면 첫 번째 유효 위치로 동적 갱신
|
||||
if (!selectedLocation || !validLocations.includes(selectedLocation)) {
|
||||
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>>, // 유형별 위치 분포
|
||||
locWarning: 0,
|
||||
typeWarning: 0
|
||||
};
|
||||
const intStats = {
|
||||
total: 0,
|
||||
locCounts: {} as Record<string, number>,
|
||||
typeCounts: {} as Record<string, number>,
|
||||
typeLocMap: {} as Record<string, Record<string, number>>
|
||||
};
|
||||
|
||||
const checkAnomaly = (serviceType: string, loc: string, type: string) => {
|
||||
if (serviceType !== '외부') return { isWarning: false, isLocWarning: false, isTypeWarning: false };
|
||||
@@ -159,38 +337,81 @@ export function createListView(container: HTMLElement, config: ListViewConfig) {
|
||||
<div class="stat-group-item bordered">${generateDetailStatHTML('내부 (테스트) 상세', intStats)}</div>
|
||||
</div>
|
||||
|
||||
<div class="dashboard-main-flex">
|
||||
<div class="list-section">
|
||||
<div class="section-header-flex">
|
||||
<h4 id="list-section-title">자산 현황 목록</h4>
|
||||
<div class="filter-controls">
|
||||
<span class="filter-label">위치:</span>
|
||||
<select id="select-loc" class="filter-select">
|
||||
<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(--border-color);">
|
||||
<div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 1rem; flex-shrink: 0;">
|
||||
<h4 id="list-section-title" style="font-size: 14px; font-weight: 700; color: var(--text-main); margin:0;">
|
||||
${isPcView ? `🔄 PC 유동 이력 (${new Date().getMonth() + 1}월)` : '자산 현황 목록'}
|
||||
</h4>
|
||||
${!isPcView ? `
|
||||
<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-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';">
|
||||
<option value="">전체</option>
|
||||
${Array.from(new Set(fullList.map(a => a[ASSET_SCHEMA.LOCATION.key] || '미지정'))).sort().map(l => `<option value="${l}" ${l === selectedLocation ? 'selected' : ''}>${l}</option>`).join('')}
|
||||
${validLocations.map(l => `<option value="${l}" ${l === selectedLocation ? 'selected' : ''}>${l}</option>`).join('')}
|
||||
</select>
|
||||
<select id="select-detail-loc" class="filter-select select-detail-loc"></select>
|
||||
</div>
|
||||
` : ''}
|
||||
</div>
|
||||
<div class="mini-table-container">
|
||||
<table class="mini-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th style="width: 80px;">분류</th>
|
||||
<th>용도/자산명</th>
|
||||
<th style="width: 90px;">관리자(정)</th>
|
||||
<th style="width: 90px;">관리자(부)</th>
|
||||
<th style="width: 100px;">상세위치</th>
|
||||
</tr>
|
||||
<div style="flex: 1; overflow-y: auto;">
|
||||
<table style="width: 100%; border-collapse: collapse; table-layout: fixed;">
|
||||
<thead style="position: sticky; top: 0; background: #fff; z-index: 10;">
|
||||
${isPcView ? `
|
||||
<tr style="text-align: left; font-size: 11px; color: var(--text-muted);">
|
||||
<th style="padding: 10px 8px; font-weight: 700; border-bottom: 2px solid var(--border-color); width: 120px; background: #fff; white-space: nowrap;">일자</th>
|
||||
<th style="padding: 10px 8px; font-weight: 700; border-bottom: 2px solid var(--border-color); width: 100px; background: #fff; white-space: nowrap; text-align: center;">담당자</th>
|
||||
<th style="padding: 10px 8px; font-weight: 700; border-bottom: 2px solid var(--border-color); width: 60px; background: #fff; white-space: nowrap; text-align: center;">구분</th>
|
||||
<th style="padding: 10px 8px; font-weight: 700; border-bottom: 2px solid var(--border-color); width: 90px; background: #fff; white-space: nowrap; text-align: center;">사용자</th>
|
||||
<th style="padding: 10px 8px; font-weight: 700; border-bottom: 2px solid var(--border-color); width: 90px; background: #fff; white-space: nowrap; text-align: center;">인수자</th>
|
||||
<th style="padding: 10px 8px; font-weight: 700; border-bottom: 2px solid var(--border-color); width: 160px; background: #fff; white-space: nowrap; text-align: center;">자산번호</th>
|
||||
<th style="padding: 10px 8px; font-weight: 700; border-bottom: 2px solid var(--border-color); background: #fff; white-space: nowrap;">상세</th>
|
||||
</tr>
|
||||
` : `
|
||||
<tr style="text-align: left; font-size: 11px; color: var(--text-muted);">
|
||||
<th style="padding: 10px 0; font-weight: 700; border-bottom: 2px solid var(--border-color); width: 80px; text-align:center; background: #fff;">분류</th>
|
||||
<th style="padding: 10px 0; font-weight: 700; border-bottom: 2px solid var(--border-color); width: 130px; background: #fff;">용도/자산명</th>
|
||||
<th style="padding: 10px 0; font-weight: 700; border-bottom: 2px solid var(--border-color); text-align:center; width: 90px; background: #fff;">관리자(정)</th>
|
||||
<th style="padding: 10px 0; font-weight: 700; border-bottom: 2px solid var(--border-color); text-align:center; width: 90px; background: #fff;">관리자(부)</th>
|
||||
<th style="padding: 10px 0; text-align: center; font-weight: 700; border-bottom: 2px solid var(--border-color); width: 100px; background: #fff;">상세위치</th>
|
||||
</tr>
|
||||
`}
|
||||
</thead>
|
||||
<tbody id="system-status-tbody"></tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="system-detail-panel" class="detail-panel">
|
||||
<div id="detail-empty-state" class="detail-empty-state">
|
||||
<p>목록에서 자산을 선택하면 상세 정보가 표시됩니다.</p>
|
||||
<!-- 우측: 상세 정보 패널 (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" style="height: 100%; display: flex; flex-direction: column; color: var(--text-muted); text-align: center; overflow-y: auto; box-sizing: border-box; width: 100%; justify-content: ${isPcView ? 'flex-start' : 'center'}; align-items: ${isPcView ? 'stretch' : 'center'};">
|
||||
${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;">
|
||||
<h4 style="margin: 0; font-size: 14px; font-weight: 700; color: #E11D48; display: flex; align-items: center; gap: 6px; white-space: nowrap;">
|
||||
⚠️ 사양 주의 장비 현황 (부족/오버스펙)
|
||||
</h4>
|
||||
</div>
|
||||
<div style="flex: 1; overflow-y: auto;">
|
||||
<table style="width: 100%; border-collapse: collapse; table-layout: fixed;">
|
||||
<thead style="position: sticky; top: 0; background: #fff; z-index: 10;">
|
||||
<tr style="text-align: left; font-size: 11px; color: var(--text-muted);">
|
||||
<th style="padding: 10px 0; font-weight: 700; border-bottom: 2px solid var(--border-color); width: 65px; background: #fff; white-space: nowrap;">사용자</th>
|
||||
<th style="padding: 10px 0; font-weight: 700; border-bottom: 2px solid var(--border-color); width: 115px; background: #fff; white-space: nowrap;">부서 (직무)</th>
|
||||
<th style="padding: 10px 0; font-weight: 700; border-bottom: 2px solid var(--border-color); width: 65px; background: #fff; white-space: nowrap; text-align: center;">상태</th>
|
||||
<th style="padding: 10px 0; font-weight: 700; border-bottom: 2px solid var(--border-color); background: #fff; white-space: nowrap;">자산코드</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="spec-mismatch-tbody" style="font-size: 12px;">
|
||||
<tr><td colspan="4" style="text-align:center; padding:1.5rem; color:#94A3B8;">사양 주의 자산이 없습니다.</td></tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
` : `
|
||||
<p style="font-size: 1.125rem; font-weight: 500; color: #94A3B8;">목록에서 자산을 선택하면<br>상세 정보와 배치도가 표시됩니다.</p>
|
||||
`}
|
||||
</div>
|
||||
<div id="detail-content" class="detail-content hidden">
|
||||
<div class="detail-summary-header">
|
||||
@@ -199,15 +420,26 @@ export function createListView(container: HTMLElement, config: ListViewConfig) {
|
||||
<div class="summary-item"><label>유형</label><div id="detail-asset-type" class="type-value"></div></div>
|
||||
<div class="summary-item flex-1"><label>메모 요약</label><div id="detail-memo" class="memo-value"></div></div>
|
||||
</div>
|
||||
<button id="btn-view-full-detail" class="btn btn-primary">상세 보기</button>
|
||||
<button id="btn-view-flow-logs" style="flex-shrink: 0; padding: 6px 16px; font-size: 12px; font-weight: 700; background: white; color: var(--primary-color); border: 1px solid var(--primary-color); border-radius: 4px; cursor: pointer; transition: opacity 0.2s; margin-right: 8px;">
|
||||
${isPcView ? '목록 보기' : '이력 보기'}
|
||||
</button>
|
||||
<button id="btn-view-full-detail" style="flex-shrink: 0; padding: 6px 16px; font-size: 12px; font-weight: 700; background: var(--primary-color); color: white; border: none; border-radius: 4px; cursor: pointer; transition: opacity 0.2s;">상세 보기</button>
|
||||
</div>
|
||||
<div class="detail-map-section">
|
||||
<div class="map-label-header"><label>설치 위치 배치도</label></div>
|
||||
<div id="detail-photo-wrapper" class="map-photo-wrapper">
|
||||
<div class="layout-map-container readonly">
|
||||
<img id="detail-photo" src="" class="map-photo" />
|
||||
<div id="detail-marker" class="layout-marker pulse-marker hidden"></div>
|
||||
<div id="detail-overlay-layer" class="digital-overlay-layer"></div>
|
||||
|
||||
<!-- 메인 배치도 영역 -->
|
||||
<div style="flex: 1; display: flex; flex-direction: column; min-height: 0; overflow: hidden;">
|
||||
<div style="margin-bottom: 0.75rem; flex-shrink: 0; display: flex; justify-content: space-between; align-items: center;">
|
||||
<label style="font-size: 11px; font-weight: 700; color: var(--text-main); text-transform: uppercase;">설치 위치 배치도</label>
|
||||
</div>
|
||||
<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(--border-color); background: #f0f0f0;">
|
||||
<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" class="digital-overlay-layer" style="position: absolute; top: 0; left: 0; width: 100%; height: 100%; pointer-events: none; display: flex; align-items: center; justify-content: center;"></div>
|
||||
</div>
|
||||
<div id="detail-no-photo" style="display: none; height: 100%; flex-direction: column; align-items: center; justify-content: center; gap: 1rem;">
|
||||
<span style="color: #94A3B8; font-size: 13px; font-weight: 500;">등록된 배치도가 없습니다.</span>
|
||||
</div>
|
||||
<div id="detail-no-photo" class="no-photo-state hidden"><span>등록된 배치도가 없습니다.</span></div>
|
||||
</div>
|
||||
@@ -251,79 +483,472 @@ export function createListView(container: HTMLElement, config: ListViewConfig) {
|
||||
const locImgs = IMAGE_LOCATIONS[bldg.trim()]?.[detail.trim()] || null;
|
||||
const imgPath = (savedImg && locImgs?.includes(savedImg)) ? savedImg : (locImgs ? locImgs[0] : null);
|
||||
|
||||
const htmlMap = document.getElementById('detail-html-map') as HTMLIFrameElement;
|
||||
const isHtmlMap = imgPath?.toLowerCase().endsWith('.html');
|
||||
|
||||
// 좌표가 없으면 사진이 있어도 '정보 없음' 상태로 유도 (사용자 요청)
|
||||
if (imgPath && hasCoords) {
|
||||
photo.src = imgPath;
|
||||
photo.classList.remove('hidden');
|
||||
if (noPhoto) noPhoto.classList.add('hidden');
|
||||
photo.onload = () => {
|
||||
const updateMarkerPos = () => {
|
||||
const imgW = photo.clientWidth; const imgH = photo.clientHeight;
|
||||
if (marker) {
|
||||
marker.style.left = `calc(50% - ${imgW/2}px + ${ (parseFloat(x as string) * imgW) / 100 }px)`;
|
||||
marker.style.top = `calc(50% - ${imgH/2}px + ${ (parseFloat(y as string) * imgH) / 100 }px)`;
|
||||
marker.classList.remove('hidden');
|
||||
}
|
||||
if (overlayLayer) {
|
||||
overlayLayer.style.width = `${imgW}px`; overlayLayer.style.height = `${imgH}px`;
|
||||
overlayLayer.style.left = `calc(50% - ${imgW/2}px)`; overlayLayer.style.top = `calc(50% - ${imgH/2}px)`;
|
||||
const boxes = dynamicMapConfig[imgPath] || [];
|
||||
if (boxes.length > 0) {
|
||||
overlayLayer.innerHTML = `<svg viewBox="0 0 100 100" preserveAspectRatio="none" style="width:100%; height:100%;"><g class="seat-group">
|
||||
${boxes.map(b => {
|
||||
const isSelected = b.x === x && b.y === y;
|
||||
return `<rect class="map-seat-obj" x="${b.x}" y="${b.y}" width="${b.w}" height="${b.h}" rx="0.5" style="fill:${isSelected ? 'rgba(255, 61, 0, 0.4)' : 'rgba(30, 81, 73, 0.02)'}; stroke:${isSelected ? '#FF3D00' : 'rgba(30, 81, 73, 0.15)'};" />`;
|
||||
}).join('')}</g></svg>`;
|
||||
if (isHtmlMap) {
|
||||
// HTML 지도 처리
|
||||
photo.style.display = 'none';
|
||||
if (marker) marker.style.display = 'none';
|
||||
if (overlayLayer) overlayLayer.innerHTML = '';
|
||||
if (htmlMap) {
|
||||
htmlMap.src = `${imgPath}?markerX=${x}&markerY=${y}`;
|
||||
htmlMap.style.display = 'block';
|
||||
}
|
||||
} else {
|
||||
// 일반 이미지 지도 처리
|
||||
if (htmlMap) {
|
||||
htmlMap.src = '';
|
||||
htmlMap.style.display = 'none';
|
||||
}
|
||||
photo.src = imgPath;
|
||||
photo.style.display = 'block';
|
||||
}
|
||||
|
||||
if (noPhoto) noPhoto.style.display = 'none';
|
||||
|
||||
if (!isHtmlMap) {
|
||||
photo.onload = () => {
|
||||
const updateMarkerPos = () => {
|
||||
const imgW = photo.clientWidth;
|
||||
const imgH = photo.clientHeight;
|
||||
|
||||
if (marker) {
|
||||
marker.style.left = `calc(50% - ${imgW/2}px + ${ (parseFloat(x as string) * imgW) / 100 }px)`;
|
||||
marker.style.top = `calc(50% - ${imgH/2}px + ${ (parseFloat(y as string) * imgH) / 100 }px)`;
|
||||
marker.style.display = 'block';
|
||||
}
|
||||
}
|
||||
|
||||
if (overlayLayer) {
|
||||
overlayLayer.style.width = `${imgW}px`;
|
||||
overlayLayer.style.height = `${imgH}px`;
|
||||
overlayLayer.style.left = `calc(50% - ${imgW/2}px)`;
|
||||
overlayLayer.style.top = `calc(50% - ${imgH/2}px)`;
|
||||
|
||||
const boxes = dynamicMapConfig[imgPath] || [];
|
||||
if (boxes.length > 0) {
|
||||
overlayLayer.innerHTML = `
|
||||
<svg viewBox="0 0 100 100" preserveAspectRatio="none" style="width:100%; height:100%;">
|
||||
<g class="seat-group">
|
||||
${boxes.map((b, i) => {
|
||||
const isSelected = b.x === x && b.y === y;
|
||||
const fill = isSelected ? 'rgba(255, 61, 0, 0.4)' : 'rgba(30, 81, 73, 0.02)';
|
||||
const stroke = isSelected ? '#FF3D00' : 'rgba(30, 81, 73, 0.15)';
|
||||
const strokeWidth = isSelected ? '0.8' : '0.2';
|
||||
|
||||
if (isSelected && marker) {
|
||||
marker.style.left = `calc(50% - ${imgW/2}px + ${ (parseFloat(b.x) + parseFloat(b.w)/2) * imgW / 100 }px)`;
|
||||
marker.style.top = `calc(50% - ${imgH/2}px + ${ (parseFloat(b.y) + parseFloat(b.h)/2) * imgH / 100 }px)`;
|
||||
}
|
||||
|
||||
return `<rect class="map-seat-obj" x="${b.x}" y="${b.y}" width="${b.w}" height="${b.h}" rx="0.5" style="fill:${fill}; stroke:${stroke}; stroke-width:${strokeWidth};" />`;
|
||||
}).join('')}
|
||||
</g>
|
||||
</svg>
|
||||
`;
|
||||
} else {
|
||||
overlayLayer.innerHTML = '';
|
||||
}
|
||||
}
|
||||
};
|
||||
updateMarkerPos();
|
||||
window.addEventListener('resize', updateMarkerPos);
|
||||
};
|
||||
updateMarkerPos();
|
||||
window.addEventListener('resize', updateMarkerPos);
|
||||
};
|
||||
}
|
||||
} else {
|
||||
photo.classList.add('hidden');
|
||||
if (marker) marker.classList.add('hidden');
|
||||
photo.style.display = 'none';
|
||||
if (htmlMap) {
|
||||
htmlMap.src = '';
|
||||
htmlMap.style.display = 'none';
|
||||
}
|
||||
if (marker) marker.style.display = 'none';
|
||||
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 => {
|
||||
const rIsWarning = (r as HTMLElement).style.borderLeftColor === 'rgb(225, 29, 72)';
|
||||
(r as HTMLElement).style.backgroundColor = rIsWarning ? '#FFF1F2' : 'transparent';
|
||||
});
|
||||
}
|
||||
updateFlowLogsSection();
|
||||
};
|
||||
}
|
||||
};
|
||||
|
||||
const updateTableOnly = () => {
|
||||
let filtered = selectedLocation ? fullList.filter(a => (a[ASSET_SCHEMA.LOCATION.key] || '미지정') === selectedLocation) : fullList;
|
||||
if (selectedDetailLocation) filtered = filtered.filter(a => (a[ASSET_SCHEMA.LOC_DETAIL.key] || '미지정') === selectedDetailLocation);
|
||||
const displayList = (!selectedLocation && !selectedDetailLocation) ? filtered.slice(0, 10) : filtered;
|
||||
const now = new Date();
|
||||
const currentYear = now.getFullYear();
|
||||
const currentMonthNum = now.getMonth() + 1;
|
||||
const currentYearMonth = `${currentYear}-${String(currentMonthNum).padStart(2, '0')}`;
|
||||
|
||||
const tbody = document.getElementById('system-status-tbody');
|
||||
if (tbody) {
|
||||
tbody.innerHTML = displayList.length === 0
|
||||
? `<tr><td colspan="5" class="empty-cell">조회된 자산이 없습니다.</td></tr>`
|
||||
: displayList.map(asset => {
|
||||
const serviceType = asset.service_type || '외부';
|
||||
const loc = asset[ASSET_SCHEMA.LOCATION.key] || '';
|
||||
const isWarning = serviceType === '외부' && loc !== 'IDC' && loc !== '미지정';
|
||||
return `
|
||||
<tr class="mini-row ${isWarning ? 'warning' : ''}" data-id="${asset.id}">
|
||||
<td><div class="cell-service-type"><span class="fw-800">${serviceType}</span></div></td>
|
||||
<td title="${asset.asset_purpose || ''}">${asset.asset_purpose || '-'}</td>
|
||||
<td>${asset.manager_primary || '-'}</td>
|
||||
<td>${asset.manager_secondary || '-'}</td>
|
||||
<td>${asset.location_detail || '-'}</td>
|
||||
</tr>`;
|
||||
}).join('');
|
||||
tbody.querySelectorAll('.mini-row').forEach(row => {
|
||||
row.addEventListener('click', () => {
|
||||
tbody.querySelectorAll('.mini-row').forEach(r => r.classList.remove('active'));
|
||||
row.classList.add('active');
|
||||
const asset = fullList.find(a => a.id === (row as HTMLElement).getAttribute('data-id'));
|
||||
if (asset) updateDetailPanel(asset);
|
||||
});
|
||||
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}월)`;
|
||||
}
|
||||
|
||||
const logs = state.masterData.logs || [];
|
||||
const flowLogs = logs.filter((log: any) => {
|
||||
const details = log.details || '';
|
||||
if (details.trim().startsWith('{')) {
|
||||
try {
|
||||
const info = JSON.parse(details);
|
||||
return info && (info.type === 'checkout' || info.type === 'return' || info.type === 'move');
|
||||
} catch (e) {}
|
||||
}
|
||||
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);
|
||||
});
|
||||
|
||||
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 = '-';
|
||||
|
||||
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 || '-';
|
||||
} 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 || '-';
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 구분 뱃지 생성
|
||||
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>';
|
||||
}
|
||||
|
||||
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; 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>
|
||||
`;
|
||||
}).join('');
|
||||
}
|
||||
} else {
|
||||
// 기존의 자산 현황 목록 갱신
|
||||
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>`
|
||||
: 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 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 = '형식 부적절';
|
||||
|
||||
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>`;
|
||||
}).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);
|
||||
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(() => {
|
||||
document.getElementById('select-loc')?.addEventListener('change', (e) => { selectedLocation = (e.target as HTMLSelectElement).value || null; updateTableOnly(); });
|
||||
document.getElementById('select-detail-loc')?.addEventListener('change', (e) => { selectedDetailLocation = (e.target as HTMLSelectElement).value || null; updateTableOnly(); });
|
||||
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();
|
||||
});
|
||||
selectDetailLoc?.addEventListener('change', (e) => {
|
||||
selectedDetailLocation = (e.target as HTMLSelectElement).value || null;
|
||||
updateTableOnly();
|
||||
updateFlowLogsSection();
|
||||
});
|
||||
updateTableOnly();
|
||||
updateFlowLogsSection();
|
||||
}, 50);
|
||||
};
|
||||
|
||||
@@ -360,13 +985,47 @@ export function createListView(container: HTMLElement, config: ListViewConfig) {
|
||||
|
||||
toggleWrapper.addEventListener('click', (e) => {
|
||||
const btn = (e.target as HTMLElement).closest('.toggle-btn') as HTMLButtonElement;
|
||||
if (btn) {
|
||||
toggleWrapper.querySelectorAll('.toggle-btn').forEach(b => b.classList.remove('active'));
|
||||
btn.classList.add('active'); (state as any).currentViewMode = btn.getAttribute('data-mode');
|
||||
switchView();
|
||||
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();
|
||||
});
|
||||
|
||||
// 필터 바 초기화
|
||||
renderFilterBar(filterBar, {
|
||||
...config.filterOptions,
|
||||
initialFilters: currentFilters,
|
||||
onFilterChange: (filters) => {
|
||||
Object.assign(currentFilters, filters);
|
||||
updateTable();
|
||||
}
|
||||
});
|
||||
|
||||
renderFilterBar(filterBar, { ...config.filterOptions, onFilterChange: (filters) => { currentFilters = { ...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();
|
||||
uniqueValues.forEach(val => {
|
||||
const opt = document.createElement('option');
|
||||
opt.value = String(val);
|
||||
opt.textContent = String(val);
|
||||
if (initialValue && String(val) === initialValue) {
|
||||
opt.selected = true;
|
||||
}
|
||||
select.appendChild(opt);
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
if (config.filterOptions.showLoc) populateSelect('#filter-loc', ASSET_SCHEMA.LOCATION.key, currentFilters.loc);
|
||||
if (config.filterOptions.showDept) populateSelect('#filter-dept', ASSET_SCHEMA.CURRENT_DEPT.key, currentFilters.dept);
|
||||
if (config.filterOptions.showCorp) populateSelect('#filter-corp', ASSET_SCHEMA.PURCHASE_CORP.key, currentFilters.corp);
|
||||
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();
|
||||
}
|
||||
|
||||
66
src/views/List/PartsMasterListView.ts
Normal file
66
src/views/List/PartsMasterListView.ts
Normal file
@@ -0,0 +1,66 @@
|
||||
import { state } from '../../core/state';
|
||||
import { openPartsMasterModal } from '../../components/Modal/PartsMasterModal';
|
||||
import { formatInline } from '../../core/utils';
|
||||
import { createListView } from './ListFactory';
|
||||
|
||||
export function renderPartsMasterList(container: HTMLElement) {
|
||||
createListView(container, {
|
||||
title: '부품 마스터',
|
||||
dataSource: () => state.masterData.partsMaster || [],
|
||||
searchKeys: ['component_name', 'category', 'score_tier'],
|
||||
filterOptions: {
|
||||
keywordLabel: '부품명 / 등급 검색',
|
||||
showLoc: false,
|
||||
showDept: false,
|
||||
showType: false
|
||||
},
|
||||
onRowClick: (component) => openPartsMasterModal(component, 'view'),
|
||||
columns: [
|
||||
{
|
||||
header: 'ID',
|
||||
sortKey: 'id',
|
||||
align: 'center',
|
||||
width: '5%',
|
||||
render: c => c.id.toString()
|
||||
},
|
||||
{
|
||||
header: '분류',
|
||||
sortKey: 'category',
|
||||
align: 'center',
|
||||
width: '15%',
|
||||
render: c => {
|
||||
let badgeClass = 'badge-primary';
|
||||
if (c.category === 'CPU') badgeClass = 'b-primary';
|
||||
else if (c.category === 'GPU') badgeClass = 'b-purple';
|
||||
else if (c.category === 'RAM') badgeClass = 'b-green';
|
||||
return `<span class="badge ${badgeClass}">${c.category}</span>`;
|
||||
}
|
||||
},
|
||||
{
|
||||
header: '부품 표준 명칭',
|
||||
sortKey: 'component_name',
|
||||
render: c => formatInline(c.component_name || '-')
|
||||
},
|
||||
{
|
||||
header: '성능 등급',
|
||||
sortKey: 'score_tier',
|
||||
align: 'center',
|
||||
width: '15%',
|
||||
render: c => c.score_tier || '-'
|
||||
},
|
||||
{
|
||||
header: '감점 점수',
|
||||
sortKey: 'deduction',
|
||||
align: 'center',
|
||||
width: '15%',
|
||||
render: c => {
|
||||
const score = c.deduction || 0;
|
||||
let color = '#3b82f6'; // blue
|
||||
if (score >= 20) color = '#ef4444'; // red
|
||||
else if (score >= 10) color = '#f59e0b'; // orange
|
||||
return `<strong style="color: ${color}; font-size: 14px;">-${score}점</strong>`;
|
||||
}
|
||||
}
|
||||
]
|
||||
});
|
||||
}
|
||||
@@ -1,14 +1,102 @@
|
||||
import { state } from '../../core/state';
|
||||
import { openHwModal } from '../../components/Modal/HWModal';
|
||||
import { sortAssets, formatInline } from '../../core/utils';
|
||||
import { sortAssets, formatInline, calculatePcScoreDeductive, getPcGrade } from '../../core/utils';
|
||||
import { ASSET_SCHEMA } from '../../core/schema';
|
||||
import { createListView } from './ListFactory';
|
||||
|
||||
export function renderPcList(container: HTMLElement) {
|
||||
container.innerHTML = `
|
||||
<div style="display: flex; flex-direction: column; align-items: center; justify-content: center; height: 100%; color: var(--text-muted);">
|
||||
<div style="font-size: 1.5rem; font-weight: 600; margin-bottom: 1rem;">PC 관리</div>
|
||||
<p>해당 페이지는 다른 작업자에 의해 개발 중입니다.</p>
|
||||
</div>
|
||||
`;
|
||||
createListView(container, {
|
||||
title: 'PC',
|
||||
dataSource: () => {
|
||||
const list = (state.masterData.pc || []).filter((a: any) => a.asset_type !== '서버PC');
|
||||
list.forEach((a: any) => {
|
||||
a['_pc_score'] = calculatePcScoreDeductive(a[ASSET_SCHEMA.CPU.key], a[ASSET_SCHEMA.RAM.key], a[ASSET_SCHEMA.GPU.key], a.purchase_date);
|
||||
});
|
||||
return sortAssets(list);
|
||||
},
|
||||
searchKeys: ['CURRENT_DEPT', 'CURRENT_USER', 'MODEL_NAME', 'MAC_ADDR', 'MANAGER_MAIN', 'ASSET_TYPE'],
|
||||
filterOptions: {
|
||||
keywordLabel: `통합 검색 (${ASSET_SCHEMA.MODEL_NAME.ui}/${ASSET_SCHEMA.MANAGER_MAIN.ui}/${ASSET_SCHEMA.CURRENT_USER.ui})`,
|
||||
showLoc: true,
|
||||
showDept: true,
|
||||
showType: true,
|
||||
showStatus: true
|
||||
},
|
||||
onRowClick: (asset) => openHwModal(asset, 'view'),
|
||||
columns: [
|
||||
{
|
||||
header: ASSET_SCHEMA.HW_STATUS.ui,
|
||||
sortKey: ASSET_SCHEMA.HW_STATUS.key,
|
||||
align: 'center',
|
||||
width: '8%',
|
||||
render: a => {
|
||||
const status = a[ASSET_SCHEMA.HW_STATUS.key] || '재고';
|
||||
let badgeClass = 'badge-light';
|
||||
if (status === '운영') badgeClass = 'b-green';
|
||||
else if (status === '재고') badgeClass = 'b-yellow';
|
||||
else if (status === '수리') badgeClass = 'b-purple';
|
||||
else if (status === '폐기') badgeClass = 'badge-muted';
|
||||
return `<span class="badge ${badgeClass}">${status}</span>`;
|
||||
}
|
||||
},
|
||||
{ header: ASSET_SCHEMA.CURRENT_USER.ui, sortKey: ASSET_SCHEMA.CURRENT_USER.key, align: 'center', render: a => a[ASSET_SCHEMA.CURRENT_USER.key] || '-' },
|
||||
{ header: ASSET_SCHEMA.USER_POSITION.ui, sortKey: ASSET_SCHEMA.USER_POSITION.key, align: 'center', render: a => a[ASSET_SCHEMA.USER_POSITION.key] || '-' },
|
||||
{ header: ASSET_SCHEMA.ASSET_TYPE.ui, sortKey: ASSET_SCHEMA.ASSET_TYPE.key, align: 'center', width: '10%', render: a => a[ASSET_SCHEMA.ASSET_TYPE.key] || '-' },
|
||||
{ header: ASSET_SCHEMA.CPU.ui, sortKey: ASSET_SCHEMA.CPU.key, align: 'center', render: a => a[ASSET_SCHEMA.CPU.key] || '' },
|
||||
{ header: ASSET_SCHEMA.MAINBOARD.ui, sortKey: ASSET_SCHEMA.MAINBOARD.key, align: 'center', render: a => a[ASSET_SCHEMA.MAINBOARD.key] || '-' },
|
||||
{ header: ASSET_SCHEMA.RAM.ui, sortKey: ASSET_SCHEMA.RAM.key, align: 'center', render: a => a[ASSET_SCHEMA.RAM.key] || '' },
|
||||
{ header: ASSET_SCHEMA.GPU.ui, sortKey: ASSET_SCHEMA.GPU.key, align: 'center', render: a => a[ASSET_SCHEMA.GPU.key] || '-' },
|
||||
{
|
||||
header: 'SSD',
|
||||
align: 'center',
|
||||
width: '8%',
|
||||
render: a => {
|
||||
try {
|
||||
const vols = a.volumes ? (typeof a.volumes === 'string' ? JSON.parse(a.volumes) : a.volumes) : [];
|
||||
if (Array.isArray(vols)) {
|
||||
const ssds = vols.filter((v: any) => v && String(v.type).toUpperCase() === 'SSD');
|
||||
if (ssds.length > 0) {
|
||||
return ssds.map((v: any) => `${v.capacity || ''}${v.unit || 'GB'}`).join(' / ');
|
||||
}
|
||||
}
|
||||
} catch (e) {}
|
||||
return '-';
|
||||
}
|
||||
},
|
||||
{
|
||||
header: 'HDD',
|
||||
align: 'center',
|
||||
width: '12%',
|
||||
render: a => {
|
||||
try {
|
||||
const vols = a.volumes ? (typeof a.volumes === 'string' ? JSON.parse(a.volumes) : a.volumes) : [];
|
||||
if (Array.isArray(vols)) {
|
||||
const hdds = vols.filter((v: any) => v && String(v.type).toUpperCase() === 'HDD');
|
||||
if (hdds.length > 0) {
|
||||
return hdds.map((v: any) => `${v.capacity || ''}${v.unit || 'GB'}`).join(' / ');
|
||||
}
|
||||
}
|
||||
} catch (e) {}
|
||||
return '-';
|
||||
}
|
||||
},
|
||||
{
|
||||
header: ASSET_SCHEMA.MAC_ADDR.ui,
|
||||
sortKey: ASSET_SCHEMA.MAC_ADDR.key,
|
||||
align: 'center',
|
||||
render: a => `<span style="font-family:monospace; font-size:11px;">${a[ASSET_SCHEMA.MAC_ADDR.key] || '-'}</span>`
|
||||
},
|
||||
{
|
||||
header: '성능 등급',
|
||||
sortKey: '_pc_score',
|
||||
align: 'center',
|
||||
width: '8%',
|
||||
render: a => {
|
||||
const score = a._pc_score !== undefined ? a._pc_score : calculatePcScoreDeductive(a[ASSET_SCHEMA.CPU.key], a[ASSET_SCHEMA.RAM.key], a[ASSET_SCHEMA.GPU.key], a.purchase_date);
|
||||
const grade = getPcGrade(score);
|
||||
return `<span class="badge ${grade.class}" title="성능 점수: ${score}점">${grade.name}</span>`;
|
||||
}
|
||||
}
|
||||
]
|
||||
});
|
||||
}
|
||||
|
||||
60
src/views/List/UserListView.ts
Normal file
60
src/views/List/UserListView.ts
Normal file
@@ -0,0 +1,60 @@
|
||||
import { state } from '../../core/state';
|
||||
import { openUserModal } from '../../components/Modal/UserModal';
|
||||
import { formatInline } from '../../core/utils';
|
||||
import { createListView } from './ListFactory';
|
||||
|
||||
export function renderUserList(container: HTMLElement) {
|
||||
createListView(container, {
|
||||
title: '사용자',
|
||||
dataSource: () => state.masterData.users || [],
|
||||
searchKeys: ['emp_no', 'user_name', 'dept_name', 'position', 'status'],
|
||||
filterOptions: {
|
||||
keywordLabel: '사번/이름/부서/직급 검색',
|
||||
showCorp: false,
|
||||
showDept: true,
|
||||
showType: false
|
||||
},
|
||||
onRowClick: (user) => openUserModal(user, 'view'),
|
||||
columns: [
|
||||
{
|
||||
header: '사번',
|
||||
sortKey: 'emp_no',
|
||||
align: 'center',
|
||||
width: '15%',
|
||||
render: u => formatInline(u.emp_no || '-')
|
||||
},
|
||||
{
|
||||
header: '이름',
|
||||
sortKey: 'user_name',
|
||||
align: 'center',
|
||||
width: '15%',
|
||||
render: u => formatInline(u.user_name || '-')
|
||||
},
|
||||
{
|
||||
header: '조직 (부서)',
|
||||
sortKey: 'dept_name',
|
||||
align: 'left',
|
||||
width: '25%',
|
||||
render: u => formatInline(u.dept_name || '-')
|
||||
},
|
||||
{
|
||||
header: '직급 (직무)',
|
||||
sortKey: 'position',
|
||||
align: 'left',
|
||||
width: '25%',
|
||||
render: u => formatInline(u.position || '-')
|
||||
},
|
||||
{
|
||||
header: '상태',
|
||||
sortKey: 'status',
|
||||
align: 'center',
|
||||
width: '10%',
|
||||
render: u => {
|
||||
const status = u.status || '재직';
|
||||
const badgeClass = status === '퇴직' ? 'badge-danger' : 'badge-success';
|
||||
return `<span class="badge ${badgeClass}">${status}</span>`;
|
||||
}
|
||||
}
|
||||
]
|
||||
});
|
||||
}
|
||||
@@ -9,11 +9,13 @@ import { renderCloudList } from './List/CloudListView';
|
||||
import { renderDomainList } from './List/DomainListView';
|
||||
import { renderNetworkList } from './List/NetworkListView';
|
||||
import { renderPcPartList } from './List/PcPartListView';
|
||||
import { renderPartsMasterList } from './List/PartsMasterListView';
|
||||
import { renderSpaceInfoList } from './List/SpaceInfoListView';
|
||||
import { renderGiftList } from './List/GiftListView';
|
||||
import { renderFacilityList } from './List/FacilityListView';
|
||||
import { renderCostList } from './List/CostListView';
|
||||
import { createIcons, Plus, X, LayoutDashboard, Monitor, Server, Database, Laptop, CalendarClock, Key, Cpu, Layers, Users, Paperclip, Edit2, RefreshCcw } from 'lucide';
|
||||
import { renderUserList } from './List/UserListView';
|
||||
import { createIcons, Plus, X, LayoutDashboard, Monitor, Server, Database, Laptop, CalendarClock, Key, Cpu, Layers, Users, Paperclip, Edit2, RefreshCcw, Settings } from 'lucide';
|
||||
|
||||
/**
|
||||
* 자산 목록 테이블 렌더링 통합 허브
|
||||
@@ -36,6 +38,7 @@ export function renderSWTable(mainContent: HTMLElement) {
|
||||
else if (tab === '업무지원장비') renderEquipmentList(container);
|
||||
else if (tab === '네트워크') renderNetworkList(container);
|
||||
else if (tab === 'PC부품') renderPcPartList(container);
|
||||
else if (tab === '부품 마스터') renderPartsMasterList(container);
|
||||
else if (tab === '공간정보장비') renderSpaceInfoList(container);
|
||||
else {
|
||||
container.innerHTML = `<div style="padding:2rem; color:var(--text-muted);">"${tab}" 탭에 대한 하드웨어 리스트 뷰가 정의되지 않았습니다.</div>`;
|
||||
@@ -50,6 +53,7 @@ export function renderSWTable(mainContent: HTMLElement) {
|
||||
if (tab === '도메인') renderDomainList(container);
|
||||
else if (tab === '클라우드') renderCloudList(container);
|
||||
else if (tab === '비용관리') renderCostList(container);
|
||||
else if (tab === '사용자') renderUserList(container);
|
||||
else {
|
||||
container.innerHTML = `<div style="padding:2rem; color:var(--text-muted);">"${tab}" 탭에 대한 운영지원 리스트 뷰가 정의되지 않았습니다.</div>`;
|
||||
}
|
||||
@@ -69,7 +73,7 @@ export function renderSWTable(mainContent: HTMLElement) {
|
||||
|
||||
// 전역 아이콘 초기화 (한 번 더 실행하여 누락 방지)
|
||||
createIcons({
|
||||
icons: { Plus, X, LayoutDashboard, Monitor, Server, Database, Laptop, CalendarClock, Key, Cpu, Layers, Users, Paperclip, Edit2, RefreshCcw }
|
||||
icons: { Plus, X, LayoutDashboard, Monitor, Server, Database, Laptop, CalendarClock, Key, Cpu, Layers, Users, Paperclip, Edit2, RefreshCcw, Settings }
|
||||
});
|
||||
} catch (err: any) {
|
||||
console.error('❌ Error rendering table view:', err);
|
||||
|
||||
Reference in New Issue
Block a user