feat: 대시보드 구분선 디자인 전환, 폰트 확대 및 버그 수정

This commit is contained in:
2026-06-12 08:49:04 +09:00
parent 0c1977f707
commit 8a3727ea61
22 changed files with 2238 additions and 1813 deletions

View File

@@ -15,17 +15,30 @@ export interface FilterOptions {
showLoc?: boolean;
showField?: boolean;
showType?: boolean;
showStatus?: boolean;
extraHTML?: string;
onFilterChange: (filters: any) => void;
initialFilters?: any;
}
export function renderFilterBar(container: HTMLElement, options: FilterOptions) {
const { keywordLabel = '통합 검색', showCorp = false, showDept = false, showLoc = false, showField = false, showType = false, extraHTML = '', onFilterChange } = options;
const {
keywordLabel = '통합 검색',
showCorp = false,
showDept = false,
showLoc = false,
showField = false,
showType = false,
showStatus = false,
extraHTML = '',
onFilterChange,
initialFilters = { keyword: '', corp: '', dept: '', loc: '', field: '', type: '', status: '' }
} = options;
container.innerHTML = `
<div class="search-item flex-1">
<label>${keywordLabel}</label>
<input type="text" id="filter-keyword" placeholder="검색어를 입력하세요..." autocomplete="off">
<input type="text" id="filter-keyword" placeholder="검색어를 입력하세요..." autocomplete="off" value="${initialFilters.keyword || ''}">
</div>
${showType ? `
<div class="search-item">
@@ -34,21 +47,28 @@ export function renderFilterBar(container: HTMLElement, options: FilterOptions)
<option value="">전체 유형</option>
</select>
</div>` : ''}
${showStatus ? `
<div class="search-item">
<label>${ASSET_SCHEMA.HW_STATUS.ui}</label>
<select id="filter-status">
<option value="">전체 상태</option>
</select>
</div>` : ''}
${showField ? `
<div class="search-item">
<label>${ASSET_SCHEMA.SW_FIELD.ui}</label>
<select id="filter-field">
<option value="">전체 분야</option>
<option value="업무공통">업무공통</option>
<option value="개발S/W">개발S/W</option>
<option value="디자인">디자인</option>
<option value="설계S/W">설계S/W</option>
<option value="업무공통" ${initialFilters.field === '업무공통' ? 'selected' : ''}>업무공통</option>
<option value="개발S/W" ${initialFilters.field === '개발S/W' ? 'selected' : ''}>개발S/W</option>
<option value="디자인" ${initialFilters.field === '디자인' ? 'selected' : ''}>디자인</option>
<option value="설계S/W" ${initialFilters.field === '설계S/W' ? 'selected' : ''}>설계S/W</option>
</select>
</div>` : ''}
${showCorp ? `
<div class="search-item">
<label>${ASSET_SCHEMA.PURCHASE_CORP.ui}</label>
<select id="filter-corp">${generateOptionsHTML(CORP_LIST, '', true)}</select>
<select id="filter-corp">${generateOptionsHTML(CORP_LIST, initialFilters.corp || '', true)}</select>
</div>` : ''}
${showLoc ? `
<div class="search-item">
@@ -75,7 +95,8 @@ export function renderFilterBar(container: HTMLElement, options: FilterOptions)
dept: (container.querySelector('#filter-dept') as HTMLSelectElement)?.value || '',
loc: (container.querySelector('#filter-loc') as HTMLSelectElement)?.value || '',
field: (container.querySelector('#filter-field') as HTMLSelectElement)?.value || '',
type: (container.querySelector('#filter-type') as HTMLSelectElement)?.value || ''
type: (container.querySelector('#filter-type') as HTMLSelectElement)?.value || '',
status: (container.querySelector('#filter-status') as HTMLSelectElement)?.value || ''
};
onFilterChange(filters);
};
@@ -86,9 +107,10 @@ export function renderFilterBar(container: HTMLElement, options: FilterOptions)
container.querySelector('#filter-loc')?.addEventListener('change', triggerChange);
container.querySelector('#filter-field')?.addEventListener('change', triggerChange);
container.querySelector('#filter-type')?.addEventListener('change', triggerChange);
container.querySelector('#filter-status')?.addEventListener('change', triggerChange);
container.querySelector('#btn-reset-filters')?.addEventListener('click', () => {
['filter-keyword', 'filter-corp', 'filter-dept', 'filter-loc', 'filter-field', 'filter-type'].forEach(id => {
['filter-keyword', 'filter-corp', 'filter-dept', 'filter-loc', 'filter-field', 'filter-type', 'filter-status'].forEach(id => {
const el = container.querySelector(`#${id}`);
if (el) (el as any).value = '';
});
@@ -109,7 +131,8 @@ export function applyCommonFilters(list: any[], filters: any, searchKeys: (keyof
const matchLoc = !filters.loc || (item[ASSET_SCHEMA.LOCATION.key] || item[ASSET_SCHEMA.LOCATION.db]) === filters.loc;
const matchField = !filters.field || (item[ASSET_SCHEMA.SW_FIELD.key] || item[ASSET_SCHEMA.SW_FIELD.db]) === filters.field;
const matchType = !filters.type || (item[ASSET_SCHEMA.ASSET_TYPE.key] || item[ASSET_SCHEMA.ASSET_TYPE.db]) === filters.type;
const matchStatus = !filters.status || (item[ASSET_SCHEMA.HW_STATUS.key] || item[ASSET_SCHEMA.HW_STATUS.db]) === filters.status;
return matchKeyword && matchCorp && matchDept && matchLoc && matchField && matchType;
return matchKeyword && matchCorp && matchDept && matchLoc && matchField && matchType && matchStatus;
});
}

View File

@@ -155,6 +155,11 @@ export const PAGE_DESCRIPTIONS: Record<string, { title: string; description: str
title: '사무용 가구 관리',
description: '책상, 의자, 캐비닛 등 사무 환경 구성을 위한 가구 자산의 배치 현황을 관리합니다.',
icon: 'armchair'
},
'사용자': {
title: '임직원 사용자 관리',
description: 'IT 자산 할당 및 관리의 기준이 되는 사내 임직원(사용자) 정보를 데이터베이스 기반으로 직접 등록하고 수정합니다.',
icon: 'users'
}
};

View File

@@ -11,6 +11,7 @@ export interface MasterAssetData {
network: any[];
survey: any[];
pcParts: any[];
partsMaster: any[];
equipment: any[];
officeSupplies: any[];
swInternal: any[];
@@ -41,6 +42,7 @@ export interface AppState {
masterData: MasterAssetData;
activeCharts: any[];
currentUserRole: 'admin' | 'user';
listFilters?: Record<string, any>;
}
// 초기 상태
@@ -50,10 +52,11 @@ export const state: AppState = {
viewMode: 'location',
activeCharts: [],
currentUserRole: 'user',
listFilters: {},
masterData: {
users: [],
pc: [], server: [], storage: [], network: [],
survey: [], pcParts: [], equipment: [], officeSupplies: [],
survey: [], pcParts: [], partsMaster: [], equipment: [], officeSupplies: [],
swInternal: [], swExternal: [], cloud: [], domain: [],
cost: [], vip: [],
subSw: [], permSw: [],
@@ -160,3 +163,69 @@ export async function deleteAsset(category: string, assetId: string) {
}
return false;
}
export async function savePartsMaster(component: any) {
try {
const url = `${API_BASE_URL}/api/hardware-components/save`;
const response = await fetch(url, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(component)
});
if (response.ok) {
await loadMasterDataFromDB(); // 전역 상태 갱신
return true;
}
} catch (err) {
console.error('부품 마스터 저장 실패:', err);
}
return false;
}
export async function deletePartsMaster(id: number) {
try {
const url = `${API_BASE_URL}/api/hardware-components/${id}`;
const response = await fetch(url, { method: 'DELETE' });
if (response.ok) {
await loadMasterDataFromDB(); // 전역 상태 갱신
return true;
}
} catch (err) {
console.error('부품 마스터 삭제 실패:', err);
}
return false;
}
export async function saveSystemUser(user: any) {
try {
const url = `${API_BASE_URL}/api/system-users/save`;
const response = await fetch(url, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(user)
});
if (response.ok) {
await loadMasterDataFromDB(); // 전역 상태 갱신
return true;
}
} catch (err) {
console.error('사용자 정보 저장 실패:', err);
}
return false;
}
export async function deleteSystemUser(id: string) {
try {
const url = `${API_BASE_URL}/api/system-users/${id}`;
const response = await fetch(url, { method: 'DELETE' });
if (response.ok) {
await loadMasterDataFromDB(); // 전역 상태 갱신
return true;
}
} catch (err) {
console.error('사용자 정보 삭제 실패:', err);
}
return false;
}

View File

@@ -17,7 +17,7 @@ export function renderPageHeader(container: HTMLElement, pageId: string) {
header.className = 'page-header';
header.innerHTML = `
<div class="page-title-group">
<h2 class="page-title"><i data-lucide="${config.icon}"></i> ${config.title}</h2>
<h2 class="page-title">${config.title}</h2>
<p class="page-description">${config.description}</p>
</div>
`;
@@ -158,3 +158,201 @@ export function dynamicSort<T>(list: T[], key: string, direction: 'asc' | 'desc'
export function getActionButtonsHTML(): string {
return '';
}
/**
* 100점 만점 감점형 PC 성능 점수 계산 (CPU + RAM + GPU + 연식)
*/
export 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 function getPcGrade(score: number): { name: string; class: string; color: string } {
if (score >= 85) return { name: '최상급', class: 'b-purple', color: '#7C3AED' };
if (score >= 70) return { name: '상급', class: 'b-primary', color: '#4F46E5' };
if (score >= 40) return { name: '중급', class: 'b-green', color: '#10B981' };
return { name: '보급', class: 'b-yellow', color: '#F59E0B' };
}
/**
* Windows 11 업그레이드 지원 불가능한 하드웨어 조건인지 판별
*/
export function isWindows11Incompatible(cpu: string, ram: string): boolean {
if (!cpu) return true;
const cpuUpper = cpu.toUpperCase();
// 1. RAM 4GB 미만은 공식 미지원
if (ram) {
const ramMatch = ram.toUpperCase().match(/(\d+)\s*GB/);
if (ramMatch && ramMatch[1]) {
const ramVal = parseInt(ramMatch[1], 10);
if (ramVal < 4) return true;
}
}
// 2. CPU 세대 검사
// Intel CPU 세대 판정
const intelMatch = cpuUpper.match(/I\d-?(\d+)/);
if (intelMatch && intelMatch[1]) {
const numStr = intelMatch[1];
let gen = 0;
if (numStr.length === 5) gen = parseInt(numStr.substring(0, 2), 10);
else if (numStr.length === 4) gen = parseInt(numStr.substring(0, 1), 10);
else if (numStr.length === 3) gen = parseInt(numStr.substring(0, 1), 10); // 3자리수 구형 세대 (예: i5-750)
if (gen > 0 && gen < 8) return true; // 8세대 미만 불가
return false;
}
// AMD Ryzen CPU 세대 판정
const amdMatch = cpuUpper.match(/RYZEN\s?\d\s?-?(\d+)/);
if (amdMatch && amdMatch[1]) {
const numStr = amdMatch[1];
let amdGen = 0;
if (numStr.length === 4) amdGen = parseInt(numStr.substring(0, 1), 10); // 1xxx, 2xxx 등
if (amdGen > 0 && amdGen < 2) return true; // Ryzen 1세대 이하는 불가
return false;
}
// Apple Silicon은 지원
if (cpuUpper.includes('APPLE') || cpuUpper.includes('M1') || cpuUpper.includes('M2') || cpuUpper.includes('M3') || cpuUpper.includes('M4')) {
return false;
}
// 그 외 확실한 구형 CPU 제품군
const knownOldCpus = ['CORE2', 'CORE 2', 'PENTIUM', 'CELERON', 'ATHLON', 'PHENOM', 'XEON'];
if (knownOldCpus.some(name => cpuUpper.includes(name))) {
return true;
}
// 세대 매칭은 안되었으나 Intel Core i 시리즈 구조이면 구형(1세대 등)으로 간주
if (cpuUpper.includes('I3') || cpuUpper.includes('I5') || cpuUpper.includes('I7') || cpuUpper.includes('I9')) {
// i5-620M 처럼 옛날 구형 모바일 칩 등
return true;
}
return false;
}