chore: clean up build artifacts, temporary excel locks, duplicate plans, and commit current project state
Some checks failed
ITAM Code Check / build-and-config-check (push) Successful in 18s
ITAM Docker Build Check / docker-build-check (push) Failing after 21s

This commit is contained in:
이태훈
2026-06-22 11:26:26 +09:00
parent 7b631ab858
commit 621b05a890
135 changed files with 22565 additions and 42690 deletions

View File

@@ -1,280 +1,280 @@
import { createIcons, BookOpen, X, ChevronDown, ChevronRight, RefreshCw } from 'lucide';
import { state } from '../core/state';
import './guide.css';
// ─── 자산별 가이드 콘텐츠 정의 (SW_Table 브랜치 전체 복구) ───
interface GuideTabConfig {
id: string;
label: string;
content: string;
}
const GUIDE_TABS: GuideTabConfig[] = [
{
id: 'overview',
label: '📋 개요',
content: `
<section class="guide-section">
<h3>IT 자산관리 시스템 개요</h3>
<p class="guide-text">
HM IT 자산관리 시스템(ITAM)은 기업의 IT 자산을 <strong>도입부터 폐기까지</strong> 전 과정에서 효율적으로 관리하기 위한 통합 플랫폼입니다.<br>
하드웨어(PC, 서버, 스토리지, 전산비품, 모바일기기)와 소프트웨어(구독SW, 영구SW, 클라우드)를 체계적으로 추적하고 유지보수합니다.
</p>
</section>
<section class="guide-section">
<h3>전체 자산관리 프로세스</h3>
<div class="flow-container">
<div class="flow-row">
<div class="flow-step">
<span class="step-number">1</span>
<div><span class="step-label">도입/구매</span><p class="step-desc">자산 구매 요청 → 승인 → 발주</p></div>
</div>
<span class="flow-arrow-right"><i data-lucide="chevron-right"></i></span>
<div class="flow-step">
<span class="step-number">2</span>
<div><span class="step-label">등록/배정</span><p class="step-desc">자산번호 부여 → 시스템 등록 → 사용자 할당</p></div>
</div>
<span class="flow-arrow-right"><i data-lucide="chevron-right"></i></span>
<div class="flow-step">
<span class="step-number">3</span>
<div><span class="step-label">운영/유지</span><p class="step-desc">현황 모니터링 → 점검/수리 → 이력 관리</p></div>
</div>
<span class="flow-arrow-right"><i data-lucide="chevron-right"></i></span>
<div class="flow-step">
<span class="step-number">4</span>
<div><span class="step-label">반납/폐기</span><p class="step-desc">자산 회수 → 데이터 소거 → 폐기 처리</p></div>
</div>
</div>
</div>
</section>
<section class="guide-section">
<h3>시스템 기본 사용방법</h3>
<table class="guide-info-table">
<thead><tr><th>기능</th><th>방법</th></tr></thead>
<tbody>
<tr><td><strong>자산 조회</strong></td><td>상단 카테고리(하드웨어/소프트웨어) 및 하위 탭 선택 후 데이터 조회</td></tr>
<tr><td><strong>자산 등록</strong></td><td>[자산 추가] 버튼 클릭 후 상세 정보 입력 및 저장</td></tr>
<tr><td><strong>정보 수정</strong></td><td>목록 행 클릭 후 나타나는 모달에서 내용 변경 및 저장</td></tr>
</tbody>
</table>
</section>
`
},
{
id: 'pc',
label: '💻 개인PC',
content: `
<section class="guide-section">
<h3>개인PC 관리 가이드</h3>
<p class="guide-text">
임직원에게 지급되는 데스크톱 및 노트북을 관리합니다. 자산의 지급, 교체, 반납까지의 전체 생애주기를 시스템에서 추적합니다.
</p>
</section>
<section class="guide-section">
<h3>관리 프로세스</h3>
<div class="flow-container">
<div class="flow-row">
<div class="flow-step">
<span class="step-number">1</span>
<div><span class="step-label">구매 및 입고</span><p class="step-desc">구매 요청 → 발주 → 입고 검수</p></div>
</div>
<span class="flow-arrow-right"><i data-lucide="chevron-right"></i></span>
<div class="flow-step">
<span class="step-number">2</span>
<div><span class="step-label">자산 등록</span><p class="step-desc">자산번호 부여, 상세 사양 등록</p></div>
</div>
</div>
<i data-lucide="chevron-down" class="flow-arrow"></i>
<div class="flow-row">
<div class="flow-step">
<span class="step-number">3</span>
<div><span class="step-label">사용자 지급</span><p class="step-desc">사용자 지정 및 설치위치 기록</p></div>
</div>
<span class="flow-arrow-right"><i data-lucide="chevron-right"></i></span>
<div class="flow-step">
<span class="step-number">4</span>
<div><span class="step-label">운영 관리</span><p class="step-desc">보안 점검 및 수리 이력 관리</p></div>
</div>
</div>
<i data-lucide="chevron-down" class="flow-arrow"></i>
<div class="flow-row">
<div class="flow-step">
<span class="step-number">5</span>
<div><span class="step-label">교체/반납</span><p class="step-desc">장비 회수 및 데이터 소거</p></div>
</div>
<span class="flow-arrow-right"><i data-lucide="chevron-right"></i></span>
<div class="flow-step">
<span class="step-number">6</span>
<div><span class="step-label">폐기 처리</span><p class="step-desc">불용 처리 및 매각/폐기 등록</p></div>
</div>
</div>
</div>
</section>
<section class="guide-section">
<h3>주요 관리 항목</h3>
<table class="guide-info-table">
<thead><tr><th>항목</th><th>설명</th><th>관리 주기</th></tr></thead>
<tbody>
<tr><td>구매법인</td><td>자산의 소유 법인</td><td>등록 시</td></tr>
<tr><td>사용자/조직</td><td>실제 사용자 및 소속 부서</td><td>변동 시</td></tr>
<tr><td>자산번호</td><td>고유 식별 번호 (바코드)</td><td>등록 시</td></tr>
<tr><td>모델명/사양</td><td>제조사 모델 및 CPU/RAM 등</td><td>등록 시</td></tr>
<tr><td>구매금액</td><td>구매 비용 (부가세 포함)</td><td>등록 시</td></tr> </tbody>
</table>
</section>
<div class="guide-tip">
<strong>관리 팁:</strong> 자산 이력에서 '분출'과 '반납' 로그를 꼼꼼히 기록하면 자산의 실제 위치를 정확히 파악할 수 있습니다.
</div>
`
},
{
id: 'server',
label: '🖥️ 서버/스토리지',
content: `
<section class="guide-section">
<h3>인프라 자산 관리 가이드</h3>
<p class="guide-text">
서버실 및 IDC에 설치된 물리 서버와 스토리지 장비를 관리합니다. 고가의 자산이므로 담당자(정/부) 지정이 필수입니다.
</p>
</section>
<section class="guide-section">
<h3>관리 프로세스</h3>
<div class="flow-container">
<div class="flow-row">
<div class="flow-step">
<span class="step-number">1</span>
<div><span class="step-label">도입 계획</span><p class="step-desc">사양 확정 및 구매 승인</p></div>
</div>
<span class="flow-arrow-right"><i data-lucide="chevron-right"></i></span>
<div class="flow-step">
<span class="step-number">2</span>
<div><span class="step-label">설치 및 등록</span><p class="step-desc">네트워크 설정 및 자산번호 부여</p></div>
</div>
<span class="flow-arrow-right"><i data-lucide="chevron-right"></i></span>
<div class="flow-step">
<span class="step-number">3</span>
<div><span class="step-label">운영 관리</span><p class="step-desc">정기 점검 및 장애 이력 관리</p></div>
</div>
</div>
</div>
</section>
<section class="guide-section">
<h3>필수 입력 항목</h3>
<table class="guide-info-table">
<thead><tr><th>항목</th><th>중요성</th></tr></thead>
<tbody>
<tr><td><strong>IP 주소</strong></td><td>서버 접속 및 모니터링을 위한 필수 정보</td></tr>
<tr><td><strong>설치위치</strong></td><td>IDC 또는 서버실 내의 정확한 랙 위치</td></tr>
<tr><td><strong>담당자(정/부)</strong></td><td>비상 시 연락 가능한 관리 책임자</td></tr>
<tr><td><strong>용도/상세</strong></td><td>운영 중인 서비스 및 상세 업무 설명</td></tr>
</tbody>
</table>
</section>
<div class="guide-warn">
<strong>주의 사항:</strong> 서버 자산의 IP가 변경될 경우 시스템에 즉시 반영하여 네트워크 관리 대장과의 정합성을 유지해야 합니다.
</div>
`
},
{
id: 'software',
label: '💾 소프트웨어',
content: `
<section class="guide-section">
<h3>소프트웨어 자산 관리 가이드</h3>
<p class="guide-text">
구독형(SaaS) 및 영구형 라이선스를 관리합니다. 불법 소프트웨어 사용 방지와 비용 최적화가 주 목적입니다.
</p>
</section>
<section class="guide-section">
<h3>라이선스 관리 포인트</h3>
<table class="guide-info-table">
<thead><tr><th>구분</th><th>관리 내용</th></tr></thead>
<tbody>
<tr><td><strong>구독형(Sub)</strong></td><td>구독 만료일 도래 전 갱신 여부 결정 및 비용 정산</td></tr>
<tr><td><strong>영구형(Perm)</strong></td><td>보유 수량 대비 실제 설치 수량 매핑 (초과 사용 금지)</td></tr>
<tr><td><strong>운영서비스</strong></td><td>도메인, 메일 등 매월 또는 매년 발생하는 비용 추적</td></tr>
</tbody>
</table>
</section>
<div class="guide-tip">
<strong>팁:</strong> 소프트웨어 상세 페이지의 [사용자 할당] 기능을 활용하여 누가 어떤 라이선스를 사용하는지 체계적으로 관리하세요.
</div>
`
}
];
// ─── 가이드 모달 초기화 ───
export function initGuide() {
const body = document.body;
if (document.getElementById('guide-overlay')) return;
const overlay = document.createElement('div');
overlay.className = 'modal-overlay hidden';
overlay.id = 'guide-overlay';
const tabsHtml = GUIDE_TABS.map((tab, i) =>
`<div class="guide-tab ${i === 0 ? 'active' : ''}" data-guide-tab="${tab.id}">${tab.label}</div>`
).join('');
const panelsHtml = GUIDE_TABS.map((tab, i) =>
`<div class="guide-tab-panel ${i === 0 ? 'active' : ''}" data-guide-panel="${tab.id}">${tab.content}</div>`
).join('');
overlay.innerHTML = `
<div class="modal-content wide" id="guide-modal" style="height: 90vh;">
<div class="modal-header">
<h2><i data-lucide="book-open"></i> 자산관리 프로세스 가이드 (Standard)</h2>
<button class="btn-icon" id="btn-close-guide">
<i data-lucide="x"></i>
</button>
</div>
<div class="guide-tabs-container">
<div class="guide-tabs">${tabsHtml}</div>
</div>
<div class="modal-body" style="padding-top: 0;">
<div class="guide-body">${panelsHtml}</div>
</div>
</div>
`;
body.appendChild(overlay);
const openGuide = () => {
console.log('📖 Opening Full Guide Modal...');
overlay.classList.remove('hidden');
};
const closeGuide = () => overlay.classList.add('hidden');
const triggerBtn = document.getElementById('btn-open-guide-header');
if (triggerBtn) {
triggerBtn.addEventListener('click', openGuide);
}
overlay.addEventListener('click', (e) => { if (e.target === overlay) closeGuide(); });
document.getElementById('btn-close-guide')?.addEventListener('click', closeGuide);
const tabs = overlay.querySelectorAll('.guide-tab');
const panels = overlay.querySelectorAll('.guide-tab-panel');
tabs.forEach(tab => {
tab.addEventListener('click', () => {
const targetId = tab.getAttribute('data-guide-tab');
tabs.forEach(t => t.classList.remove('active'));
panels.forEach(p => p.classList.remove('active'));
tab.classList.add('active');
overlay.querySelector(`.guide-tab-panel[data-guide-panel="${targetId}"]`)?.classList.add('active');
});
});
createIcons({ icons: { BookOpen, X, ChevronDown, ChevronRight, RefreshCw } });
}
import { createIcons, BookOpen, X, ChevronDown, ChevronRight, RefreshCw } from 'lucide';
import { state } from '../core/state';
import './guide.css';
// ─── 자산별 가이드 콘텐츠 정의 (SW_Table 브랜치 전체 복구) ───
interface GuideTabConfig {
id: string;
label: string;
content: string;
}
const GUIDE_TABS: GuideTabConfig[] = [
{
id: 'overview',
label: '📋 개요',
content: `
<section class="guide-section">
<h3>IT 자산관리 시스템 개요</h3>
<p class="guide-text">
HM IT 자산관리 시스템(ITAM)은 기업의 IT 자산을 <strong>도입부터 폐기까지</strong> 전 과정에서 효율적으로 관리하기 위한 통합 플랫폼입니다.<br>
하드웨어(PC, 서버, 스토리지, 전산비품, 모바일기기)와 소프트웨어(구독SW, 영구SW, 클라우드)를 체계적으로 추적하고 유지보수합니다.
</p>
</section>
<section class="guide-section">
<h3>전체 자산관리 프로세스</h3>
<div class="flow-container">
<div class="flow-row">
<div class="flow-step">
<span class="step-number">1</span>
<div><span class="step-label">도입/구매</span><p class="step-desc">자산 구매 요청 → 승인 → 발주</p></div>
</div>
<span class="flow-arrow-right"><i data-lucide="chevron-right"></i></span>
<div class="flow-step">
<span class="step-number">2</span>
<div><span class="step-label">등록/배정</span><p class="step-desc">자산번호 부여 → 시스템 등록 → 사용자 할당</p></div>
</div>
<span class="flow-arrow-right"><i data-lucide="chevron-right"></i></span>
<div class="flow-step">
<span class="step-number">3</span>
<div><span class="step-label">운영/유지</span><p class="step-desc">현황 모니터링 → 점검/수리 → 이력 관리</p></div>
</div>
<span class="flow-arrow-right"><i data-lucide="chevron-right"></i></span>
<div class="flow-step">
<span class="step-number">4</span>
<div><span class="step-label">반납/폐기</span><p class="step-desc">자산 회수 → 데이터 소거 → 폐기 처리</p></div>
</div>
</div>
</div>
</section>
<section class="guide-section">
<h3>시스템 기본 사용방법</h3>
<table class="guide-info-table">
<thead><tr><th>기능</th><th>방법</th></tr></thead>
<tbody>
<tr><td><strong>자산 조회</strong></td><td>상단 카테고리(하드웨어/소프트웨어) 및 하위 탭 선택 후 데이터 조회</td></tr>
<tr><td><strong>자산 등록</strong></td><td>[자산 추가] 버튼 클릭 후 상세 정보 입력 및 저장</td></tr>
<tr><td><strong>정보 수정</strong></td><td>목록 행 클릭 후 나타나는 모달에서 내용 변경 및 저장</td></tr>
</tbody>
</table>
</section>
`
},
{
id: 'pc',
label: '💻 개인PC',
content: `
<section class="guide-section">
<h3>개인PC 관리 가이드</h3>
<p class="guide-text">
임직원에게 지급되는 데스크톱 및 노트북을 관리합니다. 자산의 지급, 교체, 반납까지의 전체 생애주기를 시스템에서 추적합니다.
</p>
</section>
<section class="guide-section">
<h3>관리 프로세스</h3>
<div class="flow-container">
<div class="flow-row">
<div class="flow-step">
<span class="step-number">1</span>
<div><span class="step-label">구매 및 입고</span><p class="step-desc">구매 요청 → 발주 → 입고 검수</p></div>
</div>
<span class="flow-arrow-right"><i data-lucide="chevron-right"></i></span>
<div class="flow-step">
<span class="step-number">2</span>
<div><span class="step-label">자산 등록</span><p class="step-desc">자산번호 부여, 상세 사양 등록</p></div>
</div>
</div>
<i data-lucide="chevron-down" class="flow-arrow"></i>
<div class="flow-row">
<div class="flow-step">
<span class="step-number">3</span>
<div><span class="step-label">사용자 지급</span><p class="step-desc">사용자 지정 및 설치위치 기록</p></div>
</div>
<span class="flow-arrow-right"><i data-lucide="chevron-right"></i></span>
<div class="flow-step">
<span class="step-number">4</span>
<div><span class="step-label">운영 관리</span><p class="step-desc">보안 점검 및 수리 이력 관리</p></div>
</div>
</div>
<i data-lucide="chevron-down" class="flow-arrow"></i>
<div class="flow-row">
<div class="flow-step">
<span class="step-number">5</span>
<div><span class="step-label">교체/반납</span><p class="step-desc">장비 회수 및 데이터 소거</p></div>
</div>
<span class="flow-arrow-right"><i data-lucide="chevron-right"></i></span>
<div class="flow-step">
<span class="step-number">6</span>
<div><span class="step-label">폐기 처리</span><p class="step-desc">불용 처리 및 매각/폐기 등록</p></div>
</div>
</div>
</div>
</section>
<section class="guide-section">
<h3>주요 관리 항목</h3>
<table class="guide-info-table">
<thead><tr><th>항목</th><th>설명</th><th>관리 주기</th></tr></thead>
<tbody>
<tr><td>구매법인</td><td>자산의 소유 법인</td><td>등록 시</td></tr>
<tr><td>사용자/조직</td><td>실제 사용자 및 소속 부서</td><td>변동 시</td></tr>
<tr><td>자산번호</td><td>고유 식별 번호 (바코드)</td><td>등록 시</td></tr>
<tr><td>모델명/사양</td><td>제조사 모델 및 CPU/RAM 등</td><td>등록 시</td></tr>
<tr><td>구매금액</td><td>구매 비용 (부가세 포함)</td><td>등록 시</td></tr> </tbody>
</table>
</section>
<div class="guide-tip">
<strong>관리 팁:</strong> 자산 이력에서 '분출'과 '반납' 로그를 꼼꼼히 기록하면 자산의 실제 위치를 정확히 파악할 수 있습니다.
</div>
`
},
{
id: 'server',
label: '🖥️ 서버/스토리지',
content: `
<section class="guide-section">
<h3>인프라 자산 관리 가이드</h3>
<p class="guide-text">
서버실 및 IDC에 설치된 물리 서버와 스토리지 장비를 관리합니다. 고가의 자산이므로 담당자(정/부) 지정이 필수입니다.
</p>
</section>
<section class="guide-section">
<h3>관리 프로세스</h3>
<div class="flow-container">
<div class="flow-row">
<div class="flow-step">
<span class="step-number">1</span>
<div><span class="step-label">도입 계획</span><p class="step-desc">사양 확정 및 구매 승인</p></div>
</div>
<span class="flow-arrow-right"><i data-lucide="chevron-right"></i></span>
<div class="flow-step">
<span class="step-number">2</span>
<div><span class="step-label">설치 및 등록</span><p class="step-desc">네트워크 설정 및 자산번호 부여</p></div>
</div>
<span class="flow-arrow-right"><i data-lucide="chevron-right"></i></span>
<div class="flow-step">
<span class="step-number">3</span>
<div><span class="step-label">운영 관리</span><p class="step-desc">정기 점검 및 장애 이력 관리</p></div>
</div>
</div>
</div>
</section>
<section class="guide-section">
<h3>필수 입력 항목</h3>
<table class="guide-info-table">
<thead><tr><th>항목</th><th>중요성</th></tr></thead>
<tbody>
<tr><td><strong>IP 주소</strong></td><td>서버 접속 및 모니터링을 위한 필수 정보</td></tr>
<tr><td><strong>설치위치</strong></td><td>IDC 또는 서버실 내의 정확한 랙 위치</td></tr>
<tr><td><strong>담당자(정/부)</strong></td><td>비상 시 연락 가능한 관리 책임자</td></tr>
<tr><td><strong>용도/상세</strong></td><td>운영 중인 서비스 및 상세 업무 설명</td></tr>
</tbody>
</table>
</section>
<div class="guide-warn">
<strong>주의 사항:</strong> 서버 자산의 IP가 변경될 경우 시스템에 즉시 반영하여 네트워크 관리 대장과의 정합성을 유지해야 합니다.
</div>
`
},
{
id: 'software',
label: '💾 소프트웨어',
content: `
<section class="guide-section">
<h3>소프트웨어 자산 관리 가이드</h3>
<p class="guide-text">
구독형(SaaS) 및 영구형 라이선스를 관리합니다. 불법 소프트웨어 사용 방지와 비용 최적화가 주 목적입니다.
</p>
</section>
<section class="guide-section">
<h3>라이선스 관리 포인트</h3>
<table class="guide-info-table">
<thead><tr><th>구분</th><th>관리 내용</th></tr></thead>
<tbody>
<tr><td><strong>구독형(Sub)</strong></td><td>구독 만료일 도래 전 갱신 여부 결정 및 비용 정산</td></tr>
<tr><td><strong>영구형(Perm)</strong></td><td>보유 수량 대비 실제 설치 수량 매핑 (초과 사용 금지)</td></tr>
<tr><td><strong>운영서비스</strong></td><td>도메인, 메일 등 매월 또는 매년 발생하는 비용 추적</td></tr>
</tbody>
</table>
</section>
<div class="guide-tip">
<strong>팁:</strong> 소프트웨어 상세 페이지의 [사용자 할당] 기능을 활용하여 누가 어떤 라이선스를 사용하는지 체계적으로 관리하세요.
</div>
`
}
];
// ─── 가이드 모달 초기화 ───
export function initGuide() {
const body = document.body;
if (document.getElementById('guide-overlay')) return;
const overlay = document.createElement('div');
overlay.className = 'modal-overlay hidden';
overlay.id = 'guide-overlay';
const tabsHtml = GUIDE_TABS.map((tab, i) =>
`<div class="guide-tab ${i === 0 ? 'active' : ''}" data-guide-tab="${tab.id}">${tab.label}</div>`
).join('');
const panelsHtml = GUIDE_TABS.map((tab, i) =>
`<div class="guide-tab-panel ${i === 0 ? 'active' : ''}" data-guide-panel="${tab.id}">${tab.content}</div>`
).join('');
overlay.innerHTML = `
<div class="modal-content wide" id="guide-modal" style="height: 90vh;">
<div class="modal-header">
<h2><i data-lucide="book-open"></i> 자산관리 프로세스 가이드 (Standard)</h2>
<button class="btn-icon" id="btn-close-guide">
<i data-lucide="x"></i>
</button>
</div>
<div class="guide-tabs-container">
<div class="guide-tabs">${tabsHtml}</div>
</div>
<div class="modal-body" style="padding-top: 0;">
<div class="guide-body">${panelsHtml}</div>
</div>
</div>
`;
body.appendChild(overlay);
const openGuide = () => {
console.log('📖 Opening Full Guide Modal...');
overlay.classList.remove('hidden');
};
const closeGuide = () => overlay.classList.add('hidden');
const triggerBtn = document.getElementById('btn-open-guide-header');
if (triggerBtn) {
triggerBtn.addEventListener('click', openGuide);
}
overlay.addEventListener('click', (e) => { if (e.target === overlay) closeGuide(); });
document.getElementById('btn-close-guide')?.addEventListener('click', closeGuide);
const tabs = overlay.querySelectorAll('.guide-tab');
const panels = overlay.querySelectorAll('.guide-tab-panel');
tabs.forEach(tab => {
tab.addEventListener('click', () => {
const targetId = tab.getAttribute('data-guide-tab');
tabs.forEach(t => t.classList.remove('active'));
panels.forEach(p => p.classList.remove('active'));
tab.classList.add('active');
overlay.querySelector(`.guide-tab-panel[data-guide-panel="${targetId}"]`)?.classList.add('active');
});
});
createIcons({ icons: { BookOpen, X, ChevronDown, ChevronRight, RefreshCw } });
}

View File

@@ -1,143 +1,143 @@
import { createIcons, X } from 'lucide';
import { setEditLock } from './ModalUtils';
import './modal.css';
/**
* 모든 모달의 공통 기능을 관리하는 베이스 추상 클래스입니다.
*/
export abstract class BaseModal {
protected idPrefix: string;
protected title: string;
protected currentAsset: any | null = null;
protected isEditMode: boolean = false;
protected currentMode: 'view' | 'edit' | 'add' = 'view';
protected modalEl: HTMLElement | null = null;
protected formEl: HTMLFormElement | null = null;
constructor(idPrefix: string, title: string) {
this.idPrefix = idPrefix;
this.title = title;
}
/**
* 모달 초기화: HTML 삽입 및 공통 이벤트 바인딩
*/
public init(onSave: () => void, closeModalsFn: () => void) {
// 1. 프레임 HTML 삽입 (자식 클래스에서 정의한 HTML 사용)
if (!document.getElementById(`${this.idPrefix}-asset-modal`)) {
document.body.insertAdjacentHTML('beforeend', this.renderFrameHTML());
}
this.modalEl = document.getElementById(`${this.idPrefix}-asset-modal`);
this.formEl = document.getElementById(`${this.idPrefix}-asset-form`) as HTMLFormElement;
// 2. 공통 버튼 이벤트 바인딩 (닫기, 취소 등)
const btnCloseHeader = document.getElementById(`btn-close-${this.idPrefix}-modal`);
const btnCancelFooter = document.getElementById(`btn-cancel-${this.idPrefix}-modal`);
const closeAction = () => {
this.close();
closeModalsFn(); // 전역 모달 상태 해제 콜백
};
btnCloseHeader?.addEventListener('click', closeAction);
btnCancelFooter?.addEventListener('click', closeAction);
// 3. 자식 클래스 전용 초기화 로직 실행
this.initChildLogic(onSave, closeModalsFn);
// 4. 아이콘 초기화
createIcons({ icons: { X } });
}
/**
* 모달 열기: 데이터 바인딩 및 모드 설정
*/
public open(asset: any, mode: 'view' | 'edit' | 'add' = 'view') {
this.currentAsset = asset;
this.currentMode = mode;
this.isEditMode = (mode === 'add' || mode === 'edit');
// 폼 초기화 추가
if (this.formEl) this.formEl.reset();
// fillFormData를 먼저 호출하여 동적 요소들을 생성한 후 잠금 처리
this.fillFormData(asset);
this.setEditLockMode(mode);
if (this.modalEl) {
this.modalEl.classList.remove('hidden');
const content = this.modalEl.querySelector('.modal-content');
if (content) {
if (mode === 'view') content.classList.add('is-view-mode');
else content.classList.remove('is-view-mode');
}
}
this.onAfterOpen(asset, mode);
}
/**
* 모달 닫기: 상태 초기화
*/
public close() {
if (this.modalEl) {
this.modalEl.classList.add('hidden');
}
this.isEditMode = false;
this.currentAsset = null;
this.onAfterClose();
}
/**
* 조회/수정 모드에 따른 UI 잠금 및 버튼 제어
*/
protected setEditLockMode(mode: 'view' | 'edit' | 'add') {
setEditLock(`${this.idPrefix}-asset-form`, mode, {
saveBtnId: `btn-save-${this.idPrefix}-asset`,
revertBtnId: `btn-revert-${this.idPrefix}-edit`,
addLogBtnId: `btn-add-${this.idPrefix}-log`
});
}
// --- 추상 메서드: 자식 클래스에서 구현해야 함 ---
protected abstract renderFrameHTML(): string;
protected abstract initChildLogic(onSave: () => void, closeModals: () => void): void;
protected abstract fillFormData(asset: any): void;
protected abstract onAfterOpen(asset: any, mode: string): void;
// --- 훅(Hook) 메서드: 필요 시 오버라이드 ---
protected onAfterClose(): void {}
}
/**
* --- 레거시 호환성을 위한 함수형 익스포트 ---
* 기존 코드들이 참조하고 있는 함수들을 유지합니다.
*/
export function closeModals() {
const modals = document.querySelectorAll('.modal-overlay');
modals.forEach(modal => modal.classList.add('hidden'));
}
export function initBaseModal() {
// ESC 키로 모든 모달 닫기 (위치보기 팝업이 있으면 그것부터 닫음)
window.addEventListener('keydown', (e) => {
if (e.key === 'Escape') {
const picker = document.querySelector('.image-picker-overlay');
if (picker) {
picker.remove();
} else {
closeModals();
}
}
});
return { closeAllModals: closeModals };
}
export function openModal(modalId: string) {
const modal = document.getElementById(modalId);
if (modal) {
modal.classList.remove('hidden');
}
}
import { createIcons, X } from 'lucide';
import { setEditLock } from './ModalUtils';
import './modal.css';
/**
* 모든 모달의 공통 기능을 관리하는 베이스 추상 클래스입니다.
*/
export abstract class BaseModal {
protected idPrefix: string;
protected title: string;
protected currentAsset: any | null = null;
protected isEditMode: boolean = false;
protected currentMode: 'view' | 'edit' | 'add' = 'view';
protected modalEl: HTMLElement | null = null;
protected formEl: HTMLFormElement | null = null;
constructor(idPrefix: string, title: string) {
this.idPrefix = idPrefix;
this.title = title;
}
/**
* 모달 초기화: HTML 삽입 및 공통 이벤트 바인딩
*/
public init(onSave: () => void, closeModalsFn: () => void) {
// 1. 프레임 HTML 삽입 (자식 클래스에서 정의한 HTML 사용)
if (!document.getElementById(`${this.idPrefix}-asset-modal`)) {
document.body.insertAdjacentHTML('beforeend', this.renderFrameHTML());
}
this.modalEl = document.getElementById(`${this.idPrefix}-asset-modal`);
this.formEl = document.getElementById(`${this.idPrefix}-asset-form`) as HTMLFormElement;
// 2. 공통 버튼 이벤트 바인딩 (닫기, 취소 등)
const btnCloseHeader = document.getElementById(`btn-close-${this.idPrefix}-modal`);
const btnCancelFooter = document.getElementById(`btn-cancel-${this.idPrefix}-modal`);
const closeAction = () => {
this.close();
closeModalsFn(); // 전역 모달 상태 해제 콜백
};
btnCloseHeader?.addEventListener('click', closeAction);
btnCancelFooter?.addEventListener('click', closeAction);
// 3. 자식 클래스 전용 초기화 로직 실행
this.initChildLogic(onSave, closeModalsFn);
// 4. 아이콘 초기화
createIcons({ icons: { X } });
}
/**
* 모달 열기: 데이터 바인딩 및 모드 설정
*/
public open(asset: any, mode: 'view' | 'edit' | 'add' = 'view') {
this.currentAsset = asset;
this.currentMode = mode;
this.isEditMode = (mode === 'add' || mode === 'edit');
// 폼 초기화 추가
if (this.formEl) this.formEl.reset();
// fillFormData를 먼저 호출하여 동적 요소들을 생성한 후 잠금 처리
this.fillFormData(asset);
this.setEditLockMode(mode);
if (this.modalEl) {
this.modalEl.classList.remove('hidden');
const content = this.modalEl.querySelector('.modal-content');
if (content) {
if (mode === 'view') content.classList.add('is-view-mode');
else content.classList.remove('is-view-mode');
}
}
this.onAfterOpen(asset, mode);
}
/**
* 모달 닫기: 상태 초기화
*/
public close() {
if (this.modalEl) {
this.modalEl.classList.add('hidden');
}
this.isEditMode = false;
this.currentAsset = null;
this.onAfterClose();
}
/**
* 조회/수정 모드에 따른 UI 잠금 및 버튼 제어
*/
protected setEditLockMode(mode: 'view' | 'edit' | 'add') {
setEditLock(`${this.idPrefix}-asset-form`, mode, {
saveBtnId: `btn-save-${this.idPrefix}-asset`,
revertBtnId: `btn-revert-${this.idPrefix}-edit`,
addLogBtnId: `btn-add-${this.idPrefix}-log`
});
}
// --- 추상 메서드: 자식 클래스에서 구현해야 함 ---
protected abstract renderFrameHTML(): string;
protected abstract initChildLogic(onSave: () => void, closeModals: () => void): void;
protected abstract fillFormData(asset: any): void;
protected abstract onAfterOpen(asset: any, mode: string): void;
// --- 훅(Hook) 메서드: 필요 시 오버라이드 ---
protected onAfterClose(): void {}
}
/**
* --- 레거시 호환성을 위한 함수형 익스포트 ---
* 기존 코드들이 참조하고 있는 함수들을 유지합니다.
*/
export function closeModals() {
const modals = document.querySelectorAll('.modal-overlay');
modals.forEach(modal => modal.classList.add('hidden'));
}
export function initBaseModal() {
// ESC 키로 모든 모달 닫기 (위치보기 팝업이 있으면 그것부터 닫음)
window.addEventListener('keydown', (e) => {
if (e.key === 'Escape') {
const picker = document.querySelector('.image-picker-overlay');
if (picker) {
picker.remove();
} else {
closeModals();
}
}
});
return { closeAllModals: closeModals };
}
export function openModal(modalId: string) {
const modal = document.getElementById(modalId);
if (modal) {
modal.classList.remove('hidden');
}
}

View File

@@ -1,136 +1,136 @@
import { state } from '../../core/state';
import { ASSET_SCHEMA } from '../../core/schema';
import { createIcons, X } from 'lucide';
const DASHBOARD_DETAIL_MODAL_HTML = `
<div id="dashboard-detail-modal" class="modal-overlay hidden">
<div class="modal-content wide">
<div class="modal-header">
<h2 id="dashboard-detail-modal-title" class="modal-title">상세 목록</h2>
<button id="btn-close-dashboard-detail-modal" class="btn-icon" aria-label="닫기"><i data-lucide="x"></i></button>
</div>
<div class="modal-body">
<div class="table-container">
<table>
<thead></thead>
<tbody id="dashboard-detail-tbody"></tbody>
</table>
</div>
</div>
<div class="modal-footer">
<div></div>
<button id="btn-cancel-dashboard-detail-modal" class="btn btn-outline">닫기</button>
</div>
</div>
</div>
`;
export function initDashboardDetailModal() {
if (!document.getElementById('dashboard-detail-modal')) {
document.body.insertAdjacentHTML('beforeend', DASHBOARD_DETAIL_MODAL_HTML);
}
const modal = document.getElementById('dashboard-detail-modal')!;
const closeBtn = document.getElementById('btn-close-dashboard-detail-modal')!;
const cancelBtn = document.getElementById('btn-cancel-dashboard-detail-modal')!;
const closeModal = () => modal.classList.add('hidden');
closeBtn.addEventListener('click', closeModal);
cancelBtn.addEventListener('click', closeModal);
modal.addEventListener('click', (e) => { if (e.target === modal) closeModal(); });
createIcons({ icons: { X } });
}
export function openDashboardDetail(title: string, list: any[]) {
const modal = document.getElementById('dashboard-detail-modal');
if (!modal) return;
const titleEl = document.getElementById('dashboard-detail-modal-title');
const tbody = document.getElementById('dashboard-detail-tbody');
if (!titleEl || !tbody) return;
const thead = tbody.closest('table')?.querySelector('thead');
if (!thead) return;
titleEl.textContent = title;
thead.innerHTML = `<tr><th>No</th><th>유형</th><th>명칭/모델</th><th>위치</th><th>담당/사용자</th><th>구매일자</th><th>금액</th></tr>`;
tbody.innerHTML = '';
if (list.length === 0) {
tbody.innerHTML = `<tr><td colspan="7" style="text-align:center; padding: 2rem;">해당 조건의 자산이 없습니다.</td></tr>`;
} else {
list.forEach((asset, idx) => {
let manager = asset[ASSET_SCHEMA.MANAGER_MAIN.key] || asset.user_current || '-';
let name = asset[ASSET_SCHEMA.MODEL_NAME.key] || asset[ASSET_SCHEMA.ASSET_NAME.key] || '-';
const tr = document.createElement('tr');
tr.innerHTML = `<td>${idx+1}</td><td>${asset.category || asset[ASSET_SCHEMA.ASSET_TYPE.key]}</td><td>${name}</td><td>${asset[ASSET_SCHEMA.LOCATION.key]||'-'}</td><td>${manager}</td><td>${asset[ASSET_SCHEMA.PURCHASE_DATE.key]||'-'}</td><td>${asset[ASSET_SCHEMA.PURCHASE_AMOUNT.key]||'-'}</td>`;
tbody.appendChild(tr);
});
}
modal.classList.remove('hidden');
}
export function openSwDashboardDetail(title: string, list: any[]) {
const modal = document.getElementById('dashboard-detail-modal');
if (!modal) return;
const titleEl = document.getElementById('dashboard-detail-modal-title');
const tbody = document.getElementById('dashboard-detail-tbody');
if (!titleEl || !tbody) return;
const thead = tbody.closest('table')?.querySelector('thead');
if (!thead) return;
titleEl.textContent = title;
thead.innerHTML = `<tr><th>No</th><th>유형</th><th>법인</th><th>제품명</th><th>수량</th><th>금액</th></tr>`;
tbody.innerHTML = '';
list.forEach((sw, idx) => {
const tr = document.createElement('tr');
tr.innerHTML = `<td>${idx+1}</td><td>${sw.asset_type || sw.type}</td><td>${sw[ASSET_SCHEMA.PURCHASE_CORP.key]}</td><td>${sw[ASSET_SCHEMA.PRODUCT_NAME.key]}</td><td>${sw[ASSET_SCHEMA.ASSET_COUNT.key]}</td><td>${sw[ASSET_SCHEMA.PURCHASE_AMOUNT.key]}</td>`;
tbody.appendChild(tr);
});
modal.classList.remove('hidden');
}
export function openSwUsageDetail(title: string, list: any[]) {
const modal = document.getElementById('dashboard-detail-modal');
if (!modal) return;
const titleEl = document.getElementById('dashboard-detail-modal-title');
const tbody = document.getElementById('dashboard-detail-tbody');
if (!titleEl || !tbody) return;
const thead = tbody.closest('table')?.querySelector('thead');
if (!thead) return;
titleEl.textContent = title;
thead.innerHTML = `<tr><th>No</th><th>법인</th><th>제품명</th><th>수량</th><th>사용중</th><th>사용가능</th></tr>`;
tbody.innerHTML = '';
list.forEach((sw, idx) => {
const assigned = state.masterData.swUsers.filter(u => u.sw_id === sw.id).length;
const qty = Number(sw[ASSET_SCHEMA.ASSET_COUNT.key] || 0);
const tr = document.createElement('tr');
tr.innerHTML = `<td>${idx+1}</td><td>${sw[ASSET_SCHEMA.PURCHASE_CORP.key]}</td><td>${sw[ASSET_SCHEMA.PRODUCT_NAME.key]}</td><td>${qty}</td><td>${assigned}</td><td>${qty - assigned}</td>`;
tbody.appendChild(tr);
});
modal.classList.remove('hidden');
}
export function openCloudDashboardDetail(title: string, list: any[]) {
const modal = document.getElementById('dashboard-detail-modal');
if (!modal) return;
const titleEl = document.getElementById('dashboard-detail-modal-title');
const tbody = document.getElementById('dashboard-detail-tbody');
if (!titleEl || !tbody) return;
const thead = tbody.closest('table')?.querySelector('thead');
if (!thead) return;
titleEl.textContent = title;
thead.innerHTML = `<tr><th>No</th><th>플랫폼/목적</th><th>법인</th><th>제품명</th><th>결제일</th><th>당월청구액(원)</th></tr>`;
tbody.innerHTML = '';
if (list.length === 0) {
tbody.innerHTML = `<tr><td colspan="6" style="text-align:center; padding: 2rem;">해당 내역이 없습니다.</td></tr>`;
} else {
list.forEach((sw, idx) => {
const priceStr = sw[ASSET_SCHEMA.PURCHASE_AMOUNT.key] ? Number(String(sw[ASSET_SCHEMA.PURCHASE_AMOUNT.key]).replace(/[^0-9]/g, '')).toLocaleString() : '0';
const tr = document.createElement('tr');
tr.innerHTML = `<td>${idx+1}</td><td>${sw[ASSET_SCHEMA.DEV_OBJ.key]||'-'}</td><td>${sw[ASSET_SCHEMA.PURCHASE_CORP.key]||'-'}</td><td>${sw[ASSET_SCHEMA.PRODUCT_NAME.key]||'-'}</td><td>${sw.pay_day ? sw.pay_day + '일' : '-'}</td><td>₩ ${priceStr}</td>`;
tbody.appendChild(tr);
});
}
modal.classList.remove('hidden');
}
import { state } from '../../core/state';
import { ASSET_SCHEMA } from '../../core/schema';
import { createIcons, X } from 'lucide';
const DASHBOARD_DETAIL_MODAL_HTML = `
<div id="dashboard-detail-modal" class="modal-overlay hidden">
<div class="modal-content wide">
<div class="modal-header">
<h2 id="dashboard-detail-modal-title" class="modal-title">상세 목록</h2>
<button id="btn-close-dashboard-detail-modal" class="btn-icon" aria-label="닫기"><i data-lucide="x"></i></button>
</div>
<div class="modal-body">
<div class="table-container">
<table>
<thead></thead>
<tbody id="dashboard-detail-tbody"></tbody>
</table>
</div>
</div>
<div class="modal-footer">
<div></div>
<button id="btn-cancel-dashboard-detail-modal" class="btn btn-outline">닫기</button>
</div>
</div>
</div>
`;
export function initDashboardDetailModal() {
if (!document.getElementById('dashboard-detail-modal')) {
document.body.insertAdjacentHTML('beforeend', DASHBOARD_DETAIL_MODAL_HTML);
}
const modal = document.getElementById('dashboard-detail-modal')!;
const closeBtn = document.getElementById('btn-close-dashboard-detail-modal')!;
const cancelBtn = document.getElementById('btn-cancel-dashboard-detail-modal')!;
const closeModal = () => modal.classList.add('hidden');
closeBtn.addEventListener('click', closeModal);
cancelBtn.addEventListener('click', closeModal);
modal.addEventListener('click', (e) => { if (e.target === modal) closeModal(); });
createIcons({ icons: { X } });
}
export function openDashboardDetail(title: string, list: any[]) {
const modal = document.getElementById('dashboard-detail-modal');
if (!modal) return;
const titleEl = document.getElementById('dashboard-detail-modal-title');
const tbody = document.getElementById('dashboard-detail-tbody');
if (!titleEl || !tbody) return;
const thead = tbody.closest('table')?.querySelector('thead');
if (!thead) return;
titleEl.textContent = title;
thead.innerHTML = `<tr><th>No</th><th>유형</th><th>명칭/모델</th><th>위치</th><th>담당/사용자</th><th>구매일자</th><th>금액</th></tr>`;
tbody.innerHTML = '';
if (list.length === 0) {
tbody.innerHTML = `<tr><td colspan="7" style="text-align:center; padding: 2rem;">해당 조건의 자산이 없습니다.</td></tr>`;
} else {
list.forEach((asset, idx) => {
let manager = asset[ASSET_SCHEMA.MANAGER_MAIN.key] || asset.user_current || '-';
let name = asset[ASSET_SCHEMA.MODEL_NAME.key] || asset[ASSET_SCHEMA.ASSET_NAME.key] || '-';
const tr = document.createElement('tr');
tr.innerHTML = `<td>${idx+1}</td><td>${asset.category || asset[ASSET_SCHEMA.ASSET_TYPE.key]}</td><td>${name}</td><td>${asset[ASSET_SCHEMA.LOCATION.key]||'-'}</td><td>${manager}</td><td>${asset[ASSET_SCHEMA.PURCHASE_DATE.key]||'-'}</td><td>${asset[ASSET_SCHEMA.PURCHASE_AMOUNT.key]||'-'}</td>`;
tbody.appendChild(tr);
});
}
modal.classList.remove('hidden');
}
export function openSwDashboardDetail(title: string, list: any[]) {
const modal = document.getElementById('dashboard-detail-modal');
if (!modal) return;
const titleEl = document.getElementById('dashboard-detail-modal-title');
const tbody = document.getElementById('dashboard-detail-tbody');
if (!titleEl || !tbody) return;
const thead = tbody.closest('table')?.querySelector('thead');
if (!thead) return;
titleEl.textContent = title;
thead.innerHTML = `<tr><th>No</th><th>유형</th><th>법인</th><th>제품명</th><th>수량</th><th>금액</th></tr>`;
tbody.innerHTML = '';
list.forEach((sw, idx) => {
const tr = document.createElement('tr');
tr.innerHTML = `<td>${idx+1}</td><td>${sw.asset_type || sw.type}</td><td>${sw[ASSET_SCHEMA.PURCHASE_CORP.key]}</td><td>${sw[ASSET_SCHEMA.PRODUCT_NAME.key]}</td><td>${sw[ASSET_SCHEMA.ASSET_COUNT.key]}</td><td>${sw[ASSET_SCHEMA.PURCHASE_AMOUNT.key]}</td>`;
tbody.appendChild(tr);
});
modal.classList.remove('hidden');
}
export function openSwUsageDetail(title: string, list: any[]) {
const modal = document.getElementById('dashboard-detail-modal');
if (!modal) return;
const titleEl = document.getElementById('dashboard-detail-modal-title');
const tbody = document.getElementById('dashboard-detail-tbody');
if (!titleEl || !tbody) return;
const thead = tbody.closest('table')?.querySelector('thead');
if (!thead) return;
titleEl.textContent = title;
thead.innerHTML = `<tr><th>No</th><th>법인</th><th>제품명</th><th>수량</th><th>사용중</th><th>사용가능</th></tr>`;
tbody.innerHTML = '';
list.forEach((sw, idx) => {
const assigned = state.masterData.swUsers.filter(u => u.sw_id === sw.id).length;
const qty = Number(sw[ASSET_SCHEMA.ASSET_COUNT.key] || 0);
const tr = document.createElement('tr');
tr.innerHTML = `<td>${idx+1}</td><td>${sw[ASSET_SCHEMA.PURCHASE_CORP.key]}</td><td>${sw[ASSET_SCHEMA.PRODUCT_NAME.key]}</td><td>${qty}</td><td>${assigned}</td><td>${qty - assigned}</td>`;
tbody.appendChild(tr);
});
modal.classList.remove('hidden');
}
export function openCloudDashboardDetail(title: string, list: any[]) {
const modal = document.getElementById('dashboard-detail-modal');
if (!modal) return;
const titleEl = document.getElementById('dashboard-detail-modal-title');
const tbody = document.getElementById('dashboard-detail-tbody');
if (!titleEl || !tbody) return;
const thead = tbody.closest('table')?.querySelector('thead');
if (!thead) return;
titleEl.textContent = title;
thead.innerHTML = `<tr><th>No</th><th>플랫폼/목적</th><th>법인</th><th>제품명</th><th>결제일</th><th>당월청구액(원)</th></tr>`;
tbody.innerHTML = '';
if (list.length === 0) {
tbody.innerHTML = `<tr><td colspan="6" style="text-align:center; padding: 2rem;">해당 내역이 없습니다.</td></tr>`;
} else {
list.forEach((sw, idx) => {
const priceStr = sw[ASSET_SCHEMA.PURCHASE_AMOUNT.key] ? Number(String(sw[ASSET_SCHEMA.PURCHASE_AMOUNT.key]).replace(/[^0-9]/g, '')).toLocaleString() : '0';
const tr = document.createElement('tr');
tr.innerHTML = `<td>${idx+1}</td><td>${sw[ASSET_SCHEMA.DEV_OBJ.key]||'-'}</td><td>${sw[ASSET_SCHEMA.PURCHASE_CORP.key]||'-'}</td><td>${sw[ASSET_SCHEMA.PRODUCT_NAME.key]||'-'}</td><td>${sw.pay_day ? sw.pay_day + '일' : '-'}</td><td>₩ ${priceStr}</td>`;
tbody.appendChild(tr);
});
}
modal.classList.remove('hidden');
}

View File

@@ -1,212 +1,212 @@
import { state, saveAsset, deleteAsset } from '../../core/state';
import { BaseModal } from './BaseModal';
import { CORP_LIST } from './SharedData';
import { generateOptionsHTML, setFieldValue, getFieldValue } from './ModalUtils';
import { createIcons, X, Save, History, Plus } from 'lucide';
import { formatExcelDate } from '../../core/excelHandler';
import { UI_TEXT } from '../../core/schema';
class DomainAssetModal extends BaseModal {
constructor() {
super('domain', '도메인 정보');
}
protected renderFrameHTML(): string {
return `
<div id="domain-asset-modal" class="modal-overlay hidden">
<div class="modal-content wide">
<div class="modal-header">
<div class="header-left">
<h2 id="domain-modal-title" class="modal-title">${this.title}</h2>
<div id="domain-header-identity" class="header-identity"></div>
</div>
<button id="btn-close-domain-modal" class="btn-icon" aria-label="닫기">&times;</button>
</div>
<div class="modal-body">
<div class="modal-body-split">
<div class="modal-form-area">
<form id="domain-asset-form" class="grid-form">
<input type="hidden" id="domain-id" name="id" />
<div class="form-section-title">기본 정보</div>
<div class="form-group">
<label>구분</label>
<select id="domain-type" name="type">
<option value="호스팅">호스팅</option>
<option value="도메인">도메인</option>
<option value="기타">기타</option>
</select>
</div>
<div class="form-group">
<label>관리법인</label>
<select id="domain-corp" name="corp">${generateOptionsHTML(CORP_LIST)}</select>
</div>
<div class="form-group full-width">
<label>서비스명</label>
<input type="text" id="domain-service-name" name="service_name" required />
</div>
<div class="form-group full-width">
<label>관리도메인</label>
<input type="text" id="domain-name" name="domain_name" required />
</div>
<div class="form-section-title">계약 및 비용</div>
<div class="form-group">
<label>계약시작일</label>
<input type="date" id="domain-start-date" name="start_date" />
</div>
<div class="form-group">
<label>만료예정일</label>
<input type="date" id="domain-expiry-date" name="expiry_date" />
</div>
<div class="form-group">
<label>비용 (연간/월간)</label>
<input type="text" id="domain-price" name="price" oninput="this.value = this.value.replace(/[^0-9]/g, '').replace(/\\B(?=(\\d{3})+(?!\\d))/g, ',')" />
</div>
<div class="form-section-title">담당자 및 비고</div>
<div class="form-group">
<label>정담당자</label>
<input type="text" id="domain-manager-main" name="manager_main" />
</div>
<div class="form-group">
<label>부담당자</label>
<input type="text" id="domain-manager-sub" name="manager_sub" />
</div>
<div class="form-group full-width">
<label>비고</label>
<textarea id="domain-remarks" name="remarks" rows="3"></textarea>
</div>
</form>
</div>
<div class="modal-history-area">
<div class="history-header">
<h3><i data-lucide="history"></i> 변경 이력</h3>
<button type="button" id="btn-add-domain-log" class="btn btn-outline btn-sm">
이력 추가 <i data-lucide="plus"></i>
</button>
</div>
<div id="domain-history-list" class="history-timeline"></div>
</div>
</div>
</div>
<div class="modal-footer">
<button id="btn-delete-domain-asset" class="btn btn-outline btn-danger">삭제</button>
<div class="footer-actions">
<button id="btn-revert-domain-edit" class="btn btn-outline hidden">수정 취소</button>
<button id="btn-cancel-domain-modal" class="btn btn-outline">닫기</button>
<button id="btn-save-domain-asset" class="btn btn-primary">수정</button>
</div>
</div>
</div>
</div>
`;
}
protected initChildLogic(onSave: () => void, closeModals: () => void): void {
const saveBtn = document.getElementById('btn-save-domain-asset')!;
const revertBtn = document.getElementById('btn-revert-domain-edit')!;
const deleteBtn = document.getElementById('btn-delete-domain-asset')!;
saveBtn.addEventListener('click', async () => {
if (!this.currentAsset) return;
if (!this.isEditMode) {
this.setEditLockMode('edit');
this.isEditMode = true;
return;
}
const formData = new FormData(this.formEl!);
const updated = { ...this.currentAsset };
formData.forEach((value, key) => { updated[key] = value; });
if (!updated.service_name || !updated.domain_name) {
alert('서비스명과 관리도메인은 필수 입력 사항입니다.');
return;
}
if (await saveAsset('domain', updated)) {
alert(UI_TEXT.MESSAGES.SAVE_SUCCESS);
onSave(); this.close(); closeModals();
}
});
revertBtn.addEventListener('click', () => {
this.setEditLockMode('view');
if (this.currentAsset) this.fillFormData(this.currentAsset);
});
deleteBtn.addEventListener('click', async () => {
if (!this.currentAsset || !confirm(UI_TEXT.MESSAGES.CONFIRM_DELETE)) return;
if (await deleteAsset('domain', this.currentAsset.id)) {
alert('성공적으로 삭제되었습니다.');
onSave(); this.close(); closeModals();
}
});
createIcons({ icons: { History, Plus, Save, X } });
}
protected fillFormData(asset: any): void {
setFieldValue('domain-id', asset.id);
setFieldValue('domain-type', asset.type || '호스팅');
setFieldValue('domain-corp', asset.corp || '');
setFieldValue('domain-service-name', asset.service_name || '');
setFieldValue('domain-name', asset.domain_name || '');
setFieldValue('domain-start-date', formatExcelDate(asset.start_date));
setFieldValue('domain-expiry-date', formatExcelDate(asset.expiry_date));
setFieldValue('domain-price', asset.price || '');
setFieldValue('domain-manager-main', asset.manager_main || '');
setFieldValue('domain-manager-sub', asset.manager_sub || '');
setFieldValue('domain-remarks', asset.remarks || '');
this.renderHistory(asset.id);
this.updateHeaderIdentity(asset);
}
protected onAfterOpen(asset: any, mode: string): void {
const titleEl = document.getElementById('domain-modal-title');
if (titleEl) titleEl.textContent = (mode === 'add') ? '신규 도메인 등록' : '도메인 정보 상세';
const deleteBtn = document.getElementById('btn-delete-domain-asset');
if (deleteBtn) deleteBtn.style.display = (mode === 'add') ? 'none' : 'block';
this.updateHeaderIdentity(asset);
}
private updateHeaderIdentity(asset: any) {
const container = document.getElementById('domain-header-identity');
if (!container) return;
if (this.currentMode === 'add') {
container.innerHTML = '<span class="badge badge-primary">신규 등록</span>';
return;
}
const type = getFieldValue('domain-type') || asset.type || '';
const serviceName = getFieldValue('domain-service-name') || asset.service_name || '';
const domainName = getFieldValue('domain-name') || asset.domain_name || '';
container.innerHTML = `
<span class="asset-code-title">${serviceName}</span>
<span class="service-type-badge">${type}</span>
<span class="asset-type-label">${domainName}</span>
`;
}
private renderHistory(assetId: string) {
const container = document.getElementById('domain-history-list');
if (!container) return;
const logs = (state.masterData.logs || []).filter(l => l.asset_id === assetId);
if (logs.length === 0) {
container.innerHTML = '<div style="color:var(--mute); padding:1rem; text-align:center;">이력이 없습니다.</div>';
} else {
container.innerHTML = logs.map(l => `<div class="history-item"><div class="history-date">${l.log_date || ''}</div><div class="history-user">${l.log_user || '시스템'}</div><div class="history-details">${l.details}</div></div>`).join('');
}
}
}
export const domainModal = new DomainAssetModal();
export function initDomainModal(onSave: () => void, closeModals: () => void) { domainModal.init(onSave, closeModals); }
export function openDomainModal(asset: any, mode: 'view' | 'edit' | 'add' = 'view') { domainModal.open(asset, mode); }
import { state, saveAsset, deleteAsset } from '../../core/state';
import { BaseModal } from './BaseModal';
import { CORP_LIST } from './SharedData';
import { generateOptionsHTML, setFieldValue, getFieldValue } from './ModalUtils';
import { createIcons, X, Save, History, Plus } from 'lucide';
import { formatExcelDate } from '../../core/excelHandler';
import { UI_TEXT } from '../../core/schema';
class DomainAssetModal extends BaseModal {
constructor() {
super('domain', '도메인 정보');
}
protected renderFrameHTML(): string {
return `
<div id="domain-asset-modal" class="modal-overlay hidden">
<div class="modal-content wide">
<div class="modal-header">
<div class="header-left">
<h2 id="domain-modal-title" class="modal-title">${this.title}</h2>
<div id="domain-header-identity" class="header-identity"></div>
</div>
<button id="btn-close-domain-modal" class="btn-icon" aria-label="닫기">&times;</button>
</div>
<div class="modal-body">
<div class="modal-body-split">
<div class="modal-form-area">
<form id="domain-asset-form" class="grid-form">
<input type="hidden" id="domain-id" name="id" />
<div class="form-section-title">기본 정보</div>
<div class="form-group">
<label>구분</label>
<select id="domain-type" name="type">
<option value="호스팅">호스팅</option>
<option value="도메인">도메인</option>
<option value="기타">기타</option>
</select>
</div>
<div class="form-group">
<label>관리법인</label>
<select id="domain-corp" name="corp">${generateOptionsHTML(CORP_LIST)}</select>
</div>
<div class="form-group full-width">
<label>서비스명</label>
<input type="text" id="domain-service-name" name="service_name" required />
</div>
<div class="form-group full-width">
<label>관리도메인</label>
<input type="text" id="domain-name" name="domain_name" required />
</div>
<div class="form-section-title">계약 및 비용</div>
<div class="form-group">
<label>계약시작일</label>
<input type="date" id="domain-start-date" name="start_date" />
</div>
<div class="form-group">
<label>만료예정일</label>
<input type="date" id="domain-expiry-date" name="expiry_date" />
</div>
<div class="form-group">
<label>비용 (연간/월간)</label>
<input type="text" id="domain-price" name="price" oninput="this.value = this.value.replace(/[^0-9]/g, '').replace(/\\B(?=(\\d{3})+(?!\\d))/g, ',')" />
</div>
<div class="form-section-title">담당자 및 비고</div>
<div class="form-group">
<label>정담당자</label>
<input type="text" id="domain-manager-main" name="manager_main" />
</div>
<div class="form-group">
<label>부담당자</label>
<input type="text" id="domain-manager-sub" name="manager_sub" />
</div>
<div class="form-group full-width">
<label>비고</label>
<textarea id="domain-remarks" name="remarks" rows="3"></textarea>
</div>
</form>
</div>
<div class="modal-history-area">
<div class="history-header">
<h3><i data-lucide="history"></i> 변경 이력</h3>
<button type="button" id="btn-add-domain-log" class="btn btn-outline btn-sm">
이력 추가 <i data-lucide="plus"></i>
</button>
</div>
<div id="domain-history-list" class="history-timeline"></div>
</div>
</div>
</div>
<div class="modal-footer">
<button id="btn-delete-domain-asset" class="btn btn-outline btn-danger">삭제</button>
<div class="footer-actions">
<button id="btn-revert-domain-edit" class="btn btn-outline hidden">수정 취소</button>
<button id="btn-cancel-domain-modal" class="btn btn-outline">닫기</button>
<button id="btn-save-domain-asset" class="btn btn-primary">수정</button>
</div>
</div>
</div>
</div>
`;
}
protected initChildLogic(onSave: () => void, closeModals: () => void): void {
const saveBtn = document.getElementById('btn-save-domain-asset')!;
const revertBtn = document.getElementById('btn-revert-domain-edit')!;
const deleteBtn = document.getElementById('btn-delete-domain-asset')!;
saveBtn.addEventListener('click', async () => {
if (!this.currentAsset) return;
if (!this.isEditMode) {
this.setEditLockMode('edit');
this.isEditMode = true;
return;
}
const formData = new FormData(this.formEl!);
const updated = { ...this.currentAsset };
formData.forEach((value, key) => { updated[key] = value; });
if (!updated.service_name || !updated.domain_name) {
alert('서비스명과 관리도메인은 필수 입력 사항입니다.');
return;
}
if (await saveAsset('domain', updated)) {
alert(UI_TEXT.MESSAGES.SAVE_SUCCESS);
onSave(); this.close(); closeModals();
}
});
revertBtn.addEventListener('click', () => {
this.setEditLockMode('view');
if (this.currentAsset) this.fillFormData(this.currentAsset);
});
deleteBtn.addEventListener('click', async () => {
if (!this.currentAsset || !confirm(UI_TEXT.MESSAGES.CONFIRM_DELETE)) return;
if (await deleteAsset('domain', this.currentAsset.id)) {
alert('성공적으로 삭제되었습니다.');
onSave(); this.close(); closeModals();
}
});
createIcons({ icons: { History, Plus, Save, X } });
}
protected fillFormData(asset: any): void {
setFieldValue('domain-id', asset.id);
setFieldValue('domain-type', asset.type || '호스팅');
setFieldValue('domain-corp', asset.corp || '');
setFieldValue('domain-service-name', asset.service_name || '');
setFieldValue('domain-name', asset.domain_name || '');
setFieldValue('domain-start-date', formatExcelDate(asset.start_date));
setFieldValue('domain-expiry-date', formatExcelDate(asset.expiry_date));
setFieldValue('domain-price', asset.price || '');
setFieldValue('domain-manager-main', asset.manager_main || '');
setFieldValue('domain-manager-sub', asset.manager_sub || '');
setFieldValue('domain-remarks', asset.remarks || '');
this.renderHistory(asset.id);
this.updateHeaderIdentity(asset);
}
protected onAfterOpen(asset: any, mode: string): void {
const titleEl = document.getElementById('domain-modal-title');
if (titleEl) titleEl.textContent = (mode === 'add') ? '신규 도메인 등록' : '도메인 정보 상세';
const deleteBtn = document.getElementById('btn-delete-domain-asset');
if (deleteBtn) deleteBtn.style.display = (mode === 'add') ? 'none' : 'block';
this.updateHeaderIdentity(asset);
}
private updateHeaderIdentity(asset: any) {
const container = document.getElementById('domain-header-identity');
if (!container) return;
if (this.currentMode === 'add') {
container.innerHTML = '<span class="badge badge-primary">신규 등록</span>';
return;
}
const type = getFieldValue('domain-type') || asset.type || '';
const serviceName = getFieldValue('domain-service-name') || asset.service_name || '';
const domainName = getFieldValue('domain-name') || asset.domain_name || '';
container.innerHTML = `
<span class="asset-code-title">${serviceName}</span>
<span class="service-type-badge">${type}</span>
<span class="asset-type-label">${domainName}</span>
`;
}
private renderHistory(assetId: string) {
const container = document.getElementById('domain-history-list');
if (!container) return;
const logs = (state.masterData.logs || []).filter(l => l.asset_id === assetId);
if (logs.length === 0) {
container.innerHTML = '<div style="color:var(--mute); padding:1rem; text-align:center;">이력이 없습니다.</div>';
} else {
container.innerHTML = logs.map(l => `<div class="history-item"><div class="history-date">${l.log_date || ''}</div><div class="history-user">${l.log_user || '시스템'}</div><div class="history-details">${l.details}</div></div>`).join('');
}
}
}
export const domainModal = new DomainAssetModal();
export function initDomainModal(onSave: () => void, closeModals: () => void) { domainModal.init(onSave, closeModals); }
export function openDomainModal(asset: any, mode: 'view' | 'edit' | 'add' = 'view') { domainModal.open(asset, mode); }

File diff suppressed because it is too large Load Diff

View File

@@ -1,295 +1,295 @@
import { state, saveJobSpec, deleteJobSpec } from '../../core/state';
import { BaseModal } from './BaseModal';
import { setFieldValue } from './ModalUtils';
import { UI_TEXT } from '../../core/schema';
import { calculatePcScoreDeductive } from '../../core/utils';
class JobSpecModal extends BaseModal {
constructor() {
super('job-spec', '직무별 기준 사양');
}
protected renderFrameHTML(): string {
return `
<div id="job-spec-asset-modal" class="modal-overlay hidden">
<div class="modal-content narrow">
<div class="modal-header">
<div class="header-left">
<h2 id="job-spec-modal-title" class="modal-title">\${this.title}</h2>
<div id="job-spec-header-identity" class="header-identity"></div>
</div>
<button id="btn-close-job-spec-modal" class="btn-icon" aria-label="닫기">&times;</button>
</div>
<div class="modal-body">
<form id="job-spec-asset-form" class="grid-form vertical-form">
<input type="hidden" id="job-spec-id" name="id" />
<div class="form-group">
<label>직무명</label>
<input type="text" id="job-spec-job-name" name="job_name" placeholder="예: BIM 모델러, 개발자, 엔지니어" required />
</div>
<div class="form-group relative">
<label>권장 CPU 사양</label>
<input type="text" id="job-spec-cpu-standard" name="cpu_standard" placeholder="CPU 검색..." required autocomplete="off" />
<div id="job-spec-cpu-autocomplete" class="autocomplete-list hidden"></div>
</div>
<div class="form-group relative">
<label>권장 RAM 사양</label>
<input type="text" id="job-spec-ram-standard" name="ram_standard" placeholder="RAM 검색..." required autocomplete="off" />
<div id="job-spec-ram-autocomplete" class="autocomplete-list hidden"></div>
</div>
<div class="form-group relative">
<label>권장 GPU 사양</label>
<input type="text" id="job-spec-gpu-standard" name="gpu_standard" placeholder="GPU 검색..." required autocomplete="off" />
<div id="job-spec-gpu-autocomplete" class="autocomplete-list hidden"></div>
</div>
<div class="form-group">
<label>성능 기준 점수 (이상, 자동 계산됨)</label>
<input type="number" id="job-spec-min-score" name="min_score" placeholder="자동 계산 대기..." required readonly />
</div>
<div class="form-group">
<label>비고 (메모)</label>
<textarea id="job-spec-remarks" name="remarks" placeholder="기타 필요 사양 및 안내 사항" rows="3"></textarea>
</div>
</form>
</div>
<div class="modal-footer">
<button id="btn-delete-job-spec-asset" class="btn btn-outline btn-danger">삭제</button>
<div class="footer-actions">
<button id="btn-revert-job-spec-edit" class="btn btn-outline hidden">수정 취소</button>
<button id="btn-cancel-job-spec-modal" class="btn btn-outline">닫기</button>
<button id="btn-save-job-spec-asset" class="btn btn-primary">수정</button>
</div>
</div>
</div>
</div>
<style>
.autocomplete-list {
position: absolute;
top: 100%;
left: 0;
right: 0;
max-height: 150px;
overflow-y: auto;
background-color: white;
border: 1px solid var(--border-color, #E2E8F0);
border-top: none;
border-radius: 0 0 4px 4px;
z-index: 1000;
box-shadow: 0 4px 6px -1px rgba(0, 0, 0, 0.1);
}
.autocomplete-item {
padding: 8px 12px;
font-size: 13px;
color: #334155;
cursor: pointer;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.autocomplete-item:hover {
background-color: #F1F5F9;
color: #1E5149;
font-weight: 600;
}
</style>
`;
}
protected initChildLogic(onSave: () => void, closeModals: () => void): void {
const saveBtn = document.getElementById('btn-save-job-spec-asset')!;
const revertBtn = document.getElementById('btn-revert-job-spec-edit')!;
const deleteBtn = document.getElementById('btn-delete-job-spec-asset')!;
saveBtn.addEventListener('click', async () => {
if (!this.currentAsset) return;
if (!this.isEditMode) {
this.setEditLockMode('edit');
this.isEditMode = true;
return;
}
const jobName = (document.getElementById('job-spec-job-name') as HTMLInputElement).value.trim();
const cpuStd = (document.getElementById('job-spec-cpu-standard') as HTMLInputElement).value.trim();
const ramStd = (document.getElementById('job-spec-ram-standard') as HTMLInputElement).value.trim();
const gpuStd = (document.getElementById('job-spec-gpu-standard') as HTMLInputElement).value.trim();
const minScoreStr = (document.getElementById('job-spec-min-score') as HTMLInputElement).value;
const remarks = (document.getElementById('job-spec-remarks') as HTMLTextAreaElement).value.trim();
if (!jobName) {
alert('직무명을 입력해 주세요.');
return;
}
const updated = {
id: this.currentAsset.id || null,
job_name: jobName,
cpu_standard: cpuStd,
ram_standard: ramStd,
gpu_standard: gpuStd,
min_score: minScoreStr !== '' ? parseInt(minScoreStr, 10) : 0,
remarks: remarks
};
if (await saveJobSpec(updated)) {
alert(UI_TEXT.MESSAGES.SAVE_SUCCESS);
onSave(); this.close(); closeModals();
}
});
revertBtn.addEventListener('click', () => {
this.setEditLockMode('view');
if (this.currentAsset) this.fillFormData(this.currentAsset);
});
deleteBtn.addEventListener('click', async () => {
if (!this.currentAsset || !this.currentAsset.id) return;
if (!confirm('정말로 이 직무별 기준 사양을 삭제하시겠습니까?')) return;
if (await deleteJobSpec(this.currentAsset.id)) {
alert('성공적으로 삭제되었습니다.');
onSave(); this.close(); closeModals();
}
});
// 자동완성 바인딩
this.bindAutocomplete('job-spec-cpu-standard', 'job-spec-cpu-autocomplete', 'CPU');
this.bindAutocomplete('job-spec-ram-standard', 'job-spec-ram-autocomplete', 'RAM');
this.bindAutocomplete('job-spec-gpu-standard', 'job-spec-gpu-autocomplete', 'GPU');
// 실시간 점수 계산 이벤트 바인딩
const inputs = ['job-spec-cpu-standard', 'job-spec-ram-standard', 'job-spec-gpu-standard'];
inputs.forEach(id => {
const el = document.getElementById(id);
el?.addEventListener('input', () => this.updateMinScore());
el?.addEventListener('change', () => this.updateMinScore());
});
}
private bindAutocomplete(inputId: string, autocompleteId: string, category: string) {
const input = document.getElementById(inputId) as HTMLInputElement;
const list = document.getElementById(autocompleteId) as HTMLDivElement;
if (!input || !list) return;
const showList = (filterText: string = '') => {
if (!this.isEditMode) return;
const items = (state.masterData.partsMaster || []).filter((c: any) => c.category === category);
const filtered = filterText
? items.filter((c: any) => c.component_name.toLowerCase().includes(filterText.toLowerCase()))
: items;
if (filtered.length === 0) {
list.innerHTML = '<div class="autocomplete-item" style="color: #94a3b8; cursor: default;">검색 결과 없음</div>';
} else {
list.innerHTML = filtered.map((c: any) => `<div class="autocomplete-item" data-val="${c.component_name}">${c.component_name}</div>`).join('');
}
list.classList.remove('hidden');
};
input.addEventListener('focus', () => {
showList(input.value);
});
input.addEventListener('input', () => {
showList(input.value);
});
list.addEventListener('mousedown', (e) => {
const item = (e.target as HTMLElement).closest('.autocomplete-item');
if (item && item.getAttribute('data-val')) {
input.value = item.getAttribute('data-val') || '';
list.classList.add('hidden');
this.updateMinScore();
}
});
document.addEventListener('mousedown', (e) => {
if (e.target !== input && !list.contains(e.target as Node)) {
list.classList.add('hidden');
}
});
}
private updateMinScore(): void {
const cpu = (document.getElementById('job-spec-cpu-standard') as HTMLInputElement)?.value || '';
const ram = (document.getElementById('job-spec-ram-standard') as HTMLInputElement)?.value || '';
const gpu = (document.getElementById('job-spec-gpu-standard') as HTMLInputElement)?.value || '';
const score = calculatePcScoreDeductive(cpu, ram, gpu, '');
const minScoreEl = document.getElementById('job-spec-min-score') as HTMLInputElement;
if (minScoreEl) {
minScoreEl.value = score.toString();
}
}
protected fillFormData(asset: any): void {
setFieldValue('job-spec-id', asset.id || '');
setFieldValue('job-spec-job-name', asset.job_name || '');
setFieldValue('job-spec-cpu-standard', asset.cpu_standard || '');
setFieldValue('job-spec-ram-standard', asset.ram_standard || '');
setFieldValue('job-spec-gpu-standard', asset.gpu_standard || '');
setFieldValue('job-spec-min-score', asset.min_score !== undefined ? asset.min_score.toString() : '100');
setFieldValue('job-spec-remarks', asset.remarks || '');
this.updateHeaderIdentity(asset);
}
protected onAfterOpen(asset: any, mode: string): void {
const titleEl = document.getElementById('job-spec-modal-title');
if (titleEl) {
if (mode === 'add') {
titleEl.textContent = '신규 직무별 기준 사양 등록';
} else {
titleEl.textContent = '직무별 기준 사양 상세 편집';
}
}
const deleteBtn = document.getElementById('btn-delete-job-spec-asset')!;
const saveBtn = document.getElementById('btn-save-job-spec-asset')!;
deleteBtn.style.display = (mode === 'add') ? 'none' : 'block';
if (mode === 'add' || mode === 'edit') {
saveBtn.textContent = (mode === 'add') ? '등록' : '저장';
saveBtn.style.display = 'block';
} else {
saveBtn.textContent = '수정';
saveBtn.style.display = 'block';
}
this.updateHeaderIdentity(asset);
}
private updateHeaderIdentity(asset: any) {
const container = document.getElementById('job-spec-header-identity');
if (!container) return;
if (this.currentMode === 'add') {
container.innerHTML = '<span class="badge badge-primary">신규 등록</span>';
return;
}
const jobName = asset.job_name || '';
const minScore = asset.min_score || 0;
container.innerHTML = `
<span class="asset-code-title">${jobName}</span>
<span class="service-type-badge">${minScore}점 기준</span>
`;
}
}
export const jobSpecModal = new JobSpecModal();
export function initJobSpecModal(onSave: () => void, closeModals: () => void) {
jobSpecModal.init(onSave, closeModals);
}
export function openJobSpecModal(asset: any, mode: 'view' | 'edit' | 'add' = 'view') {
jobSpecModal.open(asset, mode);
}
import { state, saveJobSpec, deleteJobSpec } from '../../core/state';
import { BaseModal } from './BaseModal';
import { setFieldValue } from './ModalUtils';
import { UI_TEXT } from '../../core/schema';
import { calculatePcScoreDeductive } from '../../core/utils';
class JobSpecModal extends BaseModal {
constructor() {
super('job-spec', '직무별 기준 사양');
}
protected renderFrameHTML(): string {
return `
<div id="job-spec-asset-modal" class="modal-overlay hidden">
<div class="modal-content narrow">
<div class="modal-header">
<div class="header-left">
<h2 id="job-spec-modal-title" class="modal-title">\${this.title}</h2>
<div id="job-spec-header-identity" class="header-identity"></div>
</div>
<button id="btn-close-job-spec-modal" class="btn-icon" aria-label="닫기">&times;</button>
</div>
<div class="modal-body">
<form id="job-spec-asset-form" class="grid-form vertical-form">
<input type="hidden" id="job-spec-id" name="id" />
<div class="form-group">
<label>직무명</label>
<input type="text" id="job-spec-job-name" name="job_name" placeholder="예: BIM 모델러, 개발자, 엔지니어" required />
</div>
<div class="form-group relative">
<label>권장 CPU 사양</label>
<input type="text" id="job-spec-cpu-standard" name="cpu_standard" placeholder="CPU 검색..." required autocomplete="off" />
<div id="job-spec-cpu-autocomplete" class="autocomplete-list hidden"></div>
</div>
<div class="form-group relative">
<label>권장 RAM 사양</label>
<input type="text" id="job-spec-ram-standard" name="ram_standard" placeholder="RAM 검색..." required autocomplete="off" />
<div id="job-spec-ram-autocomplete" class="autocomplete-list hidden"></div>
</div>
<div class="form-group relative">
<label>권장 GPU 사양</label>
<input type="text" id="job-spec-gpu-standard" name="gpu_standard" placeholder="GPU 검색..." required autocomplete="off" />
<div id="job-spec-gpu-autocomplete" class="autocomplete-list hidden"></div>
</div>
<div class="form-group">
<label>성능 기준 점수 (이상, 자동 계산됨)</label>
<input type="number" id="job-spec-min-score" name="min_score" placeholder="자동 계산 대기..." required readonly />
</div>
<div class="form-group">
<label>비고 (메모)</label>
<textarea id="job-spec-remarks" name="remarks" placeholder="기타 필요 사양 및 안내 사항" rows="3"></textarea>
</div>
</form>
</div>
<div class="modal-footer">
<button id="btn-delete-job-spec-asset" class="btn btn-outline btn-danger">삭제</button>
<div class="footer-actions">
<button id="btn-revert-job-spec-edit" class="btn btn-outline hidden">수정 취소</button>
<button id="btn-cancel-job-spec-modal" class="btn btn-outline">닫기</button>
<button id="btn-save-job-spec-asset" class="btn btn-primary">수정</button>
</div>
</div>
</div>
</div>
<style>
.autocomplete-list {
position: absolute;
top: 100%;
left: 0;
right: 0;
max-height: 150px;
overflow-y: auto;
background-color: white;
border: 1px solid var(--border-color, #E2E8F0);
border-top: none;
border-radius: 0 0 4px 4px;
z-index: 1000;
box-shadow: 0 4px 6px -1px rgba(0, 0, 0, 0.1);
}
.autocomplete-item {
padding: 8px 12px;
font-size: 13px;
color: #334155;
cursor: pointer;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.autocomplete-item:hover {
background-color: #F1F5F9;
color: #1E5149;
font-weight: 600;
}
</style>
`;
}
protected initChildLogic(onSave: () => void, closeModals: () => void): void {
const saveBtn = document.getElementById('btn-save-job-spec-asset')!;
const revertBtn = document.getElementById('btn-revert-job-spec-edit')!;
const deleteBtn = document.getElementById('btn-delete-job-spec-asset')!;
saveBtn.addEventListener('click', async () => {
if (!this.currentAsset) return;
if (!this.isEditMode) {
this.setEditLockMode('edit');
this.isEditMode = true;
return;
}
const jobName = (document.getElementById('job-spec-job-name') as HTMLInputElement).value.trim();
const cpuStd = (document.getElementById('job-spec-cpu-standard') as HTMLInputElement).value.trim();
const ramStd = (document.getElementById('job-spec-ram-standard') as HTMLInputElement).value.trim();
const gpuStd = (document.getElementById('job-spec-gpu-standard') as HTMLInputElement).value.trim();
const minScoreStr = (document.getElementById('job-spec-min-score') as HTMLInputElement).value;
const remarks = (document.getElementById('job-spec-remarks') as HTMLTextAreaElement).value.trim();
if (!jobName) {
alert('직무명을 입력해 주세요.');
return;
}
const updated = {
id: this.currentAsset.id || null,
job_name: jobName,
cpu_standard: cpuStd,
ram_standard: ramStd,
gpu_standard: gpuStd,
min_score: minScoreStr !== '' ? parseInt(minScoreStr, 10) : 0,
remarks: remarks
};
if (await saveJobSpec(updated)) {
alert(UI_TEXT.MESSAGES.SAVE_SUCCESS);
onSave(); this.close(); closeModals();
}
});
revertBtn.addEventListener('click', () => {
this.setEditLockMode('view');
if (this.currentAsset) this.fillFormData(this.currentAsset);
});
deleteBtn.addEventListener('click', async () => {
if (!this.currentAsset || !this.currentAsset.id) return;
if (!confirm('정말로 이 직무별 기준 사양을 삭제하시겠습니까?')) return;
if (await deleteJobSpec(this.currentAsset.id)) {
alert('성공적으로 삭제되었습니다.');
onSave(); this.close(); closeModals();
}
});
// 자동완성 바인딩
this.bindAutocomplete('job-spec-cpu-standard', 'job-spec-cpu-autocomplete', 'CPU');
this.bindAutocomplete('job-spec-ram-standard', 'job-spec-ram-autocomplete', 'RAM');
this.bindAutocomplete('job-spec-gpu-standard', 'job-spec-gpu-autocomplete', 'GPU');
// 실시간 점수 계산 이벤트 바인딩
const inputs = ['job-spec-cpu-standard', 'job-spec-ram-standard', 'job-spec-gpu-standard'];
inputs.forEach(id => {
const el = document.getElementById(id);
el?.addEventListener('input', () => this.updateMinScore());
el?.addEventListener('change', () => this.updateMinScore());
});
}
private bindAutocomplete(inputId: string, autocompleteId: string, category: string) {
const input = document.getElementById(inputId) as HTMLInputElement;
const list = document.getElementById(autocompleteId) as HTMLDivElement;
if (!input || !list) return;
const showList = (filterText: string = '') => {
if (!this.isEditMode) return;
const items = (state.masterData.partsMaster || []).filter((c: any) => c.category === category);
const filtered = filterText
? items.filter((c: any) => c.component_name.toLowerCase().includes(filterText.toLowerCase()))
: items;
if (filtered.length === 0) {
list.innerHTML = '<div class="autocomplete-item" style="color: #94a3b8; cursor: default;">검색 결과 없음</div>';
} else {
list.innerHTML = filtered.map((c: any) => `<div class="autocomplete-item" data-val="${c.component_name}">${c.component_name}</div>`).join('');
}
list.classList.remove('hidden');
};
input.addEventListener('focus', () => {
showList(input.value);
});
input.addEventListener('input', () => {
showList(input.value);
});
list.addEventListener('mousedown', (e) => {
const item = (e.target as HTMLElement).closest('.autocomplete-item');
if (item && item.getAttribute('data-val')) {
input.value = item.getAttribute('data-val') || '';
list.classList.add('hidden');
this.updateMinScore();
}
});
document.addEventListener('mousedown', (e) => {
if (e.target !== input && !list.contains(e.target as Node)) {
list.classList.add('hidden');
}
});
}
private updateMinScore(): void {
const cpu = (document.getElementById('job-spec-cpu-standard') as HTMLInputElement)?.value || '';
const ram = (document.getElementById('job-spec-ram-standard') as HTMLInputElement)?.value || '';
const gpu = (document.getElementById('job-spec-gpu-standard') as HTMLInputElement)?.value || '';
const score = calculatePcScoreDeductive(cpu, ram, gpu, '');
const minScoreEl = document.getElementById('job-spec-min-score') as HTMLInputElement;
if (minScoreEl) {
minScoreEl.value = score.toString();
}
}
protected fillFormData(asset: any): void {
setFieldValue('job-spec-id', asset.id || '');
setFieldValue('job-spec-job-name', asset.job_name || '');
setFieldValue('job-spec-cpu-standard', asset.cpu_standard || '');
setFieldValue('job-spec-ram-standard', asset.ram_standard || '');
setFieldValue('job-spec-gpu-standard', asset.gpu_standard || '');
setFieldValue('job-spec-min-score', asset.min_score !== undefined ? asset.min_score.toString() : '100');
setFieldValue('job-spec-remarks', asset.remarks || '');
this.updateHeaderIdentity(asset);
}
protected onAfterOpen(asset: any, mode: string): void {
const titleEl = document.getElementById('job-spec-modal-title');
if (titleEl) {
if (mode === 'add') {
titleEl.textContent = '신규 직무별 기준 사양 등록';
} else {
titleEl.textContent = '직무별 기준 사양 상세 편집';
}
}
const deleteBtn = document.getElementById('btn-delete-job-spec-asset')!;
const saveBtn = document.getElementById('btn-save-job-spec-asset')!;
deleteBtn.style.display = (mode === 'add') ? 'none' : 'block';
if (mode === 'add' || mode === 'edit') {
saveBtn.textContent = (mode === 'add') ? '등록' : '저장';
saveBtn.style.display = 'block';
} else {
saveBtn.textContent = '수정';
saveBtn.style.display = 'block';
}
this.updateHeaderIdentity(asset);
}
private updateHeaderIdentity(asset: any) {
const container = document.getElementById('job-spec-header-identity');
if (!container) return;
if (this.currentMode === 'add') {
container.innerHTML = '<span class="badge badge-primary">신규 등록</span>';
return;
}
const jobName = asset.job_name || '';
const minScore = asset.min_score || 0;
container.innerHTML = `
<span class="asset-code-title">${jobName}</span>
<span class="service-type-badge">${minScore}점 기준</span>
`;
}
}
export const jobSpecModal = new JobSpecModal();
export function initJobSpecModal(onSave: () => void, closeModals: () => void) {
jobSpecModal.init(onSave, closeModals);
}
export function openJobSpecModal(asset: any, mode: 'view' | 'edit' | 'add' = 'view') {
jobSpecModal.open(asset, mode);
}

View File

@@ -1,261 +1,261 @@
import { LOCATION_DATA } from './SharedData';
/**
* 모달 조작 및 UI 생성을 위한 공통 유틸리티
*/
// 1. Select 박스의 Option HTML 생성
export function generateOptionsHTML(list: string[], defaultValue: string = '', includeSelectHint: boolean = true): string {
let html = includeSelectHint ? '<option value="">선택</option>' : '';
html += list.map(item => `<option value="${item}" ${item === defaultValue ? 'selected' : ''}>${item}</option>`).join('');
return html;
}
// 2. 안전하게 폼 필드 값 설정 (Null 에러 방지)
export function setFieldValue(id: string, value: any) {
const el = document.getElementById(id) as HTMLInputElement | HTMLSelectElement | HTMLTextAreaElement;
if (el) {
el.value = value || '';
}
}
// 3. 안전하게 폼 필드 값 읽기
export function getFieldValue(id: string): string {
const el = document.getElementById(id) as HTMLInputElement | HTMLSelectElement | HTMLTextAreaElement;
return el ? el.value : '';
}
// 4. 위치 정보 파싱 및 UI 세팅
export function parseAndSetLocation(bldg: string, detail: string, bldgId: string, detailId: string, etcGroupId?: string, etcInputId?: string) {
const bldgSelect = document.getElementById(bldgId) as HTMLSelectElement;
const detailSelect = document.getElementById(detailId) as HTMLSelectElement;
const etcGroup = etcGroupId ? document.getElementById(etcGroupId) : null;
const etcInput = etcInputId ? document.getElementById(etcInputId) as HTMLInputElement : null;
if (!bldgSelect || !detailSelect) return;
// 초기화
bldgSelect.value = '';
detailSelect.innerHTML = '<option value="">선택</option>';
if (etcGroup) etcGroup.style.display = 'none';
if (!bldg) return;
if (LOCATION_DATA[bldg]) {
bldgSelect.value = bldg;
// 상세 목록 갱신
detailSelect.innerHTML = generateOptionsHTML(LOCATION_DATA[bldg]);
if (detail) {
detailSelect.value = detail;
if (detail === '기타' && etcGroup && etcInput) {
etcGroup.style.display = 'flex';
// 기타 입력값은 기존 로직 보존을 위해 location_detail을 그대로 쓰거나
// 하위 호환성을 위해 남겨둠
}
}
}
}
// 5. 위치 종속성(Cascade) 이벤트 바인딩
export function bindLocationEvents(bldgId: string, detailId: string, etcGroupId: string, etcInputId: string) {
const bldgSelect = document.getElementById(bldgId) as HTMLSelectElement;
const detailSelect = document.getElementById(detailId) as HTMLSelectElement;
const etcGroup = document.getElementById(etcGroupId);
const etcInput = document.getElementById(etcInputId) as HTMLInputElement;
if (!bldgSelect || !detailSelect) return;
bldgSelect.addEventListener('change', () => {
const bldg = bldgSelect.value;
detailSelect.innerHTML = generateOptionsHTML(LOCATION_DATA[bldg] || []);
if (etcGroup) etcGroup.style.display = 'none';
if (etcInput) etcInput.value = '';
});
detailSelect.addEventListener('change', () => {
if (etcGroup) {
etcGroup.style.display = detailSelect.value === '기타' ? 'flex' : 'none';
}
});
}
// 6. 위치 문자열 조합 (저장용)
export function getCombinedLocation(bldgId: string, detailId: string, etcInputId: string): string {
const bldg = getFieldValue(bldgId);
const detail = getFieldValue(detailId);
const etc = getFieldValue(etcInputId);
let combined = bldg;
if (detail) combined += ` ${detail}`;
if (detail === '기타' && etc) combined += ` ${etc}`;
return combined.trim();
}
// 7. 조회/수정 모드 UI 통합 제어
export function setEditLock(
formId: string,
mode: 'view' | 'add' | 'edit',
options: {
saveBtnId: string,
revertBtnId: string,
generateBtnId?: string,
addLogBtnId?: string
}
) {
const form = document.getElementById(formId) as HTMLFormElement;
const saveBtn = document.getElementById(options.saveBtnId);
const revertBtn = document.getElementById(options.revertBtnId);
const generateBtn = options.generateBtnId ? document.getElementById(options.generateBtnId) : null;
const addLogBtn = options.addLogBtnId ? document.getElementById(options.addLogBtnId) : null;
if (!form) return;
const isEdit = (mode === 'add' || mode === 'edit');
if (isEdit) {
// 편집 모드 활성화
form.classList.remove('is-view-mode');
form.classList.add('is-edit-mode');
if (saveBtn) saveBtn.textContent = (mode === 'add' ? '등록' : '저장');
if (revertBtn) revertBtn.classList.toggle('hidden', mode === 'add');
// 모든 필드 활성화
const inputs = form.querySelectorAll('input, select, textarea');
inputs.forEach(input => {
const el = input as HTMLInputElement | HTMLSelectElement | HTMLTextAreaElement;
// 자산번호 및 ID 필드는 편집 모드에서도 잠금 유지 (disabled는 해제하되 readOnly를 적용하여 폼 데이터 수집 가능하게 함)
if (el.name !== 'asset_code' && !el.id.includes('asset-id') && !el.id.includes('id-hidden')) {
el.disabled = false;
if ('readOnly' in el) (el as HTMLInputElement).readOnly = false;
} else {
el.disabled = false;
if ('readOnly' in el) (el as HTMLInputElement).readOnly = true;
}
});
if (generateBtn) generateBtn.style.display = (mode === 'add' ? 'flex' : 'none');
if (addLogBtn) addLogBtn.style.display = 'flex';
} else {
// 조회 모드 (잠금)
form.classList.remove('is-edit-mode');
form.classList.add('is-view-mode');
if (saveBtn) saveBtn.textContent = '수정';
if (revertBtn) revertBtn.classList.add('hidden');
// 모든 필드 잠금
const inputs = form.querySelectorAll('input, select, textarea');
inputs.forEach(input => {
const el = input as HTMLInputElement | HTMLSelectElement | HTMLTextAreaElement;
el.disabled = true;
if ('readOnly' in el) (el as HTMLInputElement).readOnly = true;
});
if (generateBtn) generateBtn.style.display = 'none';
if (addLogBtn) addLogBtn.style.display = 'none';
}
}
/**
* 8. 공통 모달 프레임 템플릿 생성
* @param idPrefix 필드 ID의 접두사 (예: 'hw', 'sw', 'pc')
* @param title 모달 제목
* @param formContent 각 모달마다 다른 폼 본문 HTML
* @param options 설정 (이력 영역 제목 등)
*/
export function createModalFrameHTML(
idPrefix: string,
title: string,
formContent: string,
options: { historyTitle: string, addLogBtnId: string }
): string {
return `
<div id="${idPrefix}-asset-modal" class="modal-overlay hidden">
<div class="modal-content wide">
<div class="modal-header">
<h2 id="${idPrefix}-modal-title">${title}</h2>
<button id="btn-close-${idPrefix}-modal" class="btn-icon" aria-label="닫기"><i data-lucide="x"></i></button>
</div>
<div class="modal-body">
<div class="modal-body-split">
<div class="modal-form-area">
<form id="${idPrefix}-asset-form" class="grid-form">
<input type="hidden" id="${idPrefix}-asset-id" />
<input type="hidden" id="${idPrefix}-asset-type-hidden" />
${formContent}
</form>
</div>
<div class="modal-history-area">
<div class="history-header">
<h3><i data-lucide="history" class="icon-sm"></i> ${options.historyTitle}</h3>
<button type="button" id="btn-add-${idPrefix}-log" class="btn btn-outline btn-sm">
내역 추가 <i data-lucide="plus" class="icon-sm"></i>
</button>
</div>
<div id="${idPrefix}-history-list" class="history-timeline"></div>
</div>
</div>
</div>
<div class="modal-footer">
<button id="btn-delete-${idPrefix}-asset" class="btn btn-outline btn-danger">삭제</button>
<div class="footer-actions">
<button id="btn-revert-${idPrefix}-edit" class="btn btn-outline hidden">수정 취소</button>
<button id="btn-cancel-${idPrefix}-modal" class="btn btn-outline">닫기</button>
<button id="btn-save-${idPrefix}-asset" class="btn btn-primary">수정</button>
</div>
</div>
</div>
</div>
`;
}
/**
* 9. 데이터 ↔ 폼 자동 매핑 (유지보수 핵심)
*/
export function autoFillForm(idPrefix: string, data: any, fieldMap: Record<string, string>) {
Object.entries(fieldMap).forEach(([fieldId, dataKey]) => {
setFieldValue(`${idPrefix}-${fieldId}`, data[dataKey]);
});
}
export function autoExtractForm(idPrefix: string, fieldMap: Record<string, string>): any {
const result: any = {};
Object.entries(fieldMap).forEach(([fieldId, dataKey]) => {
result[dataKey] = getFieldValue(`${idPrefix}-${fieldId}`);
});
return result;
}
/**
* 10. 날짜 자동 마스킹 및 포커스 제어 (Auto-jump)
*/
export function applyDateMask(el: HTMLInputElement) {
if (!el) return;
el.placeholder = 'YYYY-MM-DD';
el.maxLength = 10;
el.addEventListener('input', (e) => {
let value = el.value.replace(/[^0-9]/g, ''); // 숫자만 남김
let result = '';
if (value.length <= 4) {
result = value;
} else if (value.length <= 6) {
result = value.substring(0, 4) + '-' + value.substring(4);
} else {
result = value.substring(0, 4) + '-' + value.substring(4, 6) + '-' + value.substring(6, 10);
}
el.value = result;
});
// 엔터 키나 입력 완료 시 유효성 검사 (선택 사항)
el.addEventListener('blur', () => {
const val = el.value;
if (val && !/^\d{4}-\d{2}-\d{2}$/.test(val)) {
// 형식이 맞지 않으면 경고 효과 등을 줄 수 있음
}
});
}
import { LOCATION_DATA } from './SharedData';
/**
* 모달 조작 및 UI 생성을 위한 공통 유틸리티
*/
// 1. Select 박스의 Option HTML 생성
export function generateOptionsHTML(list: string[], defaultValue: string = '', includeSelectHint: boolean = true): string {
let html = includeSelectHint ? '<option value="">선택</option>' : '';
html += list.map(item => `<option value="${item}" ${item === defaultValue ? 'selected' : ''}>${item}</option>`).join('');
return html;
}
// 2. 안전하게 폼 필드 값 설정 (Null 에러 방지)
export function setFieldValue(id: string, value: any) {
const el = document.getElementById(id) as HTMLInputElement | HTMLSelectElement | HTMLTextAreaElement;
if (el) {
el.value = value || '';
}
}
// 3. 안전하게 폼 필드 값 읽기
export function getFieldValue(id: string): string {
const el = document.getElementById(id) as HTMLInputElement | HTMLSelectElement | HTMLTextAreaElement;
return el ? el.value : '';
}
// 4. 위치 정보 파싱 및 UI 세팅
export function parseAndSetLocation(bldg: string, detail: string, bldgId: string, detailId: string, etcGroupId?: string, etcInputId?: string) {
const bldgSelect = document.getElementById(bldgId) as HTMLSelectElement;
const detailSelect = document.getElementById(detailId) as HTMLSelectElement;
const etcGroup = etcGroupId ? document.getElementById(etcGroupId) : null;
const etcInput = etcInputId ? document.getElementById(etcInputId) as HTMLInputElement : null;
if (!bldgSelect || !detailSelect) return;
// 초기화
bldgSelect.value = '';
detailSelect.innerHTML = '<option value="">선택</option>';
if (etcGroup) etcGroup.style.display = 'none';
if (!bldg) return;
if (LOCATION_DATA[bldg]) {
bldgSelect.value = bldg;
// 상세 목록 갱신
detailSelect.innerHTML = generateOptionsHTML(LOCATION_DATA[bldg]);
if (detail) {
detailSelect.value = detail;
if (detail === '기타' && etcGroup && etcInput) {
etcGroup.style.display = 'flex';
// 기타 입력값은 기존 로직 보존을 위해 location_detail을 그대로 쓰거나
// 하위 호환성을 위해 남겨둠
}
}
}
}
// 5. 위치 종속성(Cascade) 이벤트 바인딩
export function bindLocationEvents(bldgId: string, detailId: string, etcGroupId: string, etcInputId: string) {
const bldgSelect = document.getElementById(bldgId) as HTMLSelectElement;
const detailSelect = document.getElementById(detailId) as HTMLSelectElement;
const etcGroup = document.getElementById(etcGroupId);
const etcInput = document.getElementById(etcInputId) as HTMLInputElement;
if (!bldgSelect || !detailSelect) return;
bldgSelect.addEventListener('change', () => {
const bldg = bldgSelect.value;
detailSelect.innerHTML = generateOptionsHTML(LOCATION_DATA[bldg] || []);
if (etcGroup) etcGroup.style.display = 'none';
if (etcInput) etcInput.value = '';
});
detailSelect.addEventListener('change', () => {
if (etcGroup) {
etcGroup.style.display = detailSelect.value === '기타' ? 'flex' : 'none';
}
});
}
// 6. 위치 문자열 조합 (저장용)
export function getCombinedLocation(bldgId: string, detailId: string, etcInputId: string): string {
const bldg = getFieldValue(bldgId);
const detail = getFieldValue(detailId);
const etc = getFieldValue(etcInputId);
let combined = bldg;
if (detail) combined += ` ${detail}`;
if (detail === '기타' && etc) combined += ` ${etc}`;
return combined.trim();
}
// 7. 조회/수정 모드 UI 통합 제어
export function setEditLock(
formId: string,
mode: 'view' | 'add' | 'edit',
options: {
saveBtnId: string,
revertBtnId: string,
generateBtnId?: string,
addLogBtnId?: string
}
) {
const form = document.getElementById(formId) as HTMLFormElement;
const saveBtn = document.getElementById(options.saveBtnId);
const revertBtn = document.getElementById(options.revertBtnId);
const generateBtn = options.generateBtnId ? document.getElementById(options.generateBtnId) : null;
const addLogBtn = options.addLogBtnId ? document.getElementById(options.addLogBtnId) : null;
if (!form) return;
const isEdit = (mode === 'add' || mode === 'edit');
if (isEdit) {
// 편집 모드 활성화
form.classList.remove('is-view-mode');
form.classList.add('is-edit-mode');
if (saveBtn) saveBtn.textContent = (mode === 'add' ? '등록' : '저장');
if (revertBtn) revertBtn.classList.toggle('hidden', mode === 'add');
// 모든 필드 활성화
const inputs = form.querySelectorAll('input, select, textarea');
inputs.forEach(input => {
const el = input as HTMLInputElement | HTMLSelectElement | HTMLTextAreaElement;
// 자산번호 및 ID 필드는 편집 모드에서도 잠금 유지 (disabled는 해제하되 readOnly를 적용하여 폼 데이터 수집 가능하게 함)
if (el.name !== 'asset_code' && !el.id.includes('asset-id') && !el.id.includes('id-hidden')) {
el.disabled = false;
if ('readOnly' in el) (el as HTMLInputElement).readOnly = false;
} else {
el.disabled = false;
if ('readOnly' in el) (el as HTMLInputElement).readOnly = true;
}
});
if (generateBtn) generateBtn.style.display = (mode === 'add' ? 'flex' : 'none');
if (addLogBtn) addLogBtn.style.display = 'flex';
} else {
// 조회 모드 (잠금)
form.classList.remove('is-edit-mode');
form.classList.add('is-view-mode');
if (saveBtn) saveBtn.textContent = '수정';
if (revertBtn) revertBtn.classList.add('hidden');
// 모든 필드 잠금
const inputs = form.querySelectorAll('input, select, textarea');
inputs.forEach(input => {
const el = input as HTMLInputElement | HTMLSelectElement | HTMLTextAreaElement;
el.disabled = true;
if ('readOnly' in el) (el as HTMLInputElement).readOnly = true;
});
if (generateBtn) generateBtn.style.display = 'none';
if (addLogBtn) addLogBtn.style.display = 'none';
}
}
/**
* 8. 공통 모달 프레임 템플릿 생성
* @param idPrefix 필드 ID의 접두사 (예: 'hw', 'sw', 'pc')
* @param title 모달 제목
* @param formContent 각 모달마다 다른 폼 본문 HTML
* @param options 설정 (이력 영역 제목 등)
*/
export function createModalFrameHTML(
idPrefix: string,
title: string,
formContent: string,
options: { historyTitle: string, addLogBtnId: string }
): string {
return `
<div id="${idPrefix}-asset-modal" class="modal-overlay hidden">
<div class="modal-content wide">
<div class="modal-header">
<h2 id="${idPrefix}-modal-title">${title}</h2>
<button id="btn-close-${idPrefix}-modal" class="btn-icon" aria-label="닫기"><i data-lucide="x"></i></button>
</div>
<div class="modal-body">
<div class="modal-body-split">
<div class="modal-form-area">
<form id="${idPrefix}-asset-form" class="grid-form">
<input type="hidden" id="${idPrefix}-asset-id" />
<input type="hidden" id="${idPrefix}-asset-type-hidden" />
${formContent}
</form>
</div>
<div class="modal-history-area">
<div class="history-header">
<h3><i data-lucide="history" class="icon-sm"></i> ${options.historyTitle}</h3>
<button type="button" id="btn-add-${idPrefix}-log" class="btn btn-outline btn-sm">
내역 추가 <i data-lucide="plus" class="icon-sm"></i>
</button>
</div>
<div id="${idPrefix}-history-list" class="history-timeline"></div>
</div>
</div>
</div>
<div class="modal-footer">
<button id="btn-delete-${idPrefix}-asset" class="btn btn-outline btn-danger">삭제</button>
<div class="footer-actions">
<button id="btn-revert-${idPrefix}-edit" class="btn btn-outline hidden">수정 취소</button>
<button id="btn-cancel-${idPrefix}-modal" class="btn btn-outline">닫기</button>
<button id="btn-save-${idPrefix}-asset" class="btn btn-primary">수정</button>
</div>
</div>
</div>
</div>
`;
}
/**
* 9. 데이터 ↔ 폼 자동 매핑 (유지보수 핵심)
*/
export function autoFillForm(idPrefix: string, data: any, fieldMap: Record<string, string>) {
Object.entries(fieldMap).forEach(([fieldId, dataKey]) => {
setFieldValue(`${idPrefix}-${fieldId}`, data[dataKey]);
});
}
export function autoExtractForm(idPrefix: string, fieldMap: Record<string, string>): any {
const result: any = {};
Object.entries(fieldMap).forEach(([fieldId, dataKey]) => {
result[dataKey] = getFieldValue(`${idPrefix}-${fieldId}`);
});
return result;
}
/**
* 10. 날짜 자동 마스킹 및 포커스 제어 (Auto-jump)
*/
export function applyDateMask(el: HTMLInputElement) {
if (!el) return;
el.placeholder = 'YYYY-MM-DD';
el.maxLength = 10;
el.addEventListener('input', (e) => {
let value = el.value.replace(/[^0-9]/g, ''); // 숫자만 남김
let result = '';
if (value.length <= 4) {
result = value;
} else if (value.length <= 6) {
result = value.substring(0, 4) + '-' + value.substring(4);
} else {
result = value.substring(0, 4) + '-' + value.substring(4, 6) + '-' + value.substring(6, 10);
}
el.value = result;
});
// 엔터 키나 입력 완료 시 유효성 검사 (선택 사항)
el.addEventListener('blur', () => {
const val = el.value;
if (val && !/^\d{4}-\d{2}-\d{2}$/.test(val)) {
// 형식이 맞지 않으면 경고 효과 등을 줄 수 있음
}
});
}

File diff suppressed because it is too large Load Diff

View File

@@ -1,171 +1,171 @@
import { state, savePartsMaster, deletePartsMaster } from '../../core/state';
import { BaseModal } from './BaseModal';
import { generateOptionsHTML, setFieldValue, getFieldValue } from './ModalUtils';
import { createIcons, X, Save, Plus } from 'lucide';
import { UI_TEXT } from '../../core/schema';
class PartsMasterModal extends BaseModal {
constructor() {
super('parts-master', '부품 표준 정보');
}
protected renderFrameHTML(): string {
return `
<div id="parts-master-asset-modal" class="modal-overlay hidden">
<div class="modal-content narrow">
<div class="modal-header">
<div class="header-left">
<h2 id="parts-master-modal-title" class="modal-title">${this.title}</h2>
<div id="parts-master-header-identity" class="header-identity"></div>
</div>
<button id="btn-close-parts-master-modal" class="btn-icon" aria-label="닫기">&times;</button>
</div>
<div class="modal-body">
<form id="parts-master-asset-form" class="grid-form vertical-form">
<input type="hidden" id="parts-master-id" name="id" />
<div class="form-group">
<label>부품 분류</label>
<select id="parts-master-category" name="category">
<option value="CPU">CPU</option>
<option value="GPU">GPU</option>
<option value="RAM">RAM</option>
</select>
</div>
<div class="form-group">
<label>부품 표준 명칭</label>
<input type="text" id="parts-master-component-name" name="component_name" placeholder="예: Intel Core i7-14700K" required />
</div>
<div class="form-group">
<label>성능 등급</label>
<input type="text" id="parts-master-score-tier" name="score_tier" placeholder="예: i7 / S / 최적" required />
</div>
<div class="form-group">
<label>감점 점수 (양수로 입력)</label>
<input type="number" id="parts-master-deduction" name="deduction" placeholder="예: 5" required />
</div>
</form>
</div>
<div class="modal-footer">
<button id="btn-delete-parts-master-asset" class="btn btn-outline btn-danger">삭제</button>
<div class="footer-actions">
<button id="btn-revert-parts-master-edit" class="btn btn-outline hidden">수정 취소</button>
<button id="btn-cancel-parts-master-modal" class="btn btn-outline">닫기</button>
<button id="btn-save-parts-master-asset" class="btn btn-primary">수정</button>
</div>
</div>
</div>
</div>
`;
}
protected initChildLogic(onSave: () => void, closeModals: () => void): void {
const saveBtn = document.getElementById('btn-save-parts-master-asset')!;
const revertBtn = document.getElementById('btn-revert-parts-master-edit')!;
const deleteBtn = document.getElementById('btn-delete-parts-master-asset')!;
saveBtn.addEventListener('click', async () => {
if (!this.currentAsset) return;
if (!this.isEditMode) {
this.setEditLockMode('edit');
this.isEditMode = true;
return;
}
const category = (document.getElementById('parts-master-category') as HTMLSelectElement).value;
const compName = (document.getElementById('parts-master-component-name') as HTMLInputElement).value.trim();
const tier = (document.getElementById('parts-master-score-tier') as HTMLInputElement).value.trim();
const deductStr = (document.getElementById('parts-master-deduction') as HTMLInputElement).value;
if (!compName || !tier || deductStr === '') {
alert('모든 필드를 올바르게 입력해 주세요.');
return;
}
const updated = {
id: this.currentAsset.id || null,
category,
component_name: compName,
score_tier: tier,
deduction: parseInt(deductStr, 10)
};
if (await savePartsMaster(updated)) {
alert(UI_TEXT.MESSAGES.SAVE_SUCCESS);
onSave(); this.close(); closeModals();
}
});
revertBtn.addEventListener('click', () => {
this.setEditLockMode('view');
if (this.currentAsset) this.fillFormData(this.currentAsset);
});
deleteBtn.addEventListener('click', async () => {
if (!this.currentAsset || !this.currentAsset.id) return;
if (!confirm('정말로 이 부품 마스터 정보를 삭제하시겠습니까?\n삭제 시 기존 등록 PC 중 이 부품명을 사용하는 PC의 자동완성 정합성 체크에 영향을 줄 수 있습니다.')) return;
if (await deletePartsMaster(Number(this.currentAsset.id))) {
alert('성공적으로 삭제되었습니다.');
onSave(); this.close(); closeModals();
}
});
createIcons({ icons: { Plus, X, Save } });
}
protected fillFormData(asset: any): void {
setFieldValue('parts-master-id', asset.id || '');
setFieldValue('parts-master-category', asset.category || 'CPU');
setFieldValue('parts-master-component-name', asset.component_name || '');
setFieldValue('parts-master-score-tier', asset.score_tier || '');
setFieldValue('parts-master-deduction', asset.deduction !== undefined ? asset.deduction.toString() : '0');
this.updateHeaderIdentity(asset);
}
protected onAfterOpen(asset: any, mode: string): void {
const titleEl = document.getElementById('parts-master-modal-title');
if (titleEl) {
titleEl.textContent = (mode === 'add') ? '신규 부품 마스터 등록' : '부품 마스터 상세 편집';
}
const deleteBtn = document.getElementById('btn-delete-parts-master-asset')!;
const saveBtn = document.getElementById('btn-save-parts-master-asset')!;
deleteBtn.style.display = (mode === 'add') ? 'none' : 'block';
if (mode === 'add' || mode === 'edit') {
saveBtn.textContent = (mode === 'add') ? '등록' : '저장';
saveBtn.style.display = 'block';
} else {
saveBtn.textContent = '수정';
saveBtn.style.display = 'block';
}
this.updateHeaderIdentity(asset);
}
private updateHeaderIdentity(asset: any) {
const container = document.getElementById('parts-master-header-identity');
if (!container) return;
if (this.currentMode === 'add') {
container.innerHTML = '<span class="badge badge-primary">신규 등록</span>';
return;
}
const cat = asset.category || '';
const name = asset.component_name || '';
container.innerHTML = `
<span class="asset-code-title">${name}</span>
<span class="service-type-badge">${cat}</span>
`;
}
}
export const partsMasterModal = new PartsMasterModal();
export function initPartsMasterModal(onSave: () => void, closeModals: () => void) { partsMasterModal.init(onSave, closeModals); }
export function openPartsMasterModal(asset: any, mode: 'view' | 'edit' | 'add' = 'view') { partsMasterModal.open(asset, mode); }
import { state, savePartsMaster, deletePartsMaster } from '../../core/state';
import { BaseModal } from './BaseModal';
import { generateOptionsHTML, setFieldValue, getFieldValue } from './ModalUtils';
import { createIcons, X, Save, Plus } from 'lucide';
import { UI_TEXT } from '../../core/schema';
class PartsMasterModal extends BaseModal {
constructor() {
super('parts-master', '부품 표준 정보');
}
protected renderFrameHTML(): string {
return `
<div id="parts-master-asset-modal" class="modal-overlay hidden">
<div class="modal-content narrow">
<div class="modal-header">
<div class="header-left">
<h2 id="parts-master-modal-title" class="modal-title">${this.title}</h2>
<div id="parts-master-header-identity" class="header-identity"></div>
</div>
<button id="btn-close-parts-master-modal" class="btn-icon" aria-label="닫기">&times;</button>
</div>
<div class="modal-body">
<form id="parts-master-asset-form" class="grid-form vertical-form">
<input type="hidden" id="parts-master-id" name="id" />
<div class="form-group">
<label>부품 분류</label>
<select id="parts-master-category" name="category">
<option value="CPU">CPU</option>
<option value="GPU">GPU</option>
<option value="RAM">RAM</option>
</select>
</div>
<div class="form-group">
<label>부품 표준 명칭</label>
<input type="text" id="parts-master-component-name" name="component_name" placeholder="예: Intel Core i7-14700K" required />
</div>
<div class="form-group">
<label>성능 등급</label>
<input type="text" id="parts-master-score-tier" name="score_tier" placeholder="예: i7 / S / 최적" required />
</div>
<div class="form-group">
<label>감점 점수 (양수로 입력)</label>
<input type="number" id="parts-master-deduction" name="deduction" placeholder="예: 5" required />
</div>
</form>
</div>
<div class="modal-footer">
<button id="btn-delete-parts-master-asset" class="btn btn-outline btn-danger">삭제</button>
<div class="footer-actions">
<button id="btn-revert-parts-master-edit" class="btn btn-outline hidden">수정 취소</button>
<button id="btn-cancel-parts-master-modal" class="btn btn-outline">닫기</button>
<button id="btn-save-parts-master-asset" class="btn btn-primary">수정</button>
</div>
</div>
</div>
</div>
`;
}
protected initChildLogic(onSave: () => void, closeModals: () => void): void {
const saveBtn = document.getElementById('btn-save-parts-master-asset')!;
const revertBtn = document.getElementById('btn-revert-parts-master-edit')!;
const deleteBtn = document.getElementById('btn-delete-parts-master-asset')!;
saveBtn.addEventListener('click', async () => {
if (!this.currentAsset) return;
if (!this.isEditMode) {
this.setEditLockMode('edit');
this.isEditMode = true;
return;
}
const category = (document.getElementById('parts-master-category') as HTMLSelectElement).value;
const compName = (document.getElementById('parts-master-component-name') as HTMLInputElement).value.trim();
const tier = (document.getElementById('parts-master-score-tier') as HTMLInputElement).value.trim();
const deductStr = (document.getElementById('parts-master-deduction') as HTMLInputElement).value;
if (!compName || !tier || deductStr === '') {
alert('모든 필드를 올바르게 입력해 주세요.');
return;
}
const updated = {
id: this.currentAsset.id || null,
category,
component_name: compName,
score_tier: tier,
deduction: parseInt(deductStr, 10)
};
if (await savePartsMaster(updated)) {
alert(UI_TEXT.MESSAGES.SAVE_SUCCESS);
onSave(); this.close(); closeModals();
}
});
revertBtn.addEventListener('click', () => {
this.setEditLockMode('view');
if (this.currentAsset) this.fillFormData(this.currentAsset);
});
deleteBtn.addEventListener('click', async () => {
if (!this.currentAsset || !this.currentAsset.id) return;
if (!confirm('정말로 이 부품 마스터 정보를 삭제하시겠습니까?\n삭제 시 기존 등록 PC 중 이 부품명을 사용하는 PC의 자동완성 정합성 체크에 영향을 줄 수 있습니다.')) return;
if (await deletePartsMaster(Number(this.currentAsset.id))) {
alert('성공적으로 삭제되었습니다.');
onSave(); this.close(); closeModals();
}
});
createIcons({ icons: { Plus, X, Save } });
}
protected fillFormData(asset: any): void {
setFieldValue('parts-master-id', asset.id || '');
setFieldValue('parts-master-category', asset.category || 'CPU');
setFieldValue('parts-master-component-name', asset.component_name || '');
setFieldValue('parts-master-score-tier', asset.score_tier || '');
setFieldValue('parts-master-deduction', asset.deduction !== undefined ? asset.deduction.toString() : '0');
this.updateHeaderIdentity(asset);
}
protected onAfterOpen(asset: any, mode: string): void {
const titleEl = document.getElementById('parts-master-modal-title');
if (titleEl) {
titleEl.textContent = (mode === 'add') ? '신규 부품 마스터 등록' : '부품 마스터 상세 편집';
}
const deleteBtn = document.getElementById('btn-delete-parts-master-asset')!;
const saveBtn = document.getElementById('btn-save-parts-master-asset')!;
deleteBtn.style.display = (mode === 'add') ? 'none' : 'block';
if (mode === 'add' || mode === 'edit') {
saveBtn.textContent = (mode === 'add') ? '등록' : '저장';
saveBtn.style.display = 'block';
} else {
saveBtn.textContent = '수정';
saveBtn.style.display = 'block';
}
this.updateHeaderIdentity(asset);
}
private updateHeaderIdentity(asset: any) {
const container = document.getElementById('parts-master-header-identity');
if (!container) return;
if (this.currentMode === 'add') {
container.innerHTML = '<span class="badge badge-primary">신규 등록</span>';
return;
}
const cat = asset.category || '';
const name = asset.component_name || '';
container.innerHTML = `
<span class="asset-code-title">${name}</span>
<span class="service-type-badge">${cat}</span>
`;
}
}
export const partsMasterModal = new PartsMasterModal();
export function initPartsMasterModal(onSave: () => void, closeModals: () => void) { partsMasterModal.init(onSave, closeModals); }
export function openPartsMasterModal(asset: any, mode: 'view' | 'edit' | 'add' = 'view') { partsMasterModal.open(asset, mode); }

View File

@@ -1,398 +1,398 @@
import { state, saveAsset, deleteAsset } from '../../core/state';
import { BaseModal } from './BaseModal';
import { openSwUserModal } from './SWUserModal';
import { createIcons, History, Plus, X, Save, RotateCcw, Calendar, Users } from 'lucide';
import { CORP_LIST } from './SharedData';
import { ASSET_SCHEMA, UI_TEXT } from '../../core/schema';
import { API_BASE_URL } from '../../core/utils';
import {
generateOptionsHTML,
setFieldValue,
getFieldValue,
applyDateMask
} from './ModalUtils';
class SwAssetModal extends BaseModal {
constructor() {
super('sw', '소프트웨어 상세 정보');
}
protected renderFrameHTML(): string {
return `
<div id="sw-asset-modal" class="modal-overlay hidden">
<div class="modal-content wide">
<div class="modal-header">
<div class="header-left">
<h2 id="sw-modal-title" class="modal-title">${this.title}</h2>
<div id="sw-header-identity" class="header-identity"></div>
</div>
<button id="btn-close-sw-modal" class="btn-icon" aria-label="닫기">&times;</button>
</div>
<div class="modal-body">
<div class="modal-body-split">
<div class="modal-form-area">
<form id="sw-asset-form" class="grid-form">
<input type="hidden" id="sw-asset-id" name="id" />
<div class="form-section-title">기본 정보 (Identity)</div>
<div class="form-group">
<label>자산 유형</label>
<select id="sw-asset-type" name="asset_type" required>
<option value="내부SW">내부SW</option>
<option value="외부SW">외부SW</option>
<option value="클라우드">클라우드</option>
</select>
</div>
<div class="form-group">
<label>${ASSET_SCHEMA.SW_FIELD.ui}</label>
<select id="sw-분야" name="sw_field" required>
<option value="업무공통">업무공통</option>
<option value="개발S/W">개발S/W</option>
<option value="디자인">디자인</option>
<option value="설계S/W">설계S/W</option>
</select>
</div>
<div class="form-group">
<label>${ASSET_SCHEMA.PURCHASE_CORP.ui}</label>
<select id="sw-법인" name="purchase_corp" required>${generateOptionsHTML(CORP_LIST)}</select>
</div>
<div class="form-group full-width">
<label>${ASSET_SCHEMA.PRODUCT_NAME.ui}</label>
<input type="text" id="sw-제품명" name="product_name" required />
</div>
<div class="form-group cloud-only">
<label>${ASSET_SCHEMA.DEV_OBJ.ui} / 플랫폼</label>
<input type="text" id="sw-플랫폼명" name="dev_objective" placeholder="개발목적 또는 플랫폼명" />
</div>
<div class="form-group">
<label>${ASSET_SCHEMA.CURRENT_DEPT.ui}</label>
<input type="text" id="sw-부서" name="current_dept" />
</div>
<div class="form-group sw-user-tracking">
<label>${ASSET_SCHEMA.CURRENT_USER.ui}</label>
<input type="text" id="sw-user-current" name="user_current" />
</div>
<div class="form-group sw-user-tracking">
<label>${ASSET_SCHEMA.PREV_USER.ui}</label>
<input type="text" id="sw-previous-user" name="previous_user" />
</div>
<div class="form-section-title">라이선스 및 계약 정보</div>
<div class="form-group sw-standard-field">
<label>${ASSET_SCHEMA.ASSET_COUNT.ui}</label>
<input type="number" id="sw-수량" name="asset_count" min="0" />
</div>
<div class="form-group sw-standard-field">
<label>${ASSET_SCHEMA.PURCHASE_AMOUNT.ui}</label>
<input type="text" id="sw-금액" name="purchase_amount" oninput="this.value = this.value.replace(/[^0-9]/g, '').replace(/\\B(?=(\\d{3})+(?!\\d))/g, ',')" />
</div>
<div class="form-group cloud-only">
<label>${ASSET_SCHEMA.EMAIL_ACCOUNT.ui}</label>
<input type="text" id="sw-계정명" name="email_account" />
</div>
<div class="form-group cloud-only">
<label>${ASSET_SCHEMA.PURCHASE_METHOD.ui}</label>
<select id="sw-결제수단" name="purchase_method">
<option value="">선택안함</option>
<option value="법인카드">법인카드</option>
<option value="인보이스">인보이스</option>
</select>
</div>
<div class="form-section-title">관리 및 비고</div>
<div class="form-group sw-standard-field">
<label>${ASSET_SCHEMA.PURCHASE_DATE.ui}</label>
<div class="input-with-btn">
<input type="text" id="sw-구매일" name="purchase_date" />
<button type="button" class="btn-icon" onclick="const p = document.getElementById('sw-구매일-picker'); p.value = document.getElementById('sw-구매일').value; p.showPicker();">
<i data-lucide="calendar"></i>
</button>
<input type="date" id="sw-구매일-picker" class="hidden-picker" onchange="document.getElementById('sw-구매일').value = this.value" tabindex="-1" />
</div>
</div>
<div class="form-group sw-standard-field">
<label>${ASSET_SCHEMA.PURCHASE_VENDOR.ui}</label>
<input type="text" id="sw-납품업체" name="purchase_vendor" />
</div>
<div class="form-group sw-standard-field">
<label>${ASSET_SCHEMA.DEV_MGR.ui}</label>
<input type="text" id="sw-개발담당자" name="dev_manager" />
</div>
<div class="form-group sw-standard-field">
<label>${ASSET_SCHEMA.PLANNING_MGR.ui}</label>
<input type="text" id="sw-기획담당자" name="planning_manager" />
</div>
<div class="form-group sw-standard-field">
<label>${ASSET_SCHEMA.SALES_MGR.ui}</label>
<input type="text" id="sw-영업담당자" name="sales_manager" />
</div>
<div class="form-group sw-standard-field" id="sw-expiry-group">
<label>${ASSET_SCHEMA.EXPIRED_DATE.ui}</label>
<div class="input-with-btn">
<input type="text" id="sw-만료일" name="expiry_date" />
<button type="button" class="btn-icon" onclick="const p = document.getElementById('sw-만료일-picker'); p.value = document.getElementById('sw-만료일').value; p.showPicker();">
<i data-lucide="calendar"></i>
</button>
<input type="date" id="sw-만료일-picker" class="hidden-picker" onchange="document.getElementById('sw-만료일').value = this.value" tabindex="-1" />
</div>
</div>
<div class="form-group full-width">
<label>${ASSET_SCHEMA.MEMO.ui}</label>
<textarea id="sw-비고" name="memo" rows="2"></textarea>
</div>
</form>
<div id="sw-user-section" class="user-management-section">
<button type="button" id="btn-open-sw-user" class="btn btn-outline btn-sm" title="사용자 관리">
<i data-lucide="users"></i> 사용자 관리
</button>
</div>
</div>
<div class="modal-history-area">
<div class="history-header">
<h3><i data-lucide="history"></i> 업데이트 내역</h3>
<button type="button" id="btn-open-sw-update" class="btn btn-outline btn-sm">
계약 업데이트 <i data-lucide="rotate-ccw"></i>
</button>
</div>
<div id="sw-history-list" class="history-timeline"></div>
</div>
</div>
</div>
<div class="modal-footer">
<button id="btn-delete-sw-asset" class="btn btn-outline btn-danger">삭제</button>
<div class="footer-actions">
<button id="btn-revert-sw-edit" class="btn btn-outline hidden">수정 취소</button>
<button id="btn-cancel-sw-modal" class="btn btn-outline">닫기</button>
<button id="btn-save-sw-asset" class="btn btn-primary">수정</button>
</div>
</div>
</div>
</div>
<!-- 계약 업데이트 서브 모달 -->
<div id="sw-update-modal" class="modal-overlay hidden sub-modal">
<div class="modal-content narrow">
<div class="modal-header">
<h2 class="modal-title">계약 업데이트 반영</h2>
<button id="btn-close-sw-update" class="btn-icon">&times;</button>
</div>
<div class="modal-body">
<div class="grid-form vertical-form">
<div class="form-group">
<label>업데이트 일자</label>
<input type="date" id="sw-update-date" />
</div>
<div class="form-group sub-sw-update">
<label>새로운 계약 기간</label>
<div class="input-with-btn">
<input type="text" id="sw-update-start" placeholder="YYYY-MM-DD" />
<span>~</span>
<input type="text" id="sw-update-end" placeholder="YYYY-MM-DD" />
</div>
</div>
<div class="form-group">
<label>발생 비용</label>
<input type="text" id="sw-update-cost" oninput="this.value = this.value.replace(/[^0-9]/g, '') ? Number(this.value.replace(/[^0-9]/g, '')).toLocaleString() : ''" placeholder="ex) 500,000" />
</div>
<div class="form-group">
<label>상세 내용 (메모)</label>
<input type="text" id="sw-update-note" placeholder="예: 25년도 구독 연장 결제 완료" />
</div>
</div>
</div>
<div class="modal-footer">
<div></div>
<div class="footer-actions">
<button id="btn-cancel-sw-update" class="btn btn-outline">취소</button>
<button id="btn-save-sw-update" class="btn btn-primary">반영하기</button>
</div>
</div>
</div>
</div>
<style>
.hidden-picker {
position: absolute;
width: 0;
height: 0;
opacity: 0;
pointer-events: none;
}
</style>
`;
}
protected initChildLogic(onSave: () => void, closeModals: () => void): void {
const saveBtn = document.getElementById('btn-save-sw-asset')!;
const revertBtn = document.getElementById('btn-revert-sw-edit')!;
const deleteBtn = document.getElementById('btn-delete-sw-asset')!;
const typeSelect = document.getElementById('sw-asset-type') as HTMLSelectElement;
const userAssignBtn = document.getElementById('btn-open-sw-user')!;
const btnOpenUpdate = document.getElementById('btn-open-sw-update')!;
typeSelect?.addEventListener('change', () => this.applySwTypeUI(typeSelect.value));
['sw-구매일', 'sw-시작일', 'sw-만료일', 'sw-update-start', 'sw-update-end'].forEach(id => {
const el = document.getElementById(id) as HTMLInputElement;
if (el) applyDateMask(el);
});
userAssignBtn.addEventListener('click', () => {
if (this.currentAsset) openSwUserModal(this.currentAsset);
});
const subModal = document.getElementById('sw-update-modal')!;
const closeUpdate = () => subModal.classList.add('hidden');
document.getElementById('btn-close-sw-update')?.addEventListener('click', closeUpdate);
document.getElementById('btn-cancel-sw-update')?.addEventListener('click', closeUpdate);
btnOpenUpdate?.addEventListener('click', (e) => {
e.preventDefault();
if (!this.isEditMode) { alert('자산을 수정 모드로 변경한 후 업데이트를 진행해주세요.'); return; }
subModal.classList.remove('hidden');
});
document.getElementById('btn-save-sw-update')?.addEventListener('click', async (e) => {
e.preventDefault();
const date = (document.getElementById('sw-update-date') as HTMLInputElement).value;
const start = (document.getElementById('sw-update-start') as HTMLInputElement).value;
const end = (document.getElementById('sw-update-end') as HTMLInputElement).value;
const cost = (document.getElementById('sw-update-cost') as HTMLInputElement).value;
const note = (document.getElementById('sw-update-note') as HTMLInputElement).value;
if (start) setFieldValue('sw-시작일', start);
if (end) setFieldValue('sw-만료일', end);
if (cost) setFieldValue('sw-금액', cost);
const log = { assetId: this.currentAsset.id, date, details: `[계약갱신] ${note} (${start} ~ ${end}, 비용: ${cost})`, user: '관리자' };
await fetch(`${API_BASE_URL}/api/asset/history/batch`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify([...state.masterData.logs, log])
});
closeUpdate(); onSave();
});
revertBtn.addEventListener('click', () => {
this.setEditLockMode('view');
if (this.currentAsset) this.fillFormData(this.currentAsset);
});
saveBtn.addEventListener('click', async () => {
if (!this.currentAsset) return;
if (!this.isEditMode) { this.setEditLockMode('edit'); this.isEditMode = true; return; }
const type = getFieldValue('sw-asset-type');
const formData = new FormData(this.formEl!);
const updated = { ...this.currentAsset };
formData.forEach((value, key) => { updated[key] = value; });
let categoryKey = (type === '내부SW') ? 'swInternal' : (type === '클라우드' ? 'cloud' : 'swExternal');
if (await saveAsset(categoryKey, updated)) { onSave(); this.close(); closeModals(); }
});
deleteBtn.addEventListener('click', async () => {
if (!this.currentAsset || !confirm(UI_TEXT.MESSAGES.CONFIRM_DELETE)) return;
const type = this.currentAsset.asset_type || this.currentAsset.type;
let categoryKey = (type === '내부SW') ? 'swInternal' : (type === '클라우드' ? 'cloud' : 'swExternal');
if (await deleteAsset(categoryKey, this.currentAsset.id)) {
alert('성공적으로 삭제되었습니다.'); onSave(); this.close(); closeModals();
}
});
createIcons({ icons: { History, Plus, Save, Calendar, Users, RotateCcw } });
}
protected fillFormData(asset: any): void {
setFieldValue('sw-asset-id', asset.id);
setFieldValue('sw-asset-type', asset.asset_type || asset.type);
setFieldValue('sw-분야', asset.sw_field || '');
setFieldValue('sw-법인', asset.purchase_corp || '');
setFieldValue('sw-부서', asset.current_dept || '');
setFieldValue('sw-user-current', asset.user_current || '');
setFieldValue('sw-previous-user', asset.previous_user || '');
setFieldValue('sw-제품명', asset.product_name || '');
setFieldValue('sw-수량', asset.asset_count || '');
setFieldValue('sw-금액', asset.purchase_amount || '');
setFieldValue('sw-구매일', asset.purchase_date || '');
setFieldValue('sw-납품업체', asset.purchase_vendor || '');
setFieldValue('sw-개발담당자', asset.dev_manager || '');
setFieldValue('sw-기획담당자', asset.planning_manager || '');
setFieldValue('sw-영업담당자', asset.sales_manager || '');
setFieldValue('sw-비고', asset.memo || '');
if (asset.type === '클라우드' || asset.asset_type === '클라우드') {
setFieldValue('sw-플랫폼명', asset.dev_objective || '');
setFieldValue('sw-계정명', asset.email_account || '');
setFieldValue('sw-결제수단', asset.purchase_method || '');
} else {
setFieldValue('sw-만료일', asset.expiry_date || '');
}
this.renderHistory(asset.id);
this.updateHeaderIdentity(asset);
}
protected onAfterOpen(asset: any, mode: string): void {
this.applySwTypeUI(asset.asset_type || asset.type);
this.updateHeaderIdentity(asset);
}
private updateHeaderIdentity(asset: any) {
const container = document.getElementById('sw-header-identity');
if (!container) return;
if (this.currentMode === 'add') {
container.innerHTML = '<span class="badge badge-primary">신규 등록</span>';
return;
}
const type = getFieldValue('sw-asset-type') || asset.asset_type || asset.type || '';
const name = getFieldValue('sw-제품명') || asset.product_name || '';
const corp = getFieldValue('sw-법인') || asset.purchase_corp || '';
container.innerHTML = `
<span class="asset-code-title">${name}</span>
<span class="service-type-badge">${corp}</span>
<span class="asset-type-label">${type}</span>
`;
}
private applySwTypeUI(type: string) {
const cloudFields = document.querySelectorAll('.cloud-only');
const swFields = document.querySelectorAll('.sw-standard-field');
const userSection = document.getElementById('sw-user-section');
const expiryGroup = document.getElementById('sw-expiry-group');
const userTracking = document.querySelectorAll('.sw-user-tracking');
if (type === '클라우드') {
cloudFields.forEach(el => (el as HTMLElement).style.display = 'flex');
swFields.forEach(el => (el as HTMLElement).style.display = 'none');
if (userSection) userSection.style.display = 'none';
userTracking.forEach(el => (el as HTMLElement).style.display = 'none');
} else {
cloudFields.forEach(el => (el as HTMLElement).style.display = 'none');
swFields.forEach(el => (el as HTMLElement).style.display = 'flex');
if (userSection) userSection.style.display = 'block';
if (type === '외부SW' || type === '내부SW') {
if (expiryGroup) expiryGroup.style.display = 'flex';
userTracking.forEach(el => (el as HTMLElement).style.display = (type === '외부SW') ? 'flex' : 'none');
}
}
}
private renderHistory(swId: string) {
const container = document.getElementById('sw-history-list');
if (!container) return;
const logs = (state.masterData.logs || []).filter(l => l.asset_id === swId);
if (logs.length === 0) { container.innerHTML = '<div class="empty-history">수정 이력이 없습니다.</div>'; return; }
container.innerHTML = logs.map(l => `<div class="history-item"><div class="history-date">${l.log_date || ''}</div><div class="history-user">${l.log_user || '시스템'}</div><div class="history-details">${l.details}</div></div>`).join('');
}
}
export const swModal = new SwAssetModal();
export function initSwModal(onSave: () => void, closeModals: () => void) { swModal.init(onSave, closeModals); }
export function openSwModal(asset: any, mode: 'view' | 'add' | 'edit' = 'view') { swModal.open(asset, mode); }
import { state, saveAsset, deleteAsset } from '../../core/state';
import { BaseModal } from './BaseModal';
import { openSwUserModal } from './SWUserModal';
import { createIcons, History, Plus, X, Save, RotateCcw, Calendar, Users } from 'lucide';
import { CORP_LIST } from './SharedData';
import { ASSET_SCHEMA, UI_TEXT } from '../../core/schema';
import { API_BASE_URL } from '../../core/utils';
import {
generateOptionsHTML,
setFieldValue,
getFieldValue,
applyDateMask
} from './ModalUtils';
class SwAssetModal extends BaseModal {
constructor() {
super('sw', '소프트웨어 상세 정보');
}
protected renderFrameHTML(): string {
return `
<div id="sw-asset-modal" class="modal-overlay hidden">
<div class="modal-content wide">
<div class="modal-header">
<div class="header-left">
<h2 id="sw-modal-title" class="modal-title">${this.title}</h2>
<div id="sw-header-identity" class="header-identity"></div>
</div>
<button id="btn-close-sw-modal" class="btn-icon" aria-label="닫기">&times;</button>
</div>
<div class="modal-body">
<div class="modal-body-split">
<div class="modal-form-area">
<form id="sw-asset-form" class="grid-form">
<input type="hidden" id="sw-asset-id" name="id" />
<div class="form-section-title">기본 정보 (Identity)</div>
<div class="form-group">
<label>자산 유형</label>
<select id="sw-asset-type" name="asset_type" required>
<option value="내부SW">내부SW</option>
<option value="외부SW">외부SW</option>
<option value="클라우드">클라우드</option>
</select>
</div>
<div class="form-group">
<label>${ASSET_SCHEMA.SW_FIELD.ui}</label>
<select id="sw-분야" name="sw_field" required>
<option value="업무공통">업무공통</option>
<option value="개발S/W">개발S/W</option>
<option value="디자인">디자인</option>
<option value="설계S/W">설계S/W</option>
</select>
</div>
<div class="form-group">
<label>${ASSET_SCHEMA.PURCHASE_CORP.ui}</label>
<select id="sw-법인" name="purchase_corp" required>${generateOptionsHTML(CORP_LIST)}</select>
</div>
<div class="form-group full-width">
<label>${ASSET_SCHEMA.PRODUCT_NAME.ui}</label>
<input type="text" id="sw-제품명" name="product_name" required />
</div>
<div class="form-group cloud-only">
<label>${ASSET_SCHEMA.DEV_OBJ.ui} / 플랫폼</label>
<input type="text" id="sw-플랫폼명" name="dev_objective" placeholder="개발목적 또는 플랫폼명" />
</div>
<div class="form-group">
<label>${ASSET_SCHEMA.CURRENT_DEPT.ui}</label>
<input type="text" id="sw-부서" name="current_dept" />
</div>
<div class="form-group sw-user-tracking">
<label>${ASSET_SCHEMA.CURRENT_USER.ui}</label>
<input type="text" id="sw-user-current" name="user_current" />
</div>
<div class="form-group sw-user-tracking">
<label>${ASSET_SCHEMA.PREV_USER.ui}</label>
<input type="text" id="sw-previous-user" name="previous_user" />
</div>
<div class="form-section-title">라이선스 및 계약 정보</div>
<div class="form-group sw-standard-field">
<label>${ASSET_SCHEMA.ASSET_COUNT.ui}</label>
<input type="number" id="sw-수량" name="asset_count" min="0" />
</div>
<div class="form-group sw-standard-field">
<label>${ASSET_SCHEMA.PURCHASE_AMOUNT.ui}</label>
<input type="text" id="sw-금액" name="purchase_amount" oninput="this.value = this.value.replace(/[^0-9]/g, '').replace(/\\B(?=(\\d{3})+(?!\\d))/g, ',')" />
</div>
<div class="form-group cloud-only">
<label>${ASSET_SCHEMA.EMAIL_ACCOUNT.ui}</label>
<input type="text" id="sw-계정명" name="email_account" />
</div>
<div class="form-group cloud-only">
<label>${ASSET_SCHEMA.PURCHASE_METHOD.ui}</label>
<select id="sw-결제수단" name="purchase_method">
<option value="">선택안함</option>
<option value="법인카드">법인카드</option>
<option value="인보이스">인보이스</option>
</select>
</div>
<div class="form-section-title">관리 및 비고</div>
<div class="form-group sw-standard-field">
<label>${ASSET_SCHEMA.PURCHASE_DATE.ui}</label>
<div class="input-with-btn">
<input type="text" id="sw-구매일" name="purchase_date" />
<button type="button" class="btn-icon" onclick="const p = document.getElementById('sw-구매일-picker'); p.value = document.getElementById('sw-구매일').value; p.showPicker();">
<i data-lucide="calendar"></i>
</button>
<input type="date" id="sw-구매일-picker" class="hidden-picker" onchange="document.getElementById('sw-구매일').value = this.value" tabindex="-1" />
</div>
</div>
<div class="form-group sw-standard-field">
<label>${ASSET_SCHEMA.PURCHASE_VENDOR.ui}</label>
<input type="text" id="sw-납품업체" name="purchase_vendor" />
</div>
<div class="form-group sw-standard-field">
<label>${ASSET_SCHEMA.DEV_MGR.ui}</label>
<input type="text" id="sw-개발담당자" name="dev_manager" />
</div>
<div class="form-group sw-standard-field">
<label>${ASSET_SCHEMA.PLANNING_MGR.ui}</label>
<input type="text" id="sw-기획담당자" name="planning_manager" />
</div>
<div class="form-group sw-standard-field">
<label>${ASSET_SCHEMA.SALES_MGR.ui}</label>
<input type="text" id="sw-영업담당자" name="sales_manager" />
</div>
<div class="form-group sw-standard-field" id="sw-expiry-group">
<label>${ASSET_SCHEMA.EXPIRED_DATE.ui}</label>
<div class="input-with-btn">
<input type="text" id="sw-만료일" name="expiry_date" />
<button type="button" class="btn-icon" onclick="const p = document.getElementById('sw-만료일-picker'); p.value = document.getElementById('sw-만료일').value; p.showPicker();">
<i data-lucide="calendar"></i>
</button>
<input type="date" id="sw-만료일-picker" class="hidden-picker" onchange="document.getElementById('sw-만료일').value = this.value" tabindex="-1" />
</div>
</div>
<div class="form-group full-width">
<label>${ASSET_SCHEMA.MEMO.ui}</label>
<textarea id="sw-비고" name="memo" rows="2"></textarea>
</div>
</form>
<div id="sw-user-section" class="user-management-section">
<button type="button" id="btn-open-sw-user" class="btn btn-outline btn-sm" title="사용자 관리">
<i data-lucide="users"></i> 사용자 관리
</button>
</div>
</div>
<div class="modal-history-area">
<div class="history-header">
<h3><i data-lucide="history"></i> 업데이트 내역</h3>
<button type="button" id="btn-open-sw-update" class="btn btn-outline btn-sm">
계약 업데이트 <i data-lucide="rotate-ccw"></i>
</button>
</div>
<div id="sw-history-list" class="history-timeline"></div>
</div>
</div>
</div>
<div class="modal-footer">
<button id="btn-delete-sw-asset" class="btn btn-outline btn-danger">삭제</button>
<div class="footer-actions">
<button id="btn-revert-sw-edit" class="btn btn-outline hidden">수정 취소</button>
<button id="btn-cancel-sw-modal" class="btn btn-outline">닫기</button>
<button id="btn-save-sw-asset" class="btn btn-primary">수정</button>
</div>
</div>
</div>
</div>
<!-- 계약 업데이트 서브 모달 -->
<div id="sw-update-modal" class="modal-overlay hidden sub-modal">
<div class="modal-content narrow">
<div class="modal-header">
<h2 class="modal-title">계약 업데이트 반영</h2>
<button id="btn-close-sw-update" class="btn-icon">&times;</button>
</div>
<div class="modal-body">
<div class="grid-form vertical-form">
<div class="form-group">
<label>업데이트 일자</label>
<input type="date" id="sw-update-date" />
</div>
<div class="form-group sub-sw-update">
<label>새로운 계약 기간</label>
<div class="input-with-btn">
<input type="text" id="sw-update-start" placeholder="YYYY-MM-DD" />
<span>~</span>
<input type="text" id="sw-update-end" placeholder="YYYY-MM-DD" />
</div>
</div>
<div class="form-group">
<label>발생 비용</label>
<input type="text" id="sw-update-cost" oninput="this.value = this.value.replace(/[^0-9]/g, '') ? Number(this.value.replace(/[^0-9]/g, '')).toLocaleString() : ''" placeholder="ex) 500,000" />
</div>
<div class="form-group">
<label>상세 내용 (메모)</label>
<input type="text" id="sw-update-note" placeholder="예: 25년도 구독 연장 결제 완료" />
</div>
</div>
</div>
<div class="modal-footer">
<div></div>
<div class="footer-actions">
<button id="btn-cancel-sw-update" class="btn btn-outline">취소</button>
<button id="btn-save-sw-update" class="btn btn-primary">반영하기</button>
</div>
</div>
</div>
</div>
<style>
.hidden-picker {
position: absolute;
width: 0;
height: 0;
opacity: 0;
pointer-events: none;
}
</style>
`;
}
protected initChildLogic(onSave: () => void, closeModals: () => void): void {
const saveBtn = document.getElementById('btn-save-sw-asset')!;
const revertBtn = document.getElementById('btn-revert-sw-edit')!;
const deleteBtn = document.getElementById('btn-delete-sw-asset')!;
const typeSelect = document.getElementById('sw-asset-type') as HTMLSelectElement;
const userAssignBtn = document.getElementById('btn-open-sw-user')!;
const btnOpenUpdate = document.getElementById('btn-open-sw-update')!;
typeSelect?.addEventListener('change', () => this.applySwTypeUI(typeSelect.value));
['sw-구매일', 'sw-시작일', 'sw-만료일', 'sw-update-start', 'sw-update-end'].forEach(id => {
const el = document.getElementById(id) as HTMLInputElement;
if (el) applyDateMask(el);
});
userAssignBtn.addEventListener('click', () => {
if (this.currentAsset) openSwUserModal(this.currentAsset);
});
const subModal = document.getElementById('sw-update-modal')!;
const closeUpdate = () => subModal.classList.add('hidden');
document.getElementById('btn-close-sw-update')?.addEventListener('click', closeUpdate);
document.getElementById('btn-cancel-sw-update')?.addEventListener('click', closeUpdate);
btnOpenUpdate?.addEventListener('click', (e) => {
e.preventDefault();
if (!this.isEditMode) { alert('자산을 수정 모드로 변경한 후 업데이트를 진행해주세요.'); return; }
subModal.classList.remove('hidden');
});
document.getElementById('btn-save-sw-update')?.addEventListener('click', async (e) => {
e.preventDefault();
const date = (document.getElementById('sw-update-date') as HTMLInputElement).value;
const start = (document.getElementById('sw-update-start') as HTMLInputElement).value;
const end = (document.getElementById('sw-update-end') as HTMLInputElement).value;
const cost = (document.getElementById('sw-update-cost') as HTMLInputElement).value;
const note = (document.getElementById('sw-update-note') as HTMLInputElement).value;
if (start) setFieldValue('sw-시작일', start);
if (end) setFieldValue('sw-만료일', end);
if (cost) setFieldValue('sw-금액', cost);
const log = { assetId: this.currentAsset.id, date, details: `[계약갱신] ${note} (${start} ~ ${end}, 비용: ${cost})`, user: '관리자' };
await fetch(`${API_BASE_URL}/api/asset/history/batch`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify([...state.masterData.logs, log])
});
closeUpdate(); onSave();
});
revertBtn.addEventListener('click', () => {
this.setEditLockMode('view');
if (this.currentAsset) this.fillFormData(this.currentAsset);
});
saveBtn.addEventListener('click', async () => {
if (!this.currentAsset) return;
if (!this.isEditMode) { this.setEditLockMode('edit'); this.isEditMode = true; return; }
const type = getFieldValue('sw-asset-type');
const formData = new FormData(this.formEl!);
const updated = { ...this.currentAsset };
formData.forEach((value, key) => { updated[key] = value; });
let categoryKey = (type === '내부SW') ? 'swInternal' : (type === '클라우드' ? 'cloud' : 'swExternal');
if (await saveAsset(categoryKey, updated)) { onSave(); this.close(); closeModals(); }
});
deleteBtn.addEventListener('click', async () => {
if (!this.currentAsset || !confirm(UI_TEXT.MESSAGES.CONFIRM_DELETE)) return;
const type = this.currentAsset.asset_type || this.currentAsset.type;
let categoryKey = (type === '내부SW') ? 'swInternal' : (type === '클라우드' ? 'cloud' : 'swExternal');
if (await deleteAsset(categoryKey, this.currentAsset.id)) {
alert('성공적으로 삭제되었습니다.'); onSave(); this.close(); closeModals();
}
});
createIcons({ icons: { History, Plus, Save, Calendar, Users, RotateCcw } });
}
protected fillFormData(asset: any): void {
setFieldValue('sw-asset-id', asset.id);
setFieldValue('sw-asset-type', asset.asset_type || asset.type);
setFieldValue('sw-분야', asset.sw_field || '');
setFieldValue('sw-법인', asset.purchase_corp || '');
setFieldValue('sw-부서', asset.current_dept || '');
setFieldValue('sw-user-current', asset.user_current || '');
setFieldValue('sw-previous-user', asset.previous_user || '');
setFieldValue('sw-제품명', asset.product_name || '');
setFieldValue('sw-수량', asset.asset_count || '');
setFieldValue('sw-금액', asset.purchase_amount || '');
setFieldValue('sw-구매일', asset.purchase_date || '');
setFieldValue('sw-납품업체', asset.purchase_vendor || '');
setFieldValue('sw-개발담당자', asset.dev_manager || '');
setFieldValue('sw-기획담당자', asset.planning_manager || '');
setFieldValue('sw-영업담당자', asset.sales_manager || '');
setFieldValue('sw-비고', asset.memo || '');
if (asset.type === '클라우드' || asset.asset_type === '클라우드') {
setFieldValue('sw-플랫폼명', asset.dev_objective || '');
setFieldValue('sw-계정명', asset.email_account || '');
setFieldValue('sw-결제수단', asset.purchase_method || '');
} else {
setFieldValue('sw-만료일', asset.expiry_date || '');
}
this.renderHistory(asset.id);
this.updateHeaderIdentity(asset);
}
protected onAfterOpen(asset: any, mode: string): void {
this.applySwTypeUI(asset.asset_type || asset.type);
this.updateHeaderIdentity(asset);
}
private updateHeaderIdentity(asset: any) {
const container = document.getElementById('sw-header-identity');
if (!container) return;
if (this.currentMode === 'add') {
container.innerHTML = '<span class="badge badge-primary">신규 등록</span>';
return;
}
const type = getFieldValue('sw-asset-type') || asset.asset_type || asset.type || '';
const name = getFieldValue('sw-제품명') || asset.product_name || '';
const corp = getFieldValue('sw-법인') || asset.purchase_corp || '';
container.innerHTML = `
<span class="asset-code-title">${name}</span>
<span class="service-type-badge">${corp}</span>
<span class="asset-type-label">${type}</span>
`;
}
private applySwTypeUI(type: string) {
const cloudFields = document.querySelectorAll('.cloud-only');
const swFields = document.querySelectorAll('.sw-standard-field');
const userSection = document.getElementById('sw-user-section');
const expiryGroup = document.getElementById('sw-expiry-group');
const userTracking = document.querySelectorAll('.sw-user-tracking');
if (type === '클라우드') {
cloudFields.forEach(el => (el as HTMLElement).style.display = 'flex');
swFields.forEach(el => (el as HTMLElement).style.display = 'none');
if (userSection) userSection.style.display = 'none';
userTracking.forEach(el => (el as HTMLElement).style.display = 'none');
} else {
cloudFields.forEach(el => (el as HTMLElement).style.display = 'none');
swFields.forEach(el => (el as HTMLElement).style.display = 'flex');
if (userSection) userSection.style.display = 'block';
if (type === '외부SW' || type === '내부SW') {
if (expiryGroup) expiryGroup.style.display = 'flex';
userTracking.forEach(el => (el as HTMLElement).style.display = (type === '외부SW') ? 'flex' : 'none');
}
}
}
private renderHistory(swId: string) {
const container = document.getElementById('sw-history-list');
if (!container) return;
const logs = (state.masterData.logs || []).filter(l => l.asset_id === swId);
if (logs.length === 0) { container.innerHTML = '<div class="empty-history">수정 이력이 없습니다.</div>'; return; }
container.innerHTML = logs.map(l => `<div class="history-item"><div class="history-date">${l.log_date || ''}</div><div class="history-user">${l.log_user || '시스템'}</div><div class="history-details">${l.details}</div></div>`).join('');
}
}
export const swModal = new SwAssetModal();
export function initSwModal(onSave: () => void, closeModals: () => void) { swModal.init(onSave, closeModals); }
export function openSwModal(asset: any, mode: 'view' | 'add' | 'edit' = 'view') { swModal.open(asset, mode); }

View File

@@ -1,270 +1,270 @@
import { state } from '../../core/state';
import { BaseModal } from './BaseModal';
import { createIcons, Edit2, X, Paperclip, Calendar, Plus } from 'lucide';
import { ORG_LIST } from './SharedData';
import { generateOptionsHTML, setFieldValue, getFieldValue, applyDateMask } from './ModalUtils';
class SwUserModal extends BaseModal {
private tempSwUsers: any[] = [];
constructor() {
super('sw-user', '소프트웨어 사용자 관리');
}
protected renderFrameHTML(): string {
return `
<div id="sw-user-asset-modal" class="modal-overlay hidden">
<div class="modal-content wide">
<div class="modal-header">
<h2 id="sw-user-title" class="modal-title">${this.title}</h2>
<button id="btn-close-sw-user-modal" class="btn-icon" aria-label="닫기">&times;</button>
</div>
<div class="modal-body">
<div class="sw-info-summary" id="sw-user-sw-info"></div>
<div class="flex justify-between items-center mb-4">
<h3 class="detail-section-title mb-0">할당된 사용자 목록</h3>
<button type="button" id="btn-open-add-user" class="btn btn-primary btn-sm"><i data-lucide="plus" class="icon-sm"></i> 사용자 추가</button>
</div>
<div class="table-container">
<table>
<thead>
<tr>
<th>조직</th>
<th>부서</th>
<th>직위</th>
<th>이름</th>
<th class="text-center">사용기간</th>
<th class="text-center">신청서</th>
<th class="text-center">관리</th>
</tr>
</thead>
<tbody id="sw-user-table-body"></tbody>
</table>
</div>
<!-- 더미 폼 (BaseModal 필수 요건 충족용) -->
<form id="sw-user-asset-form" class="hidden"></form>
</div>
<div class="modal-footer">
<button id="btn-cancel-sw-user" class="btn btn-outline">취소</button>
<button id="btn-save-sw-user" class="btn btn-primary">저장</button>
</div>
</div>
</div>
<!-- 사용자 추가/수정 서브 모달 -->
<div id="sw-user-edit-modal" class="modal-overlay hidden sub-modal">
<div class="modal-content narrow">
<div class="modal-header">
<h3 id="sw-user-edit-title" class="modal-title">사용자 정보</h3>
<button id="btn-close-user-edit" class="btn-icon">&times;</button>
</div>
<div class="modal-body">
<form id="sw-user-edit-form" class="grid-form vertical-form">
<input type="hidden" id="edit-user-index" value="-1" />
<div class="form-group">
<label>조직</label>
<select id="new-user-조직">${generateOptionsHTML(ORG_LIST)}</select>
</div>
<div class="form-group">
<label>부서</label>
<input type="text" id="new-user-부서" />
</div>
<div class="form-group">
<label>직위</label>
<input type="text" id="new-user-직위" />
</div>
<div class="form-group">
<label>이름</label>
<input type="text" id="new-user-이름" required />
</div>
<div class="form-group">
<label>사용 시작일</label>
<div class="input-with-btn">
<input type="text" id="new-user-시작일" />
<button type="button" class="btn-icon" onclick="const p = document.getElementById('new-user-시작일-picker'); p.value = document.getElementById('new-user-시작일').value; p.showPicker();">
<i data-lucide="calendar" class="icon-sm"></i>
</button>
<input type="date" id="new-user-시작일-picker" class="hidden-picker" onchange="document.getElementById('new-user-시작일').value = this.value" tabindex="-1" />
</div>
</div>
<div class="form-group">
<label>사용 종료일</label>
<div class="input-with-btn">
<input type="text" id="new-user-종료일" />
<button type="button" class="btn-icon" onclick="const p = document.getElementById('new-user-종료일-picker'); p.value = document.getElementById('new-user-종료일').value; p.showPicker();">
<i data-lucide="calendar" class="icon-sm"></i>
</button>
<input type="date" id="new-user-종료일-picker" class="hidden-picker" onchange="document.getElementById('new-user-종료일').value = this.value" tabindex="-1" />
</div>
</div>
<div class="form-group">
<label>신청서 (증빙)</label>
<input type="file" id="new-user-신청서" />
</div>
</form>
</div>
<div class="modal-footer">
<button id="btn-close-user-sub" class="btn btn-outline">취소</button>
<button id="btn-confirm-user-edit" class="btn btn-primary">확인</button>
</div>
</div>
</div>
<style>
.hidden-picker {
position: absolute;
width: 0;
height: 0;
opacity: 0;
pointer-events: none;
}
</style>
`;
}
protected initChildLogic(onSave: () => void, closeModals: () => void): void {
const mainSaveBtn = document.getElementById('btn-save-sw-user')!;
const addUserBtn = document.getElementById('btn-open-add-user')!;
const confirmUserBtn = document.getElementById('btn-confirm-user-edit')!;
['new-user-시작일', 'new-user-종료일'].forEach(id => {
const el = document.getElementById(id) as HTMLInputElement;
if (el) applyDateMask(el);
});
addUserBtn.addEventListener('click', () => this.openUserEditSubModal());
confirmUserBtn.addEventListener('click', () => this.saveUserDataToList());
mainSaveBtn.addEventListener('click', () => {
if (!this.currentAsset) return;
const existingIdx = state.masterData.swUsers.findIndex(u => u.sw_id === this.currentAsset!.id);
const newMapping = {
sw_id: this.currentAsset!.id,
userData: this.tempSwUsers.map(u => [u., u., u., u., u., u.])
};
if (existingIdx > -1) state.masterData.swUsers[existingIdx] = newMapping as any;
else state.masterData.swUsers.push(newMapping as any);
onSave(); this.close(); closeModals();
});
document.getElementById('btn-close-sw-user-modal')?.addEventListener('click', () => this.close());
document.getElementById('btn-cancel-sw-user')?.addEventListener('click', () => this.close());
const subModal = document.getElementById('sw-user-edit-modal')!;
const closeSub = () => subModal.classList.add('hidden');
document.getElementById('btn-close-user-edit')?.addEventListener('click', closeSub);
document.getElementById('btn-close-user-sub')?.addEventListener('click', closeSub);
createIcons({ icons: { X, Plus, Calendar, Edit2, Paperclip } });
}
protected fillFormData(asset: any): void {
const swInfo = document.getElementById('sw-user-sw-info')!;
swInfo.innerHTML = `
<div class="sw-info-header border-b border-hairline pb-4 mb-6">
<div class="detail-label-sm">${asset.purchase_corp || asset. || ''}</div>
<div class="asset-code-title">${asset.product_name || asset. || ''}</div>
</div>
`;
const existingMapping = state.masterData.swUsers.find(u => u.sw_id === asset.id);
this.tempSwUsers = existingMapping ? (existingMapping.userData || []).map((u: any) => ({
조직: u[0], 부서: u[1], 직위: u[2], 이름: u[3], 사용기간: u[4], 신청서명: u[5]
})) : [];
this.renderUserList();
}
protected onAfterOpen(): void {}
private renderUserList() {
const tbody = document.getElementById('sw-user-table-body')!;
if (!tbody) return;
tbody.innerHTML = '';
if (this.tempSwUsers.length === 0) {
tbody.innerHTML = '<tr><td colspan="7" class="empty-cell text-center p-8">할당된 사용자가 없습니다.</td></tr>';
return;
}
this.tempSwUsers.forEach((user, idx) => {
const tr = document.createElement('tr');
tr.innerHTML = `
<td>${user. || ''}</td>
<td>${user. || ''}</td>
<td>${user. || ''}</td>
<td>${user. || ''}</td>
<td class="text-center">${user. || ''}</td>
<td class="text-center">${user. ? '<i data-lucide="paperclip" class="text-primary icon-sm"></i>' : '-'}</td>
<td class="text-center">
<div class="flex gap-2 justify-center items-center">
<button class="btn btn-outline btn-sm btn-edit-user" data-idx="${idx}">수정</button>
<button class="btn-circle-remove btn-del-user" data-idx="${idx}">&times;</button>
</div>
</td>
`;
tbody.appendChild(tr);
});
tbody.querySelectorAll('.btn-edit-user').forEach(btn => {
btn.addEventListener('click', (e) => {
const idx = parseInt((e.currentTarget as HTMLElement).getAttribute('data-idx')!);
this.openUserEditSubModal(idx);
});
});
tbody.querySelectorAll('.btn-del-user').forEach(btn => {
btn.addEventListener('click', (e) => {
const idx = parseInt((e.currentTarget as HTMLElement).getAttribute('data-idx')!);
if (confirm('사용자 할당을 삭제하시겠습니까?')) {
this.tempSwUsers.splice(idx, 1); this.renderUserList();
}
});
});
createIcons({ icons: { Paperclip } });
}
private openUserEditSubModal(idx: number = -1) {
const subModal = document.getElementById('sw-user-edit-modal')!;
const form = document.getElementById('sw-user-edit-form') as HTMLFormElement;
form.reset();
setFieldValue('edit-user-index', idx);
if (idx > -1) {
const user = this.tempSwUsers[idx];
setFieldValue('new-user-조직', user.);
setFieldValue('new-user-부서', user.);
setFieldValue('new-user-직위', user.);
setFieldValue('new-user-이름', user.);
if (user. && user..includes('~')) {
const parts = user..split('~');
setFieldValue('new-user-시작일', parts[0].trim());
setFieldValue('new-user-종료일', parts[1].trim());
}
}
subModal.classList.remove('hidden');
}
private saveUserDataToList() {
const idx = parseInt(getFieldValue('edit-user-index'));
const Input = document.getElementById('new-user-신청서') as HTMLInputElement;
const = Input.files && Input.files.length > 0 ? Input.files[0].name : (idx > -1 ? this.tempSwUsers[idx]. : '');
const userData: any = {
조직: getFieldValue('new-user-조직'),
부서: getFieldValue('new-user-부서'),
직위: getFieldValue('new-user-직위'),
이름: getFieldValue('new-user-이름'),
: `${getFieldValue('new-user-시작일')} ~ ${getFieldValue('new-user-종료일')}`,
};
if (idx === -1) this.tempSwUsers.push(userData);
else this.tempSwUsers[idx] = userData;
document.getElementById('sw-user-edit-modal')?.classList.add('hidden');
this.renderUserList();
}
}
export const swUserModal = new SwUserModal();
export function initSwUserModal(onSave: () => void, closeModals: () => void) { swUserModal.init(onSave, closeModals); }
export function openSwUserModal(asset: any) { swUserModal.open(asset); }
import { state } from '../../core/state';
import { BaseModal } from './BaseModal';
import { createIcons, Edit2, X, Paperclip, Calendar, Plus } from 'lucide';
import { ORG_LIST } from './SharedData';
import { generateOptionsHTML, setFieldValue, getFieldValue, applyDateMask } from './ModalUtils';
class SwUserModal extends BaseModal {
private tempSwUsers: any[] = [];
constructor() {
super('sw-user', '소프트웨어 사용자 관리');
}
protected renderFrameHTML(): string {
return `
<div id="sw-user-asset-modal" class="modal-overlay hidden">
<div class="modal-content wide">
<div class="modal-header">
<h2 id="sw-user-title" class="modal-title">${this.title}</h2>
<button id="btn-close-sw-user-modal" class="btn-icon" aria-label="닫기">&times;</button>
</div>
<div class="modal-body">
<div class="sw-info-summary" id="sw-user-sw-info"></div>
<div class="flex justify-between items-center mb-4">
<h3 class="detail-section-title mb-0">할당된 사용자 목록</h3>
<button type="button" id="btn-open-add-user" class="btn btn-primary btn-sm"><i data-lucide="plus" class="icon-sm"></i> 사용자 추가</button>
</div>
<div class="table-container">
<table>
<thead>
<tr>
<th>조직</th>
<th>부서</th>
<th>직위</th>
<th>이름</th>
<th class="text-center">사용기간</th>
<th class="text-center">신청서</th>
<th class="text-center">관리</th>
</tr>
</thead>
<tbody id="sw-user-table-body"></tbody>
</table>
</div>
<!-- 더미 폼 (BaseModal 필수 요건 충족용) -->
<form id="sw-user-asset-form" class="hidden"></form>
</div>
<div class="modal-footer">
<button id="btn-cancel-sw-user" class="btn btn-outline">취소</button>
<button id="btn-save-sw-user" class="btn btn-primary">저장</button>
</div>
</div>
</div>
<!-- 사용자 추가/수정 서브 모달 -->
<div id="sw-user-edit-modal" class="modal-overlay hidden sub-modal">
<div class="modal-content narrow">
<div class="modal-header">
<h3 id="sw-user-edit-title" class="modal-title">사용자 정보</h3>
<button id="btn-close-user-edit" class="btn-icon">&times;</button>
</div>
<div class="modal-body">
<form id="sw-user-edit-form" class="grid-form vertical-form">
<input type="hidden" id="edit-user-index" value="-1" />
<div class="form-group">
<label>조직</label>
<select id="new-user-조직">${generateOptionsHTML(ORG_LIST)}</select>
</div>
<div class="form-group">
<label>부서</label>
<input type="text" id="new-user-부서" />
</div>
<div class="form-group">
<label>직위</label>
<input type="text" id="new-user-직위" />
</div>
<div class="form-group">
<label>이름</label>
<input type="text" id="new-user-이름" required />
</div>
<div class="form-group">
<label>사용 시작일</label>
<div class="input-with-btn">
<input type="text" id="new-user-시작일" />
<button type="button" class="btn-icon" onclick="const p = document.getElementById('new-user-시작일-picker'); p.value = document.getElementById('new-user-시작일').value; p.showPicker();">
<i data-lucide="calendar" class="icon-sm"></i>
</button>
<input type="date" id="new-user-시작일-picker" class="hidden-picker" onchange="document.getElementById('new-user-시작일').value = this.value" tabindex="-1" />
</div>
</div>
<div class="form-group">
<label>사용 종료일</label>
<div class="input-with-btn">
<input type="text" id="new-user-종료일" />
<button type="button" class="btn-icon" onclick="const p = document.getElementById('new-user-종료일-picker'); p.value = document.getElementById('new-user-종료일').value; p.showPicker();">
<i data-lucide="calendar" class="icon-sm"></i>
</button>
<input type="date" id="new-user-종료일-picker" class="hidden-picker" onchange="document.getElementById('new-user-종료일').value = this.value" tabindex="-1" />
</div>
</div>
<div class="form-group">
<label>신청서 (증빙)</label>
<input type="file" id="new-user-신청서" />
</div>
</form>
</div>
<div class="modal-footer">
<button id="btn-close-user-sub" class="btn btn-outline">취소</button>
<button id="btn-confirm-user-edit" class="btn btn-primary">확인</button>
</div>
</div>
</div>
<style>
.hidden-picker {
position: absolute;
width: 0;
height: 0;
opacity: 0;
pointer-events: none;
}
</style>
`;
}
protected initChildLogic(onSave: () => void, closeModals: () => void): void {
const mainSaveBtn = document.getElementById('btn-save-sw-user')!;
const addUserBtn = document.getElementById('btn-open-add-user')!;
const confirmUserBtn = document.getElementById('btn-confirm-user-edit')!;
['new-user-시작일', 'new-user-종료일'].forEach(id => {
const el = document.getElementById(id) as HTMLInputElement;
if (el) applyDateMask(el);
});
addUserBtn.addEventListener('click', () => this.openUserEditSubModal());
confirmUserBtn.addEventListener('click', () => this.saveUserDataToList());
mainSaveBtn.addEventListener('click', () => {
if (!this.currentAsset) return;
const existingIdx = state.masterData.swUsers.findIndex(u => u.sw_id === this.currentAsset!.id);
const newMapping = {
sw_id: this.currentAsset!.id,
userData: this.tempSwUsers.map(u => [u., u., u., u., u., u.])
};
if (existingIdx > -1) state.masterData.swUsers[existingIdx] = newMapping as any;
else state.masterData.swUsers.push(newMapping as any);
onSave(); this.close(); closeModals();
});
document.getElementById('btn-close-sw-user-modal')?.addEventListener('click', () => this.close());
document.getElementById('btn-cancel-sw-user')?.addEventListener('click', () => this.close());
const subModal = document.getElementById('sw-user-edit-modal')!;
const closeSub = () => subModal.classList.add('hidden');
document.getElementById('btn-close-user-edit')?.addEventListener('click', closeSub);
document.getElementById('btn-close-user-sub')?.addEventListener('click', closeSub);
createIcons({ icons: { X, Plus, Calendar, Edit2, Paperclip } });
}
protected fillFormData(asset: any): void {
const swInfo = document.getElementById('sw-user-sw-info')!;
swInfo.innerHTML = `
<div class="sw-info-header border-b border-hairline pb-4 mb-6">
<div class="detail-label-sm">${asset.purchase_corp || asset. || ''}</div>
<div class="asset-code-title">${asset.product_name || asset. || ''}</div>
</div>
`;
const existingMapping = state.masterData.swUsers.find(u => u.sw_id === asset.id);
this.tempSwUsers = existingMapping ? (existingMapping.userData || []).map((u: any) => ({
조직: u[0], 부서: u[1], 직위: u[2], 이름: u[3], 사용기간: u[4], 신청서명: u[5]
})) : [];
this.renderUserList();
}
protected onAfterOpen(): void {}
private renderUserList() {
const tbody = document.getElementById('sw-user-table-body')!;
if (!tbody) return;
tbody.innerHTML = '';
if (this.tempSwUsers.length === 0) {
tbody.innerHTML = '<tr><td colspan="7" class="empty-cell text-center p-8">할당된 사용자가 없습니다.</td></tr>';
return;
}
this.tempSwUsers.forEach((user, idx) => {
const tr = document.createElement('tr');
tr.innerHTML = `
<td>${user. || ''}</td>
<td>${user. || ''}</td>
<td>${user. || ''}</td>
<td>${user. || ''}</td>
<td class="text-center">${user. || ''}</td>
<td class="text-center">${user. ? '<i data-lucide="paperclip" class="text-primary icon-sm"></i>' : '-'}</td>
<td class="text-center">
<div class="flex gap-2 justify-center items-center">
<button class="btn btn-outline btn-sm btn-edit-user" data-idx="${idx}">수정</button>
<button class="btn-circle-remove btn-del-user" data-idx="${idx}">&times;</button>
</div>
</td>
`;
tbody.appendChild(tr);
});
tbody.querySelectorAll('.btn-edit-user').forEach(btn => {
btn.addEventListener('click', (e) => {
const idx = parseInt((e.currentTarget as HTMLElement).getAttribute('data-idx')!);
this.openUserEditSubModal(idx);
});
});
tbody.querySelectorAll('.btn-del-user').forEach(btn => {
btn.addEventListener('click', (e) => {
const idx = parseInt((e.currentTarget as HTMLElement).getAttribute('data-idx')!);
if (confirm('사용자 할당을 삭제하시겠습니까?')) {
this.tempSwUsers.splice(idx, 1); this.renderUserList();
}
});
});
createIcons({ icons: { Paperclip } });
}
private openUserEditSubModal(idx: number = -1) {
const subModal = document.getElementById('sw-user-edit-modal')!;
const form = document.getElementById('sw-user-edit-form') as HTMLFormElement;
form.reset();
setFieldValue('edit-user-index', idx);
if (idx > -1) {
const user = this.tempSwUsers[idx];
setFieldValue('new-user-조직', user.);
setFieldValue('new-user-부서', user.);
setFieldValue('new-user-직위', user.);
setFieldValue('new-user-이름', user.);
if (user. && user..includes('~')) {
const parts = user..split('~');
setFieldValue('new-user-시작일', parts[0].trim());
setFieldValue('new-user-종료일', parts[1].trim());
}
}
subModal.classList.remove('hidden');
}
private saveUserDataToList() {
const idx = parseInt(getFieldValue('edit-user-index'));
const Input = document.getElementById('new-user-신청서') as HTMLInputElement;
const = Input.files && Input.files.length > 0 ? Input.files[0].name : (idx > -1 ? this.tempSwUsers[idx]. : '');
const userData: any = {
조직: getFieldValue('new-user-조직'),
부서: getFieldValue('new-user-부서'),
직위: getFieldValue('new-user-직위'),
이름: getFieldValue('new-user-이름'),
: `${getFieldValue('new-user-시작일')} ~ ${getFieldValue('new-user-종료일')}`,
};
if (idx === -1) this.tempSwUsers.push(userData);
else this.tempSwUsers[idx] = userData;
document.getElementById('sw-user-edit-modal')?.classList.add('hidden');
this.renderUserList();
}
}
export const swUserModal = new SwUserModal();
export function initSwUserModal(onSave: () => void, closeModals: () => void) { swUserModal.init(onSave, closeModals); }
export function openSwUserModal(asset: any) { swUserModal.open(asset); }

View File

@@ -1,81 +1,81 @@
/**
* 모든 모달에서 공통으로 사용하는 리스트 데이터 및 설정
*/
// 구매법인 목록
export const CORP_LIST = ['한맥', '삼안', 'PTC', '바론'];
// 사용조직 목록
export const ORG_LIST = ['한맥', '삼안', '장헌', '한라', 'PTC', '기술개발센터', '총괄기획실'];
// 하드웨어 상태 목록
export const HW_STATUS_LIST = ['운영', '재고', '수리', '폐기', '기타'];
// 구분(Category) -> 유형(Asset Type) 관계 정의 (통합 관리)
export const CATEGORY_TYPE_MAP: Record<string, string[]> = {
'서버': ['서버 렉', '가상서버(VM)', '워크스테이션', '저장시스템_렉(NAS)', '저장시스템_렉(DAS)', '저장시스템_미니(NAS)', '저장시스템_미니(DAS)'],
'PC': ['개인PC', '노트북', '공용PC', '서버PC'],
'저장매체': ['SSD', 'HDD', '외장HDD'],
'네트워크': ['스위치', '허브', '방화벽', '라우터', '공유기', '허브'],
'PC부품': ['CPU', 'RAM', 'GPU', 'SSD', 'HDD', 'RAM', '모니터'],
'공간정보장비': ['드론', '측량장비', '보조기기'],
'업무지원장비': ['카메라', '스피커', 'TV', '모바일', '유선전화기', 'XR', '프린터', '전산소모품'],
'외부SW': ['영구', '구독'],
'내부SW': ['판매용', 'Solutions', 'Inhouse', 'Engine&Module'],
'비용관리': ['클라우드', '도메인', '전화', '인터넷', '이메일'],
'내빈/외빈': ['선물'],
'시설자산': ['사무가구']
};
// 설치위치 종속성 데이터
export const LOCATION_DATA: Record<string, string[]> = {
'한맥빌딩': ['MDF실', '1층', '2층', '3층', '4층', '5층', '6층', '7층', '파고라'],
'기술개발센터': ['서버실', '센터내부'],
'유니온빌딩': ['4층', '5층', '6층'],
'뉴코아빌딩': ['4층', '6층', '7층'],
'IDC': ['서관202', '서관203', '서관204', '서관205', '동관53', '동관54']
};
// 유형별 자산번호 접두사(Prefix) 매핑
export const TYPE_PREFIX_MAP: Record<string, string> = {
'서버': 'SVR', '워크스테이션': 'SVR', '개인PC': 'PC', '공용PC': 'PC', '서버PC': 'PC',
'저장시스템_렉(NAS)': 'DSS', '저장시스템_렉(DAS)': 'DSS', '저장시스템_미니(NAS)': 'DSS', '저장시스템_미니(DAS)': 'DSS',
'저장매체': 'STM', 'HDD': 'HDD', 'SSD': 'SSD',
'노트북': 'NBK', '태블릿': 'TAB',
'드론': 'DRO', '측량장비': 'SUR', '보조기기': 'SUR', '허브': 'NET',
'구독SW': 'SW', '영구SW': 'SW', '내부' : 'SW_INT', '외부':'SW_EXT'
};
// 배치도 이미지 매핑 데이터
export const IMAGE_LOCATIONS: Record<string, Record<string, string[]>> = {
'IDC': {
'서관202': ['img/location_photo/IDC/서관202.png'],
'서관203': ['img/location_photo/IDC/서관203.png'],
'서관204': ['img/location_photo/IDC/서관204.png'],
'서관205': ['img/location_photo/IDC/서관205.png'],
'동관53': ['img/location_photo/IDC/동관53.png'],
'동관54': ['img/location_photo/IDC/동관54.png'],
},
'기술개발센터': {
'서버실': [
'img/location_photo/기술개발센터/서버실/서버실_1.png',
'img/location_photo/기술개발센터/서버실/서버실_2.png'
],
'센터내부': ['img/location_photo/기술개발센터/센터내부/센터내부.png']
},
'한맥빌딩': {
'1층': ['img/location_photo/한맥빌딩/1층.png'],
'2층': ['img/location_photo/한맥빌딩/2층.png'],
'3층': ['img/location_photo/한맥빌딩/3층.png'],
'4층': ['img/location_photo/한맥빌딩/4층.png'],
'5층': ['img/location_photo/한맥빌딩/5층.png'],
'6층': ['img/location_photo/한맥빌딩/6층.png'],
'7층': ['img/location_photo/한맥빌딩/7층.png'],
'MDF실': [
'img/location_photo/한맥빌딩/MDF실/MDF_1.png',
'img/location_photo/한맥빌딩/MDF실/MDF_2.png',
'img/location_photo/한맥빌딩/MDF실/MDF_3.png',
'img/location_photo/한맥빌딩/MDF실/MDF_4.png'
]
}
};
/**
* 모든 모달에서 공통으로 사용하는 리스트 데이터 및 설정
*/
// 구매법인 목록
export const CORP_LIST = ['한맥', '삼안', 'PTC', '바론'];
// 사용조직 목록
export const ORG_LIST = ['한맥', '삼안', '장헌', '한라', 'PTC', '기술개발센터', '총괄기획실'];
// 하드웨어 상태 목록
export const HW_STATUS_LIST = ['운영', '재고', '수리', '폐기', '기타'];
// 구분(Category) -> 유형(Asset Type) 관계 정의 (통합 관리)
export const CATEGORY_TYPE_MAP: Record<string, string[]> = {
'서버': ['서버 렉', '가상서버(VM)', '워크스테이션', '저장시스템_렉(NAS)', '저장시스템_렉(DAS)', '저장시스템_미니(NAS)', '저장시스템_미니(DAS)'],
'PC': ['개인PC', '노트북', '공용PC', '서버PC'],
'저장매체': ['SSD', 'HDD', '외장HDD'],
'네트워크': ['스위치', '허브', '방화벽', '라우터', '공유기', '허브'],
'PC부품': ['CPU', 'RAM', 'GPU', 'SSD', 'HDD', 'RAM', '모니터'],
'공간정보장비': ['드론', '측량장비', '보조기기'],
'업무지원장비': ['카메라', '스피커', 'TV', '모바일', '유선전화기', 'XR', '프린터', '전산소모품'],
'외부SW': ['영구', '구독'],
'내부SW': ['판매용', 'Solutions', 'Inhouse', 'Engine&Module'],
'비용관리': ['클라우드', '도메인', '전화', '인터넷', '이메일'],
'내빈/외빈': ['선물'],
'시설자산': ['사무가구']
};
// 설치위치 종속성 데이터
export const LOCATION_DATA: Record<string, string[]> = {
'한맥빌딩': ['MDF실', '1층', '2층', '3층', '4층', '5층', '6층', '7층', '파고라'],
'기술개발센터': ['서버실', '센터내부'],
'유니온빌딩': ['4층', '5층', '6층'],
'뉴코아빌딩': ['4층', '6층', '7층'],
'IDC': ['서관202', '서관203', '서관204', '서관205', '동관53', '동관54']
};
// 유형별 자산번호 접두사(Prefix) 매핑
export const TYPE_PREFIX_MAP: Record<string, string> = {
'서버': 'SVR', '워크스테이션': 'SVR', '개인PC': 'PC', '공용PC': 'PC', '서버PC': 'PC',
'저장시스템_렉(NAS)': 'DSS', '저장시스템_렉(DAS)': 'DSS', '저장시스템_미니(NAS)': 'DSS', '저장시스템_미니(DAS)': 'DSS',
'저장매체': 'STM', 'HDD': 'HDD', 'SSD': 'SSD',
'노트북': 'NBK', '태블릿': 'TAB',
'드론': 'DRO', '측량장비': 'SUR', '보조기기': 'SUR', '허브': 'NET',
'구독SW': 'SW', '영구SW': 'SW', '내부' : 'SW_INT', '외부':'SW_EXT'
};
// 배치도 이미지 매핑 데이터
export const IMAGE_LOCATIONS: Record<string, Record<string, string[]>> = {
'IDC': {
'서관202': ['img/location_photo/IDC/서관202.png'],
'서관203': ['img/location_photo/IDC/서관203.png'],
'서관204': ['img/location_photo/IDC/서관204.png'],
'서관205': ['img/location_photo/IDC/서관205.png'],
'동관53': ['img/location_photo/IDC/동관53.png'],
'동관54': ['img/location_photo/IDC/동관54.png'],
},
'기술개발센터': {
'서버실': [
'img/location_photo/기술개발센터/서버실/서버실_1.png',
'img/location_photo/기술개발센터/서버실/서버실_2.png'
],
'센터내부': ['img/location_photo/기술개발센터/센터내부/센터내부.png']
},
'한맥빌딩': {
'1층': ['img/location_photo/한맥빌딩/1층.png'],
'2층': ['img/location_photo/한맥빌딩/2층.png'],
'3층': ['img/location_photo/한맥빌딩/3층.png'],
'4층': ['img/location_photo/한맥빌딩/4층.png'],
'5층': ['img/location_photo/한맥빌딩/5층.png'],
'6층': ['img/location_photo/한맥빌딩/6층.png'],
'7층': ['img/location_photo/한맥빌딩/7층.png'],
'MDF실': [
'img/location_photo/한맥빌딩/MDF실/MDF_1.png',
'img/location_photo/한맥빌딩/MDF실/MDF_2.png',
'img/location_photo/한맥빌딩/MDF실/MDF_3.png',
'img/location_photo/한맥빌딩/MDF실/MDF_4.png'
]
}
};

View File

@@ -1,180 +1,180 @@
import { state, saveSystemUser, deleteSystemUser } from '../../core/state';
import { BaseModal } from './BaseModal';
import { setFieldValue } from './ModalUtils';
import { createIcons, X, Save } from 'lucide';
import { UI_TEXT } from '../../core/schema';
class UserModal extends BaseModal {
constructor() {
super('user', '임직원 정보');
}
protected renderFrameHTML(): string {
return `
<div id="user-asset-modal" class="modal-overlay hidden">
<div class="modal-content narrow">
<div class="modal-header">
<div class="header-left">
<h2 id="user-modal-title" class="modal-title">${this.title}</h2>
<div id="user-header-identity" class="header-identity"></div>
</div>
<button id="btn-close-user-modal" class="btn-icon" aria-label="닫기">&times;</button>
</div>
<div class="modal-body">
<form id="user-asset-form" class="grid-form vertical-form">
<input type="hidden" id="user-id" name="id" />
<div class="form-group">
<label>사번</label>
<input type="text" id="user-emp-no" name="emp_no" placeholder="예: HM202601" required />
</div>
<div class="form-group">
<label>사용자명</label>
<input type="text" id="user-name-input" name="user_name" placeholder="예: 홍길동" required />
</div>
<div class="form-group">
<label>사용조직 (부서)</label>
<input type="text" id="user-dept" name="dept_name" placeholder="예: 기술개발센터" required />
</div>
<div class="form-group">
<label>직무 (직급)</label>
<input type="text" id="user-position-input" name="position" placeholder="예: BIM모델러" required />
</div>
<div class="form-group">
<label>상태</label>
<select id="user-status" name="status">
<option value="재직">재직</option>
<option value="퇴직">퇴직</option>
</select>
</div>
</form>
</div>
<div class="modal-footer">
<button id="btn-delete-user-asset" class="btn btn-outline btn-danger">삭제</button>
<div class="footer-actions">
<button id="btn-revert-user-edit" class="btn btn-outline hidden">수정 취소</button>
<button id="btn-cancel-user-modal" class="btn btn-outline">닫기</button>
<button id="btn-save-user-asset" class="btn btn-primary">수정</button>
</div>
</div>
</div>
</div>
`;
}
protected initChildLogic(onSave: () => void, closeModals: () => void): void {
const saveBtn = document.getElementById('btn-save-user-asset')!;
const revertBtn = document.getElementById('btn-revert-user-edit')!;
const deleteBtn = document.getElementById('btn-delete-user-asset')!;
saveBtn.addEventListener('click', async () => {
if (!this.currentAsset) return;
if (!this.isEditMode) {
this.setEditLockMode('edit');
this.isEditMode = true;
return;
}
const empNo = (document.getElementById('user-emp-no') as HTMLInputElement).value.trim();
const userName = (document.getElementById('user-name-input') as HTMLInputElement).value.trim();
const deptName = (document.getElementById('user-dept') as HTMLInputElement).value.trim();
const position = (document.getElementById('user-position-input') as HTMLInputElement).value.trim();
const status = (document.getElementById('user-status') as HTMLSelectElement).value;
if (!empNo || !userName || !deptName || !position) {
alert('모든 필수 입력 필드를 채워주세요.');
return;
}
const updated = {
id: this.currentAsset.id || null,
emp_no: empNo,
user_name: userName,
dept_name: deptName,
position: position,
status: status
};
if (await saveSystemUser(updated)) {
alert(UI_TEXT.MESSAGES.SAVE_SUCCESS);
onSave(); this.close(); closeModals();
}
});
revertBtn.addEventListener('click', () => {
this.setEditLockMode('view');
if (this.currentAsset) this.fillFormData(this.currentAsset);
});
deleteBtn.addEventListener('click', async () => {
if (!this.currentAsset || !this.currentAsset.id) return;
if (!confirm('정말로 이 임직원 정보를 삭제하시겠습니까?')) return;
if (await deleteSystemUser(this.currentAsset.id)) {
alert('성공적으로 삭제되었습니다.');
onSave(); this.close(); closeModals();
}
});
createIcons({ icons: { Save, X } });
}
protected fillFormData(asset: any): void {
setFieldValue('user-id', asset.id || '');
setFieldValue('user-emp-no', asset.emp_no || '');
setFieldValue('user-name-input', asset.user_name || '');
setFieldValue('user-dept', asset.dept_name || '');
setFieldValue('user-position-input', asset.position || '');
setFieldValue('user-status', asset.status || '재직');
this.updateHeaderIdentity(asset);
}
protected onAfterOpen(asset: any, mode: string): void {
const titleEl = document.getElementById('user-modal-title');
if (titleEl) {
titleEl.textContent = (mode === 'add') ? '신규 임직원 등록' : '임직원 정보 수정';
}
const deleteBtn = document.getElementById('btn-delete-user-asset')!;
const saveBtn = document.getElementById('btn-save-user-asset')!;
deleteBtn.style.display = (mode === 'add') ? 'none' : 'block';
if (mode === 'add' || mode === 'edit') {
saveBtn.textContent = mode === 'add' ? '등록' : '저장';
saveBtn.style.display = 'block';
} else {
saveBtn.textContent = '수정';
saveBtn.style.display = 'block';
}
this.updateHeaderIdentity(asset);
}
private updateHeaderIdentity(asset: any) {
const container = document.getElementById('user-header-identity');
if (!container) return;
if (this.currentMode === 'add') {
container.innerHTML = '<span class="badge badge-primary">신규 등록</span>';
return;
}
const empNo = asset.emp_no || '';
const userName = asset.user_name || '';
const dept = asset.dept_name || '';
container.innerHTML = `
<span class="asset-code-title">${userName}</span>
<span class="service-type-badge">${empNo}</span>
<span class="asset-type-label">${dept}</span>
`;
}
}
export const userModal = new UserModal();
export function initUserModal(onSave: () => void, closeModals: () => void) { userModal.init(onSave, closeModals); }
export function openUserModal(asset: any, mode: 'view' | 'edit' | 'add' = 'view') { userModal.open(asset, mode); }
import { state, saveSystemUser, deleteSystemUser } from '../../core/state';
import { BaseModal } from './BaseModal';
import { setFieldValue } from './ModalUtils';
import { createIcons, X, Save } from 'lucide';
import { UI_TEXT } from '../../core/schema';
class UserModal extends BaseModal {
constructor() {
super('user', '임직원 정보');
}
protected renderFrameHTML(): string {
return `
<div id="user-asset-modal" class="modal-overlay hidden">
<div class="modal-content narrow">
<div class="modal-header">
<div class="header-left">
<h2 id="user-modal-title" class="modal-title">${this.title}</h2>
<div id="user-header-identity" class="header-identity"></div>
</div>
<button id="btn-close-user-modal" class="btn-icon" aria-label="닫기">&times;</button>
</div>
<div class="modal-body">
<form id="user-asset-form" class="grid-form vertical-form">
<input type="hidden" id="user-id" name="id" />
<div class="form-group">
<label>사번</label>
<input type="text" id="user-emp-no" name="emp_no" placeholder="예: HM202601" required />
</div>
<div class="form-group">
<label>사용자명</label>
<input type="text" id="user-name-input" name="user_name" placeholder="예: 홍길동" required />
</div>
<div class="form-group">
<label>사용조직 (부서)</label>
<input type="text" id="user-dept" name="dept_name" placeholder="예: 기술개발센터" required />
</div>
<div class="form-group">
<label>직무 (직급)</label>
<input type="text" id="user-position-input" name="position" placeholder="예: BIM모델러" required />
</div>
<div class="form-group">
<label>상태</label>
<select id="user-status" name="status">
<option value="재직">재직</option>
<option value="퇴직">퇴직</option>
</select>
</div>
</form>
</div>
<div class="modal-footer">
<button id="btn-delete-user-asset" class="btn btn-outline btn-danger">삭제</button>
<div class="footer-actions">
<button id="btn-revert-user-edit" class="btn btn-outline hidden">수정 취소</button>
<button id="btn-cancel-user-modal" class="btn btn-outline">닫기</button>
<button id="btn-save-user-asset" class="btn btn-primary">수정</button>
</div>
</div>
</div>
</div>
`;
}
protected initChildLogic(onSave: () => void, closeModals: () => void): void {
const saveBtn = document.getElementById('btn-save-user-asset')!;
const revertBtn = document.getElementById('btn-revert-user-edit')!;
const deleteBtn = document.getElementById('btn-delete-user-asset')!;
saveBtn.addEventListener('click', async () => {
if (!this.currentAsset) return;
if (!this.isEditMode) {
this.setEditLockMode('edit');
this.isEditMode = true;
return;
}
const empNo = (document.getElementById('user-emp-no') as HTMLInputElement).value.trim();
const userName = (document.getElementById('user-name-input') as HTMLInputElement).value.trim();
const deptName = (document.getElementById('user-dept') as HTMLInputElement).value.trim();
const position = (document.getElementById('user-position-input') as HTMLInputElement).value.trim();
const status = (document.getElementById('user-status') as HTMLSelectElement).value;
if (!empNo || !userName || !deptName || !position) {
alert('모든 필수 입력 필드를 채워주세요.');
return;
}
const updated = {
id: this.currentAsset.id || null,
emp_no: empNo,
user_name: userName,
dept_name: deptName,
position: position,
status: status
};
if (await saveSystemUser(updated)) {
alert(UI_TEXT.MESSAGES.SAVE_SUCCESS);
onSave(); this.close(); closeModals();
}
});
revertBtn.addEventListener('click', () => {
this.setEditLockMode('view');
if (this.currentAsset) this.fillFormData(this.currentAsset);
});
deleteBtn.addEventListener('click', async () => {
if (!this.currentAsset || !this.currentAsset.id) return;
if (!confirm('정말로 이 임직원 정보를 삭제하시겠습니까?')) return;
if (await deleteSystemUser(this.currentAsset.id)) {
alert('성공적으로 삭제되었습니다.');
onSave(); this.close(); closeModals();
}
});
createIcons({ icons: { Save, X } });
}
protected fillFormData(asset: any): void {
setFieldValue('user-id', asset.id || '');
setFieldValue('user-emp-no', asset.emp_no || '');
setFieldValue('user-name-input', asset.user_name || '');
setFieldValue('user-dept', asset.dept_name || '');
setFieldValue('user-position-input', asset.position || '');
setFieldValue('user-status', asset.status || '재직');
this.updateHeaderIdentity(asset);
}
protected onAfterOpen(asset: any, mode: string): void {
const titleEl = document.getElementById('user-modal-title');
if (titleEl) {
titleEl.textContent = (mode === 'add') ? '신규 임직원 등록' : '임직원 정보 수정';
}
const deleteBtn = document.getElementById('btn-delete-user-asset')!;
const saveBtn = document.getElementById('btn-save-user-asset')!;
deleteBtn.style.display = (mode === 'add') ? 'none' : 'block';
if (mode === 'add' || mode === 'edit') {
saveBtn.textContent = mode === 'add' ? '등록' : '저장';
saveBtn.style.display = 'block';
} else {
saveBtn.textContent = '수정';
saveBtn.style.display = 'block';
}
this.updateHeaderIdentity(asset);
}
private updateHeaderIdentity(asset: any) {
const container = document.getElementById('user-header-identity');
if (!container) return;
if (this.currentMode === 'add') {
container.innerHTML = '<span class="badge badge-primary">신규 등록</span>';
return;
}
const empNo = asset.emp_no || '';
const userName = asset.user_name || '';
const dept = asset.dept_name || '';
container.innerHTML = `
<span class="asset-code-title">${userName}</span>
<span class="service-type-badge">${empNo}</span>
<span class="asset-type-label">${dept}</span>
`;
}
}
export const userModal = new UserModal();
export function initUserModal(onSave: () => void, closeModals: () => void) { userModal.init(onSave, closeModals); }
export function openUserModal(asset: any, mode: 'view' | 'edit' | 'add' = 'view') { userModal.open(asset, mode); }

File diff suppressed because it is too large Load Diff

View File

@@ -1,120 +1,120 @@
import { state } from '../core/state';
const MENU_CONFIG: any = {
hw: {
label: '하드웨어',
tabs: ['대시보드', '서버', 'PC', '스토리지', '공간정보장비', 'PC부품', '부품 마스터', '네트워크', '업무지원장비']
},
sw: {
label: '소프트웨어',
tabs: ['외부SW', '내부SW']
},
ops: {
label: '운영지원',
tabs: ['클라우드', '도메인', '비용관리', '사용자']
},
vip: {
label: '내빈/외빈',
tabs: ['선물']
},
fac: {
label: '시설자산',
tabs: ['사무가구']
}
};
export function renderNavigation(onTabChange: (tab: string) => void) {
const header = document.querySelector('.main-header') as HTMLElement;
const headerContainer = document.querySelector('.header-container')!;
if (!headerContainer) return;
const render = () => {
// 1. 헤더 구조 (Vercel Style: Clean Single Row)
headerContainer.innerHTML = `
<div class="brand" id="btn-home-logo" style="cursor: pointer;">
<img src="img/image_92.png" class="main-logo" alt="HM Logo" />
<h1>한맥자산관리시스템</h1>
</div>
<nav class="integrated-nav" id="main-nav-list"></nav>
<div class="header-actions">
<div class="role-toggle-wrapper">
<span class="role-label user ${state.currentUserRole === 'user' ? 'active' : ''}">실무자</span>
<label class="role-toggle">
<input type="checkbox" id="role-toggle-checkbox" ${state.currentUserRole === 'admin' ? 'checked' : ''}>
<span class="role-slider"></span>
</label>
<span class="role-label admin ${state.currentUserRole === 'admin' ? 'active' : ''}">관리자</span>
</div>
<div class="notification-area">
<button class="icon-btn" title="알림"><i data-lucide="bell" style="width:18px; height:18px;"></i></button>
</div>
</div>
`;
const navList = document.getElementById('main-nav-list')!;
// 2. GNB 메뉴 렌더링 (Ghost Tab Style)
Object.keys(MENU_CONFIG).forEach(catKey => {
const config = MENU_CONFIG[catKey];
const visibleTabs = config.tabs.filter((tab: string) => {
if (state.currentUserRole === 'admin') return tab === '대시보드';
return tab !== '대시보드';
});
if (visibleTabs.length === 0) return;
visibleTabs.forEach((tab: string) => {
if (tab === '부품 마스터') return;
const item = document.createElement('div');
const isActive = state.activeSubTab === tab;
item.className = `gnb-trigger ${isActive ? 'active' : ''}`;
item.textContent = tab;
item.style.fontSize = 'var(--fs-sm)'; // Ensure small but standard font
item.addEventListener('click', (e) => {
e.stopPropagation();
state.activeCategory = catKey as any;
state.activeSubTab = tab;
render();
onTabChange(tab);
});
navList.appendChild(item);
});
});
// 3. 관리자 전용 '관리도구'
if (state.currentUserRole === 'admin') {
const adminTrigger = document.createElement('div');
adminTrigger.className = 'gnb-trigger admin-trigger';
adminTrigger.innerHTML = '관리도구';
adminTrigger.addEventListener('click', () => window.open('/map_editor.html', '_blank'));
navList.appendChild(adminTrigger);
}
// 4. 이벤트 바인딩
document.getElementById('btn-home-logo')?.addEventListener('click', () => location.reload());
const roleToggle = document.getElementById('role-toggle-checkbox') as HTMLInputElement;
roleToggle?.addEventListener('change', () => {
state.currentUserRole = roleToggle.checked ? 'admin' : 'user';
if (state.currentUserRole === 'admin') {
state.activeCategory = 'hw';
state.activeSubTab = '대시보드';
} else {
state.activeCategory = 'hw';
state.activeSubTab = '서버';
}
render();
onTabChange(state.activeSubTab);
});
// 아이콘 생성
// @ts-ignore
if (window.lucide) window.lucide.createIcons();
};
render();
}
import { state } from '../core/state';
const MENU_CONFIG: any = {
hw: {
label: '하드웨어',
tabs: ['대시보드', '서버', 'PC', '스토리지', '공간정보장비', 'PC부품', '부품 마스터', '네트워크', '업무지원장비']
},
sw: {
label: '소프트웨어',
tabs: ['외부SW', '내부SW']
},
ops: {
label: '운영지원',
tabs: ['클라우드', '도메인', '비용관리', '사용자']
},
vip: {
label: '내빈/외빈',
tabs: ['선물']
},
fac: {
label: '시설자산',
tabs: ['사무가구']
}
};
export function renderNavigation(onTabChange: (tab: string) => void) {
const header = document.querySelector('.main-header') as HTMLElement;
const headerContainer = document.querySelector('.header-container')!;
if (!headerContainer) return;
const render = () => {
// 1. 헤더 구조 (Vercel Style: Clean Single Row)
headerContainer.innerHTML = `
<div class="brand" id="btn-home-logo" style="cursor: pointer;">
<img src="img/image_92.png" class="main-logo" alt="HM Logo" />
<h1>한맥자산관리시스템</h1>
</div>
<nav class="integrated-nav" id="main-nav-list"></nav>
<div class="header-actions">
<div class="role-toggle-wrapper">
<span class="role-label user ${state.currentUserRole === 'user' ? 'active' : ''}">실무자</span>
<label class="role-toggle">
<input type="checkbox" id="role-toggle-checkbox" ${state.currentUserRole === 'admin' ? 'checked' : ''}>
<span class="role-slider"></span>
</label>
<span class="role-label admin ${state.currentUserRole === 'admin' ? 'active' : ''}">관리자</span>
</div>
<div class="notification-area">
<button class="icon-btn" title="알림"><i data-lucide="bell" style="width:18px; height:18px;"></i></button>
</div>
</div>
`;
const navList = document.getElementById('main-nav-list')!;
// 2. GNB 메뉴 렌더링 (Ghost Tab Style)
Object.keys(MENU_CONFIG).forEach(catKey => {
const config = MENU_CONFIG[catKey];
const visibleTabs = config.tabs.filter((tab: string) => {
if (state.currentUserRole === 'admin') return tab === '대시보드';
return tab !== '대시보드';
});
if (visibleTabs.length === 0) return;
visibleTabs.forEach((tab: string) => {
if (tab === '부품 마스터') return;
const item = document.createElement('div');
const isActive = state.activeSubTab === tab;
item.className = `gnb-trigger ${isActive ? 'active' : ''}`;
item.textContent = tab;
item.style.fontSize = 'var(--fs-sm)'; // Ensure small but standard font
item.addEventListener('click', (e) => {
e.stopPropagation();
state.activeCategory = catKey as any;
state.activeSubTab = tab;
render();
onTabChange(tab);
});
navList.appendChild(item);
});
});
// 3. 관리자 전용 '관리도구'
if (state.currentUserRole === 'admin') {
const adminTrigger = document.createElement('div');
adminTrigger.className = 'gnb-trigger admin-trigger';
adminTrigger.innerHTML = '관리도구';
adminTrigger.addEventListener('click', () => window.open('/map_editor.html', '_blank'));
navList.appendChild(adminTrigger);
}
// 4. 이벤트 바인딩
document.getElementById('btn-home-logo')?.addEventListener('click', () => location.reload());
const roleToggle = document.getElementById('role-toggle-checkbox') as HTMLInputElement;
roleToggle?.addEventListener('change', () => {
state.currentUserRole = roleToggle.checked ? 'admin' : 'user';
if (state.currentUserRole === 'admin') {
state.activeCategory = 'hw';
state.activeSubTab = '대시보드';
} else {
state.activeCategory = 'hw';
state.activeSubTab = '서버';
}
render();
onTabChange(state.activeSubTab);
});
// 아이콘 생성
// @ts-ignore
if (window.lucide) window.lucide.createIcons();
};
render();
}

View File

@@ -1,188 +1,188 @@
/* ITAM Guide Modal Styles - Updated to match common modal style */
/* Tab Container (below header) */
.guide-tabs-container {
background: #FAFAFA;
border-bottom: 1px solid var(--border-color);
padding: 0 1.5rem;
flex-shrink: 0;
}
.guide-tabs {
display: flex;
gap: 2px;
overflow-x: auto;
scrollbar-width: none;
}
.guide-tabs::-webkit-scrollbar { display: none; }
.guide-tab {
padding: 0.75rem 1.25rem;
font-size: 24px;
font-weight: 700;
color: var(--text-muted);
cursor: pointer;
border-bottom: 2px solid transparent;
transition: all 0.2s ease;
white-space: nowrap;
}
.guide-tab:hover {
color: var(--primary-color);
background: rgba(30, 81, 73, 0.04);
}
.guide-tab.active {
color: var(--primary-color);
border-bottom-color: var(--primary-color);
background: white;
}
/* Content Area */
.guide-body {
padding-bottom: 2rem;
}
.guide-tab-panel {
display: none;
padding: 1.5rem 0;
animation: guideFadeIn 0.3s ease;
}
.guide-tab-panel.active {
display: block;
}
@keyframes guideFadeIn {
from { opacity: 0; transform: translateY(6px); }
to { opacity: 1; transform: translateY(0); }
}
/* Section Styles */
.guide-section {
display: flex;
flex-direction: column;
gap: 0.75rem;
margin-bottom: 2rem;
}
.guide-section:last-child {
margin-bottom: 0;
}
.guide-section h3 {
font-size: 1.73rem;
padding-bottom: 0.5rem;
border-bottom: 2px solid var(--primary-color);
color: var(--primary-color);
margin: 0;
display: flex;
align-items: center;
gap: 8px;
}
.guide-text {
font-size: 24px;
color: var(--text-main);
line-height: 1.7;
margin: 0;
}
/* Flowchart Styles */
.flow-container {
display: flex;
flex-direction: column;
align-items: center;
gap: 0.75rem;
padding: 1.5rem;
background-color: #f9fafb;
border-radius: 8px;
border: 1px solid var(--border-color);
}
.flow-row {
display: flex;
width: 100%;
gap: 1rem;
align-items: center;
}
.flow-step {
flex: 1;
background: white;
padding: 1rem;
border-radius: 6px;
border: 1px solid var(--border-color);
display: flex;
align-items: flex-start;
gap: 12px;
box-shadow: 0 1px 3px rgba(0,0,0,0.05);
}
.flow-step .step-number {
width: 24px;
height: 24px;
min-width: 24px;
border-radius: 50%;
background-color: var(--primary-color);
color: white;
font-size: 23px;
font-weight: 800;
display: flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
}
.flow-step .step-label {
font-weight: 800;
color: var(--text-main);
font-size: 24px;
display: block;
}
.flow-step .step-desc {
font-size: 23px;
color: var(--text-muted);
line-height: 1.5;
margin-top: 4px;
}
.flow-arrow-right {
color: var(--text-muted);
display: flex;
align-items: center;
}
/* Info Table Style */
.guide-info-table {
width: 100%;
border-collapse: collapse;
font-size: 24px;
}
.guide-info-table th {
background: #f8faf9;
color: var(--primary-color);
font-weight: 800;
padding: 0.75rem;
text-align: left;
border-bottom: 1px solid var(--border-color);
}
.guide-info-table td {
padding: 0.75rem;
border-bottom: 1px solid #f3f4f6;
color: var(--text-main);
}
/* Tip Box Style */
.guide-tip {
background: var(--primary-light);
border-left: 4px solid var(--primary-color);
padding: 1rem;
font-size: 24px;
color: var(--primary-color);
line-height: 1.6;
}
/* ITAM Guide Modal Styles - Updated to match common modal style */
/* Tab Container (below header) */
.guide-tabs-container {
background: #FAFAFA;
border-bottom: 1px solid var(--border-color);
padding: 0 1.5rem;
flex-shrink: 0;
}
.guide-tabs {
display: flex;
gap: 2px;
overflow-x: auto;
scrollbar-width: none;
}
.guide-tabs::-webkit-scrollbar { display: none; }
.guide-tab {
padding: 0.75rem 1.25rem;
font-size: 24px;
font-weight: 700;
color: var(--text-muted);
cursor: pointer;
border-bottom: 2px solid transparent;
transition: all 0.2s ease;
white-space: nowrap;
}
.guide-tab:hover {
color: var(--primary-color);
background: rgba(30, 81, 73, 0.04);
}
.guide-tab.active {
color: var(--primary-color);
border-bottom-color: var(--primary-color);
background: white;
}
/* Content Area */
.guide-body {
padding-bottom: 2rem;
}
.guide-tab-panel {
display: none;
padding: 1.5rem 0;
animation: guideFadeIn 0.3s ease;
}
.guide-tab-panel.active {
display: block;
}
@keyframes guideFadeIn {
from { opacity: 0; transform: translateY(6px); }
to { opacity: 1; transform: translateY(0); }
}
/* Section Styles */
.guide-section {
display: flex;
flex-direction: column;
gap: 0.75rem;
margin-bottom: 2rem;
}
.guide-section:last-child {
margin-bottom: 0;
}
.guide-section h3 {
font-size: 1.73rem;
padding-bottom: 0.5rem;
border-bottom: 2px solid var(--primary-color);
color: var(--primary-color);
margin: 0;
display: flex;
align-items: center;
gap: 8px;
}
.guide-text {
font-size: 24px;
color: var(--text-main);
line-height: 1.7;
margin: 0;
}
/* Flowchart Styles */
.flow-container {
display: flex;
flex-direction: column;
align-items: center;
gap: 0.75rem;
padding: 1.5rem;
background-color: #f9fafb;
border-radius: 8px;
border: 1px solid var(--border-color);
}
.flow-row {
display: flex;
width: 100%;
gap: 1rem;
align-items: center;
}
.flow-step {
flex: 1;
background: white;
padding: 1rem;
border-radius: 6px;
border: 1px solid var(--border-color);
display: flex;
align-items: flex-start;
gap: 12px;
box-shadow: 0 1px 3px rgba(0,0,0,0.05);
}
.flow-step .step-number {
width: 24px;
height: 24px;
min-width: 24px;
border-radius: 50%;
background-color: var(--primary-color);
color: white;
font-size: 23px;
font-weight: 800;
display: flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
}
.flow-step .step-label {
font-weight: 800;
color: var(--text-main);
font-size: 24px;
display: block;
}
.flow-step .step-desc {
font-size: 23px;
color: var(--text-muted);
line-height: 1.5;
margin-top: 4px;
}
.flow-arrow-right {
color: var(--text-muted);
display: flex;
align-items: center;
}
/* Info Table Style */
.guide-info-table {
width: 100%;
border-collapse: collapse;
font-size: 24px;
}
.guide-info-table th {
background: #f8faf9;
color: var(--primary-color);
font-weight: 800;
padding: 0.75rem;
text-align: left;
border-bottom: 1px solid var(--border-color);
}
.guide-info-table td {
padding: 0.75rem;
border-bottom: 1px solid #f3f4f6;
color: var(--text-main);
}
/* Tip Box Style */
.guide-tip {
background: var(--primary-light);
border-left: 4px solid var(--primary-color);
padding: 1rem;
font-size: 24px;
color: var(--primary-color);
line-height: 1.6;
}

View File

@@ -1,15 +1,15 @@
/**
* ITAM 엑셀 핸들러 (지정 날짜 포맷팅 유틸리티)
*/
export function formatExcelDate(val: any): string {
if (!val) return '';
if (typeof val === 'number') {
const date = new Date(Math.round((val - 25569) * 86400 * 1000));
return date.toISOString().split('T')[0];
}
if (typeof val === 'string') {
return val.replace(/\./g, '-').trim();
}
return String(val);
}
/**
* ITAM 엑셀 핸들러 (지정 날짜 포맷팅 유틸리티)
*/
export function formatExcelDate(val: any): string {
if (!val) return '';
if (typeof val === 'number') {
const date = new Date(Math.round((val - 25569) * 86400 * 1000));
return date.toISOString().split('T')[0];
}
if (typeof val === 'string') {
return val.replace(/\./g, '-').trim();
}
return String(val);
}

View File

@@ -1,162 +1,162 @@
import { ASSET_SCHEMA, UI_TEXT } from './schema';
import { generateOptionsHTML } from '../components/Modal/ModalUtils';
import { CORP_LIST } from '../components/Modal/SharedData';
/**
* ITAM Unified Filter Bar Component
* 검색 UI를 표준화하고 한 곳에서 관리합니다.
*/
export interface FilterOptions {
keywordLabel?: string;
showCorp?: boolean;
showDept?: boolean;
showLoc?: boolean;
showField?: boolean;
showType?: boolean;
showStatus?: boolean;
extraHTML?: string;
onFilterChange: (filters: any) => void;
initialFilters?: any;
fullList?: any[]; // For populating dynamic filters
}
/**
* 전역 액션 버튼 그룹 생성 (자산 추가 등)
*/
export function getActionButtonsHTML(): string {
return `<div id="filter-bar-actions" class="header-action-group"></div>`;
}
export function renderFilterBar(container: HTMLElement, options: FilterOptions) {
const {
keywordLabel = '통합 검색',
showCorp = false,
showDept = false,
showLoc = false,
showField = false,
showType = false,
showStatus = false,
extraHTML = '',
onFilterChange,
initialFilters = { keyword: '', corp: '', dept: '', loc: '', field: '', type: '', status: '' },
fullList = []
} = options;
container.classList.add('search-bar'); // Restored class
// Helper to get unique sorted values
const getUnique = (key: keyof typeof ASSET_SCHEMA | string) => {
const fieldKey = (ASSET_SCHEMA as any)[key]?.key || key;
return Array.from(new Set(fullList.map(item => item[fieldKey] || item[(ASSET_SCHEMA as any)[key]?.db]).filter(Boolean))).sort();
};
container.innerHTML = `
<div class="search-item flex-1">
<label>${keywordLabel}</label>
<input type="text" id="filter-keyword" placeholder="검색어를 입력하세요..." autocomplete="off" value="${initialFilters.keyword || ''}">
</div>
${showType ? `
<div class="search-item">
<label>${ASSET_SCHEMA.ASSET_TYPE.ui}</label>
<select id="filter-type">
<option value="">전체 유형</option>
${getUnique('ASSET_TYPE').map(v => `<option value="${v}" ${initialFilters.type === v ? 'selected' : ''}>${v}</option>`).join('')}
</select>
</div>` : ''}
${showStatus ? `
<div class="search-item">
<label>${ASSET_SCHEMA.HW_STATUS.ui}</label>
<select id="filter-status">
<option value="">전체 상태</option>
${getUnique('HW_STATUS').map(v => `<option value="${v}" ${initialFilters.status === v ? 'selected' : ''}>${v}</option>`).join('')}
</select>
</div>` : ''}
${showField ? `
<div class="search-item">
<label>${ASSET_SCHEMA.SW_FIELD.ui}</label>
<select id="filter-field">
<option value="">전체 분야</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, initialFilters.corp || '', true)}</select>
</div>` : ''}
${showLoc ? `
<div class="search-item">
<label>${ASSET_SCHEMA.LOCATION.ui}</label>
<select id="filter-loc">
<option value="">전체 위치</option>
${getUnique('LOCATION').map(v => `<option value="${v}" ${initialFilters.loc === v ? 'selected' : ''}>${v}</option>`).join('')}
</select>
</div>` : ''}
${showDept ? `
<div class="search-item">
<label>${ASSET_SCHEMA.CURRENT_DEPT.ui}</label>
<select id="filter-dept">
<option value="">전체 조직</option>
${getUnique('CURRENT_DEPT').map(v => `<option value="${v}" ${initialFilters.dept === v ? 'selected' : ''}>${v}</option>`).join('')}
</select>
</div>` : ''}
${extraHTML}
<button id="btn-reset-filters" class="btn btn-outline btn-reset">
<i data-lucide="refresh-ccw" class="icon-sm"></i> ${UI_TEXT.ACTION.RESET_FILTER}
</button>
${getActionButtonsHTML()}
`;
// Bind Events
const triggerChange = () => {
const filters = {
keyword: (container.querySelector('#filter-keyword') as HTMLInputElement)?.value.toLowerCase().trim() || '',
corp: (container.querySelector('#filter-corp') as HTMLSelectElement)?.value || '',
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 || '',
status: (container.querySelector('#filter-status') as HTMLSelectElement)?.value || ''
};
onFilterChange(filters);
};
container.querySelector('#filter-keyword')?.addEventListener('input', triggerChange);
container.querySelector('#filter-corp')?.addEventListener('change', triggerChange);
container.querySelector('#filter-dept')?.addEventListener('change', triggerChange);
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', 'filter-status'].forEach(id => {
const el = container.querySelector(`#${id}`);
if (el) (el as any).value = '';
});
triggerChange();
});
}
/**
* 공통 필터링 로직
*/
export function applyCommonFilters(list: any[], filters: any, searchKeys: (keyof typeof ASSET_SCHEMA)[]) {
return list.filter(item => {
const matchKeyword = !filters.keyword || searchKeys.some(key =>
String(item[ASSET_SCHEMA[key].key] || item[ASSET_SCHEMA[key].db] || '').toLowerCase().includes(filters.keyword)
);
const matchCorp = !filters.corp || (item[ASSET_SCHEMA.PURCHASE_CORP.key] || item[ASSET_SCHEMA.PURCHASE_CORP.db]) === filters.corp;
const matchDept = !filters.dept || (item[ASSET_SCHEMA.CURRENT_DEPT.key] || item[ASSET_SCHEMA.CURRENT_DEPT.db]) === filters.dept;
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 && matchStatus;
});
}
import { ASSET_SCHEMA, UI_TEXT } from './schema';
import { generateOptionsHTML } from '../components/Modal/ModalUtils';
import { CORP_LIST } from '../components/Modal/SharedData';
/**
* ITAM Unified Filter Bar Component
* 검색 UI를 표준화하고 한 곳에서 관리합니다.
*/
export interface FilterOptions {
keywordLabel?: string;
showCorp?: boolean;
showDept?: boolean;
showLoc?: boolean;
showField?: boolean;
showType?: boolean;
showStatus?: boolean;
extraHTML?: string;
onFilterChange: (filters: any) => void;
initialFilters?: any;
fullList?: any[]; // For populating dynamic filters
}
/**
* 전역 액션 버튼 그룹 생성 (자산 추가 등)
*/
export function getActionButtonsHTML(): string {
return `<div id="filter-bar-actions" class="header-action-group"></div>`;
}
export function renderFilterBar(container: HTMLElement, options: FilterOptions) {
const {
keywordLabel = '통합 검색',
showCorp = false,
showDept = false,
showLoc = false,
showField = false,
showType = false,
showStatus = false,
extraHTML = '',
onFilterChange,
initialFilters = { keyword: '', corp: '', dept: '', loc: '', field: '', type: '', status: '' },
fullList = []
} = options;
container.classList.add('search-bar'); // Restored class
// Helper to get unique sorted values
const getUnique = (key: keyof typeof ASSET_SCHEMA | string) => {
const fieldKey = (ASSET_SCHEMA as any)[key]?.key || key;
return Array.from(new Set(fullList.map(item => item[fieldKey] || item[(ASSET_SCHEMA as any)[key]?.db]).filter(Boolean))).sort();
};
container.innerHTML = `
<div class="search-item flex-1">
<label>${keywordLabel}</label>
<input type="text" id="filter-keyword" placeholder="검색어를 입력하세요..." autocomplete="off" value="${initialFilters.keyword || ''}">
</div>
${showType ? `
<div class="search-item">
<label>${ASSET_SCHEMA.ASSET_TYPE.ui}</label>
<select id="filter-type">
<option value="">전체 유형</option>
${getUnique('ASSET_TYPE').map(v => `<option value="${v}" ${initialFilters.type === v ? 'selected' : ''}>${v}</option>`).join('')}
</select>
</div>` : ''}
${showStatus ? `
<div class="search-item">
<label>${ASSET_SCHEMA.HW_STATUS.ui}</label>
<select id="filter-status">
<option value="">전체 상태</option>
${getUnique('HW_STATUS').map(v => `<option value="${v}" ${initialFilters.status === v ? 'selected' : ''}>${v}</option>`).join('')}
</select>
</div>` : ''}
${showField ? `
<div class="search-item">
<label>${ASSET_SCHEMA.SW_FIELD.ui}</label>
<select id="filter-field">
<option value="">전체 분야</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, initialFilters.corp || '', true)}</select>
</div>` : ''}
${showLoc ? `
<div class="search-item">
<label>${ASSET_SCHEMA.LOCATION.ui}</label>
<select id="filter-loc">
<option value="">전체 위치</option>
${getUnique('LOCATION').map(v => `<option value="${v}" ${initialFilters.loc === v ? 'selected' : ''}>${v}</option>`).join('')}
</select>
</div>` : ''}
${showDept ? `
<div class="search-item">
<label>${ASSET_SCHEMA.CURRENT_DEPT.ui}</label>
<select id="filter-dept">
<option value="">전체 조직</option>
${getUnique('CURRENT_DEPT').map(v => `<option value="${v}" ${initialFilters.dept === v ? 'selected' : ''}>${v}</option>`).join('')}
</select>
</div>` : ''}
${extraHTML}
<button id="btn-reset-filters" class="btn btn-outline btn-reset">
<i data-lucide="refresh-ccw" class="icon-sm"></i> ${UI_TEXT.ACTION.RESET_FILTER}
</button>
${getActionButtonsHTML()}
`;
// Bind Events
const triggerChange = () => {
const filters = {
keyword: (container.querySelector('#filter-keyword') as HTMLInputElement)?.value.toLowerCase().trim() || '',
corp: (container.querySelector('#filter-corp') as HTMLSelectElement)?.value || '',
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 || '',
status: (container.querySelector('#filter-status') as HTMLSelectElement)?.value || ''
};
onFilterChange(filters);
};
container.querySelector('#filter-keyword')?.addEventListener('input', triggerChange);
container.querySelector('#filter-corp')?.addEventListener('change', triggerChange);
container.querySelector('#filter-dept')?.addEventListener('change', triggerChange);
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', 'filter-status'].forEach(id => {
const el = container.querySelector(`#${id}`);
if (el) (el as any).value = '';
});
triggerChange();
});
}
/**
* 공통 필터링 로직
*/
export function applyCommonFilters(list: any[], filters: any, searchKeys: (keyof typeof ASSET_SCHEMA)[]) {
return list.filter(item => {
const matchKeyword = !filters.keyword || searchKeys.some(key =>
String(item[ASSET_SCHEMA[key].key] || item[ASSET_SCHEMA[key].db] || '').toLowerCase().includes(filters.keyword)
);
const matchCorp = !filters.corp || (item[ASSET_SCHEMA.PURCHASE_CORP.key] || item[ASSET_SCHEMA.PURCHASE_CORP.db]) === filters.corp;
const matchDept = !filters.dept || (item[ASSET_SCHEMA.CURRENT_DEPT.key] || item[ASSET_SCHEMA.CURRENT_DEPT.db]) === filters.dept;
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 && matchStatus;
});
}

View File

@@ -1,195 +1,195 @@
/**
* ITAM 통합 스키마 매퍼 (Unified Schema Mapper)
*
* key: 애플리케이션 내부 로직에서 사용하는 속성명
* db: MySQL 데이터베이스 컬럼명
* ui: 사용자에게 보여지는 UI 레이블
*/
export const ASSET_SCHEMA = {
// ─── 공통 필드 (Common) ───
ID: { key: 'id', db: 'id', ui: 'ID' },
ASSET_CODE: { key: 'asset_code', db: 'asset_code', ui: '자산번호' },
CATEGORY: { key: 'category', db: 'category', ui: '구분' },
ASSET_TYPE: { key: 'asset_type', db: 'asset_type', ui: '유형' },
PURCHASE_CORP: { key: 'purchase_corp',db: 'purchase_corp', ui: '구매법인' },
PURCHASE_DATE: { key: 'purchase_date',db: 'purchase_date', ui: '구매일자' },
PURCHASE_AMOUNT:{ key: 'purchase_amount', db: 'purchase_amount', ui: '구매금액' },
PURCHASE_VENDOR:{ key: 'purchase_vendor', db: 'purchase_vendor', ui: '구매업체' },
APPROVAL_DOC: { key: 'approval_document', db: 'approval_document', ui: '품의서' },
SERVICE_TYPE: { key: 'service_type', db: 'service_type', ui: '서비스 구분' },
MANAGER_MAIN: { key: 'manager_primary', db: 'manager_primary', ui: '담당자(정)' },
MANAGER_SUB: { key: 'manager_secondary', db: 'manager_secondary', ui: '담당자(부)' },
LOCATION: { key: 'location', db: 'location', ui: '자산위치' },
LOC_DETAIL: { key: 'location_detail', db: 'location_detail', ui: '상세위치' },
LOCATION_PHOTO: { key: 'location_photo', db: 'location_photo', ui: '배치도이미지' },
LOC_X: { key: 'loc_x', db: 'loc_x', ui: '위치X' },
LOC_Y: { key: 'loc_y', db: 'loc_y', ui: '위치Y' },
MEMO: { key: 'memo', db: 'memo', ui: '메모' },
// ─── 하드웨어 상세 (Hardware) ───
HW_STATUS: { key: 'hw_status', db: 'hw_status', ui: '상태' },
MODEL_NAME: { key: 'model_name', db: 'model_name', ui: '모델명' },
ASSET_NAME: { key: 'asset_name', db: 'asset_name', ui: '자산명' },
ASSET_MFR: { key: 'asset_mfr', db: 'asset_mfr', ui: '제조사' },
CURRENT_DEPT: { key: 'current_dept', db: 'current_dept', ui: '현 사용조직' },
PREV_DEPT: { key: 'previous_dept',db: 'previous_dept', ui: '직전 사용조직' },
CURRENT_USER: { key: 'user_current', db: 'user_current', ui: '현 사용자' },
EMP_NO: { key: 'emp_no', db: 'emp_no', ui: '사번' },
USER_POSITION: { key: 'user_position', db: 'user_position', ui: '직무' },
PREV_USER: { key: 'previous_user',db: 'previous_user', ui: '직전 사용자' },
CPU: { key: 'cpu', db: 'cpu', ui: 'CPU' },
RAM: { key: 'ram', db: 'ram', ui: 'RAM' },
GPU: { key: 'gpu', db: 'gpu', ui: 'GPU' },
SSD1: { key: 'ssd_1', db: 'ssd_1', ui: 'SSD1' },
SSD2: { key: 'ssd_2', db: 'ssd_2', ui: 'SSD2' },
HDD1: { key: 'hdd_1', db: 'hdd_1', ui: 'HDD1' },
HDD2: { key: 'hdd_2', db: 'hdd_2', ui: 'HDD2' },
HDD3: { key: 'hdd_3', db: 'hdd_3', ui: 'HDD3' },
HDD4: { key: 'hdd_4', db: 'hdd_4', ui: 'HDD4' },
MAINBOARD: { key: 'mainboard', db: 'mainboard', ui: '메인보드' },
OS: { key: 'os', db: 'os', ui: 'OS' },
IP_ADDR: { key: 'ip_address', db: 'ip_address', ui: 'IP 주소' },
IP_ADDR2: { key: 'ip_address_2', db: 'ip_address_2', ui: 'IP 주소 2' },
MAC_ADDR: { key: 'mac_address', db: 'mac_address', ui: 'MAC 주소' },
REMOTE_TOOL: { key: 'remote_tool', db: 'remote_tool', ui: '원격도구' },
REMOTE_ID: { key: 'remote_id', db: 'remote_id', ui: '원격 ID' },
REMOTE_PW: { key: 'remote_pw', db: 'remote_pw', ui: '원격 PW' },
MONITORING: { key: 'monitoring', db: 'monitoring', ui: '모니터링' },
VOLUME: { key: 'volume', db: 'volume', ui: '용량' },
MONITOR_INCH: { key: 'monitor_inch', db: 'monitor_inch', ui: '인치' },
ASSET_COUNT: { key: 'asset_count', db: 'asset_count', ui: '수량' },
SERIAL_NUM: { key: 'serial_num', db: 'serial_num', ui: 'S/N' },
// ─── 소프트웨어/클라우드 상세 (SW/Cloud/Domain) ───
SW_STATUS: { key: 'sw_status', db: 'sw_status', ui: '상태' },
SW_FIELD: { key: 'sw_field', db: 'sw_field', ui: '분야' },
SW_TYPE: { key: 'sw_type', db: 'sw_type', ui: '유형' },
DEV_OBJ: { key: 'dev_objective',db: 'dev_objective', ui: '목적' },
DEV_MGR: { key: 'dev_manager', db: 'dev_manager', ui: '개발담당자' },
PLANNING_MGR: { key: 'planning_manager', db: 'planning_manager', ui: '기획담당자' },
SALES_MGR: { key: 'sales_manager',db: 'sales_manager', ui: '영업담당자' },
PRODUCT_NAME: { key: 'product_name', db: 'product_name', ui: '제품명' },
DOMAIN_ADDR: { key: 'domain_address', db: 'domain_address',ui: '도메인주소' },
EMAIL_ACCOUNT: { key: 'email_account', db: 'email_account', ui: '이메일주소' },
EMAIL_PW: { key: 'email_pw', db: 'email_pw', ui: '이메일비밀번호' },
SW_ID: { key: 'sw_id', db: 'sw_id', ui: '계정ID' },
SW_PW: { key: 'sw_pw', db: 'sw_pw', ui: '비밀번호' },
PURCHASE_METHOD:{ key: 'purchase_method', db: 'purchase_method', ui: '결제수단' },
ASSET_PURPOSE: { key: 'asset_purpose', db: 'asset_purpose', ui: '용도' },
ASSET_STATUS: { key: 'asset_status', db: 'asset_status', ui: '상태' },
START_DATE: { key: 'start_date', db: 'start_date', ui: '시작일' },
EXPIRED_DATE: { key: 'expired_date', db: 'expired_date', ui: '만료일' }
};
/**
* 페이지별 헤더 정보 (타이틀, 설명, 아이콘)
*/
export const PAGE_DESCRIPTIONS: Record<string, { title: string; description: string; icon: string }> = {
'PC': {
title: '개인PC 자산 관리',
description: '임직원에게 지급된 데스크톱 및 노트북 자산의 할당 현황과 하드웨어 사양을 통합 관리합니다.',
icon: 'laptop'
},
'서버': {
title: '서버 자산 관리',
description: 'IDC 및 사내 서버실에 운영 중인 물리 서버 장비의 도입, 운영, 폐기 현황을 관리합니다.',
icon: 'server'
},
'스토리지': {
title: '스토리지 자산 관리',
description: '데이터 저장 및 백업을 위한 NAS, DAS 등 스토리지 장비의 용량과 연결 상태를 관리합니다.',
icon: 'database'
},
'네트워크': {
title: '네트워크 장비 관리',
description: '스위치, 방화벽, 공유기 등 사내 네트워크 인프라를 구성하는 주요 장비 현황을 관리합니다.',
icon: 'layers'
},
'업무지원장비': {
title: '업무 지원 장비 관리',
description: '모니터, 프린터, 스캐너 등 원활한 업무 수행을 보조하는 전산 비품들을 관리합니다.',
icon: 'monitor'
},
'PC부품': {
title: 'PC 부품 자산 관리',
description: 'CPU, RAM, GPU 등 PC 조립 및 유지보수를 위해 보유 중인 주요 부품 재고를 관리합니다.',
icon: 'cpu'
},
'공간정보장비': {
title: '공간 정보 장비 관리',
description: '측량 및 공간 정보 수집에 사용되는 특수 정밀 장비들의 이력과 상태를 관리합니다.',
icon: 'map'
},
'내부SW': {
title: '사내 개발 S/W 관리',
description: '사내에서 자체 개발하거나 운영 중인 시스템 및 소프트웨어 서비스 현황을 관리합니다.',
icon: 'code'
},
'외부SW': {
title: '외부 상용 S/W 관리',
description: '상용 소프트웨어의 라이선스 보유 현황, 사용자 할당 및 만료 일정을 관리합니다.',
icon: 'package'
},
'도메인': {
title: '도메인 자산 관리',
description: '운영 중인 서비스 도메인의 등록 정보, 관리 업체 및 갱신 만료일을 관리합니다.',
icon: 'globe'
},
'클라우드': {
title: '클라우드 자산 관리',
description: 'AWS, Azure, GCP 등 클라우드 인프라 자원 및 구독 서비스 이용 현황을 관리합니다.',
icon: 'cloud'
},
'비용관리': {
title: 'IT 비용 집행 관리',
description: '전산 자산 도입 및 유지보수에 소요되는 정기/비정기 지출 비용을 통합 관리합니다.',
icon: 'credit-card'
},
'선물': {
title: '내빈/외빈 선물 관리',
description: '내외빈 방문 시 지급되는 기념품 및 선물용 자산의 재고와 지급 이력을 관리합니다.',
icon: 'gift'
},
'사무가구': {
title: '사무용 가구 관리',
description: '책상, 의자, 캐비닛 등 사무 환경 구성을 위한 가구 자산의 배치 현황을 관리합니다.',
icon: 'armchair'
},
'사용자': {
title: '임직원 사용자 관리',
description: 'IT 자산 할당 및 관리의 기준이 되는 사내 임직원(사용자) 정보를 데이터베이스 기반으로 직접 등록하고 수정합니다.',
icon: 'users'
},
'부품 마스터': {
title: '부품 표준 정보 관리',
description: 'PC 사양 적정성 평가의 기준이 되는 부품 표준 정보 및 등급별 감점 점수를 관리합니다.',
icon: 'cpu'
},
'직무별 기준 사양': {
title: '직무별 기준 사양 관리',
description: 'BIM 모델러, 개발자, 엔지니어 등 사내 직무별 권장 하드웨어 기준 및 성능 합격 점수를 관리합니다.',
icon: 'sliders'
}
};
/**
* 용어 사전 (UI 텍스트 전용)
*/
export const UI_TEXT = {
ACTION: {
ADD: '신규 등록',
EDIT: '수정',
SAVE: '저장',
DELETE: '삭제',
CANCEL: '취소',
CLOSE: '닫기',
HISTORY_ADD: '이력 추가',
RESET_FILTER: '필터 초기화'
},
MESSAGES: {
CONFIRM_DELETE: '정말로 삭제하시겠습니까?',
SAVE_SUCCESS: '성공적으로 저장되었습니다.',
NO_DATA: '검색 결과가 없습니다.'
}
};
/**
* ITAM 통합 스키마 매퍼 (Unified Schema Mapper)
*
* key: 애플리케이션 내부 로직에서 사용하는 속성명
* db: MySQL 데이터베이스 컬럼명
* ui: 사용자에게 보여지는 UI 레이블
*/
export const ASSET_SCHEMA = {
// ─── 공통 필드 (Common) ───
ID: { key: 'id', db: 'id', ui: 'ID' },
ASSET_CODE: { key: 'asset_code', db: 'asset_code', ui: '자산번호' },
CATEGORY: { key: 'category', db: 'category', ui: '구분' },
ASSET_TYPE: { key: 'asset_type', db: 'asset_type', ui: '유형' },
PURCHASE_CORP: { key: 'purchase_corp',db: 'purchase_corp', ui: '구매법인' },
PURCHASE_DATE: { key: 'purchase_date',db: 'purchase_date', ui: '구매일자' },
PURCHASE_AMOUNT:{ key: 'purchase_amount', db: 'purchase_amount', ui: '구매금액' },
PURCHASE_VENDOR:{ key: 'purchase_vendor', db: 'purchase_vendor', ui: '구매업체' },
APPROVAL_DOC: { key: 'approval_document', db: 'approval_document', ui: '품의서' },
SERVICE_TYPE: { key: 'service_type', db: 'service_type', ui: '서비스 구분' },
MANAGER_MAIN: { key: 'manager_primary', db: 'manager_primary', ui: '담당자(정)' },
MANAGER_SUB: { key: 'manager_secondary', db: 'manager_secondary', ui: '담당자(부)' },
LOCATION: { key: 'location', db: 'location', ui: '자산위치' },
LOC_DETAIL: { key: 'location_detail', db: 'location_detail', ui: '상세위치' },
LOCATION_PHOTO: { key: 'location_photo', db: 'location_photo', ui: '배치도이미지' },
LOC_X: { key: 'loc_x', db: 'loc_x', ui: '위치X' },
LOC_Y: { key: 'loc_y', db: 'loc_y', ui: '위치Y' },
MEMO: { key: 'memo', db: 'memo', ui: '메모' },
// ─── 하드웨어 상세 (Hardware) ───
HW_STATUS: { key: 'hw_status', db: 'hw_status', ui: '상태' },
MODEL_NAME: { key: 'model_name', db: 'model_name', ui: '모델명' },
ASSET_NAME: { key: 'asset_name', db: 'asset_name', ui: '자산명' },
ASSET_MFR: { key: 'asset_mfr', db: 'asset_mfr', ui: '제조사' },
CURRENT_DEPT: { key: 'current_dept', db: 'current_dept', ui: '현 사용조직' },
PREV_DEPT: { key: 'previous_dept',db: 'previous_dept', ui: '직전 사용조직' },
CURRENT_USER: { key: 'user_current', db: 'user_current', ui: '현 사용자' },
EMP_NO: { key: 'emp_no', db: 'emp_no', ui: '사번' },
USER_POSITION: { key: 'user_position', db: 'user_position', ui: '직무' },
PREV_USER: { key: 'previous_user',db: 'previous_user', ui: '직전 사용자' },
CPU: { key: 'cpu', db: 'cpu', ui: 'CPU' },
RAM: { key: 'ram', db: 'ram', ui: 'RAM' },
GPU: { key: 'gpu', db: 'gpu', ui: 'GPU' },
SSD1: { key: 'ssd_1', db: 'ssd_1', ui: 'SSD1' },
SSD2: { key: 'ssd_2', db: 'ssd_2', ui: 'SSD2' },
HDD1: { key: 'hdd_1', db: 'hdd_1', ui: 'HDD1' },
HDD2: { key: 'hdd_2', db: 'hdd_2', ui: 'HDD2' },
HDD3: { key: 'hdd_3', db: 'hdd_3', ui: 'HDD3' },
HDD4: { key: 'hdd_4', db: 'hdd_4', ui: 'HDD4' },
MAINBOARD: { key: 'mainboard', db: 'mainboard', ui: '메인보드' },
OS: { key: 'os', db: 'os', ui: 'OS' },
IP_ADDR: { key: 'ip_address', db: 'ip_address', ui: 'IP 주소' },
IP_ADDR2: { key: 'ip_address_2', db: 'ip_address_2', ui: 'IP 주소 2' },
MAC_ADDR: { key: 'mac_address', db: 'mac_address', ui: 'MAC 주소' },
REMOTE_TOOL: { key: 'remote_tool', db: 'remote_tool', ui: '원격도구' },
REMOTE_ID: { key: 'remote_id', db: 'remote_id', ui: '원격 ID' },
REMOTE_PW: { key: 'remote_pw', db: 'remote_pw', ui: '원격 PW' },
MONITORING: { key: 'monitoring', db: 'monitoring', ui: '모니터링' },
VOLUME: { key: 'volume', db: 'volume', ui: '용량' },
MONITOR_INCH: { key: 'monitor_inch', db: 'monitor_inch', ui: '인치' },
ASSET_COUNT: { key: 'asset_count', db: 'asset_count', ui: '수량' },
SERIAL_NUM: { key: 'serial_num', db: 'serial_num', ui: 'S/N' },
// ─── 소프트웨어/클라우드 상세 (SW/Cloud/Domain) ───
SW_STATUS: { key: 'sw_status', db: 'sw_status', ui: '상태' },
SW_FIELD: { key: 'sw_field', db: 'sw_field', ui: '분야' },
SW_TYPE: { key: 'sw_type', db: 'sw_type', ui: '유형' },
DEV_OBJ: { key: 'dev_objective',db: 'dev_objective', ui: '목적' },
DEV_MGR: { key: 'dev_manager', db: 'dev_manager', ui: '개발담당자' },
PLANNING_MGR: { key: 'planning_manager', db: 'planning_manager', ui: '기획담당자' },
SALES_MGR: { key: 'sales_manager',db: 'sales_manager', ui: '영업담당자' },
PRODUCT_NAME: { key: 'product_name', db: 'product_name', ui: '제품명' },
DOMAIN_ADDR: { key: 'domain_address', db: 'domain_address',ui: '도메인주소' },
EMAIL_ACCOUNT: { key: 'email_account', db: 'email_account', ui: '이메일주소' },
EMAIL_PW: { key: 'email_pw', db: 'email_pw', ui: '이메일비밀번호' },
SW_ID: { key: 'sw_id', db: 'sw_id', ui: '계정ID' },
SW_PW: { key: 'sw_pw', db: 'sw_pw', ui: '비밀번호' },
PURCHASE_METHOD:{ key: 'purchase_method', db: 'purchase_method', ui: '결제수단' },
ASSET_PURPOSE: { key: 'asset_purpose', db: 'asset_purpose', ui: '용도' },
ASSET_STATUS: { key: 'asset_status', db: 'asset_status', ui: '상태' },
START_DATE: { key: 'start_date', db: 'start_date', ui: '시작일' },
EXPIRED_DATE: { key: 'expired_date', db: 'expired_date', ui: '만료일' }
};
/**
* 페이지별 헤더 정보 (타이틀, 설명, 아이콘)
*/
export const PAGE_DESCRIPTIONS: Record<string, { title: string; description: string; icon: string }> = {
'PC': {
title: '개인PC 자산 관리',
description: '임직원에게 지급된 데스크톱 및 노트북 자산의 할당 현황과 하드웨어 사양을 통합 관리합니다.',
icon: 'laptop'
},
'서버': {
title: '서버 자산 관리',
description: 'IDC 및 사내 서버실에 운영 중인 물리 서버 장비의 도입, 운영, 폐기 현황을 관리합니다.',
icon: 'server'
},
'스토리지': {
title: '스토리지 자산 관리',
description: '데이터 저장 및 백업을 위한 NAS, DAS 등 스토리지 장비의 용량과 연결 상태를 관리합니다.',
icon: 'database'
},
'네트워크': {
title: '네트워크 장비 관리',
description: '스위치, 방화벽, 공유기 등 사내 네트워크 인프라를 구성하는 주요 장비 현황을 관리합니다.',
icon: 'layers'
},
'업무지원장비': {
title: '업무 지원 장비 관리',
description: '모니터, 프린터, 스캐너 등 원활한 업무 수행을 보조하는 전산 비품들을 관리합니다.',
icon: 'monitor'
},
'PC부품': {
title: 'PC 부품 자산 관리',
description: 'CPU, RAM, GPU 등 PC 조립 및 유지보수를 위해 보유 중인 주요 부품 재고를 관리합니다.',
icon: 'cpu'
},
'공간정보장비': {
title: '공간 정보 장비 관리',
description: '측량 및 공간 정보 수집에 사용되는 특수 정밀 장비들의 이력과 상태를 관리합니다.',
icon: 'map'
},
'내부SW': {
title: '사내 개발 S/W 관리',
description: '사내에서 자체 개발하거나 운영 중인 시스템 및 소프트웨어 서비스 현황을 관리합니다.',
icon: 'code'
},
'외부SW': {
title: '외부 상용 S/W 관리',
description: '상용 소프트웨어의 라이선스 보유 현황, 사용자 할당 및 만료 일정을 관리합니다.',
icon: 'package'
},
'도메인': {
title: '도메인 자산 관리',
description: '운영 중인 서비스 도메인의 등록 정보, 관리 업체 및 갱신 만료일을 관리합니다.',
icon: 'globe'
},
'클라우드': {
title: '클라우드 자산 관리',
description: 'AWS, Azure, GCP 등 클라우드 인프라 자원 및 구독 서비스 이용 현황을 관리합니다.',
icon: 'cloud'
},
'비용관리': {
title: 'IT 비용 집행 관리',
description: '전산 자산 도입 및 유지보수에 소요되는 정기/비정기 지출 비용을 통합 관리합니다.',
icon: 'credit-card'
},
'선물': {
title: '내빈/외빈 선물 관리',
description: '내외빈 방문 시 지급되는 기념품 및 선물용 자산의 재고와 지급 이력을 관리합니다.',
icon: 'gift'
},
'사무가구': {
title: '사무용 가구 관리',
description: '책상, 의자, 캐비닛 등 사무 환경 구성을 위한 가구 자산의 배치 현황을 관리합니다.',
icon: 'armchair'
},
'사용자': {
title: '임직원 사용자 관리',
description: 'IT 자산 할당 및 관리의 기준이 되는 사내 임직원(사용자) 정보를 데이터베이스 기반으로 직접 등록하고 수정합니다.',
icon: 'users'
},
'부품 마스터': {
title: '부품 표준 정보 관리',
description: 'PC 사양 적정성 평가의 기준이 되는 부품 표준 정보 및 등급별 감점 점수를 관리합니다.',
icon: 'cpu'
},
'직무별 기준 사양': {
title: '직무별 기준 사양 관리',
description: 'BIM 모델러, 개발자, 엔지니어 등 사내 직무별 권장 하드웨어 기준 및 성능 합격 점수를 관리합니다.',
icon: 'sliders'
}
};
/**
* 용어 사전 (UI 텍스트 전용)
*/
export const UI_TEXT = {
ACTION: {
ADD: '신규 등록',
EDIT: '수정',
SAVE: '저장',
DELETE: '삭제',
CANCEL: '취소',
CLOSE: '닫기',
HISTORY_ADD: '이력 추가',
RESET_FILTER: '필터 초기화'
},
MESSAGES: {
CONFIRM_DELETE: '정말로 삭제하시겠습니까?',
SAVE_SUCCESS: '성공적으로 저장되었습니다.',
NO_DATA: '검색 결과가 없습니다.'
}
};

View File

@@ -1,293 +1,293 @@
import { HardwareAsset, SoftwareAsset, SWUser, HardwareLog, MasterAssetData, SystemUser } from './types';
import { API_BASE_URL } from './utils';
import { HardwareAsset, SoftwareAsset, SWUser, HardwareLog, MasterAssetData, SystemUser } from './types';
import { API_BASE_URL } from './utils';
// --- State Definitions ---
export interface AppState {
activeCategory: 'dashboard' | 'hw' | 'sw' | 'ops' | 'vip' | 'fac' | 'users' | 'etc';
activeSubTab: string;
viewMode: 'location' | 'legacy' | 'list';
masterData: MasterAssetData;
activeCharts: any[];
currentUserRole: 'admin' | 'user';
listFilters?: Record<string, any>;
}
// 초기 상태
export const state: AppState = {
activeCategory: 'hw',
activeSubTab: '대시보드',
viewMode: 'location',
activeCharts: [],
currentUserRole: 'user',
listFilters: {},
masterData: {
users: [],
pc: [], server: [], storage: [], network: [],
survey: [], pcParts: [], partsMaster: [], equipment: [], officeSupplies: [],
swInternal: [], swExternal: [], cloud: [], domain: [],
cost: [], vip: [],
hw: [], sw: [],
swUsers: [], logs: [],
jobSpecs: [],
mobile: []
}
};
(window as any).__itam_state = state;
/**
* 통합 V2 스키마에 맞춘 데이터 로드
*/
export async function loadMasterDataFromDB() {
try {
const response = await fetch(`${API_BASE_URL}/api/assets/master`);
if (!response.ok) throw new Error('Failed to fetch master data');
const data = await response.json();
// DB의 쪼개진 asset_remote 데이터로부터 가상 대표 속성(IP, MAC, 원격도구)을 주입해주는 전처리 함수
const preprocessAssets = (assets: any[]) => {
if (!Array.isArray(assets)) return;
assets.forEach((asset: any) => {
let ip = '';
let mac = '';
let remoteTool = '';
let remoteId = '';
// --- State Definitions ---
export interface AppState {
activeCategory: 'dashboard' | 'hw' | 'sw' | 'ops' | 'vip' | 'fac' | 'users' | 'etc';
activeSubTab: string;
viewMode: 'location' | 'legacy' | 'list';
masterData: MasterAssetData;
activeCharts: any[];
currentUserRole: 'admin' | 'user';
listFilters?: Record<string, any>;
}
// 초기 상태
export const state: AppState = {
activeCategory: 'hw',
activeSubTab: '대시보드',
viewMode: 'location',
activeCharts: [],
currentUserRole: 'user',
listFilters: {},
masterData: {
users: [],
pc: [], server: [], storage: [], network: [],
survey: [], pcParts: [], partsMaster: [], equipment: [], officeSupplies: [],
swInternal: [], swExternal: [], cloud: [], domain: [],
cost: [], vip: [],
hw: [], sw: [],
swUsers: [], logs: [],
jobSpecs: [],
mobile: []
}
};
(window as any).__itam_state = state;
/**
* 통합 V2 스키마에 맞춘 데이터 로드
*/
export async function loadMasterDataFromDB() {
try {
const response = await fetch(`${API_BASE_URL}/api/assets/master`);
if (!response.ok) throw new Error('Failed to fetch master data');
const data = await response.json();
// DB의 쪼개진 asset_remote 데이터로부터 가상 대표 속성(IP, MAC, 원격도구)을 주입해주는 전처리 함수
const preprocessAssets = (assets: any[]) => {
if (!Array.isArray(assets)) return;
assets.forEach((asset: any) => {
let ip = '';
let mac = '';
let remoteTool = '';
let remoteId = '';
let remotePw = '';
let rems: any[] = [];
try {
rems = asset.remotes ? (typeof asset.remotes === 'string' ? JSON.parse(asset.remotes) : asset.remotes) : [];
} catch(e) {}
if (Array.isArray(rems)) {
rems.forEach((r: any) => {
if (r.type === 'IP') {
if (!ip) ip = r.val1 || '';
if (r.val2) {
if (String(r.val2).trim().startsWith('{')) {
try {
const parsed = JSON.parse(r.val2);
remoteTool = r.name || '원격접속';
remoteId = parsed.id || '';
remotePw = parsed.pw || '';
} catch(e) {}
} else {
if (!mac) mac = r.val2 || '';
}
}
} else if (r.type === 'MAC') {
if (!mac) mac = r.val1 || '';
} else if (r.type === 'REMOTE') {
if (!remoteTool) remoteTool = r.name || '';
if (!remoteId) remoteId = r.val1 || '';
if (!remotePw) remotePw = r.val2 || '';
}
});
}
// 최상위 가상 속성 바인딩 (목록 및 위치보기 뷰어 매핑용)
asset.ip_address = ip;
asset.mac_address = mac;
asset.remote_tool = remoteTool;
asset.remote_id = remoteId;
asset.remote_pw = remotePw;
});
};
if (data) {
const keys = ['pc', 'server', 'storage', 'network', 'survey', 'equipment', 'officeSupplies'];
keys.forEach(k => {
if (data[k]) preprocessAssets(data[k]);
});
}
// 전역 상태 업데이트
state.masterData = {
...state.masterData,
...data,
jobSpecs: data.jobSpecs || [],
logs: (data.logs || []).map((l: any) => ({
...l,
assetId: l.asset_id || l.assetId,
date: l.log_date || l.date,
user: l.log_user || l.user,
log_date: l.log_date || l.date,
log_user: l.log_user || l.user
}))
};
// Mapping for backward compatibility
(state.masterData as any).equip = state.masterData.equipment;
(state.masterData as any).subSw = state.masterData.swExternal;
(state.masterData as any).permSw = state.masterData.swInternal;
// 하드웨어 통합 (대시보드 호환용)
state.masterData.hw = [
...state.masterData.pc,
...state.masterData.server,
...state.masterData.storage,
...state.masterData.network,
...state.masterData.survey,
...state.masterData.equipment,
...state.masterData.officeSupplies
];
// 소프트웨어 통합
state.masterData.sw = [
...state.masterData.swInternal,
...state.masterData.swExternal,
...(state.masterData.cloud || [])
];
console.log('✅ V2 Normalized data loaded successfully');
return true;
} catch (err) {
console.warn('⚠️ Dummy 로드 실패:', err);
}
return false;
}
export function updateState(newState: Partial<AppState>) {
Object.assign(state, newState);
}
/**
* 자산 저장 (V2 Normalized API)
*/
export async function saveAsset(category: string, asset: any) {
try {
const url = `${API_BASE_URL}/api/asset/${category}/save`;
const response = await fetch(url, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(asset)
});
if (response.ok) {
await loadMasterDataFromDB(); // 전역 상태 갱신
return true;
}
} catch (err) {
console.error('자산 저장 실패:', err);
}
return false;
}
/**
* 자산 삭제 (V2 API)
*/
export async function deleteAsset(category: string, assetId: string) {
try {
const url = `${API_BASE_URL}/api/asset/${category}/${assetId}`;
const response = await fetch(url, { method: 'DELETE' });
if (response.ok) {
await loadMasterDataFromDB(); // 전역 상태 갱신
return true;
}
} catch (err) {
console.error('자산 삭제 실패:', err);
}
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;
}
export async function saveJobSpec(spec: any) {
try {
const url = `${API_BASE_URL}/api/job-specs/save`;
const response = await fetch(url, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(spec)
});
if (response.ok) {
await loadMasterDataFromDB(); // 전역 상태 갱신
return true;
}
} catch (err) {
console.error('직무별 기준 사양 저장 실패:', err);
}
return false;
}
export async function deleteJobSpec(id: number) {
try {
const url = `${API_BASE_URL}/api/job-specs/${id}`;
const response = await fetch(url, { method: 'DELETE' });
if (response.ok) {
await loadMasterDataFromDB(); // 전역 상태 갱신
return true;
}
} catch (err) {
console.error('직무별 기준 사양 삭제 실패:', err);
}
return false;
}
let rems: any[] = [];
try {
rems = asset.remotes ? (typeof asset.remotes === 'string' ? JSON.parse(asset.remotes) : asset.remotes) : [];
} catch(e) {}
if (Array.isArray(rems)) {
rems.forEach((r: any) => {
if (r.type === 'IP') {
if (!ip) ip = r.val1 || '';
if (r.val2) {
if (String(r.val2).trim().startsWith('{')) {
try {
const parsed = JSON.parse(r.val2);
remoteTool = r.name || '원격접속';
remoteId = parsed.id || '';
remotePw = parsed.pw || '';
} catch(e) {}
} else {
if (!mac) mac = r.val2 || '';
}
}
} else if (r.type === 'MAC') {
if (!mac) mac = r.val1 || '';
} else if (r.type === 'REMOTE') {
if (!remoteTool) remoteTool = r.name || '';
if (!remoteId) remoteId = r.val1 || '';
if (!remotePw) remotePw = r.val2 || '';
}
});
}
// 최상위 가상 속성 바인딩 (목록 및 위치보기 뷰어 매핑용)
asset.ip_address = ip;
asset.mac_address = mac;
asset.remote_tool = remoteTool;
asset.remote_id = remoteId;
asset.remote_pw = remotePw;
});
};
if (data) {
const keys = ['pc', 'server', 'storage', 'network', 'survey', 'equipment', 'officeSupplies'];
keys.forEach(k => {
if (data[k]) preprocessAssets(data[k]);
});
}
// 전역 상태 업데이트
state.masterData = {
...state.masterData,
...data,
jobSpecs: data.jobSpecs || [],
logs: (data.logs || []).map((l: any) => ({
...l,
assetId: l.asset_id || l.assetId,
date: l.log_date || l.date,
user: l.log_user || l.user,
log_date: l.log_date || l.date,
log_user: l.log_user || l.user
}))
};
// Mapping for backward compatibility
(state.masterData as any).equip = state.masterData.equipment;
(state.masterData as any).subSw = state.masterData.swExternal;
(state.masterData as any).permSw = state.masterData.swInternal;
// 하드웨어 통합 (대시보드 호환용)
state.masterData.hw = [
...state.masterData.pc,
...state.masterData.server,
...state.masterData.storage,
...state.masterData.network,
...state.masterData.survey,
...state.masterData.equipment,
...state.masterData.officeSupplies
];
// 소프트웨어 통합
state.masterData.sw = [
...state.masterData.swInternal,
...state.masterData.swExternal,
...(state.masterData.cloud || [])
];
console.log('✅ V2 Normalized data loaded successfully');
return true;
} catch (err) {
console.warn('⚠️ Dummy 로드 실패:', err);
}
return false;
}
export function updateState(newState: Partial<AppState>) {
Object.assign(state, newState);
}
/**
* 자산 저장 (V2 Normalized API)
*/
export async function saveAsset(category: string, asset: any) {
try {
const url = `${API_BASE_URL}/api/asset/${category}/save`;
const response = await fetch(url, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(asset)
});
if (response.ok) {
await loadMasterDataFromDB(); // 전역 상태 갱신
return true;
}
} catch (err) {
console.error('자산 저장 실패:', err);
}
return false;
}
/**
* 자산 삭제 (V2 API)
*/
export async function deleteAsset(category: string, assetId: string) {
try {
const url = `${API_BASE_URL}/api/asset/${category}/${assetId}`;
const response = await fetch(url, { method: 'DELETE' });
if (response.ok) {
await loadMasterDataFromDB(); // 전역 상태 갱신
return true;
}
} catch (err) {
console.error('자산 삭제 실패:', err);
}
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;
}
export async function saveJobSpec(spec: any) {
try {
const url = `${API_BASE_URL}/api/job-specs/save`;
const response = await fetch(url, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(spec)
});
if (response.ok) {
await loadMasterDataFromDB(); // 전역 상태 갱신
return true;
}
} catch (err) {
console.error('직무별 기준 사양 저장 실패:', err);
}
return false;
}
export async function deleteJobSpec(id: number) {
try {
const url = `${API_BASE_URL}/api/job-specs/${id}`;
const response = await fetch(url, { method: 'DELETE' });
if (response.ok) {
await loadMasterDataFromDB(); // 전역 상태 갱신
return true;
}
} catch (err) {
console.error('직무별 기준 사양 삭제 실패:', err);
}
return false;
}

View File

@@ -1,46 +1,46 @@
/**
* 공통 테이블 핸들러
*/
export type SortDirection = 'asc' | 'desc';
export interface SortState {
key: string;
direction: SortDirection;
}
/**
* 테이블 헤더에 정렬 이벤트를 바인딩합니다.
* @param table 대상 테이블 요소
* @param currentState 현재 정렬 상태
* @param onSort 정렬 변경 시 호출될 콜백
*/
export function setupTableSorting(
table: HTMLTableElement,
currentState: SortState,
onSort: (key: string, direction: SortDirection) => void
) {
const headers = table.querySelectorAll('th[data-sort]');
headers.forEach(th => {
const key = th.getAttribute('data-sort')!;
th.classList.add('sortable');
// 현재 정렬 상태 표시
if (currentState.key === key) {
th.classList.add(currentState.direction);
} else {
th.classList.remove('asc', 'desc');
}
(th as HTMLElement).onclick = () => {
let nextDirection: SortDirection = 'asc';
if (currentState.key === key) {
nextDirection = currentState.direction === 'asc' ? 'desc' : 'asc';
}
onSort(key, nextDirection);
};
});
}
/**
* 공통 테이블 핸들러
*/
export type SortDirection = 'asc' | 'desc';
export interface SortState {
key: string;
direction: SortDirection;
}
/**
* 테이블 헤더에 정렬 이벤트를 바인딩합니다.
* @param table 대상 테이블 요소
* @param currentState 현재 정렬 상태
* @param onSort 정렬 변경 시 호출될 콜백
*/
export function setupTableSorting(
table: HTMLTableElement,
currentState: SortState,
onSort: (key: string, direction: SortDirection) => void
) {
const headers = table.querySelectorAll('th[data-sort]');
headers.forEach(th => {
const key = th.getAttribute('data-sort')!;
th.classList.add('sortable');
// 현재 정렬 상태 표시
if (currentState.key === key) {
th.classList.add(currentState.direction);
} else {
th.classList.remove('asc', 'desc');
}
(th as HTMLElement).onclick = () => {
let nextDirection: SortDirection = 'asc';
if (currentState.key === key) {
nextDirection = currentState.direction === 'asc' ? 'desc' : 'asc';
}
onSort(key, nextDirection);
};
});
}

View File

@@ -1,155 +1,155 @@
/**
* ITAM Global Type Definitions
*/
export interface BaseAsset {
id: string;
asset_code?: string;
category?: string;
asset_type?: string;
purchase_corp?: string;
purchase_date?: string;
purchase_amount?: number | string;
purchase_vendor?: string;
approval_document?: string;
service_type?: string;
manager_primary?: string;
manager_secondary?: string;
location?: string;
location_detail?: string;
location_photo?: string;
loc_x?: number;
loc_y?: number;
memo?: string;
updated_at?: string;
created_at?: string;
}
export interface HardwareAsset extends BaseAsset {
hw_status?: string;
model_name?: string;
asset_name?: string;
asset_mfr?: string;
current_dept?: string;
previous_dept?: string;
user_current?: string;
emp_no?: string;
user_position?: string;
previous_user?: string;
cpu?: string;
ram?: string;
gpu?: string;
ssd_1?: string;
ssd_2?: string;
hdd_1?: string;
hdd_2?: string;
hdd_3?: string;
hdd_4?: string;
mainboard?: string;
os?: string;
ip_address?: string;
ip_address_2?: string;
mac_address?: string;
remote_tool?: string;
remote_id?: string;
remote_pw?: string;
monitoring?: string;
volume?: string;
monitor_inch?: string;
asset_count?: number | string;
serial_num?: string;
// Normalized V3 fields
volumes?: any[];
remotes?: any[];
}
export interface SoftwareAsset extends BaseAsset {
sw_status?: string;
sw_field?: string;
sw_type?: string;
dev_objective?: string;
dev_manager?: string;
planning_manager?: string;
sales_manager?: string;
product_name?: string;
domain_address?: string;
email_account?: string;
email_pw?: string;
sw_id?: string;
sw_pw?: string;
purchase_method?: string;
asset_purpose?: string;
asset_status?: string;
start_date?: string;
expired_date?: string;
}
export interface SWUser {
id: string;
sw_id: string;
user_name: string;
dept: string;
corp: string;
emp_no?: string;
created_at?: string;
[key: string]: any;
}
export interface HardwareLog {
id: string;
asset_id: string;
log_date: string;
log_user: string;
event_type: string;
details: string;
old_dept?: string;
new_dept?: string;
old_user?: string;
new_user?: string;
created_at?: string;
}
export interface SystemUser {
id: string;
emp_no: string;
user_name: string;
dept_name: string;
position: string;
status: string;
created_at?: string;
updated_at?: string;
}
export interface PartsMaster {
id: number | string;
category: string;
component_name: string;
score_tier: string;
deduction: number;
}
export interface MasterAssetData {
users: SystemUser[];
pc: HardwareAsset[];
server: HardwareAsset[];
storage: HardwareAsset[];
network: HardwareAsset[];
survey: HardwareAsset[];
pcParts: HardwareAsset[];
partsMaster: PartsMaster[];
equipment: HardwareAsset[];
officeSupplies: HardwareAsset[];
swInternal: SoftwareAsset[];
swExternal: SoftwareAsset[];
cloud: SoftwareAsset[];
domain: SoftwareAsset[];
cost: any[];
vip: HardwareAsset[];
swUsers: SWUser[];
logs: HardwareLog[];
jobSpecs?: any[];
mobile?: HardwareAsset[];
// Integrated arrays
hw: HardwareAsset[];
sw: SoftwareAsset[];
}
/**
* ITAM Global Type Definitions
*/
export interface BaseAsset {
id: string;
asset_code?: string;
category?: string;
asset_type?: string;
purchase_corp?: string;
purchase_date?: string;
purchase_amount?: number | string;
purchase_vendor?: string;
approval_document?: string;
service_type?: string;
manager_primary?: string;
manager_secondary?: string;
location?: string;
location_detail?: string;
location_photo?: string;
loc_x?: number;
loc_y?: number;
memo?: string;
updated_at?: string;
created_at?: string;
}
export interface HardwareAsset extends BaseAsset {
hw_status?: string;
model_name?: string;
asset_name?: string;
asset_mfr?: string;
current_dept?: string;
previous_dept?: string;
user_current?: string;
emp_no?: string;
user_position?: string;
previous_user?: string;
cpu?: string;
ram?: string;
gpu?: string;
ssd_1?: string;
ssd_2?: string;
hdd_1?: string;
hdd_2?: string;
hdd_3?: string;
hdd_4?: string;
mainboard?: string;
os?: string;
ip_address?: string;
ip_address_2?: string;
mac_address?: string;
remote_tool?: string;
remote_id?: string;
remote_pw?: string;
monitoring?: string;
volume?: string;
monitor_inch?: string;
asset_count?: number | string;
serial_num?: string;
// Normalized V3 fields
volumes?: any[];
remotes?: any[];
}
export interface SoftwareAsset extends BaseAsset {
sw_status?: string;
sw_field?: string;
sw_type?: string;
dev_objective?: string;
dev_manager?: string;
planning_manager?: string;
sales_manager?: string;
product_name?: string;
domain_address?: string;
email_account?: string;
email_pw?: string;
sw_id?: string;
sw_pw?: string;
purchase_method?: string;
asset_purpose?: string;
asset_status?: string;
start_date?: string;
expired_date?: string;
}
export interface SWUser {
id: string;
sw_id: string;
user_name: string;
dept: string;
corp: string;
emp_no?: string;
created_at?: string;
[key: string]: any;
}
export interface HardwareLog {
id: string;
asset_id: string;
log_date: string;
log_user: string;
event_type: string;
details: string;
old_dept?: string;
new_dept?: string;
old_user?: string;
new_user?: string;
created_at?: string;
}
export interface SystemUser {
id: string;
emp_no: string;
user_name: string;
dept_name: string;
position: string;
status: string;
created_at?: string;
updated_at?: string;
}
export interface PartsMaster {
id: number | string;
category: string;
component_name: string;
score_tier: string;
deduction: number;
}
export interface MasterAssetData {
users: SystemUser[];
pc: HardwareAsset[];
server: HardwareAsset[];
storage: HardwareAsset[];
network: HardwareAsset[];
survey: HardwareAsset[];
pcParts: HardwareAsset[];
partsMaster: PartsMaster[];
equipment: HardwareAsset[];
officeSupplies: HardwareAsset[];
swInternal: SoftwareAsset[];
swExternal: SoftwareAsset[];
cloud: SoftwareAsset[];
domain: SoftwareAsset[];
cost: any[];
vip: HardwareAsset[];
swUsers: SWUser[];
logs: HardwareLog[];
jobSpecs?: any[];
mobile?: HardwareAsset[];
// Integrated arrays
hw: HardwareAsset[];
sw: SoftwareAsset[];
}

View File

@@ -1,359 +1,359 @@
import { PAGE_DESCRIPTIONS } from './schema';
export const API_BASE_URL = '';
/**
* ITAM 공통 유틸리티 함수
*/
/**
* 페이지 헤더(타이틀 및 설명) 렌더링
*/
export function renderPageHeader(container: HTMLElement, pageId: string) {
const config = PAGE_DESCRIPTIONS[pageId];
if (!config) return;
const header = document.createElement('div');
header.className = 'page-header';
header.innerHTML = `
<div class="page-title-group">
<h2 class="page-title">${config.title}</h2>
<p class="page-description">${config.description}</p>
</div>
`;
container.appendChild(header);
}
/**
* 숫자에 천 단위 콤마 추가 (금액 표시용)
*/
export function formatPrice(value: string | number): string {
if (value === undefined || value === null) return '';
const num = String(value).replace(/[^0-9]/g, '');
if (!num) return '';
return num.replace(/\B(?=(\d{3})+(?!\d))/g, ',');
}
/**
* HTML 배지 생성 (정/부 담당자, 원격도구 등)
*/
export function createBadge(text: string, type: 'primary' | 'muted' | 'success' | 'danger' = 'primary'): string {
return `<span class="badge badge-${type}">${text}</span>`;
}
/**
* 텍스트 내 줄바꿈을 구분자(/)로 변경하여 한 줄로 표시
*/
export function formatInline(value: any): string {
return String(value || '').replace(/\n/g, ' / ').trim();
}
/**
* 날짜 문자열 포맷팅 (YYYY.MM.DD -> YYYY-MM-DD)
*/
export function normalizeDate(dateStr: string): string {
if (!dateStr) return '';
let str = String(dateStr).replace(/\./g, '-').trim();
// YYYYMM 형식 처리 (6자리 숫자)
if (/^\d{6}$/.test(str)) {
return `${str.substring(0, 4)}-${str.substring(4, 6)}`;
}
return str;
}
/**
* 구매일로부터 현재까지의 경과 연수 계산 (소수점 첫째자리)
*/
export function calculateAssetAge(purchaseDate: string): number {
const normalized = normalizeDate(purchaseDate);
if (!normalized) return 0;
const purchase = new Date(normalized);
if (isNaN(purchase.getTime())) return 0;
const diffMs = Date.now() - purchase.getTime();
const age = diffMs / (1000 * 60 * 60 * 24 * 365.25);
return Math.max(0, parseFloat(age.toFixed(1)));
}
/**
* 고유 ID 생성 (7자리 랜덤 문자열)
*/
export function generateId(): string {
return Math.random().toString(36).substring(2, 9);
}
/**
* 두 자산 객체 간의 변경 사항 감지
*/
export function getAssetChanges(oldAsset: any, newAsset: any, fields: {key: string, label: string}[]): string {
const changes: string[] = [];
fields.forEach(field => {
const oldVal = String(oldAsset[field.key] || '').trim();
const newVal = String(newAsset[field.key] || '').trim();
if (oldVal !== newVal) {
changes.push(`${field.label}: ${oldVal || '없음'}${newVal || '없음'}`);
}
});
return changes.join('\n');
}
/**
* 자산 목록 정렬 (기본: 법인별 -> 자산번호 순)
*/
export function sortAssets<T>(list: T[]): T[] {
return [...list].sort((a: any, b: any) => {
// 1순위: 법인 (가나다순)
const corpA = String(a. || a.corp || '').trim();
const corpB = String(b. || b.corp || '').trim();
if (corpA < corpB) return -1;
if (corpA > corpB) return 1;
// 2순위: 자산번호/코드 (영문/숫자순)
const codeA = String(a. || a. || a.id || '').trim();
const codeB = String(b. || b. || b.id || '').trim();
if (codeA < codeB) return -1;
if (codeA > codeB) return 1;
return 0;
});
}
/**
* 동적 정렬 함수
* @param list 정렬할 목록
* @param key 정렬 기준 필드
* @param direction 정렬 방향 ('asc' | 'desc')
*/
export function dynamicSort<T>(list: T[], key: string, direction: 'asc' | 'desc'): T[] {
return [...list].sort((a: any, b: any) => {
let valA = a[key];
let valB = b[key];
// 숫자인 경우 처리
if (typeof valA === 'number' && typeof valB === 'number') {
return direction === 'asc' ? valA - valB : valB - valA;
}
// 금액 필드 (숫자형 문자열 포함) 처리
if (key === '금액' || key === 'price' || key === '수량' || key === 'qty') {
const numA = typeof valA === 'number' ? valA : parseInt(String(valA || '0').replace(/[^0-9-]/g, ''), 10);
const numB = typeof valB === 'number' ? valB : parseInt(String(valB || '0').replace(/[^0-9-]/g, ''), 10);
return direction === 'asc' ? numA - numB : numB - numA;
}
// 문자열 정렬 (기본)
valA = String(valA || '').toLowerCase();
valB = String(valB || '').toLowerCase();
if (valA < valB) return direction === 'asc' ? -1 : 1;
if (valA > valB) return direction === 'asc' ? 1 : -1;
return 0;
});
}
/**
* 목록 뷰용 액션 버튼 HTML 생성 (중복 제거를 위해 비워둠)
*/
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, isWin11Incompatible?: boolean): { 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' };
if (score >= 20 && !isWin11Incompatible) return { name: '보급', class: 'b-yellow', color: '#F59E0B' };
return { name: '교체 대상', class: 'badge-danger', color: '#EF4444' };
}
/**
* 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;
}
import { PAGE_DESCRIPTIONS } from './schema';
export const API_BASE_URL = '';
/**
* ITAM 공통 유틸리티 함수
*/
/**
* 페이지 헤더(타이틀 및 설명) 렌더링
*/
export function renderPageHeader(container: HTMLElement, pageId: string) {
const config = PAGE_DESCRIPTIONS[pageId];
if (!config) return;
const header = document.createElement('div');
header.className = 'page-header';
header.innerHTML = `
<div class="page-title-group">
<h2 class="page-title">${config.title}</h2>
<p class="page-description">${config.description}</p>
</div>
`;
container.appendChild(header);
}
/**
* 숫자에 천 단위 콤마 추가 (금액 표시용)
*/
export function formatPrice(value: string | number): string {
if (value === undefined || value === null) return '';
const num = String(value).replace(/[^0-9]/g, '');
if (!num) return '';
return num.replace(/\B(?=(\d{3})+(?!\d))/g, ',');
}
/**
* HTML 배지 생성 (정/부 담당자, 원격도구 등)
*/
export function createBadge(text: string, type: 'primary' | 'muted' | 'success' | 'danger' = 'primary'): string {
return `<span class="badge badge-${type}">${text}</span>`;
}
/**
* 텍스트 내 줄바꿈을 구분자(/)로 변경하여 한 줄로 표시
*/
export function formatInline(value: any): string {
return String(value || '').replace(/\n/g, ' / ').trim();
}
/**
* 날짜 문자열 포맷팅 (YYYY.MM.DD -> YYYY-MM-DD)
*/
export function normalizeDate(dateStr: string): string {
if (!dateStr) return '';
let str = String(dateStr).replace(/\./g, '-').trim();
// YYYYMM 형식 처리 (6자리 숫자)
if (/^\d{6}$/.test(str)) {
return `${str.substring(0, 4)}-${str.substring(4, 6)}`;
}
return str;
}
/**
* 구매일로부터 현재까지의 경과 연수 계산 (소수점 첫째자리)
*/
export function calculateAssetAge(purchaseDate: string): number {
const normalized = normalizeDate(purchaseDate);
if (!normalized) return 0;
const purchase = new Date(normalized);
if (isNaN(purchase.getTime())) return 0;
const diffMs = Date.now() - purchase.getTime();
const age = diffMs / (1000 * 60 * 60 * 24 * 365.25);
return Math.max(0, parseFloat(age.toFixed(1)));
}
/**
* 고유 ID 생성 (7자리 랜덤 문자열)
*/
export function generateId(): string {
return Math.random().toString(36).substring(2, 9);
}
/**
* 두 자산 객체 간의 변경 사항 감지
*/
export function getAssetChanges(oldAsset: any, newAsset: any, fields: {key: string, label: string}[]): string {
const changes: string[] = [];
fields.forEach(field => {
const oldVal = String(oldAsset[field.key] || '').trim();
const newVal = String(newAsset[field.key] || '').trim();
if (oldVal !== newVal) {
changes.push(`${field.label}: ${oldVal || '없음'}${newVal || '없음'}`);
}
});
return changes.join('\n');
}
/**
* 자산 목록 정렬 (기본: 법인별 -> 자산번호 순)
*/
export function sortAssets<T>(list: T[]): T[] {
return [...list].sort((a: any, b: any) => {
// 1순위: 법인 (가나다순)
const corpA = String(a. || a.corp || '').trim();
const corpB = String(b. || b.corp || '').trim();
if (corpA < corpB) return -1;
if (corpA > corpB) return 1;
// 2순위: 자산번호/코드 (영문/숫자순)
const codeA = String(a. || a. || a.id || '').trim();
const codeB = String(b. || b. || b.id || '').trim();
if (codeA < codeB) return -1;
if (codeA > codeB) return 1;
return 0;
});
}
/**
* 동적 정렬 함수
* @param list 정렬할 목록
* @param key 정렬 기준 필드
* @param direction 정렬 방향 ('asc' | 'desc')
*/
export function dynamicSort<T>(list: T[], key: string, direction: 'asc' | 'desc'): T[] {
return [...list].sort((a: any, b: any) => {
let valA = a[key];
let valB = b[key];
// 숫자인 경우 처리
if (typeof valA === 'number' && typeof valB === 'number') {
return direction === 'asc' ? valA - valB : valB - valA;
}
// 금액 필드 (숫자형 문자열 포함) 처리
if (key === '금액' || key === 'price' || key === '수량' || key === 'qty') {
const numA = typeof valA === 'number' ? valA : parseInt(String(valA || '0').replace(/[^0-9-]/g, ''), 10);
const numB = typeof valB === 'number' ? valB : parseInt(String(valB || '0').replace(/[^0-9-]/g, ''), 10);
return direction === 'asc' ? numA - numB : numB - numA;
}
// 문자열 정렬 (기본)
valA = String(valA || '').toLowerCase();
valB = String(valB || '').toLowerCase();
if (valA < valB) return direction === 'asc' ? -1 : 1;
if (valA > valB) return direction === 'asc' ? 1 : -1;
return 0;
});
}
/**
* 목록 뷰용 액션 버튼 HTML 생성 (중복 제거를 위해 비워둠)
*/
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, isWin11Incompatible?: boolean): { 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' };
if (score >= 20 && !isWin11Incompatible) return { name: '보급', class: 'b-yellow', color: '#F59E0B' };
return { name: '교체 대상', class: 'badge-danger', color: '#EF4444' };
}
/**
* 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;
}

View File

@@ -1,205 +1,205 @@
import './styles/common.css';
import './styles/login.css';
import { state, loadMasterDataFromDB, saveAsset } from './core/state';
import { renderNavigation } from './components/Navigation';
import { renderDashboard } from './views/DashboardView';
import { renderSWTable } from './views/SW_Table';
import { renderLocationView } from './views/LocationView';
import { initBaseModal } from './components/Modal/BaseModal';
import { initHwModal, openHwModal } from './components/Modal/HWModal';
import { initSwModal, openSwModal } from './components/Modal/SWModal';
import { initSwUserModal } from './components/Modal/SWUserModal';
import { initDomainModal, openDomainModal } from './components/Modal/DomainModal';
import { initPartsMasterModal, openPartsMasterModal } from './components/Modal/PartsMasterModal';
import { initJobSpecModal, openJobSpecModal } from './components/Modal/JobSpecModal';
import { initUserModal, openUserModal } from './components/Modal/UserModal';
import { activePartsMasterSubTab } from './views/List/PartsMasterListView';
import { initDashboardDetailModal } from './components/Modal/DashboardDetailModal';
import { initGuide } from './components/Guide';
import { pcFlowModal } from './components/Modal/PCFlowModal';
import { createIcons, Plus, X, LayoutDashboard, Monitor, Server, Database, Laptop, CalendarClock, Key, Cpu, Layers, Users, Paperclip, Edit2, History, RefreshCcw, BookOpen, Settings } from 'lucide';
// 화면 갱신 통합 핸들러
function refreshView(tab?: string) {
const mainContent = document.getElementById('main-content')!;
if (!mainContent) return;
const activeTab = tab || state.activeSubTab;
if (activeTab === '대시보드') {
renderDashboard(mainContent);
return;
}
// 서버 탭이 아닐 경우에는 state.viewMode가 location이더라도 강제로 목록(list) 뷰를 그리도록 함
// (state.viewMode의 원래 상태는 보존하여, 서버 탭 복귀 시 최근 보던 모드를 유지함)
const isServerTab = activeTab === '서버';
const effectiveViewMode = isServerTab ? state.viewMode : 'list';
mainContent.innerHTML = `
<div id="view-body" class="view-container"></div>
`;
const viewBody = document.getElementById('view-body')!;
if (effectiveViewMode === 'location') {
renderLocationView(viewBody);
} else {
renderSWTable(viewBody); // 리스트 형식
}
}
// 통합 갱신 (저장은 이미 개별 모달에서 처리됨)
async function refreshAllData() {
await loadMasterDataFromDB();
refreshView();
}
// --- App Initialization ---
function initApp() {
const mainContent = document.getElementById('main-content')!;
if (!mainContent) return;
const { closeAllModals } = initBaseModal();
try {
renderNavigation((tab) => {
refreshView();
});
initHwModal(() => refreshAllData(), closeAllModals);
initSwModal(() => refreshAllData(), closeAllModals);
initSwUserModal(() => {
loadMasterDataFromDB().then(() => refreshView());
}, closeAllModals);
initDomainModal(() => refreshAllData(), closeAllModals);
initPartsMasterModal(() => refreshAllData(), closeAllModals);
initJobSpecModal(() => refreshAllData(), closeAllModals);
initUserModal(() => refreshAllData(), closeAllModals);
initDashboardDetailModal();
initGuide();
pcFlowModal.init(() => {
loadMasterDataFromDB().then(() => refreshView());
});
loadMasterDataFromDB().then((success) => {
if (success) {
refreshView();
initRoleSwitcher(); // [추가] 역할 전환 토글 초기화
}
});
} catch (e) { console.error('❌ Initialization failed:', e); }
console.log('🚀 ITAM App Multi-Table Optimized');
// --- 통합 이벤트 위임 (Dynamic Elements 지원) ---
document.addEventListener('click', (e) => {
const target = e.target as HTMLElement;
// 자산 추가
if (target.closest('#btn-add-asset')) {
const tab = state.activeSubTab;
const cat = state.activeCategory;
const newId = Math.random().toString(36).substring(2, 9);
if (cat === 'hw') {
if (tab === '부품 마스터') {
if (activePartsMasterSubTab === 'job-spec') {
openJobSpecModal({ id: '' } as any, 'add');
} else {
openPartsMasterModal({ id: '' } as any, 'add');
}
} else {
openHwModal({ id: newId, asset_code: '', category: tab } as any, 'add');
}
} else if (cat === 'sw') {
const swType = tab === '외부SW' ? '외부SW' : (tab === '내부SW' ? '내부SW' : '외부SW');
openSwModal({ id: newId, asset_type: swType } as any, 'add');
} else if (cat === 'ops') {
if (tab === '도메인') openDomainModal(null);
else if (tab === '사용자') openUserModal({ id: '' }, 'add');
}
return;
}
// 부품 마스터 탭으로 바로가기 연동
if (target.closest('#btn-goto-parts-master')) {
state.activeCategory = 'hw';
state.activeSubTab = '부품 마스터';
renderNavigation((tab) => { refreshView(); });
refreshView();
return;
}
// PC 이동/반납 모달 열기
if (target.closest('#btn-pc-flow')) {
pcFlowModal.open();
return;
}
});
createIcons({
icons: { Plus, X, LayoutDashboard, Monitor, Server, Database, Laptop, CalendarClock, Key, Cpu, Layers, Users, Paperclip, Edit2, History, RefreshCcw, BookOpen, Settings }
});
window.addEventListener('refresh-view', () => refreshView());
}
/**
* 헤더 역할 전환 토글 로직
*/
function initRoleSwitcher() {
const checkbox = document.getElementById('role-toggle-checkbox') as HTMLInputElement;
const userLabel = document.querySelector('.role-label.user');
const adminLabel = document.querySelector('.role-label.admin');
if (!checkbox || !userLabel || !adminLabel) return;
checkbox.addEventListener('change', () => {
if (checkbox.checked) {
state.currentUserRole = 'admin';
userLabel.classList.remove('active');
adminLabel.classList.add('active');
document.body.classList.add('admin-mode');
// 관리자 모드 전환 시 대시보드로 이동
state.activeCategory = 'hw';
state.activeSubTab = '대시보드';
} else {
state.currentUserRole = 'user';
adminLabel.classList.remove('active');
userLabel.classList.add('active');
document.body.classList.remove('admin-mode');
// 실무자 모드 전환 시 서버 목록으로 이동
state.activeCategory = 'hw';
state.activeSubTab = '서버';
}
// 모든 렌더링을 refreshView 하나로 통합하여 규격 유지
renderNavigation(() => refreshView());
refreshView();
});
}
/**
* 앱 초기화 (로그인 과정 없이 즉시 시작)
*/
function initializeAppDirectly() {
const loginContainer = document.getElementById('login-container');
const appLayout = document.getElementById('app-layout');
// 기본 권한 설정: 실무자 (User)
state.currentUserRole = 'user';
state.activeCategory = 'hw';
state.activeSubTab = '서버'; // 실무자 기본 탭
// 화면 전환
if (loginContainer) loginContainer.style.display = 'none';
if (appLayout) appLayout.style.display = 'flex';
// 앱 초기화 및 내비게이션(헤더 포함) 렌더링
initApp();
renderNavigation((tab) => refreshView(tab));
}
document.addEventListener('DOMContentLoaded', initializeAppDirectly);
import './styles/common.css';
import './styles/login.css';
import { state, loadMasterDataFromDB, saveAsset } from './core/state';
import { renderNavigation } from './components/Navigation';
import { renderDashboard } from './views/DashboardView';
import { renderSWTable } from './views/SW_Table';
import { renderLocationView } from './views/LocationView';
import { initBaseModal } from './components/Modal/BaseModal';
import { initHwModal, openHwModal } from './components/Modal/HWModal';
import { initSwModal, openSwModal } from './components/Modal/SWModal';
import { initSwUserModal } from './components/Modal/SWUserModal';
import { initDomainModal, openDomainModal } from './components/Modal/DomainModal';
import { initPartsMasterModal, openPartsMasterModal } from './components/Modal/PartsMasterModal';
import { initJobSpecModal, openJobSpecModal } from './components/Modal/JobSpecModal';
import { initUserModal, openUserModal } from './components/Modal/UserModal';
import { activePartsMasterSubTab } from './views/List/PartsMasterListView';
import { initDashboardDetailModal } from './components/Modal/DashboardDetailModal';
import { initGuide } from './components/Guide';
import { pcFlowModal } from './components/Modal/PCFlowModal';
import { createIcons, Plus, X, LayoutDashboard, Monitor, Server, Database, Laptop, CalendarClock, Key, Cpu, Layers, Users, Paperclip, Edit2, History, RefreshCcw, BookOpen, Settings } from 'lucide';
// 화면 갱신 통합 핸들러
function refreshView(tab?: string) {
const mainContent = document.getElementById('main-content')!;
if (!mainContent) return;
const activeTab = tab || state.activeSubTab;
if (activeTab === '대시보드') {
renderDashboard(mainContent);
return;
}
// 서버 탭이 아닐 경우에는 state.viewMode가 location이더라도 강제로 목록(list) 뷰를 그리도록 함
// (state.viewMode의 원래 상태는 보존하여, 서버 탭 복귀 시 최근 보던 모드를 유지함)
const isServerTab = activeTab === '서버';
const effectiveViewMode = isServerTab ? state.viewMode : 'list';
mainContent.innerHTML = `
<div id="view-body" class="view-container"></div>
`;
const viewBody = document.getElementById('view-body')!;
if (effectiveViewMode === 'location') {
renderLocationView(viewBody);
} else {
renderSWTable(viewBody); // 리스트 형식
}
}
// 통합 갱신 (저장은 이미 개별 모달에서 처리됨)
async function refreshAllData() {
await loadMasterDataFromDB();
refreshView();
}
// --- App Initialization ---
function initApp() {
const mainContent = document.getElementById('main-content')!;
if (!mainContent) return;
const { closeAllModals } = initBaseModal();
try {
renderNavigation((tab) => {
refreshView();
});
initHwModal(() => refreshAllData(), closeAllModals);
initSwModal(() => refreshAllData(), closeAllModals);
initSwUserModal(() => {
loadMasterDataFromDB().then(() => refreshView());
}, closeAllModals);
initDomainModal(() => refreshAllData(), closeAllModals);
initPartsMasterModal(() => refreshAllData(), closeAllModals);
initJobSpecModal(() => refreshAllData(), closeAllModals);
initUserModal(() => refreshAllData(), closeAllModals);
initDashboardDetailModal();
initGuide();
pcFlowModal.init(() => {
loadMasterDataFromDB().then(() => refreshView());
});
loadMasterDataFromDB().then((success) => {
if (success) {
refreshView();
initRoleSwitcher(); // [추가] 역할 전환 토글 초기화
}
});
} catch (e) { console.error('❌ Initialization failed:', e); }
console.log('🚀 ITAM App Multi-Table Optimized');
// --- 통합 이벤트 위임 (Dynamic Elements 지원) ---
document.addEventListener('click', (e) => {
const target = e.target as HTMLElement;
// 자산 추가
if (target.closest('#btn-add-asset')) {
const tab = state.activeSubTab;
const cat = state.activeCategory;
const newId = Math.random().toString(36).substring(2, 9);
if (cat === 'hw') {
if (tab === '부품 마스터') {
if (activePartsMasterSubTab === 'job-spec') {
openJobSpecModal({ id: '' } as any, 'add');
} else {
openPartsMasterModal({ id: '' } as any, 'add');
}
} else {
openHwModal({ id: newId, asset_code: '', category: tab } as any, 'add');
}
} else if (cat === 'sw') {
const swType = tab === '외부SW' ? '외부SW' : (tab === '내부SW' ? '내부SW' : '외부SW');
openSwModal({ id: newId, asset_type: swType } as any, 'add');
} else if (cat === 'ops') {
if (tab === '도메인') openDomainModal(null);
else if (tab === '사용자') openUserModal({ id: '' }, 'add');
}
return;
}
// 부품 마스터 탭으로 바로가기 연동
if (target.closest('#btn-goto-parts-master')) {
state.activeCategory = 'hw';
state.activeSubTab = '부품 마스터';
renderNavigation((tab) => { refreshView(); });
refreshView();
return;
}
// PC 이동/반납 모달 열기
if (target.closest('#btn-pc-flow')) {
pcFlowModal.open();
return;
}
});
createIcons({
icons: { Plus, X, LayoutDashboard, Monitor, Server, Database, Laptop, CalendarClock, Key, Cpu, Layers, Users, Paperclip, Edit2, History, RefreshCcw, BookOpen, Settings }
});
window.addEventListener('refresh-view', () => refreshView());
}
/**
* 헤더 역할 전환 토글 로직
*/
function initRoleSwitcher() {
const checkbox = document.getElementById('role-toggle-checkbox') as HTMLInputElement;
const userLabel = document.querySelector('.role-label.user');
const adminLabel = document.querySelector('.role-label.admin');
if (!checkbox || !userLabel || !adminLabel) return;
checkbox.addEventListener('change', () => {
if (checkbox.checked) {
state.currentUserRole = 'admin';
userLabel.classList.remove('active');
adminLabel.classList.add('active');
document.body.classList.add('admin-mode');
// 관리자 모드 전환 시 대시보드로 이동
state.activeCategory = 'hw';
state.activeSubTab = '대시보드';
} else {
state.currentUserRole = 'user';
adminLabel.classList.remove('active');
userLabel.classList.add('active');
document.body.classList.remove('admin-mode');
// 실무자 모드 전환 시 서버 목록으로 이동
state.activeCategory = 'hw';
state.activeSubTab = '서버';
}
// 모든 렌더링을 refreshView 하나로 통합하여 규격 유지
renderNavigation(() => refreshView());
refreshView();
});
}
/**
* 앱 초기화 (로그인 과정 없이 즉시 시작)
*/
function initializeAppDirectly() {
const loginContainer = document.getElementById('login-container');
const appLayout = document.getElementById('app-layout');
// 기본 권한 설정: 실무자 (User)
state.currentUserRole = 'user';
state.activeCategory = 'hw';
state.activeSubTab = '서버'; // 실무자 기본 탭
// 화면 전환
if (loginContainer) loginContainer.style.display = 'none';
if (appLayout) appLayout.style.display = 'flex';
// 앱 초기화 및 내비게이션(헤더 포함) 렌더링
initApp();
renderNavigation((tab) => refreshView(tab));
}
document.addEventListener('DOMContentLoaded', initializeAppDirectly);

View File

@@ -1,8 +1,8 @@
import './styles/common.css';
import './views/map-editor.css';
import { MapEditor } from './views/MapEditor';
document.addEventListener('DOMContentLoaded', () => {
const editor = new MapEditor();
editor.init();
});
import './styles/common.css';
import './views/map-editor.css';
import { MapEditor } from './views/MapEditor';
document.addEventListener('DOMContentLoaded', () => {
const editor = new MapEditor();
editor.init();
});

File diff suppressed because it is too large Load Diff

View File

@@ -1,115 +1,115 @@
/* Login Screen Styles */
.login-layout {
display: flex;
align-items: center;
justify-content: center;
min-height: 100vh;
background-color: var(--bg-color);
padding: 1.5rem;
}
.login-card {
width: 100%;
max-width: 500px;
background-color: var(--white);
border: 1px solid var(--border-color);
border-radius: 12px;
padding: 3rem;
box-shadow: 0 4px 30px rgba(0, 0, 0, 0.08);
animation: slideUp 0.4s ease-out;
}
@keyframes slideUp {
from { opacity: 0; transform: translateY(10px); }
to { opacity: 1; transform: translateY(0); }
}
.login-header {
text-align: center;
margin-bottom: 2.5rem;
}
.login-logo {
height: 52px;
margin-bottom: 1.25rem;
}
.login-header h2 {
font-size: 2.33rem;
font-weight: 900;
color: var(--text-main);
margin-bottom: 0.5rem;
}
.login-header p {
font-size: 1.25rem;
color: var(--text-muted);
}
.login-selection {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 1.5rem;
}
.role-card {
display: flex;
flex-direction: column;
align-items: center;
text-align: center;
padding: 2rem 1.5rem;
border: 2px solid var(--bg-light);
border-radius: 10px;
cursor: pointer;
transition: all 0.2s cubic-bezier(0.4, 0, 0.2, 1);
background-color: var(--bg-light);
}
.role-card:hover {
border-color: var(--primary-color);
background-color: var(--white);
transform: translateY(-4px);
box-shadow: 0 10px 20px rgba(30, 81, 73, 0.08);
}
.role-icon {
width: 56px;
height: 56px;
background-color: var(--white);
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
margin-bottom: 1.25rem;
color: var(--primary-color);
border: 1px solid var(--border-color);
transition: all 0.2s;
}
.role-card:hover .role-icon {
background-color: var(--primary-color);
color: var(--white);
border-color: var(--primary-color);
}
.role-card h3 {
font-size: 1.5rem;
font-weight: 800;
color: var(--text-main);
margin-bottom: 0.5rem;
}
.role-card p {
font-size: 1.08rem;
color: var(--text-muted);
line-height: 1.4;
}
.login-footer {
margin-top: 3rem;
text-align: center;
font-size: 1rem;
color: var(--text-muted);
}
/* Login Screen Styles */
.login-layout {
display: flex;
align-items: center;
justify-content: center;
min-height: 100vh;
background-color: var(--bg-color);
padding: 1.5rem;
}
.login-card {
width: 100%;
max-width: 500px;
background-color: var(--white);
border: 1px solid var(--border-color);
border-radius: 12px;
padding: 3rem;
box-shadow: 0 4px 30px rgba(0, 0, 0, 0.08);
animation: slideUp 0.4s ease-out;
}
@keyframes slideUp {
from { opacity: 0; transform: translateY(10px); }
to { opacity: 1; transform: translateY(0); }
}
.login-header {
text-align: center;
margin-bottom: 2.5rem;
}
.login-logo {
height: 52px;
margin-bottom: 1.25rem;
}
.login-header h2 {
font-size: 2.33rem;
font-weight: 900;
color: var(--text-main);
margin-bottom: 0.5rem;
}
.login-header p {
font-size: 1.25rem;
color: var(--text-muted);
}
.login-selection {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 1.5rem;
}
.role-card {
display: flex;
flex-direction: column;
align-items: center;
text-align: center;
padding: 2rem 1.5rem;
border: 2px solid var(--bg-light);
border-radius: 10px;
cursor: pointer;
transition: all 0.2s cubic-bezier(0.4, 0, 0.2, 1);
background-color: var(--bg-light);
}
.role-card:hover {
border-color: var(--primary-color);
background-color: var(--white);
transform: translateY(-4px);
box-shadow: 0 10px 20px rgba(30, 81, 73, 0.08);
}
.role-icon {
width: 56px;
height: 56px;
background-color: var(--white);
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
margin-bottom: 1.25rem;
color: var(--primary-color);
border: 1px solid var(--border-color);
transition: all 0.2s;
}
.role-card:hover .role-icon {
background-color: var(--primary-color);
color: var(--white);
border-color: var(--primary-color);
}
.role-card h3 {
font-size: 1.5rem;
font-weight: 800;
color: var(--text-main);
margin-bottom: 0.5rem;
}
.role-card p {
font-size: 1.08rem;
color: var(--text-muted);
line-height: 1.4;
}
.login-footer {
margin-top: 3rem;
text-align: center;
font-size: 1rem;
color: var(--text-muted);
}

File diff suppressed because it is too large Load Diff

View File

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

File diff suppressed because it is too large Load Diff

View File

@@ -1,37 +1,37 @@
import { state } from '../core/state';
import { renderHwDashboard } from './Dashboard/HwDashboard';
import { renderSwDashboard } from './Dashboard/SwDashboard';
import './Dashboard/dashboard.css';
/**
* 대시보드 렌더링 통합 허브 (Vercel Style Normalized)
*/
export function renderDashboard(mainContent: HTMLElement) {
if (!mainContent) return;
// 기존 차트 리소스 해제
if (state.activeCharts) {
state.activeCharts.forEach((c: any) => {
if (c && typeof c.destroy === 'function') c.destroy();
});
}
state.activeCharts = [];
mainContent.innerHTML = `
<div class="view-content-wrapper">
<div id="dashboard-scroll-container" class="table-container" style="padding: 0;">
<div id="dashboard-inner-content"></div>
</div>
</div>
`;
const innerContent = document.getElementById('dashboard-inner-content')!;
if (state.activeCategory === 'hw') {
renderHwDashboard(innerContent);
} else if (state.activeCategory === 'sw') {
renderSwDashboard(innerContent);
} else {
innerContent.innerHTML = `<div class="dashboard-section-title" style="padding:2rem;">해당 카테고리의 대시보드는 준비 중입니다.</div>`;
}
}
import { state } from '../core/state';
import { renderHwDashboard } from './Dashboard/HwDashboard';
import { renderSwDashboard } from './Dashboard/SwDashboard';
import './Dashboard/dashboard.css';
/**
* 대시보드 렌더링 통합 허브 (Vercel Style Normalized)
*/
export function renderDashboard(mainContent: HTMLElement) {
if (!mainContent) return;
// 기존 차트 리소스 해제
if (state.activeCharts) {
state.activeCharts.forEach((c: any) => {
if (c && typeof c.destroy === 'function') c.destroy();
});
}
state.activeCharts = [];
mainContent.innerHTML = `
<div class="view-content-wrapper">
<div id="dashboard-scroll-container" class="table-container" style="padding: 0;">
<div id="dashboard-inner-content"></div>
</div>
</div>
`;
const innerContent = document.getElementById('dashboard-inner-content')!;
if (state.activeCategory === 'hw') {
renderHwDashboard(innerContent);
} else if (state.activeCategory === 'sw') {
renderSwDashboard(innerContent);
} else {
innerContent.innerHTML = `<div class="dashboard-section-title" style="padding:2rem;">해당 카테고리의 대시보드는 준비 중입니다.</div>`;
}
}

View File

@@ -1,33 +1,33 @@
import { state } from '../../core/state';
import { openSwModal } from '../../components/Modal/SWModal';
import { formatInline } from '../../core/utils';
import { ASSET_SCHEMA } from '../../core/schema';
import { createListView } from './ListFactory';
export function renderCloudList(container: HTMLElement) {
createListView(container, {
title: '클라우드',
dataSource: () => state.masterData.cloud || [],
searchKeys: ['PRODUCT_NAME', 'ASSET_PURPOSE', 'PURCHASE_VENDOR', 'ASSET_TYPE'],
filterOptions: {
keywordLabel: `통합 검색 (${ASSET_SCHEMA.PRODUCT_NAME.ui}/${ASSET_SCHEMA.PURCHASE_VENDOR.ui})`,
showCorp: true,
showDept: true,
showType: true
},
onRowClick: (asset) => openSwModal(asset, 'view'),
columns: [
{ header: ASSET_SCHEMA.PRODUCT_NAME.ui, sortKey: ASSET_SCHEMA.PRODUCT_NAME.key, render: a => a[ASSET_SCHEMA.PRODUCT_NAME.key] || '' },
{ header: ASSET_SCHEMA.ASSET_PURPOSE.ui, sortKey: ASSET_SCHEMA.ASSET_PURPOSE.key, render: a => a[ASSET_SCHEMA.ASSET_PURPOSE.key] || '' },
{ header: ASSET_SCHEMA.PURCHASE_VENDOR.ui, sortKey: ASSET_SCHEMA.PURCHASE_VENDOR.key, render: a => a[ASSET_SCHEMA.PURCHASE_VENDOR.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.PURCHASE_AMOUNT.ui,
sortKey: ASSET_SCHEMA.PURCHASE_AMOUNT.key,
align: 'right',
render: a => `<span style="font-weight:600;">₩ ${a[ASSET_SCHEMA.PURCHASE_AMOUNT.key] ? Number(String(a[ASSET_SCHEMA.PURCHASE_AMOUNT.key]).replace(/,/g, '')).toLocaleString() : '0'}</span>`
},
{ header: ASSET_SCHEMA.MEMO.ui, sortKey: ASSET_SCHEMA.MEMO.key, className: 'col-memo', render: a => formatInline(a[ASSET_SCHEMA.MEMO.key] || '') }
]
});
}
import { state } from '../../core/state';
import { openSwModal } from '../../components/Modal/SWModal';
import { formatInline } from '../../core/utils';
import { ASSET_SCHEMA } from '../../core/schema';
import { createListView } from './ListFactory';
export function renderCloudList(container: HTMLElement) {
createListView(container, {
title: '클라우드',
dataSource: () => state.masterData.cloud || [],
searchKeys: ['PRODUCT_NAME', 'ASSET_PURPOSE', 'PURCHASE_VENDOR', 'ASSET_TYPE'],
filterOptions: {
keywordLabel: `통합 검색 (${ASSET_SCHEMA.PRODUCT_NAME.ui}/${ASSET_SCHEMA.PURCHASE_VENDOR.ui})`,
showCorp: true,
showDept: true,
showType: true
},
onRowClick: (asset) => openSwModal(asset, 'view'),
columns: [
{ header: ASSET_SCHEMA.PRODUCT_NAME.ui, sortKey: ASSET_SCHEMA.PRODUCT_NAME.key, render: a => a[ASSET_SCHEMA.PRODUCT_NAME.key] || '' },
{ header: ASSET_SCHEMA.ASSET_PURPOSE.ui, sortKey: ASSET_SCHEMA.ASSET_PURPOSE.key, render: a => a[ASSET_SCHEMA.ASSET_PURPOSE.key] || '' },
{ header: ASSET_SCHEMA.PURCHASE_VENDOR.ui, sortKey: ASSET_SCHEMA.PURCHASE_VENDOR.key, render: a => a[ASSET_SCHEMA.PURCHASE_VENDOR.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.PURCHASE_AMOUNT.ui,
sortKey: ASSET_SCHEMA.PURCHASE_AMOUNT.key,
align: 'right',
render: a => `<span style="font-weight:600;">₩ ${a[ASSET_SCHEMA.PURCHASE_AMOUNT.key] ? Number(String(a[ASSET_SCHEMA.PURCHASE_AMOUNT.key]).replace(/,/g, '')).toLocaleString() : '0'}</span>`
},
{ header: ASSET_SCHEMA.MEMO.ui, sortKey: ASSET_SCHEMA.MEMO.key, className: 'col-memo', render: a => formatInline(a[ASSET_SCHEMA.MEMO.key] || '') }
]
});
}

View File

@@ -1,36 +1,36 @@
import { state } from '../../core/state';
import { sortAssets, formatInline } from '../../core/utils';
import { ASSET_SCHEMA } from '../../core/schema';
import { createListView } from './ListFactory';
export function renderCostList(container: HTMLElement) {
createListView(container, {
title: '비용관리',
dataSource: () => sortAssets(state.masterData.cloud?.filter((a: any) => a.category === '비용관리') || []),
searchKeys: ['PRODUCT_NAME', 'MANAGER_MAIN', 'EMAIL_ACCOUNT', 'ASSET_TYPE'],
filterOptions: {
keywordLabel: `통합 검색 (${ASSET_SCHEMA.PRODUCT_NAME.ui}/${ASSET_SCHEMA.MANAGER_MAIN.ui})`,
showCorp: true,
showDept: true,
showType: true
},
onRowClick: () => alert('상세 정보 준비 중입니다.'),
columns: [
{ 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.ASSET_PURPOSE.ui, sortKey: ASSET_SCHEMA.ASSET_PURPOSE.key, render: a => formatInline(a[ASSET_SCHEMA.ASSET_PURPOSE.key] || '-') },
{ header: '현 사용자', sortKey: ASSET_SCHEMA.MANAGER_MAIN.key, align: 'center', render: a => a[ASSET_SCHEMA.MANAGER_MAIN.key] || '-' },
{
header: ASSET_SCHEMA.LOCATION.ui,
sortKey: ASSET_SCHEMA.LOCATION.key,
align: 'center',
render: a => {
const loc = a[ASSET_SCHEMA.LOCATION.key] || '';
const detail = a[ASSET_SCHEMA.LOC_DETAIL.key] || '';
return detail ? `${loc}(${detail})` : (loc || '-');
}
},
{ header: ASSET_SCHEMA.EMAIL_ACCOUNT.ui, sortKey: ASSET_SCHEMA.EMAIL_ACCOUNT.key, render: a => a[ASSET_SCHEMA.EMAIL_ACCOUNT.key] || '-' },
{ header: ASSET_SCHEMA.MEMO.ui, sortKey: ASSET_SCHEMA.MEMO.key, className: 'col-memo', render: a => formatInline(a[ASSET_SCHEMA.MEMO.key] || '-') }
]
});
}
import { state } from '../../core/state';
import { sortAssets, formatInline } from '../../core/utils';
import { ASSET_SCHEMA } from '../../core/schema';
import { createListView } from './ListFactory';
export function renderCostList(container: HTMLElement) {
createListView(container, {
title: '비용관리',
dataSource: () => sortAssets(state.masterData.cloud?.filter((a: any) => a.category === '비용관리') || []),
searchKeys: ['PRODUCT_NAME', 'MANAGER_MAIN', 'EMAIL_ACCOUNT', 'ASSET_TYPE'],
filterOptions: {
keywordLabel: `통합 검색 (${ASSET_SCHEMA.PRODUCT_NAME.ui}/${ASSET_SCHEMA.MANAGER_MAIN.ui})`,
showCorp: true,
showDept: true,
showType: true
},
onRowClick: () => alert('상세 정보 준비 중입니다.'),
columns: [
{ 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.ASSET_PURPOSE.ui, sortKey: ASSET_SCHEMA.ASSET_PURPOSE.key, render: a => formatInline(a[ASSET_SCHEMA.ASSET_PURPOSE.key] || '-') },
{ header: '현 사용자', sortKey: ASSET_SCHEMA.MANAGER_MAIN.key, align: 'center', render: a => a[ASSET_SCHEMA.MANAGER_MAIN.key] || '-' },
{
header: ASSET_SCHEMA.LOCATION.ui,
sortKey: ASSET_SCHEMA.LOCATION.key,
align: 'center',
render: a => {
const loc = a[ASSET_SCHEMA.LOCATION.key] || '';
const detail = a[ASSET_SCHEMA.LOC_DETAIL.key] || '';
return detail ? `${loc}(${detail})` : (loc || '-');
}
},
{ header: ASSET_SCHEMA.EMAIL_ACCOUNT.ui, sortKey: ASSET_SCHEMA.EMAIL_ACCOUNT.key, render: a => a[ASSET_SCHEMA.EMAIL_ACCOUNT.key] || '-' },
{ header: ASSET_SCHEMA.MEMO.ui, sortKey: ASSET_SCHEMA.MEMO.key, className: 'col-memo', render: a => formatInline(a[ASSET_SCHEMA.MEMO.key] || '-') }
]
});
}

View File

@@ -1,34 +1,34 @@
import { state } from '../../core/state';
import { openDomainModal } from '../../components/Modal/DomainModal';
import { formatInline } from '../../core/utils';
import { ASSET_SCHEMA } from '../../core/schema';
import { SortState } from '../../core/tableHandler';
import { createListView } from './ListFactory';
// 정렬 상태를 모듈 수준에서 관리하여 화면 갱신 시에도 유지되도록 함
let persistentSortState: SortState = { key: '', direction: 'asc' };
export function renderDomainList(container: HTMLElement) {
createListView(container, {
title: '도메인',
dataSource: () => state.masterData.domain || [],
searchKeys: ['DOMAIN_ADDR', 'ASSET_PURPOSE', 'PRODUCT_NAME', 'ASSET_TYPE'],
persistentSortState,
emptyMessage: '등록된 도메인 정보가 없습니다.',
filterOptions: {
keywordLabel: `통합 검색 (${ASSET_SCHEMA.DOMAIN_ADDR.ui}/${ASSET_SCHEMA.PRODUCT_NAME.ui})`,
showCorp: true,
showDept: true,
showType: true
},
onRowClick: (item) => openDomainModal(item),
columns: [
{ header: ASSET_SCHEMA.DOMAIN_ADDR.ui, sortKey: ASSET_SCHEMA.DOMAIN_ADDR.key, align: 'left', render: a => a[ASSET_SCHEMA.DOMAIN_ADDR.key] || '' },
{ header: ASSET_SCHEMA.ASSET_PURPOSE.ui, sortKey: ASSET_SCHEMA.ASSET_PURPOSE.key, align: 'left', render: a => a[ASSET_SCHEMA.ASSET_PURPOSE.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.PURCHASE_CORP.ui, sortKey: ASSET_SCHEMA.PURCHASE_CORP.key, align: 'center', render: a => a[ASSET_SCHEMA.PURCHASE_CORP.key] || '' },
{ header: ASSET_SCHEMA.EXPIRED_DATE.ui, sortKey: ASSET_SCHEMA.EXPIRED_DATE.key, align: 'center', render: a => a[ASSET_SCHEMA.EXPIRED_DATE.key] || '' },
{ header: ASSET_SCHEMA.MEMO.ui, sortKey: ASSET_SCHEMA.MEMO.key, className: 'col-memo', render: a => formatInline(a[ASSET_SCHEMA.MEMO.key] || '-') }
]
});
}
import { state } from '../../core/state';
import { openDomainModal } from '../../components/Modal/DomainModal';
import { formatInline } from '../../core/utils';
import { ASSET_SCHEMA } from '../../core/schema';
import { SortState } from '../../core/tableHandler';
import { createListView } from './ListFactory';
// 정렬 상태를 모듈 수준에서 관리하여 화면 갱신 시에도 유지되도록 함
let persistentSortState: SortState = { key: '', direction: 'asc' };
export function renderDomainList(container: HTMLElement) {
createListView(container, {
title: '도메인',
dataSource: () => state.masterData.domain || [],
searchKeys: ['DOMAIN_ADDR', 'ASSET_PURPOSE', 'PRODUCT_NAME', 'ASSET_TYPE'],
persistentSortState,
emptyMessage: '등록된 도메인 정보가 없습니다.',
filterOptions: {
keywordLabel: `통합 검색 (${ASSET_SCHEMA.DOMAIN_ADDR.ui}/${ASSET_SCHEMA.PRODUCT_NAME.ui})`,
showCorp: true,
showDept: true,
showType: true
},
onRowClick: (item) => openDomainModal(item),
columns: [
{ header: ASSET_SCHEMA.DOMAIN_ADDR.ui, sortKey: ASSET_SCHEMA.DOMAIN_ADDR.key, align: 'left', render: a => a[ASSET_SCHEMA.DOMAIN_ADDR.key] || '' },
{ header: ASSET_SCHEMA.ASSET_PURPOSE.ui, sortKey: ASSET_SCHEMA.ASSET_PURPOSE.key, align: 'left', render: a => a[ASSET_SCHEMA.ASSET_PURPOSE.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.PURCHASE_CORP.ui, sortKey: ASSET_SCHEMA.PURCHASE_CORP.key, align: 'center', render: a => a[ASSET_SCHEMA.PURCHASE_CORP.key] || '' },
{ header: ASSET_SCHEMA.EXPIRED_DATE.ui, sortKey: ASSET_SCHEMA.EXPIRED_DATE.key, align: 'center', render: a => a[ASSET_SCHEMA.EXPIRED_DATE.key] || '' },
{ header: ASSET_SCHEMA.MEMO.ui, sortKey: ASSET_SCHEMA.MEMO.key, className: 'col-memo', render: a => formatInline(a[ASSET_SCHEMA.MEMO.key] || '-') }
]
});
}

View File

@@ -1,44 +1,44 @@
import { state } from '../../core/state';
import { openHwModal } from '../../components/Modal/HWModal';
import { sortAssets, formatInline } from '../../core/utils';
import { ASSET_SCHEMA } from '../../core/schema';
import { createListView } from './ListFactory';
export function renderEquipmentList(container: HTMLElement) {
createListView(container, {
title: '업무지원장비',
dataSource: () => sortAssets(state.masterData.equipment || []),
searchKeys: ['MODEL_NAME', 'CURRENT_USER', 'ASSET_MFR', 'ASSET_TYPE'],
filterOptions: {
keywordLabel: `통합 검색 (${ASSET_SCHEMA.MODEL_NAME.ui}/${ASSET_SCHEMA.ASSET_MFR.ui})`,
showLoc: true,
showDept: true,
showType: true
},
onRowClick: (asset) => openHwModal(asset, 'view'),
columns: [
{
header: ASSET_SCHEMA.HW_STATUS.ui,
sortKey: ASSET_SCHEMA.HW_STATUS.key,
align: 'center',
render: a => `<span class="badge badge-${a[ASSET_SCHEMA.HW_STATUS.key] === '대여중' ? 'primary' : 'success'}">${a[ASSET_SCHEMA.HW_STATUS.key] || '보관중'}</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.ASSET_TYPE.ui, sortKey: ASSET_SCHEMA.ASSET_TYPE.key, align: 'center', width: '10%', render: a => a[ASSET_SCHEMA.ASSET_TYPE.key] || '-' },
{ header: ASSET_SCHEMA.ASSET_MFR.ui, sortKey: ASSET_SCHEMA.ASSET_MFR.key, align: 'center', render: a => a[ASSET_SCHEMA.ASSET_MFR.key] || '' },
{ header: ASSET_SCHEMA.MODEL_NAME.ui, sortKey: ASSET_SCHEMA.MODEL_NAME.key, render: a => formatInline(a[ASSET_SCHEMA.MODEL_NAME.key] || a. || '-') },
{ header: ASSET_SCHEMA.ASSET_COUNT.ui, sortKey: ASSET_SCHEMA.ASSET_COUNT.key, align: 'center', render: a => a[ASSET_SCHEMA.ASSET_COUNT.key] || '1' },
{
header: ASSET_SCHEMA.LOCATION.ui,
sortKey: ASSET_SCHEMA.LOCATION.key,
align: 'center',
render: a => {
const loc = a[ASSET_SCHEMA.LOCATION.key] || '';
const detail = a[ASSET_SCHEMA.LOC_DETAIL.key] || '';
return detail ? `${loc}(${detail})` : (loc || '-');
}
},
{ header: ASSET_SCHEMA.MEMO.ui, sortKey: ASSET_SCHEMA.MEMO.key, className: 'col-memo', render: a => formatInline(a[ASSET_SCHEMA.MEMO.key] || '-') }
]
});
}
import { state } from '../../core/state';
import { openHwModal } from '../../components/Modal/HWModal';
import { sortAssets, formatInline } from '../../core/utils';
import { ASSET_SCHEMA } from '../../core/schema';
import { createListView } from './ListFactory';
export function renderEquipmentList(container: HTMLElement) {
createListView(container, {
title: '업무지원장비',
dataSource: () => sortAssets(state.masterData.equipment || []),
searchKeys: ['MODEL_NAME', 'CURRENT_USER', 'ASSET_MFR', 'ASSET_TYPE'],
filterOptions: {
keywordLabel: `통합 검색 (${ASSET_SCHEMA.MODEL_NAME.ui}/${ASSET_SCHEMA.ASSET_MFR.ui})`,
showLoc: true,
showDept: true,
showType: true
},
onRowClick: (asset) => openHwModal(asset, 'view'),
columns: [
{
header: ASSET_SCHEMA.HW_STATUS.ui,
sortKey: ASSET_SCHEMA.HW_STATUS.key,
align: 'center',
render: a => `<span class="badge badge-${a[ASSET_SCHEMA.HW_STATUS.key] === '대여중' ? 'primary' : 'success'}">${a[ASSET_SCHEMA.HW_STATUS.key] || '보관중'}</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.ASSET_TYPE.ui, sortKey: ASSET_SCHEMA.ASSET_TYPE.key, align: 'center', width: '10%', render: a => a[ASSET_SCHEMA.ASSET_TYPE.key] || '-' },
{ header: ASSET_SCHEMA.ASSET_MFR.ui, sortKey: ASSET_SCHEMA.ASSET_MFR.key, align: 'center', render: a => a[ASSET_SCHEMA.ASSET_MFR.key] || '' },
{ header: ASSET_SCHEMA.MODEL_NAME.ui, sortKey: ASSET_SCHEMA.MODEL_NAME.key, render: a => formatInline(a[ASSET_SCHEMA.MODEL_NAME.key] || a. || '-') },
{ header: ASSET_SCHEMA.ASSET_COUNT.ui, sortKey: ASSET_SCHEMA.ASSET_COUNT.key, align: 'center', render: a => a[ASSET_SCHEMA.ASSET_COUNT.key] || '1' },
{
header: ASSET_SCHEMA.LOCATION.ui,
sortKey: ASSET_SCHEMA.LOCATION.key,
align: 'center',
render: a => {
const loc = a[ASSET_SCHEMA.LOCATION.key] || '';
const detail = a[ASSET_SCHEMA.LOC_DETAIL.key] || '';
return detail ? `${loc}(${detail})` : (loc || '-');
}
},
{ header: ASSET_SCHEMA.MEMO.ui, sortKey: ASSET_SCHEMA.MEMO.key, className: 'col-memo', render: a => formatInline(a[ASSET_SCHEMA.MEMO.key] || '-') }
]
});
}

View File

@@ -1,43 +1,43 @@
import { state } from '../../core/state';
import { openHwModal } from '../../components/Modal/HWModal';
import { sortAssets, formatInline } from '../../core/utils';
import { ASSET_SCHEMA } from '../../core/schema';
import { createListView } from './ListFactory';
export function renderFacilityList(container: HTMLElement) {
createListView(container, {
title: '사무가구',
dataSource: () => sortAssets(state.masterData.equipment?.filter((a: any) => a.category === '시설자산') || []),
searchKeys: ['MODEL_NAME', 'ASSET_MFR', 'ASSET_TYPE'],
filterOptions: {
keywordLabel: `통합 검색 (${ASSET_SCHEMA.MODEL_NAME.ui})`,
showLoc: true,
showDept: true,
showType: true
},
onRowClick: (asset) => openHwModal(asset, 'view'),
columns: [
{
header: ASSET_SCHEMA.HW_STATUS.ui,
sortKey: ASSET_SCHEMA.HW_STATUS.key,
align: 'center',
render: a => `<span class="badge badge-success">${a[ASSET_SCHEMA.HW_STATUS.key] || '보관중'}</span>`
},
{ 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.ASSET_MFR.ui, sortKey: ASSET_SCHEMA.ASSET_MFR.key, align: 'center', render: a => a[ASSET_SCHEMA.ASSET_MFR.key] || '' },
{ header: ASSET_SCHEMA.MODEL_NAME.ui, sortKey: ASSET_SCHEMA.MODEL_NAME.key, render: a => formatInline(a[ASSET_SCHEMA.MODEL_NAME.key] || '-') },
{
header: ASSET_SCHEMA.LOCATION.ui,
sortKey: ASSET_SCHEMA.LOCATION.key,
align: 'center',
render: a => {
const loc = a[ASSET_SCHEMA.LOCATION.key] || '';
const detail = a[ASSET_SCHEMA.LOC_DETAIL.key] || '';
return detail ? `${loc}(${detail})` : (loc || '-');
}
},
{ header: ASSET_SCHEMA.ASSET_COUNT.ui, sortKey: ASSET_SCHEMA.ASSET_COUNT.key, align: 'center', render: a => a[ASSET_SCHEMA.ASSET_COUNT.key] || '1' },
{ header: ASSET_SCHEMA.MEMO.ui, sortKey: ASSET_SCHEMA.MEMO.key, className: 'col-memo', render: a => formatInline(a[ASSET_SCHEMA.MEMO.key] || '-') }
]
});
}
import { state } from '../../core/state';
import { openHwModal } from '../../components/Modal/HWModal';
import { sortAssets, formatInline } from '../../core/utils';
import { ASSET_SCHEMA } from '../../core/schema';
import { createListView } from './ListFactory';
export function renderFacilityList(container: HTMLElement) {
createListView(container, {
title: '사무가구',
dataSource: () => sortAssets(state.masterData.equipment?.filter((a: any) => a.category === '시설자산') || []),
searchKeys: ['MODEL_NAME', 'ASSET_MFR', 'ASSET_TYPE'],
filterOptions: {
keywordLabel: `통합 검색 (${ASSET_SCHEMA.MODEL_NAME.ui})`,
showLoc: true,
showDept: true,
showType: true
},
onRowClick: (asset) => openHwModal(asset, 'view'),
columns: [
{
header: ASSET_SCHEMA.HW_STATUS.ui,
sortKey: ASSET_SCHEMA.HW_STATUS.key,
align: 'center',
render: a => `<span class="badge badge-success">${a[ASSET_SCHEMA.HW_STATUS.key] || '보관중'}</span>`
},
{ 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.ASSET_MFR.ui, sortKey: ASSET_SCHEMA.ASSET_MFR.key, align: 'center', render: a => a[ASSET_SCHEMA.ASSET_MFR.key] || '' },
{ header: ASSET_SCHEMA.MODEL_NAME.ui, sortKey: ASSET_SCHEMA.MODEL_NAME.key, render: a => formatInline(a[ASSET_SCHEMA.MODEL_NAME.key] || '-') },
{
header: ASSET_SCHEMA.LOCATION.ui,
sortKey: ASSET_SCHEMA.LOCATION.key,
align: 'center',
render: a => {
const loc = a[ASSET_SCHEMA.LOCATION.key] || '';
const detail = a[ASSET_SCHEMA.LOC_DETAIL.key] || '';
return detail ? `${loc}(${detail})` : (loc || '-');
}
},
{ header: ASSET_SCHEMA.ASSET_COUNT.ui, sortKey: ASSET_SCHEMA.ASSET_COUNT.key, align: 'center', render: a => a[ASSET_SCHEMA.ASSET_COUNT.key] || '1' },
{ header: ASSET_SCHEMA.MEMO.ui, sortKey: ASSET_SCHEMA.MEMO.key, className: 'col-memo', render: a => formatInline(a[ASSET_SCHEMA.MEMO.key] || '-') }
]
});
}

View File

@@ -1,27 +1,27 @@
import { state } from '../../core/state';
import { sortAssets, formatInline } from '../../core/utils';
import { ASSET_SCHEMA } from '../../core/schema';
import { createListView } from './ListFactory';
export function renderGiftList(container: HTMLElement) {
createListView(container, {
title: '선물',
dataSource: () => sortAssets(state.masterData.equipment?.filter((a: any) => a.category === '선물') || []),
searchKeys: ['PRODUCT_NAME', 'MODEL_NAME', 'ASSET_TYPE'],
filterOptions: {
keywordLabel: `통합 검색 (${ASSET_SCHEMA.PRODUCT_NAME.ui})`,
showCorp: true,
showDept: true,
showType: true
},
onRowClick: () => alert('상세 정보 준비 중입니다.'),
columns: [
{ header: '자산명', sortKey: ASSET_SCHEMA.PRODUCT_NAME.key, render: a => formatInline(a[ASSET_SCHEMA.PRODUCT_NAME.key] || a[ASSET_SCHEMA.MODEL_NAME.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: '구매연월', sortKey: ASSET_SCHEMA.PURCHASE_DATE.key, align: 'center', render: a => a[ASSET_SCHEMA.PURCHASE_DATE.key] || '' },
{ header: ASSET_SCHEMA.EXPIRED_DATE.ui, sortKey: ASSET_SCHEMA.EXPIRED_DATE.key, align: 'center', render: a => a[ASSET_SCHEMA.EXPIRED_DATE.key] || '' },
{ header: ASSET_SCHEMA.ASSET_COUNT.ui, sortKey: ASSET_SCHEMA.ASSET_COUNT.key, align: 'center', render: a => a[ASSET_SCHEMA.ASSET_COUNT.key] || '1' },
{ header: ASSET_SCHEMA.MEMO.ui, sortKey: ASSET_SCHEMA.MEMO.key, className: 'col-memo', render: a => formatInline(a[ASSET_SCHEMA.MEMO.key] || '-') }
]
});
}
import { state } from '../../core/state';
import { sortAssets, formatInline } from '../../core/utils';
import { ASSET_SCHEMA } from '../../core/schema';
import { createListView } from './ListFactory';
export function renderGiftList(container: HTMLElement) {
createListView(container, {
title: '선물',
dataSource: () => sortAssets(state.masterData.equipment?.filter((a: any) => a.category === '선물') || []),
searchKeys: ['PRODUCT_NAME', 'MODEL_NAME', 'ASSET_TYPE'],
filterOptions: {
keywordLabel: `통합 검색 (${ASSET_SCHEMA.PRODUCT_NAME.ui})`,
showCorp: true,
showDept: true,
showType: true
},
onRowClick: () => alert('상세 정보 준비 중입니다.'),
columns: [
{ header: '자산명', sortKey: ASSET_SCHEMA.PRODUCT_NAME.key, render: a => formatInline(a[ASSET_SCHEMA.PRODUCT_NAME.key] || a[ASSET_SCHEMA.MODEL_NAME.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: '구매연월', sortKey: ASSET_SCHEMA.PURCHASE_DATE.key, align: 'center', render: a => a[ASSET_SCHEMA.PURCHASE_DATE.key] || '' },
{ header: ASSET_SCHEMA.EXPIRED_DATE.ui, sortKey: ASSET_SCHEMA.EXPIRED_DATE.key, align: 'center', render: a => a[ASSET_SCHEMA.EXPIRED_DATE.key] || '' },
{ header: ASSET_SCHEMA.ASSET_COUNT.ui, sortKey: ASSET_SCHEMA.ASSET_COUNT.key, align: 'center', render: a => a[ASSET_SCHEMA.ASSET_COUNT.key] || '1' },
{ header: ASSET_SCHEMA.MEMO.ui, sortKey: ASSET_SCHEMA.MEMO.key, className: 'col-memo', render: a => formatInline(a[ASSET_SCHEMA.MEMO.key] || '-') }
]
});
}

File diff suppressed because it is too large Load Diff

View File

@@ -1,40 +1,40 @@
import { state } from '../../core/state';
import { openHwModal } from '../../components/Modal/HWModal';
import { sortAssets, formatInline } from '../../core/utils';
import { ASSET_SCHEMA } from '../../core/schema';
import { createListView } from './ListFactory';
export function renderMobileList(container: HTMLElement) {
createListView(container, {
title: 'PC', // Legacy support
dataSource: () => sortAssets(state.masterData.mobile || []),
searchKeys: ['MODEL_NAME', 'ASSET_TYPE'],
filterOptions: {
keywordLabel: `통합 검색 (${ASSET_SCHEMA.MODEL_NAME.ui})`,
showCorp: true,
showDept: true,
showType: true
},
onRowClick: (asset) => openHwModal(asset, 'view'),
columns: [
{ header: ASSET_SCHEMA.HW_STATUS.ui, sortKey: ASSET_SCHEMA.HW_STATUS.key, align: 'center', render: a => a[ASSET_SCHEMA.HW_STATUS.key] || '운영중' },
{ header: ASSET_SCHEMA.PURCHASE_CORP.ui, sortKey: ASSET_SCHEMA.PURCHASE_CORP.key, align: 'center', render: a => a[ASSET_SCHEMA.PURCHASE_CORP.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.MODEL_NAME.ui, sortKey: ASSET_SCHEMA.MODEL_NAME.key, render: a => a[ASSET_SCHEMA.MODEL_NAME.key] || '' },
{
header: ASSET_SCHEMA.LOCATION.ui,
sortKey: ASSET_SCHEMA.LOCATION.key,
align: 'center',
render: a => {
const loc = a[ASSET_SCHEMA.LOCATION.key] || '';
const detail = a[ASSET_SCHEMA.LOC_DETAIL.key] || '';
return detail ? `${loc}(${detail})` : (loc || '-');
}
},
{ header: ASSET_SCHEMA.PURCHASE_DATE.ui, sortKey: ASSET_SCHEMA.PURCHASE_DATE.key, align: 'center', render: a => a[ASSET_SCHEMA.PURCHASE_DATE.key] || '' },
{ header: ASSET_SCHEMA.PURCHASE_AMOUNT.ui, sortKey: ASSET_SCHEMA.PURCHASE_AMOUNT.key, align: 'right', render: a => Number(a[ASSET_SCHEMA.PURCHASE_AMOUNT.key]||0).toLocaleString() },
{ header: '담당자', align: 'center', render: a => a[ASSET_SCHEMA.MANAGER_MAIN.key] || '' },
{ header: ASSET_SCHEMA.MEMO.ui, sortKey: ASSET_SCHEMA.MEMO.key, className: 'col-memo', render: a => formatInline(a[ASSET_SCHEMA.MEMO.key] || '-') }
]
});
}
import { state } from '../../core/state';
import { openHwModal } from '../../components/Modal/HWModal';
import { sortAssets, formatInline } from '../../core/utils';
import { ASSET_SCHEMA } from '../../core/schema';
import { createListView } from './ListFactory';
export function renderMobileList(container: HTMLElement) {
createListView(container, {
title: 'PC', // Legacy support
dataSource: () => sortAssets(state.masterData.mobile || []),
searchKeys: ['MODEL_NAME', 'ASSET_TYPE'],
filterOptions: {
keywordLabel: `통합 검색 (${ASSET_SCHEMA.MODEL_NAME.ui})`,
showCorp: true,
showDept: true,
showType: true
},
onRowClick: (asset) => openHwModal(asset, 'view'),
columns: [
{ header: ASSET_SCHEMA.HW_STATUS.ui, sortKey: ASSET_SCHEMA.HW_STATUS.key, align: 'center', render: a => a[ASSET_SCHEMA.HW_STATUS.key] || '운영중' },
{ header: ASSET_SCHEMA.PURCHASE_CORP.ui, sortKey: ASSET_SCHEMA.PURCHASE_CORP.key, align: 'center', render: a => a[ASSET_SCHEMA.PURCHASE_CORP.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.MODEL_NAME.ui, sortKey: ASSET_SCHEMA.MODEL_NAME.key, render: a => a[ASSET_SCHEMA.MODEL_NAME.key] || '' },
{
header: ASSET_SCHEMA.LOCATION.ui,
sortKey: ASSET_SCHEMA.LOCATION.key,
align: 'center',
render: a => {
const loc = a[ASSET_SCHEMA.LOCATION.key] || '';
const detail = a[ASSET_SCHEMA.LOC_DETAIL.key] || '';
return detail ? `${loc}(${detail})` : (loc || '-');
}
},
{ header: ASSET_SCHEMA.PURCHASE_DATE.ui, sortKey: ASSET_SCHEMA.PURCHASE_DATE.key, align: 'center', render: a => a[ASSET_SCHEMA.PURCHASE_DATE.key] || '' },
{ header: ASSET_SCHEMA.PURCHASE_AMOUNT.ui, sortKey: ASSET_SCHEMA.PURCHASE_AMOUNT.key, align: 'right', render: a => Number(a[ASSET_SCHEMA.PURCHASE_AMOUNT.key]||0).toLocaleString() },
{ header: '담당자', align: 'center', render: a => a[ASSET_SCHEMA.MANAGER_MAIN.key] || '' },
{ header: ASSET_SCHEMA.MEMO.ui, sortKey: ASSET_SCHEMA.MEMO.key, className: 'col-memo', render: a => formatInline(a[ASSET_SCHEMA.MEMO.key] || '-') }
]
});
}

View File

@@ -1,44 +1,44 @@
import { state } from '../../core/state';
import { openHwModal } from '../../components/Modal/HWModal';
import { sortAssets, formatInline } from '../../core/utils';
import { ASSET_SCHEMA } from '../../core/schema';
import { createListView } from './ListFactory';
export function renderNetworkList(container: HTMLElement) {
createListView(container, {
title: '네트워크',
dataSource: () => sortAssets(state.masterData.network || []),
searchKeys: ['MODEL_NAME', 'CURRENT_USER', 'ASSET_MFR', 'ASSET_TYPE'],
filterOptions: {
keywordLabel: `통합 검색 (${ASSET_SCHEMA.MODEL_NAME.ui}/${ASSET_SCHEMA.ASSET_MFR.ui})`,
showLoc: true,
showDept: true,
showType: true
},
onRowClick: (asset) => openHwModal(asset, 'view'),
columns: [
{
header: ASSET_SCHEMA.HW_STATUS.ui,
sortKey: ASSET_SCHEMA.HW_STATUS.key,
align: 'center',
render: a => `<span class="badge badge-success">${a[ASSET_SCHEMA.HW_STATUS.key] || '운영중'}</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.ASSET_TYPE.ui, sortKey: ASSET_SCHEMA.ASSET_TYPE.key, align: 'center', width: '10%', render: a => a[ASSET_SCHEMA.ASSET_TYPE.key] || '-' },
{ header: ASSET_SCHEMA.ASSET_MFR.ui, sortKey: ASSET_SCHEMA.ASSET_MFR.key, align: 'center', render: a => a[ASSET_SCHEMA.ASSET_MFR.key] || '' },
{ header: ASSET_SCHEMA.MODEL_NAME.ui, sortKey: ASSET_SCHEMA.MODEL_NAME.key, render: a => formatInline(a[ASSET_SCHEMA.MODEL_NAME.key] || '-') },
{ header: ASSET_SCHEMA.ASSET_COUNT.ui, sortKey: ASSET_SCHEMA.ASSET_COUNT.key, align: 'center', render: a => a[ASSET_SCHEMA.ASSET_COUNT.key] || '1' },
{
header: ASSET_SCHEMA.LOCATION.ui,
sortKey: ASSET_SCHEMA.LOCATION.key,
align: 'center',
render: a => {
const loc = a[ASSET_SCHEMA.LOCATION.key] || '';
const detail = a[ASSET_SCHEMA.LOC_DETAIL.key] || '';
return detail ? `${loc}(${detail})` : (loc || '-');
}
},
{ header: ASSET_SCHEMA.MEMO.ui, sortKey: ASSET_SCHEMA.MEMO.key, className: 'col-memo', render: a => formatInline(a[ASSET_SCHEMA.MEMO.key] || '-') }
]
});
}
import { state } from '../../core/state';
import { openHwModal } from '../../components/Modal/HWModal';
import { sortAssets, formatInline } from '../../core/utils';
import { ASSET_SCHEMA } from '../../core/schema';
import { createListView } from './ListFactory';
export function renderNetworkList(container: HTMLElement) {
createListView(container, {
title: '네트워크',
dataSource: () => sortAssets(state.masterData.network || []),
searchKeys: ['MODEL_NAME', 'CURRENT_USER', 'ASSET_MFR', 'ASSET_TYPE'],
filterOptions: {
keywordLabel: `통합 검색 (${ASSET_SCHEMA.MODEL_NAME.ui}/${ASSET_SCHEMA.ASSET_MFR.ui})`,
showLoc: true,
showDept: true,
showType: true
},
onRowClick: (asset) => openHwModal(asset, 'view'),
columns: [
{
header: ASSET_SCHEMA.HW_STATUS.ui,
sortKey: ASSET_SCHEMA.HW_STATUS.key,
align: 'center',
render: a => `<span class="badge badge-success">${a[ASSET_SCHEMA.HW_STATUS.key] || '운영중'}</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.ASSET_TYPE.ui, sortKey: ASSET_SCHEMA.ASSET_TYPE.key, align: 'center', width: '10%', render: a => a[ASSET_SCHEMA.ASSET_TYPE.key] || '-' },
{ header: ASSET_SCHEMA.ASSET_MFR.ui, sortKey: ASSET_SCHEMA.ASSET_MFR.key, align: 'center', render: a => a[ASSET_SCHEMA.ASSET_MFR.key] || '' },
{ header: ASSET_SCHEMA.MODEL_NAME.ui, sortKey: ASSET_SCHEMA.MODEL_NAME.key, render: a => formatInline(a[ASSET_SCHEMA.MODEL_NAME.key] || '-') },
{ header: ASSET_SCHEMA.ASSET_COUNT.ui, sortKey: ASSET_SCHEMA.ASSET_COUNT.key, align: 'center', render: a => a[ASSET_SCHEMA.ASSET_COUNT.key] || '1' },
{
header: ASSET_SCHEMA.LOCATION.ui,
sortKey: ASSET_SCHEMA.LOCATION.key,
align: 'center',
render: a => {
const loc = a[ASSET_SCHEMA.LOCATION.key] || '';
const detail = a[ASSET_SCHEMA.LOC_DETAIL.key] || '';
return detail ? `${loc}(${detail})` : (loc || '-');
}
},
{ header: ASSET_SCHEMA.MEMO.ui, sortKey: ASSET_SCHEMA.MEMO.key, className: 'col-memo', render: a => formatInline(a[ASSET_SCHEMA.MEMO.key] || '-') }
]
});
}

View File

@@ -1,174 +1,174 @@
import { state } from '../../core/state';
import { openPartsMasterModal } from '../../components/Modal/PartsMasterModal';
import { openJobSpecModal } from '../../components/Modal/JobSpecModal';
import { formatInline } from '../../core/utils';
import { createListView } from './ListFactory';
export let activePartsMasterSubTab: 'parts-master' | 'job-spec' = 'parts-master';
export function renderPartsMasterList(container: HTMLElement) {
if (activePartsMasterSubTab === 'parts-master') {
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 = 'badge-primary';
else if (c.category === 'GPU') badgeClass = 'badge-success';
else if (c.category === 'RAM') badgeClass = 'badge-warning';
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>`;
}
}
]
});
} else {
createListView(container, {
title: '직무별 기준 사양',
dataSource: () => state.masterData.jobSpecs || [],
searchKeys: ['job_name', 'cpu_standard', 'ram_standard', 'gpu_standard', 'remarks'],
filterOptions: {
keywordLabel: '직무명 / 사양 검색',
showLoc: false,
showDept: false,
showType: false
},
onRowClick: (jobSpec) => openJobSpecModal(jobSpec, 'view'),
columns: [
{
header: 'ID',
sortKey: 'id',
align: 'center',
width: '5%',
render: j => j.id.toString()
},
{
header: '직무명',
sortKey: 'job_name',
width: '15%',
render: j => `<strong style="color: var(--primary-color); font-size: 14px;">${formatInline(j.job_name || '-')}</strong>`
},
{
header: '권장 CPU 사양',
sortKey: 'cpu_standard',
render: j => formatInline(j.cpu_standard || '-')
},
{
header: '권장 RAM 사양',
sortKey: 'ram_standard',
width: '12%',
render: j => formatInline(j.ram_standard || '-')
},
{
header: '권장 GPU 사양',
sortKey: 'gpu_standard',
render: j => formatInline(j.gpu_standard || '-')
},
{
header: '기준 점수',
sortKey: 'min_score',
align: 'center',
width: '10%',
render: j => `<span style="font-weight: 700;">${j.min_score || 0}점 이상</span>`
},
{
header: '비고',
sortKey: 'remarks',
width: '20%',
render: j => formatInline(j.remarks || '-')
}
]
});
}
renderSubTabs(container);
}
function renderSubTabs(container: HTMLElement) {
const header = container.querySelector('.page-header');
if (!header) return;
// 기존에 생성된 탭 바가 있다면 제거하여 중복 방지 (스타일만 수정하는 최소 침습 방식)
const existingTabs = container.querySelector('.sub-tab-container');
if (existingTabs) existingTabs.remove();
const tabContainer = document.createElement('div');
tabContainer.className = 'sub-tab-container';
tabContainer.style.cssText = 'display: flex; gap: 1rem; padding: 0 2rem; border-bottom: 1px solid var(--hairline); background: var(--canvas);';
const tab1Active = activePartsMasterSubTab === 'parts-master';
const tab2Active = activePartsMasterSubTab === 'job-spec';
tabContainer.innerHTML = `
<button id="tab-parts-master" class="sub-tab-btn ${tab1Active ? 'active' : ''}" style="padding: 1rem 0.5rem; border: none; background: none; font-size: var(--fs-sm); font-weight: 600; cursor: pointer; color: ${tab1Active ? 'var(--primary)' : 'var(--mute)'}; position: relative; border-bottom: 2px solid ${tab1Active ? 'var(--primary)' : 'transparent'}; margin-bottom: -1px;">
부품 표준 등급
</button>
<button id="tab-job-spec" class="sub-tab-btn ${tab2Active ? 'active' : ''}" style="padding: 1rem 0.5rem; border: none; background: none; font-size: var(--fs-sm); font-weight: 600; cursor: pointer; color: ${tab2Active ? 'var(--primary)' : 'var(--mute)'}; position: relative; border-bottom: 2px solid ${tab2Active ? 'var(--primary)' : 'transparent'}; margin-bottom: -1px;">
직무별 기준 사양
</button>
`;
header.parentNode!.insertBefore(tabContainer, header.nextSibling);
const tabPartsMaster = tabContainer.querySelector('#tab-parts-master')!;
const tabJobSpec = tabContainer.querySelector('#tab-job-spec')!;
tabPartsMaster.addEventListener('click', () => {
if (activePartsMasterSubTab !== 'parts-master') {
activePartsMasterSubTab = 'parts-master';
renderPartsMasterList(container);
}
});
tabJobSpec.addEventListener('click', () => {
if (activePartsMasterSubTab !== 'job-spec') {
activePartsMasterSubTab = 'job-spec';
renderPartsMasterList(container);
}
});
}
import { state } from '../../core/state';
import { openPartsMasterModal } from '../../components/Modal/PartsMasterModal';
import { openJobSpecModal } from '../../components/Modal/JobSpecModal';
import { formatInline } from '../../core/utils';
import { createListView } from './ListFactory';
export let activePartsMasterSubTab: 'parts-master' | 'job-spec' = 'parts-master';
export function renderPartsMasterList(container: HTMLElement) {
if (activePartsMasterSubTab === 'parts-master') {
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 = 'badge-primary';
else if (c.category === 'GPU') badgeClass = 'badge-success';
else if (c.category === 'RAM') badgeClass = 'badge-warning';
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>`;
}
}
]
});
} else {
createListView(container, {
title: '직무별 기준 사양',
dataSource: () => state.masterData.jobSpecs || [],
searchKeys: ['job_name', 'cpu_standard', 'ram_standard', 'gpu_standard', 'remarks'],
filterOptions: {
keywordLabel: '직무명 / 사양 검색',
showLoc: false,
showDept: false,
showType: false
},
onRowClick: (jobSpec) => openJobSpecModal(jobSpec, 'view'),
columns: [
{
header: 'ID',
sortKey: 'id',
align: 'center',
width: '5%',
render: j => j.id.toString()
},
{
header: '직무명',
sortKey: 'job_name',
width: '15%',
render: j => `<strong style="color: var(--primary-color); font-size: 14px;">${formatInline(j.job_name || '-')}</strong>`
},
{
header: '권장 CPU 사양',
sortKey: 'cpu_standard',
render: j => formatInline(j.cpu_standard || '-')
},
{
header: '권장 RAM 사양',
sortKey: 'ram_standard',
width: '12%',
render: j => formatInline(j.ram_standard || '-')
},
{
header: '권장 GPU 사양',
sortKey: 'gpu_standard',
render: j => formatInline(j.gpu_standard || '-')
},
{
header: '기준 점수',
sortKey: 'min_score',
align: 'center',
width: '10%',
render: j => `<span style="font-weight: 700;">${j.min_score || 0}점 이상</span>`
},
{
header: '비고',
sortKey: 'remarks',
width: '20%',
render: j => formatInline(j.remarks || '-')
}
]
});
}
renderSubTabs(container);
}
function renderSubTabs(container: HTMLElement) {
const header = container.querySelector('.page-header');
if (!header) return;
// 기존에 생성된 탭 바가 있다면 제거하여 중복 방지 (스타일만 수정하는 최소 침습 방식)
const existingTabs = container.querySelector('.sub-tab-container');
if (existingTabs) existingTabs.remove();
const tabContainer = document.createElement('div');
tabContainer.className = 'sub-tab-container';
tabContainer.style.cssText = 'display: flex; gap: 1rem; padding: 0 2rem; border-bottom: 1px solid var(--hairline); background: var(--canvas);';
const tab1Active = activePartsMasterSubTab === 'parts-master';
const tab2Active = activePartsMasterSubTab === 'job-spec';
tabContainer.innerHTML = `
<button id="tab-parts-master" class="sub-tab-btn ${tab1Active ? 'active' : ''}" style="padding: 1rem 0.5rem; border: none; background: none; font-size: var(--fs-sm); font-weight: 600; cursor: pointer; color: ${tab1Active ? 'var(--primary)' : 'var(--mute)'}; position: relative; border-bottom: 2px solid ${tab1Active ? 'var(--primary)' : 'transparent'}; margin-bottom: -1px;">
부품 표준 등급
</button>
<button id="tab-job-spec" class="sub-tab-btn ${tab2Active ? 'active' : ''}" style="padding: 1rem 0.5rem; border: none; background: none; font-size: var(--fs-sm); font-weight: 600; cursor: pointer; color: ${tab2Active ? 'var(--primary)' : 'var(--mute)'}; position: relative; border-bottom: 2px solid ${tab2Active ? 'var(--primary)' : 'transparent'}; margin-bottom: -1px;">
직무별 기준 사양
</button>
`;
header.parentNode!.insertBefore(tabContainer, header.nextSibling);
const tabPartsMaster = tabContainer.querySelector('#tab-parts-master')!;
const tabJobSpec = tabContainer.querySelector('#tab-job-spec')!;
tabPartsMaster.addEventListener('click', () => {
if (activePartsMasterSubTab !== 'parts-master') {
activePartsMasterSubTab = 'parts-master';
renderPartsMasterList(container);
}
});
tabJobSpec.addEventListener('click', () => {
if (activePartsMasterSubTab !== 'job-spec') {
activePartsMasterSubTab = 'job-spec';
renderPartsMasterList(container);
}
});
}

View File

@@ -1,114 +1,114 @@
import { state } from '../../core/state';
import { openHwModal } from '../../components/Modal/HWModal';
import { sortAssets, formatInline, calculatePcScoreDeductive, getPcGrade, isWindows11Incompatible } from '../../core/utils';
import { ASSET_SCHEMA } from '../../core/schema';
import { createListView } from './ListFactory';
import { SortState } from '../../core/tableHandler';
let persistentSortState: SortState = { key: 'updated_at', direction: 'desc' };
export function renderPcList(container: HTMLElement) {
createListView(container, {
title: 'PC',
persistentSortState,
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);
});
// 변경일시(updated_at) 내림차순 정렬 (최신 변경 항목이 맨 위로)
return list.sort((a: any, b: any) => {
const dateA = a.updated_at || a.created_at || '';
const dateB = b.updated_at || b.created_at || '';
if (dateA < dateB) return 1;
if (dateA > dateB) return -1;
return 0;
});
},
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 isWin11Incompatible = isWindows11Incompatible(a[ASSET_SCHEMA.CPU.key], a[ASSET_SCHEMA.RAM.key]);
const grade = getPcGrade(score, isWin11Incompatible);
return `<span class="badge ${grade.class}" title="성능 점수: ${score}점">${grade.name}</span>`;
}
}
]
});
}
import { state } from '../../core/state';
import { openHwModal } from '../../components/Modal/HWModal';
import { sortAssets, formatInline, calculatePcScoreDeductive, getPcGrade, isWindows11Incompatible } from '../../core/utils';
import { ASSET_SCHEMA } from '../../core/schema';
import { createListView } from './ListFactory';
import { SortState } from '../../core/tableHandler';
let persistentSortState: SortState = { key: 'updated_at', direction: 'desc' };
export function renderPcList(container: HTMLElement) {
createListView(container, {
title: 'PC',
persistentSortState,
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);
});
// 변경일시(updated_at) 내림차순 정렬 (최신 변경 항목이 맨 위로)
return list.sort((a: any, b: any) => {
const dateA = a.updated_at || a.created_at || '';
const dateB = b.updated_at || b.created_at || '';
if (dateA < dateB) return 1;
if (dateA > dateB) return -1;
return 0;
});
},
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 isWin11Incompatible = isWindows11Incompatible(a[ASSET_SCHEMA.CPU.key], a[ASSET_SCHEMA.RAM.key]);
const grade = getPcGrade(score, isWin11Incompatible);
return `<span class="badge ${grade.class}" title="성능 점수: ${score}점">${grade.name}</span>`;
}
}
]
});
}

View File

@@ -1,45 +1,45 @@
import { state } from '../../core/state';
import { openHwModal } from '../../components/Modal/HWModal';
import { sortAssets, formatInline } from '../../core/utils';
import { ASSET_SCHEMA } from '../../core/schema';
import { createListView } from './ListFactory';
export function renderPcPartList(container: HTMLElement) {
createListView(container, {
title: 'PC부품',
dataSource: () => sortAssets(state.masterData.equipment?.filter((a: any) => a.category === 'PC부품') || []),
searchKeys: ['MODEL_NAME', 'ASSET_TYPE'],
filterOptions: {
keywordLabel: `통합 검색 (${ASSET_SCHEMA.MODEL_NAME.ui})`,
showLoc: true,
showDept: true,
showType: true
},
onRowClick: (asset) => openHwModal(asset, 'view'),
columns: [
{
header: ASSET_SCHEMA.HW_STATUS.ui,
sortKey: ASSET_SCHEMA.HW_STATUS.key,
align: 'center',
render: a => `<span class="badge badge-success">${a[ASSET_SCHEMA.HW_STATUS.key] || '보관중'}</span>`
},
{ 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.ASSET_MFR.ui, sortKey: ASSET_SCHEMA.ASSET_MFR.key, align: 'center', render: a => a[ASSET_SCHEMA.ASSET_MFR.key] || '' },
{ header: ASSET_SCHEMA.MODEL_NAME.ui, sortKey: ASSET_SCHEMA.MODEL_NAME.key, render: a => formatInline(a[ASSET_SCHEMA.MODEL_NAME.key] || '-') },
{ header: ASSET_SCHEMA.VOLUME.ui, sortKey: ASSET_SCHEMA.VOLUME.key, align: 'center', render: a => a[ASSET_SCHEMA.VOLUME.key] || '-' },
{ header: ASSET_SCHEMA.MONITOR_INCH.ui, sortKey: ASSET_SCHEMA.MONITOR_INCH.key, align: 'center', render: a => a[ASSET_SCHEMA.MONITOR_INCH.key] || '-' },
{ header: ASSET_SCHEMA.ASSET_COUNT.ui, sortKey: ASSET_SCHEMA.ASSET_COUNT.key, align: 'center', render: a => a[ASSET_SCHEMA.ASSET_COUNT.key] || '1' },
{
header: ASSET_SCHEMA.LOCATION.ui,
sortKey: ASSET_SCHEMA.LOCATION.key,
align: 'center',
render: a => {
const loc = a[ASSET_SCHEMA.LOCATION.key] || '';
const detail = a[ASSET_SCHEMA.LOC_DETAIL.key] || '';
return detail ? `${loc}(${detail})` : (loc || '-');
}
},
{ header: ASSET_SCHEMA.MEMO.ui, sortKey: ASSET_SCHEMA.MEMO.key, className: 'col-memo', render: a => formatInline(a[ASSET_SCHEMA.MEMO.key] || '-') }
]
});
}
import { state } from '../../core/state';
import { openHwModal } from '../../components/Modal/HWModal';
import { sortAssets, formatInline } from '../../core/utils';
import { ASSET_SCHEMA } from '../../core/schema';
import { createListView } from './ListFactory';
export function renderPcPartList(container: HTMLElement) {
createListView(container, {
title: 'PC부품',
dataSource: () => sortAssets(state.masterData.equipment?.filter((a: any) => a.category === 'PC부품') || []),
searchKeys: ['MODEL_NAME', 'ASSET_TYPE'],
filterOptions: {
keywordLabel: `통합 검색 (${ASSET_SCHEMA.MODEL_NAME.ui})`,
showLoc: true,
showDept: true,
showType: true
},
onRowClick: (asset) => openHwModal(asset, 'view'),
columns: [
{
header: ASSET_SCHEMA.HW_STATUS.ui,
sortKey: ASSET_SCHEMA.HW_STATUS.key,
align: 'center',
render: a => `<span class="badge badge-success">${a[ASSET_SCHEMA.HW_STATUS.key] || '보관중'}</span>`
},
{ 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.ASSET_MFR.ui, sortKey: ASSET_SCHEMA.ASSET_MFR.key, align: 'center', render: a => a[ASSET_SCHEMA.ASSET_MFR.key] || '' },
{ header: ASSET_SCHEMA.MODEL_NAME.ui, sortKey: ASSET_SCHEMA.MODEL_NAME.key, render: a => formatInline(a[ASSET_SCHEMA.MODEL_NAME.key] || '-') },
{ header: ASSET_SCHEMA.VOLUME.ui, sortKey: ASSET_SCHEMA.VOLUME.key, align: 'center', render: a => a[ASSET_SCHEMA.VOLUME.key] || '-' },
{ header: ASSET_SCHEMA.MONITOR_INCH.ui, sortKey: ASSET_SCHEMA.MONITOR_INCH.key, align: 'center', render: a => a[ASSET_SCHEMA.MONITOR_INCH.key] || '-' },
{ header: ASSET_SCHEMA.ASSET_COUNT.ui, sortKey: ASSET_SCHEMA.ASSET_COUNT.key, align: 'center', render: a => a[ASSET_SCHEMA.ASSET_COUNT.key] || '1' },
{
header: ASSET_SCHEMA.LOCATION.ui,
sortKey: ASSET_SCHEMA.LOCATION.key,
align: 'center',
render: a => {
const loc = a[ASSET_SCHEMA.LOCATION.key] || '';
const detail = a[ASSET_SCHEMA.LOC_DETAIL.key] || '';
return detail ? `${loc}(${detail})` : (loc || '-');
}
},
{ header: ASSET_SCHEMA.MEMO.ui, sortKey: ASSET_SCHEMA.MEMO.key, className: 'col-memo', render: a => formatInline(a[ASSET_SCHEMA.MEMO.key] || '-') }
]
});
}

View File

@@ -1,46 +1,46 @@
import { state } from '../../core/state';
import { openHwModal } from '../../components/Modal/HWModal';
import { sortAssets, formatInline } from '../../core/utils';
import { ASSET_SCHEMA } from '../../core/schema';
import { createListView } from './ListFactory';
export function renderServerList(container: HTMLElement) {
createListView(container, {
title: '서버',
dataSource: () => {
const serverList = state.masterData.server || [];
const serverPcList = (state.masterData.pc || []).filter((a: any) => a.asset_type === '서버PC');
return sortAssets([...serverList, ...serverPcList]);
},
searchKeys: ['CURRENT_DEPT', 'MODEL_NAME', 'ASSET_PURPOSE', 'ASSET_TYPE'],
filterOptions: {
keywordLabel: `통합 검색 (${ASSET_SCHEMA.CURRENT_DEPT.ui}/${ASSET_SCHEMA.MODEL_NAME.ui})`,
showLoc: true,
showDept: true,
showType: true
},
onRowClick: (asset) => openHwModal(asset, 'view'),
columns: [
{ header: ASSET_SCHEMA.CURRENT_DEPT.ui, sortKey: ASSET_SCHEMA.CURRENT_DEPT.key, align: 'center', render: a => a[ASSET_SCHEMA.CURRENT_DEPT.key] || '-' },
{ header: ASSET_SCHEMA.ASSET_PURPOSE.ui, sortKey: ASSET_SCHEMA.ASSET_PURPOSE.key, align: 'center', width: '15%', render: a => formatInline(a[ASSET_SCHEMA.ASSET_PURPOSE.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: '모델/메인보드',
align: 'center',
width: '15%',
render: a => formatInline(a[ASSET_SCHEMA.MODEL_NAME.key] || a[ASSET_SCHEMA.ASSET_NAME.key] || a[ASSET_SCHEMA.MAINBOARD.key] || '-')
},
{
header: ASSET_SCHEMA.LOCATION.ui,
sortKey: ASSET_SCHEMA.LOCATION.key,
align: 'center',
render: a => {
const loc = a[ASSET_SCHEMA.LOCATION.key] || '';
const detail = a[ASSET_SCHEMA.LOC_DETAIL.key] || '';
return detail ? `${loc}(${detail})` : (loc || '-');
}
},
{ header: ASSET_SCHEMA.MEMO.ui, sortKey: ASSET_SCHEMA.MEMO.key, width: '35%', className: 'col-memo', render: a => formatInline(a[ASSET_SCHEMA.MEMO.key] || '-') }
]
});
}
import { state } from '../../core/state';
import { openHwModal } from '../../components/Modal/HWModal';
import { sortAssets, formatInline } from '../../core/utils';
import { ASSET_SCHEMA } from '../../core/schema';
import { createListView } from './ListFactory';
export function renderServerList(container: HTMLElement) {
createListView(container, {
title: '서버',
dataSource: () => {
const serverList = state.masterData.server || [];
const serverPcList = (state.masterData.pc || []).filter((a: any) => a.asset_type === '서버PC');
return sortAssets([...serverList, ...serverPcList]);
},
searchKeys: ['CURRENT_DEPT', 'MODEL_NAME', 'ASSET_PURPOSE', 'ASSET_TYPE'],
filterOptions: {
keywordLabel: `통합 검색 (${ASSET_SCHEMA.CURRENT_DEPT.ui}/${ASSET_SCHEMA.MODEL_NAME.ui})`,
showLoc: true,
showDept: true,
showType: true
},
onRowClick: (asset) => openHwModal(asset, 'view'),
columns: [
{ header: ASSET_SCHEMA.CURRENT_DEPT.ui, sortKey: ASSET_SCHEMA.CURRENT_DEPT.key, align: 'center', render: a => a[ASSET_SCHEMA.CURRENT_DEPT.key] || '-' },
{ header: ASSET_SCHEMA.ASSET_PURPOSE.ui, sortKey: ASSET_SCHEMA.ASSET_PURPOSE.key, align: 'center', width: '15%', render: a => formatInline(a[ASSET_SCHEMA.ASSET_PURPOSE.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: '모델/메인보드',
align: 'center',
width: '15%',
render: a => formatInline(a[ASSET_SCHEMA.MODEL_NAME.key] || a[ASSET_SCHEMA.ASSET_NAME.key] || a[ASSET_SCHEMA.MAINBOARD.key] || '-')
},
{
header: ASSET_SCHEMA.LOCATION.ui,
sortKey: ASSET_SCHEMA.LOCATION.key,
align: 'center',
render: a => {
const loc = a[ASSET_SCHEMA.LOCATION.key] || '';
const detail = a[ASSET_SCHEMA.LOC_DETAIL.key] || '';
return detail ? `${loc}(${detail})` : (loc || '-');
}
},
{ header: ASSET_SCHEMA.MEMO.ui, sortKey: ASSET_SCHEMA.MEMO.key, width: '35%', className: 'col-memo', render: a => formatInline(a[ASSET_SCHEMA.MEMO.key] || '-') }
]
});
}

View File

@@ -1,42 +1,42 @@
import { state } from '../../core/state';
import { openHwModal } from '../../components/Modal/HWModal';
import { sortAssets, formatInline } from '../../core/utils';
import { ASSET_SCHEMA } from '../../core/schema';
import { createListView } from './ListFactory';
export function renderSpaceInfoList(container: HTMLElement) {
createListView(container, {
title: '공간정보장비',
dataSource: () => sortAssets(state.masterData.equipment?.filter((a: any) => a.category === '공간정보장비') || []),
searchKeys: ['MODEL_NAME', 'PRODUCT_NAME', 'CURRENT_USER', 'ASSET_TYPE'],
filterOptions: {
keywordLabel: `통합 검색 (${ASSET_SCHEMA.MODEL_NAME.ui}/${ASSET_SCHEMA.CURRENT_USER.ui})`,
showLoc: true,
showDept: true,
showType: true
},
onRowClick: (asset) => openHwModal(asset, 'view'),
columns: [
{
header: ASSET_SCHEMA.HW_STATUS.ui,
sortKey: ASSET_SCHEMA.HW_STATUS.key,
align: 'center',
render: a => `<span class="badge badge-success">${a[ASSET_SCHEMA.HW_STATUS.key] || '운영중'}</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.ASSET_NAME.ui, sortKey: ASSET_SCHEMA.ASSET_NAME.key, render: a => formatInline(a[ASSET_SCHEMA.PRODUCT_NAME.key] || a[ASSET_SCHEMA.MODEL_NAME.key] || a[ASSET_SCHEMA.ASSET_NAME.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.LOCATION.ui,
sortKey: ASSET_SCHEMA.LOCATION.key,
align: 'center',
render: a => {
const loc = a[ASSET_SCHEMA.LOCATION.key] || '';
const detail = a[ASSET_SCHEMA.LOC_DETAIL.key] || '';
return detail ? `${loc}(${detail})` : (loc || '-');
}
},
{ header: ASSET_SCHEMA.MEMO.ui, sortKey: ASSET_SCHEMA.MEMO.key, className: 'col-memo', render: a => formatInline(a[ASSET_SCHEMA.MEMO.key] || '-') }
]
});
}
import { state } from '../../core/state';
import { openHwModal } from '../../components/Modal/HWModal';
import { sortAssets, formatInline } from '../../core/utils';
import { ASSET_SCHEMA } from '../../core/schema';
import { createListView } from './ListFactory';
export function renderSpaceInfoList(container: HTMLElement) {
createListView(container, {
title: '공간정보장비',
dataSource: () => sortAssets(state.masterData.equipment?.filter((a: any) => a.category === '공간정보장비') || []),
searchKeys: ['MODEL_NAME', 'PRODUCT_NAME', 'CURRENT_USER', 'ASSET_TYPE'],
filterOptions: {
keywordLabel: `통합 검색 (${ASSET_SCHEMA.MODEL_NAME.ui}/${ASSET_SCHEMA.CURRENT_USER.ui})`,
showLoc: true,
showDept: true,
showType: true
},
onRowClick: (asset) => openHwModal(asset, 'view'),
columns: [
{
header: ASSET_SCHEMA.HW_STATUS.ui,
sortKey: ASSET_SCHEMA.HW_STATUS.key,
align: 'center',
render: a => `<span class="badge badge-success">${a[ASSET_SCHEMA.HW_STATUS.key] || '운영중'}</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.ASSET_NAME.ui, sortKey: ASSET_SCHEMA.ASSET_NAME.key, render: a => formatInline(a[ASSET_SCHEMA.PRODUCT_NAME.key] || a[ASSET_SCHEMA.MODEL_NAME.key] || a[ASSET_SCHEMA.ASSET_NAME.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.LOCATION.ui,
sortKey: ASSET_SCHEMA.LOCATION.key,
align: 'center',
render: a => {
const loc = a[ASSET_SCHEMA.LOCATION.key] || '';
const detail = a[ASSET_SCHEMA.LOC_DETAIL.key] || '';
return detail ? `${loc}(${detail})` : (loc || '-');
}
},
{ header: ASSET_SCHEMA.MEMO.ui, sortKey: ASSET_SCHEMA.MEMO.key, className: 'col-memo', render: a => formatInline(a[ASSET_SCHEMA.MEMO.key] || '-') }
]
});
}

View File

@@ -1,39 +1,39 @@
import { state } from '../../core/state';
import { openHwModal } from '../../components/Modal/HWModal';
import { sortAssets, formatInline } from '../../core/utils';
import { ASSET_SCHEMA } from '../../core/schema';
import { createListView } from './ListFactory';
export function renderStorageList(container: HTMLElement) {
createListView(container, {
title: '스토리지',
dataSource: () => sortAssets(state.masterData.storage || []),
searchKeys: ['MODEL_NAME', 'CURRENT_USER', 'SERIAL_NUM', 'ASSET_TYPE'],
filterOptions: {
keywordLabel: `통합 검색 (${ASSET_SCHEMA.MODEL_NAME.ui}/${ASSET_SCHEMA.CURRENT_USER.ui})`,
showLoc: true,
showDept: true,
showType: true
},
onRowClick: (asset) => openHwModal(asset, 'view'),
columns: [
{ header: ASSET_SCHEMA.HW_STATUS.ui, sortKey: ASSET_SCHEMA.HW_STATUS.key, align: 'center', render: a => a[ASSET_SCHEMA.HW_STATUS.key] || '-' },
{ 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.ASSET_TYPE.ui, sortKey: ASSET_SCHEMA.ASSET_TYPE.key, align: 'center', width: '10%', render: a => a[ASSET_SCHEMA.ASSET_TYPE.key] || '-' },
{ header: ASSET_SCHEMA.VOLUME.ui, sortKey: ASSET_SCHEMA.VOLUME.key, align: 'center', render: a => a[ASSET_SCHEMA.VOLUME.key] || '-' },
{ header: ASSET_SCHEMA.MODEL_NAME.ui, sortKey: ASSET_SCHEMA.MODEL_NAME.key, align: 'center', render: a => formatInline(a[ASSET_SCHEMA.MODEL_NAME.key] || a[ASSET_SCHEMA.ASSET_NAME.key] || '-') },
{ header: ASSET_SCHEMA.SERIAL_NUM.ui, sortKey: ASSET_SCHEMA.SERIAL_NUM.key, align: 'center', render: a => a[ASSET_SCHEMA.SERIAL_NUM.key] || '-' },
{
header: ASSET_SCHEMA.LOCATION.ui,
sortKey: ASSET_SCHEMA.LOCATION.key,
align: 'center',
render: a => {
const loc = a[ASSET_SCHEMA.LOCATION.key] || '';
const detail = a[ASSET_SCHEMA.LOC_DETAIL.key] || '';
return detail ? `${loc}(${detail})` : (loc || '-');
}
},
{ header: ASSET_SCHEMA.MEMO.ui, sortKey: ASSET_SCHEMA.MEMO.key, className: 'col-memo', render: a => formatInline(a[ASSET_SCHEMA.MEMO.key] || '-') }
]
});
}
import { state } from '../../core/state';
import { openHwModal } from '../../components/Modal/HWModal';
import { sortAssets, formatInline } from '../../core/utils';
import { ASSET_SCHEMA } from '../../core/schema';
import { createListView } from './ListFactory';
export function renderStorageList(container: HTMLElement) {
createListView(container, {
title: '스토리지',
dataSource: () => sortAssets(state.masterData.storage || []),
searchKeys: ['MODEL_NAME', 'CURRENT_USER', 'SERIAL_NUM', 'ASSET_TYPE'],
filterOptions: {
keywordLabel: `통합 검색 (${ASSET_SCHEMA.MODEL_NAME.ui}/${ASSET_SCHEMA.CURRENT_USER.ui})`,
showLoc: true,
showDept: true,
showType: true
},
onRowClick: (asset) => openHwModal(asset, 'view'),
columns: [
{ header: ASSET_SCHEMA.HW_STATUS.ui, sortKey: ASSET_SCHEMA.HW_STATUS.key, align: 'center', render: a => a[ASSET_SCHEMA.HW_STATUS.key] || '-' },
{ 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.ASSET_TYPE.ui, sortKey: ASSET_SCHEMA.ASSET_TYPE.key, align: 'center', width: '10%', render: a => a[ASSET_SCHEMA.ASSET_TYPE.key] || '-' },
{ header: ASSET_SCHEMA.VOLUME.ui, sortKey: ASSET_SCHEMA.VOLUME.key, align: 'center', render: a => a[ASSET_SCHEMA.VOLUME.key] || '-' },
{ header: ASSET_SCHEMA.MODEL_NAME.ui, sortKey: ASSET_SCHEMA.MODEL_NAME.key, align: 'center', render: a => formatInline(a[ASSET_SCHEMA.MODEL_NAME.key] || a[ASSET_SCHEMA.ASSET_NAME.key] || '-') },
{ header: ASSET_SCHEMA.SERIAL_NUM.ui, sortKey: ASSET_SCHEMA.SERIAL_NUM.key, align: 'center', render: a => a[ASSET_SCHEMA.SERIAL_NUM.key] || '-' },
{
header: ASSET_SCHEMA.LOCATION.ui,
sortKey: ASSET_SCHEMA.LOCATION.key,
align: 'center',
render: a => {
const loc = a[ASSET_SCHEMA.LOCATION.key] || '';
const detail = a[ASSET_SCHEMA.LOC_DETAIL.key] || '';
return detail ? `${loc}(${detail})` : (loc || '-');
}
},
{ header: ASSET_SCHEMA.MEMO.ui, sortKey: ASSET_SCHEMA.MEMO.key, className: 'col-memo', render: a => formatInline(a[ASSET_SCHEMA.MEMO.key] || '-') }
]
});
}

View File

@@ -1,44 +1,44 @@
import { state } from '../../core/state';
import { openSwModal } from '../../components/Modal/SWModal';
import { sortAssets, formatInline } from '../../core/utils';
import { ASSET_SCHEMA } from '../../core/schema';
import { createListView } from './ListFactory';
export function renderSwList(container: HTMLElement) {
const isInternal = state.activeSubTab === '내부SW';
createListView(container, {
title: isInternal ? '내부SW' : '외부SW',
dataSource: () => sortAssets(isInternal ? state.masterData.swInternal : state.masterData.swExternal),
searchKeys: ['PRODUCT_NAME', 'CURRENT_USER', 'CURRENT_DEPT', 'ASSET_TYPE'],
emptyMessage: '검색 결과가 없습니다.',
filterOptions: {
keywordLabel: `통합 검색 (${ASSET_SCHEMA.PRODUCT_NAME.ui}/${ASSET_SCHEMA.CURRENT_DEPT.ui})`,
showField: true,
showCorp: true,
showDept: true,
showType: true
},
onRowClick: (asset) => openSwModal(asset, 'view'),
columns: isInternal ? [
{ header: ASSET_SCHEMA.SW_FIELD.ui, sortKey: ASSET_SCHEMA.SW_FIELD.key, align: 'center', render: a => a[ASSET_SCHEMA.SW_FIELD.key] || '' },
{ header: ASSET_SCHEMA.DEV_OBJ.ui, sortKey: ASSET_SCHEMA.DEV_OBJ.key, align: 'center', render: a => a[ASSET_SCHEMA.DEV_OBJ.key] || '' },
{ header: ASSET_SCHEMA.SW_STATUS.ui, sortKey: ASSET_SCHEMA.SW_STATUS.key, align: 'center', render: a => a[ASSET_SCHEMA.SW_STATUS.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.SW_TYPE.ui, sortKey: ASSET_SCHEMA.SW_TYPE.key, align: 'center', render: a => a[ASSET_SCHEMA.SW_TYPE.key] || '내부' },
{ header: ASSET_SCHEMA.MEMO.ui, sortKey: ASSET_SCHEMA.MEMO.key, className: 'col-memo', render: a => formatInline(a[ASSET_SCHEMA.MEMO.key] || '-') }
] : [
{ header: '자산명', sortKey: ASSET_SCHEMA.PRODUCT_NAME.key, render: a => a[ASSET_SCHEMA.PRODUCT_NAME.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.SW_STATUS.ui, sortKey: ASSET_SCHEMA.SW_STATUS.key, align: 'center', render: a => a[ASSET_SCHEMA.SW_STATUS.key] || '사용중' },
{ header: ASSET_SCHEMA.SW_FIELD.ui, sortKey: ASSET_SCHEMA.SW_FIELD.key, align: 'center', render: a => a[ASSET_SCHEMA.SW_FIELD.key] || '' },
{ header: ASSET_SCHEMA.CURRENT_DEPT.ui, sortKey: ASSET_SCHEMA.CURRENT_DEPT.key, align: 'center', render: a => a[ASSET_SCHEMA.CURRENT_DEPT.key] || '' },
{ header: ASSET_SCHEMA.CURRENT_USER.ui, align: 'center', render: a => a[ASSET_SCHEMA.CURRENT_USER.key] || '-' },
{ header: ASSET_SCHEMA.PREV_USER.ui, align: 'center', render: a => a[ASSET_SCHEMA.PREV_USER.key] || '-' },
{ header: '구매연월', sortKey: ASSET_SCHEMA.PURCHASE_DATE.key, align: 'center', render: a => a[ASSET_SCHEMA.PURCHASE_DATE.key] || '' },
{ header: '시작일', align: 'center', render: a => a[ASSET_SCHEMA.PURCHASE_DATE.key] || '' },
{ header: '만료일', sortKey: ASSET_SCHEMA.EXPIRED_DATE.key, align: 'center', render: a => a[ASSET_SCHEMA.EXPIRED_DATE.key] || '' },
{ header: ASSET_SCHEMA.MEMO.ui, sortKey: ASSET_SCHEMA.MEMO.key, className: 'col-memo', render: a => formatInline(a[ASSET_SCHEMA.MEMO.key] || '-') }
]
});
}
import { state } from '../../core/state';
import { openSwModal } from '../../components/Modal/SWModal';
import { sortAssets, formatInline } from '../../core/utils';
import { ASSET_SCHEMA } from '../../core/schema';
import { createListView } from './ListFactory';
export function renderSwList(container: HTMLElement) {
const isInternal = state.activeSubTab === '내부SW';
createListView(container, {
title: isInternal ? '내부SW' : '외부SW',
dataSource: () => sortAssets(isInternal ? state.masterData.swInternal : state.masterData.swExternal),
searchKeys: ['PRODUCT_NAME', 'CURRENT_USER', 'CURRENT_DEPT', 'ASSET_TYPE'],
emptyMessage: '검색 결과가 없습니다.',
filterOptions: {
keywordLabel: `통합 검색 (${ASSET_SCHEMA.PRODUCT_NAME.ui}/${ASSET_SCHEMA.CURRENT_DEPT.ui})`,
showField: true,
showCorp: true,
showDept: true,
showType: true
},
onRowClick: (asset) => openSwModal(asset, 'view'),
columns: isInternal ? [
{ header: ASSET_SCHEMA.SW_FIELD.ui, sortKey: ASSET_SCHEMA.SW_FIELD.key, align: 'center', render: a => a[ASSET_SCHEMA.SW_FIELD.key] || '' },
{ header: ASSET_SCHEMA.DEV_OBJ.ui, sortKey: ASSET_SCHEMA.DEV_OBJ.key, align: 'center', render: a => a[ASSET_SCHEMA.DEV_OBJ.key] || '' },
{ header: ASSET_SCHEMA.SW_STATUS.ui, sortKey: ASSET_SCHEMA.SW_STATUS.key, align: 'center', render: a => a[ASSET_SCHEMA.SW_STATUS.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.SW_TYPE.ui, sortKey: ASSET_SCHEMA.SW_TYPE.key, align: 'center', render: a => a[ASSET_SCHEMA.SW_TYPE.key] || '내부' },
{ header: ASSET_SCHEMA.MEMO.ui, sortKey: ASSET_SCHEMA.MEMO.key, className: 'col-memo', render: a => formatInline(a[ASSET_SCHEMA.MEMO.key] || '-') }
] : [
{ header: '자산명', sortKey: ASSET_SCHEMA.PRODUCT_NAME.key, render: a => a[ASSET_SCHEMA.PRODUCT_NAME.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.SW_STATUS.ui, sortKey: ASSET_SCHEMA.SW_STATUS.key, align: 'center', render: a => a[ASSET_SCHEMA.SW_STATUS.key] || '사용중' },
{ header: ASSET_SCHEMA.SW_FIELD.ui, sortKey: ASSET_SCHEMA.SW_FIELD.key, align: 'center', render: a => a[ASSET_SCHEMA.SW_FIELD.key] || '' },
{ header: ASSET_SCHEMA.CURRENT_DEPT.ui, sortKey: ASSET_SCHEMA.CURRENT_DEPT.key, align: 'center', render: a => a[ASSET_SCHEMA.CURRENT_DEPT.key] || '' },
{ header: ASSET_SCHEMA.CURRENT_USER.ui, align: 'center', render: a => a[ASSET_SCHEMA.CURRENT_USER.key] || '-' },
{ header: ASSET_SCHEMA.PREV_USER.ui, align: 'center', render: a => a[ASSET_SCHEMA.PREV_USER.key] || '-' },
{ header: '구매연월', sortKey: ASSET_SCHEMA.PURCHASE_DATE.key, align: 'center', render: a => a[ASSET_SCHEMA.PURCHASE_DATE.key] || '' },
{ header: '시작일', align: 'center', render: a => a[ASSET_SCHEMA.PURCHASE_DATE.key] || '' },
{ header: '만료일', sortKey: ASSET_SCHEMA.EXPIRED_DATE.key, align: 'center', render: a => a[ASSET_SCHEMA.EXPIRED_DATE.key] || '' },
{ header: ASSET_SCHEMA.MEMO.ui, sortKey: ASSET_SCHEMA.MEMO.key, className: 'col-memo', render: a => formatInline(a[ASSET_SCHEMA.MEMO.key] || '-') }
]
});
}

View File

@@ -1,60 +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>`;
}
}
]
});
}
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>`;
}
}
]
});
}

View File

@@ -1,150 +1,150 @@
/* --- Page Header for Description --- */
.page-header {
padding: 1.5rem 2rem 0.5rem; /* Padding added for better whitespace */
}
.page-title-group {
display: flex;
flex-direction: column;
gap: 0.5rem;
}
.page-title {
font-size: var(--fs-lg);
font-weight: 600;
color: var(--primary);
display: flex;
align-items: center;
margin: 0;
line-height: 1.1;
letter-spacing: -0.02em;
}
.page-description {
font-size: var(--fs-base);
color: var(--mute);
margin: 0;
line-height: 1.5;
}
/* --- Table View Styles --- */
.table-container {
flex: 1;
background-color: var(--canvas);
overflow-x: auto;
overflow-y: auto;
position: relative;
max-width: 100%;
}
table {
width: 100%;
border-collapse: separate;
border-spacing: 0;
table-layout: fixed; /* Force fixed layout to prevent horizontal scroll */
}
th, td {
padding: 0.8rem 1rem;
border-bottom: 1px solid var(--hairline);
text-align: left;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis; /* Show ... for long text */
}
thead {
position: sticky;
top: 0;
z-index: 50;
}
th {
background-color: var(--canvas-soft) !important;
font-size: var(--fs-sm);
font-weight: 600;
color: var(--mute);
text-transform: uppercase;
letter-spacing: -0.02em;
box-shadow: inset 0 -1px 0 var(--hairline);
text-align: center; /* Set default header alignment to center */
}
td {
font-size: var(--fs-base);
color: var(--primary);
font-weight: 400;
text-align: left; /* Set default data alignment to left */
}
tbody tr:hover {
background-color: var(--canvas-soft-2);
}
/* 정렬 클래스 */
.text-center { text-align: center !important; }
.text-right { text-align: right !important; }
.text-left { text-align: left !important; }
/* 메모 컬럼 전용 */
.col-memo {
white-space: nowrap !important; /* Keep as one line */
overflow: hidden;
text-overflow: ellipsis;
}
/* --- Table Sorting --- */
th.sortable {
cursor: pointer;
user-select: none;
position: relative;
padding-right: 1.8rem !important;
}
th.sortable:hover {
background-color: var(--canvas-soft-2) !important;
color: var(--primary);
}
th.sortable::after {
content: '↕';
position: absolute;
right: 0.75rem;
top: 50%;
transform: translateY(-50%);
font-size: var(--fs-xs);
opacity: 0.4;
}
th.sortable.asc::after { content: '▲'; opacity: 1; color: var(--primary); }
th.sortable.desc::after { content: '▼'; opacity: 1; color: var(--primary); }
/* --- Compact Table (Used in Dashboards/Modals) --- */
.compact-table {
width: 100%;
border-collapse: collapse;
}
.compact-table th {
padding: 0.75rem 0.5rem;
font-size: var(--fs-sm);
font-weight: 600;
color: var(--mute);
border-bottom: 1px solid var(--hairline);
background: var(--canvas-soft);
text-transform: uppercase;
letter-spacing: -0.02em;
}
.compact-table td {
padding: 0.75rem 0.5rem;
font-size: var(--fs-sm);
border-bottom: 1px solid var(--hairline-soft, #f5f5f5);
color: var(--primary);
}
.compact-table tr.clickable-row:hover {
background: var(--canvas-soft);
cursor: pointer;
}
/* --- Page Header for Description --- */
.page-header {
padding: 1.5rem 2rem 0.5rem; /* Padding added for better whitespace */
}
.page-title-group {
display: flex;
flex-direction: column;
gap: 0.5rem;
}
.page-title {
font-size: var(--fs-lg);
font-weight: 600;
color: var(--primary);
display: flex;
align-items: center;
margin: 0;
line-height: 1.1;
letter-spacing: -0.02em;
}
.page-description {
font-size: var(--fs-base);
color: var(--mute);
margin: 0;
line-height: 1.5;
}
/* --- Table View Styles --- */
.table-container {
flex: 1;
background-color: var(--canvas);
overflow-x: auto;
overflow-y: auto;
position: relative;
max-width: 100%;
}
table {
width: 100%;
border-collapse: separate;
border-spacing: 0;
table-layout: fixed; /* Force fixed layout to prevent horizontal scroll */
}
th, td {
padding: 0.8rem 1rem;
border-bottom: 1px solid var(--hairline);
text-align: left;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis; /* Show ... for long text */
}
thead {
position: sticky;
top: 0;
z-index: 50;
}
th {
background-color: var(--canvas-soft) !important;
font-size: var(--fs-sm);
font-weight: 600;
color: var(--mute);
text-transform: uppercase;
letter-spacing: -0.02em;
box-shadow: inset 0 -1px 0 var(--hairline);
text-align: center; /* Set default header alignment to center */
}
td {
font-size: var(--fs-base);
color: var(--primary);
font-weight: 400;
text-align: left; /* Set default data alignment to left */
}
tbody tr:hover {
background-color: var(--canvas-soft-2);
}
/* 정렬 클래스 */
.text-center { text-align: center !important; }
.text-right { text-align: right !important; }
.text-left { text-align: left !important; }
/* 메모 컬럼 전용 */
.col-memo {
white-space: nowrap !important; /* Keep as one line */
overflow: hidden;
text-overflow: ellipsis;
}
/* --- Table Sorting --- */
th.sortable {
cursor: pointer;
user-select: none;
position: relative;
padding-right: 1.8rem !important;
}
th.sortable:hover {
background-color: var(--canvas-soft-2) !important;
color: var(--primary);
}
th.sortable::after {
content: '↕';
position: absolute;
right: 0.75rem;
top: 50%;
transform: translateY(-50%);
font-size: var(--fs-xs);
opacity: 0.4;
}
th.sortable.asc::after { content: '▲'; opacity: 1; color: var(--primary); }
th.sortable.desc::after { content: '▼'; opacity: 1; color: var(--primary); }
/* --- Compact Table (Used in Dashboards/Modals) --- */
.compact-table {
width: 100%;
border-collapse: collapse;
}
.compact-table th {
padding: 0.75rem 0.5rem;
font-size: var(--fs-sm);
font-weight: 600;
color: var(--mute);
border-bottom: 1px solid var(--hairline);
background: var(--canvas-soft);
text-transform: uppercase;
letter-spacing: -0.02em;
}
.compact-table td {
padding: 0.75rem 0.5rem;
font-size: var(--fs-sm);
border-bottom: 1px solid var(--hairline-soft, #f5f5f5);
color: var(--primary);
}
.compact-table tr.clickable-row:hover {
background: var(--canvas-soft);
cursor: pointer;
}

View File

@@ -1,291 +1,291 @@
import { state } from '../core/state';
import { openHwModal } from '../components/Modal/HWModal';
import { ASSET_SCHEMA } from '../core/schema';
import { LOCATION_DATA, IMAGE_LOCATIONS } from '../components/Modal/SharedData';
/**
* 위치 중심 자산 현황 뷰 (Vercel Integrated)
*/
export async function renderLocationView(container: HTMLElement) {
if (!container) return;
let currentLoc = '기술개발센터';
let currentDetail = '서버실';
let currentPage = 0;
let mapConfig: any = {};
try {
const res = await fetch('/api/maps');
mapConfig = await res.json();
} catch (err) { console.error('Failed to load map config', err); }
const render = () => {
const locImages = (IMAGE_LOCATIONS[currentLoc] && IMAGE_LOCATIONS[currentLoc][currentDetail])
? IMAGE_LOCATIONS[currentLoc][currentDetail]
: [];
const mapPath = locImages[currentPage] || '';
// 모든 하드웨어 카테고리에서 자산 검색
const allHwAssets = [
...state.masterData.pc,
...state.masterData.server,
...state.masterData.storage,
...state.masterData.network,
...state.masterData.equipment,
...state.masterData.survey,
...state.masterData.officeSupplies,
...state.masterData.pcParts
];
// map_config.json에 설정된 모든 박스를 복사해서 작업용으로 사용
const tempBoxes = (mapConfig[mapPath] || []).map((b: any) => ({ ...b }));
// DB 데이터에서 현재 지도(mapPath) 및 위치와 좌표 정보(loc_x, loc_y)가 일치하는 자산 추출
allHwAssets.forEach((asset: any) => {
const photoPath = asset.location_photo || asset.loc_img || '';
const hasCoords = asset.loc_x != null && asset.loc_y != null && asset.loc_x !== '' && asset.loc_y !== '' && asset.loc_x !== 'null' && asset.loc_y !== 'null';
if (hasCoords && photoPath.trim() === mapPath.trim()) {
const ax = parseFloat(asset.loc_x);
const ay = parseFloat(asset.loc_y);
// map_config.json에서 읽어온 박스들 중 x, y 좌표가 일치하는 빈 박스가 있는지 찾음 (오차범위 0.1 고려)
const matchedBox = tempBoxes.find((b: any) => {
const bx = parseFloat(b.x);
const by = parseFloat(b.y);
return Math.abs(bx - ax) < 0.1 && Math.abs(by - ay) < 0.1;
});
if (matchedBox) {
// 이미 매칭된 박스가 존재하고 asset_id가 비어있다면 해당 박스에 asset_id를 주입
if (matchedBox.asset_id == null) {
matchedBox.asset_id = asset.id;
}
} else {
// 일치하는 기존 박스가 없을 때만 4x4 크기의 임시 박스로 동적 생성
const alreadyMatched = tempBoxes.some((b: any) => b.asset_id === asset.id);
if (!alreadyMatched) {
tempBoxes.push({
asset_id: asset.id,
x: asset.loc_x,
y: asset.loc_y,
w: '4',
h: '4',
name: asset.asset_purpose || asset.asset_code || '미지정 자산'
});
}
}
}
});
// 최종적으로 asset_id가 null이 아닌(자산이 정상 매핑되거나 갱신된) 박스들만 남겨서 렌더링
const boxes = tempBoxes.filter((b: any) => b.asset_id != null);
container.innerHTML = `
<div class="location-view-wrapper">
<!-- 상단 통합 바 (Unified Search Bar) -->
<div class="location-filter-bar search-bar">
<div class="search-item">
<label class="list-view-toggle-label">
<input type="checkbox" id="chk-list-view-loc" />
목록보기
</label>
</div>
<div class="search-item">
<label>건물/위치</label>
<select id="sel-loc-main">
${Object.keys(LOCATION_DATA).map(loc => `<option value="${loc}" ${loc === currentLoc ? 'selected' : ''}>${loc}</option>`).join('')}
</select>
</div>
<div class="search-item">
<label>상세 위치</label>
<div class="flex items-center gap-2">
<select id="sel-loc-detail">
${(LOCATION_DATA[currentLoc] || []).map(det => `<option value="${det}" ${det === currentDetail ? 'selected' : ''}>${det}</option>`).join('')}
</select>
<!-- 페이지네이션 -->
${locImages.length > 1 ? `
<div class="map-pagination-group">
<div class="page-btns flex gap-1">
<button id="btn-prev-page" class="btn btn-outline btn-sm" ${currentPage === 0 ? 'disabled' : ''}>이전</button>
<button id="btn-next-page" class="btn btn-outline btn-sm" ${currentPage === locImages.length - 1 ? 'disabled' : ''}>다음</button>
</div>
<span class="page-info">(${currentPage + 1} / ${locImages.length})</span>
</div>
` : ''}
</div>
</div>
</div>
<div class="location-main-content">
<!-- 지도 섹션 -->
<div class="map-container-section">
<div class="map-frame-wrapper">
${mapPath ? `
<img src="${mapPath}" id="main-map-img" class="map-image">
<div id="box-overlay" class="map-overlay">
${boxes.map((box: any, idx: number) => {
const asset = allHwAssets.find(a => a.id === box.asset_id);
const name = asset ? ((asset as any).asset_purpose || asset.asset_code) : (box.name || `#${idx+1}`);
// w, h가 없거나 너무 작으면 최소 크기(3%) 보장하여 영역으로 표시
const width = Math.max(parseFloat(box.w || '3'), 3);
const height = Math.max(parseFloat(box.h || '3'), 3);
return `
<div class="location-box-area"
data-asset-id="${box.asset_id}"
data-name="${name}"
style="left:${box.x}%; top:${box.y}%; width:${width}%; height:${height}%;
border: 2px solid var(--primary-color); background: rgba(30, 81, 73, 0.1); cursor:pointer; pointer-events: auto; position: absolute;">
</div>
`}).join('')}
</div>
` : '<div class="no-map-message">해당 위치의 도면이 등록되지 않았습니다.</div>'}
</div>
</div>
<!-- 상세 정보 섹션 -->
<div class="asset-list-section">
<div class="section-header">
<h4 id="loc-list-title" class="sidebar-title">구역을 선택하세요</h4>
</div>
<div id="loc-asset-table-container" class="mini-table-wrapper">
<div class="empty-state">지도에서 자산 위치를 클릭하세요.</div>
</div>
</div>
</div>
</div>
`;
const syncOverlaySize = () => {
const img = container.querySelector('#main-map-img') as HTMLImageElement;
const overlay = container.querySelector('#box-overlay') as HTMLElement;
if (img && overlay && img.complete) {
overlay.style.width = img.clientWidth + 'px';
overlay.style.height = img.clientHeight + 'px';
overlay.style.left = img.offsetLeft + 'px';
overlay.style.top = img.offsetTop + 'px';
}
};
const img = container.querySelector('#main-map-img') as HTMLImageElement;
if (img) {
if (img.complete) {
syncOverlaySize();
setTimeout(syncOverlaySize, 50);
} else {
img.onload = syncOverlaySize;
}
}
window.removeEventListener('resize', syncOverlaySize);
window.addEventListener('resize', syncOverlaySize);
const selMain = container.querySelector('#sel-loc-main') as HTMLSelectElement;
selMain?.addEventListener('change', () => {
currentLoc = selMain.value;
currentDetail = LOCATION_DATA[currentLoc][0];
currentPage = 0;
render();
});
const selDetail = container.querySelector('#sel-loc-detail') as HTMLSelectElement;
selDetail?.addEventListener('change', () => {
currentDetail = selDetail.value;
currentPage = 0;
render();
});
container.querySelector('#btn-prev-page')?.addEventListener('click', () => { currentPage--; render(); });
container.querySelector('#btn-next-page')?.addEventListener('click', () => { currentPage++; render(); });
const chkBox = container.querySelector('#chk-list-view-loc') as HTMLInputElement;
if (chkBox) {
chkBox.checked = state.viewMode === 'list';
const handleToggle = () => {
const isListMode = chkBox.checked;
if (isListMode) {
state.viewMode = 'list';
} else {
state.viewMode = 'location';
}
window.dispatchEvent(new Event('refresh-view'));
};
chkBox.addEventListener('change', handleToggle);
}
container.querySelectorAll('.location-box-area').forEach(box => {
box.addEventListener('click', () => {
const assetId = box.getAttribute('data-asset-id');
if (!assetId) return;
const targetAsset = allHwAssets.find(a => a.id === assetId);
if (targetAsset) renderAssetDetail(targetAsset);
container.querySelectorAll('.location-box-area').forEach(b => (b as HTMLElement).style.background = 'rgba(30, 81, 73, 0.1)');
(box as HTMLElement).style.background = 'rgba(30, 81, 73, 0.4)';
});
});
};
const renderAssetDetail = (asset: any) => {
const title = container.querySelector('#loc-list-title')!;
const tableContainer = container.querySelector('#loc-asset-table-container')!;
title.innerHTML = `
<div class="detail-header-actions">
<div class="header-identity">
<span class="asset-code-title">${asset.asset_code || '미부여'}</span>
<span class="service-type-badge">${asset.service_type || '운영'}</span>
<span class="asset-type-label">${asset.asset_type || 'PC'}</span>
</div>
<button id="btn-view-from-loc" class="btn btn-primary btn-sm">조회</button>
</div>
`;
const fields = [
{ label: ASSET_SCHEMA.CURRENT_DEPT.ui, value: asset.current_dept },
{ label: ASSET_SCHEMA.HW_STATUS.ui, value: asset.hw_status },
{ label: ASSET_SCHEMA.MANAGER_MAIN.ui, value: asset.manager_primary },
{ label: ASSET_SCHEMA.MANAGER_SUB.ui, value: asset.manager_secondary },
{ label: ASSET_SCHEMA.ASSET_PURPOSE.ui, value: asset.asset_purpose, fullWidth: true },
{ label: ASSET_SCHEMA.MODEL_NAME.ui, value: asset.model_name },
{ label: ASSET_SCHEMA.OS.ui, value: asset.os },
{ label: ASSET_SCHEMA.CPU.ui, value: asset.cpu },
{ label: ASSET_SCHEMA.RAM.ui, value: asset.ram },
{ label: ASSET_SCHEMA.GPU.ui, value: asset.gpu, fullWidth: true },
{ label: ASSET_SCHEMA.IP_ADDR.ui, value: asset.ip_address },
{ label: ASSET_SCHEMA.MAC_ADDR.ui, value: asset.mac_address },
{ label: ASSET_SCHEMA.REMOTE_TOOL.ui, value: asset.remote_tool },
{ label: ASSET_SCHEMA.MONITORING.ui, value: asset.monitoring },
{ label: ASSET_SCHEMA.MEMO.ui, value: asset.memo, fullWidth: true }
];
const sectionsHTML = `
<div class="detail-section" style="margin-bottom: 0;">
<div class="detail-grid-2col" style="gap: 0.75rem 1rem;">
${fields.map(f => `
<div class="detail-item ${f.fullWidth ? 'full-width' : ''}">
<div class="detail-label-sm">${f.label}</div>
<div class="detail-value-lg">${f.value || '-'}</div>
</div>
`).join('')}
</div>
</div>
`;
tableContainer.innerHTML = `
<div class="asset-detail-sidebar">
${sectionsHTML}
</div>
`;
container.querySelector('#btn-view-from-loc')?.addEventListener('click', () => {
openHwModal(asset, 'view');
});
};
render();
}
import { state } from '../core/state';
import { openHwModal } from '../components/Modal/HWModal';
import { ASSET_SCHEMA } from '../core/schema';
import { LOCATION_DATA, IMAGE_LOCATIONS } from '../components/Modal/SharedData';
/**
* 위치 중심 자산 현황 뷰 (Vercel Integrated)
*/
export async function renderLocationView(container: HTMLElement) {
if (!container) return;
let currentLoc = '기술개발센터';
let currentDetail = '서버실';
let currentPage = 0;
let mapConfig: any = {};
try {
const res = await fetch('/api/maps');
mapConfig = await res.json();
} catch (err) { console.error('Failed to load map config', err); }
const render = () => {
const locImages = (IMAGE_LOCATIONS[currentLoc] && IMAGE_LOCATIONS[currentLoc][currentDetail])
? IMAGE_LOCATIONS[currentLoc][currentDetail]
: [];
const mapPath = locImages[currentPage] || '';
// 모든 하드웨어 카테고리에서 자산 검색
const allHwAssets = [
...state.masterData.pc,
...state.masterData.server,
...state.masterData.storage,
...state.masterData.network,
...state.masterData.equipment,
...state.masterData.survey,
...state.masterData.officeSupplies,
...state.masterData.pcParts
];
// map_config.json에 설정된 모든 박스를 복사해서 작업용으로 사용
const tempBoxes = (mapConfig[mapPath] || []).map((b: any) => ({ ...b }));
// DB 데이터에서 현재 지도(mapPath) 및 위치와 좌표 정보(loc_x, loc_y)가 일치하는 자산 추출
allHwAssets.forEach((asset: any) => {
const photoPath = asset.location_photo || asset.loc_img || '';
const hasCoords = asset.loc_x != null && asset.loc_y != null && asset.loc_x !== '' && asset.loc_y !== '' && asset.loc_x !== 'null' && asset.loc_y !== 'null';
if (hasCoords && photoPath.trim() === mapPath.trim()) {
const ax = parseFloat(asset.loc_x);
const ay = parseFloat(asset.loc_y);
// map_config.json에서 읽어온 박스들 중 x, y 좌표가 일치하는 빈 박스가 있는지 찾음 (오차범위 0.1 고려)
const matchedBox = tempBoxes.find((b: any) => {
const bx = parseFloat(b.x);
const by = parseFloat(b.y);
return Math.abs(bx - ax) < 0.1 && Math.abs(by - ay) < 0.1;
});
if (matchedBox) {
// 이미 매칭된 박스가 존재하고 asset_id가 비어있다면 해당 박스에 asset_id를 주입
if (matchedBox.asset_id == null) {
matchedBox.asset_id = asset.id;
}
} else {
// 일치하는 기존 박스가 없을 때만 4x4 크기의 임시 박스로 동적 생성
const alreadyMatched = tempBoxes.some((b: any) => b.asset_id === asset.id);
if (!alreadyMatched) {
tempBoxes.push({
asset_id: asset.id,
x: asset.loc_x,
y: asset.loc_y,
w: '4',
h: '4',
name: asset.asset_purpose || asset.asset_code || '미지정 자산'
});
}
}
}
});
// 최종적으로 asset_id가 null이 아닌(자산이 정상 매핑되거나 갱신된) 박스들만 남겨서 렌더링
const boxes = tempBoxes.filter((b: any) => b.asset_id != null);
container.innerHTML = `
<div class="location-view-wrapper">
<!-- 상단 통합 바 (Unified Search Bar) -->
<div class="location-filter-bar search-bar">
<div class="search-item">
<label class="list-view-toggle-label">
<input type="checkbox" id="chk-list-view-loc" />
목록보기
</label>
</div>
<div class="search-item">
<label>건물/위치</label>
<select id="sel-loc-main">
${Object.keys(LOCATION_DATA).map(loc => `<option value="${loc}" ${loc === currentLoc ? 'selected' : ''}>${loc}</option>`).join('')}
</select>
</div>
<div class="search-item">
<label>상세 위치</label>
<div class="flex items-center gap-2">
<select id="sel-loc-detail">
${(LOCATION_DATA[currentLoc] || []).map(det => `<option value="${det}" ${det === currentDetail ? 'selected' : ''}>${det}</option>`).join('')}
</select>
<!-- 페이지네이션 -->
${locImages.length > 1 ? `
<div class="map-pagination-group">
<div class="page-btns flex gap-1">
<button id="btn-prev-page" class="btn btn-outline btn-sm" ${currentPage === 0 ? 'disabled' : ''}>이전</button>
<button id="btn-next-page" class="btn btn-outline btn-sm" ${currentPage === locImages.length - 1 ? 'disabled' : ''}>다음</button>
</div>
<span class="page-info">(${currentPage + 1} / ${locImages.length})</span>
</div>
` : ''}
</div>
</div>
</div>
<div class="location-main-content">
<!-- 지도 섹션 -->
<div class="map-container-section">
<div class="map-frame-wrapper">
${mapPath ? `
<img src="${mapPath}" id="main-map-img" class="map-image">
<div id="box-overlay" class="map-overlay">
${boxes.map((box: any, idx: number) => {
const asset = allHwAssets.find(a => a.id === box.asset_id);
const name = asset ? ((asset as any).asset_purpose || asset.asset_code) : (box.name || `#${idx+1}`);
// w, h가 없거나 너무 작으면 최소 크기(3%) 보장하여 영역으로 표시
const width = Math.max(parseFloat(box.w || '3'), 3);
const height = Math.max(parseFloat(box.h || '3'), 3);
return `
<div class="location-box-area"
data-asset-id="${box.asset_id}"
data-name="${name}"
style="left:${box.x}%; top:${box.y}%; width:${width}%; height:${height}%;
border: 2px solid var(--primary-color); background: rgba(30, 81, 73, 0.1); cursor:pointer; pointer-events: auto; position: absolute;">
</div>
`}).join('')}
</div>
` : '<div class="no-map-message">해당 위치의 도면이 등록되지 않았습니다.</div>'}
</div>
</div>
<!-- 상세 정보 섹션 -->
<div class="asset-list-section">
<div class="section-header">
<h4 id="loc-list-title" class="sidebar-title">구역을 선택하세요</h4>
</div>
<div id="loc-asset-table-container" class="mini-table-wrapper">
<div class="empty-state">지도에서 자산 위치를 클릭하세요.</div>
</div>
</div>
</div>
</div>
`;
const syncOverlaySize = () => {
const img = container.querySelector('#main-map-img') as HTMLImageElement;
const overlay = container.querySelector('#box-overlay') as HTMLElement;
if (img && overlay && img.complete) {
overlay.style.width = img.clientWidth + 'px';
overlay.style.height = img.clientHeight + 'px';
overlay.style.left = img.offsetLeft + 'px';
overlay.style.top = img.offsetTop + 'px';
}
};
const img = container.querySelector('#main-map-img') as HTMLImageElement;
if (img) {
if (img.complete) {
syncOverlaySize();
setTimeout(syncOverlaySize, 50);
} else {
img.onload = syncOverlaySize;
}
}
window.removeEventListener('resize', syncOverlaySize);
window.addEventListener('resize', syncOverlaySize);
const selMain = container.querySelector('#sel-loc-main') as HTMLSelectElement;
selMain?.addEventListener('change', () => {
currentLoc = selMain.value;
currentDetail = LOCATION_DATA[currentLoc][0];
currentPage = 0;
render();
});
const selDetail = container.querySelector('#sel-loc-detail') as HTMLSelectElement;
selDetail?.addEventListener('change', () => {
currentDetail = selDetail.value;
currentPage = 0;
render();
});
container.querySelector('#btn-prev-page')?.addEventListener('click', () => { currentPage--; render(); });
container.querySelector('#btn-next-page')?.addEventListener('click', () => { currentPage++; render(); });
const chkBox = container.querySelector('#chk-list-view-loc') as HTMLInputElement;
if (chkBox) {
chkBox.checked = state.viewMode === 'list';
const handleToggle = () => {
const isListMode = chkBox.checked;
if (isListMode) {
state.viewMode = 'list';
} else {
state.viewMode = 'location';
}
window.dispatchEvent(new Event('refresh-view'));
};
chkBox.addEventListener('change', handleToggle);
}
container.querySelectorAll('.location-box-area').forEach(box => {
box.addEventListener('click', () => {
const assetId = box.getAttribute('data-asset-id');
if (!assetId) return;
const targetAsset = allHwAssets.find(a => a.id === assetId);
if (targetAsset) renderAssetDetail(targetAsset);
container.querySelectorAll('.location-box-area').forEach(b => (b as HTMLElement).style.background = 'rgba(30, 81, 73, 0.1)');
(box as HTMLElement).style.background = 'rgba(30, 81, 73, 0.4)';
});
});
};
const renderAssetDetail = (asset: any) => {
const title = container.querySelector('#loc-list-title')!;
const tableContainer = container.querySelector('#loc-asset-table-container')!;
title.innerHTML = `
<div class="detail-header-actions">
<div class="header-identity">
<span class="asset-code-title">${asset.asset_code || '미부여'}</span>
<span class="service-type-badge">${asset.service_type || '운영'}</span>
<span class="asset-type-label">${asset.asset_type || 'PC'}</span>
</div>
<button id="btn-view-from-loc" class="btn btn-primary btn-sm">조회</button>
</div>
`;
const fields = [
{ label: ASSET_SCHEMA.CURRENT_DEPT.ui, value: asset.current_dept },
{ label: ASSET_SCHEMA.HW_STATUS.ui, value: asset.hw_status },
{ label: ASSET_SCHEMA.MANAGER_MAIN.ui, value: asset.manager_primary },
{ label: ASSET_SCHEMA.MANAGER_SUB.ui, value: asset.manager_secondary },
{ label: ASSET_SCHEMA.ASSET_PURPOSE.ui, value: asset.asset_purpose, fullWidth: true },
{ label: ASSET_SCHEMA.MODEL_NAME.ui, value: asset.model_name },
{ label: ASSET_SCHEMA.OS.ui, value: asset.os },
{ label: ASSET_SCHEMA.CPU.ui, value: asset.cpu },
{ label: ASSET_SCHEMA.RAM.ui, value: asset.ram },
{ label: ASSET_SCHEMA.GPU.ui, value: asset.gpu, fullWidth: true },
{ label: ASSET_SCHEMA.IP_ADDR.ui, value: asset.ip_address },
{ label: ASSET_SCHEMA.MAC_ADDR.ui, value: asset.mac_address },
{ label: ASSET_SCHEMA.REMOTE_TOOL.ui, value: asset.remote_tool },
{ label: ASSET_SCHEMA.MONITORING.ui, value: asset.monitoring },
{ label: ASSET_SCHEMA.MEMO.ui, value: asset.memo, fullWidth: true }
];
const sectionsHTML = `
<div class="detail-section" style="margin-bottom: 0;">
<div class="detail-grid-2col" style="gap: 0.75rem 1rem;">
${fields.map(f => `
<div class="detail-item ${f.fullWidth ? 'full-width' : ''}">
<div class="detail-label-sm">${f.label}</div>
<div class="detail-value-lg">${f.value || '-'}</div>
</div>
`).join('')}
</div>
</div>
`;
tableContainer.innerHTML = `
<div class="asset-detail-sidebar">
${sectionsHTML}
</div>
`;
container.querySelector('#btn-view-from-loc')?.addEventListener('click', () => {
openHwModal(asset, 'view');
});
};
render();
}

View File

@@ -1,299 +1,299 @@
import { IMAGE_LOCATIONS } from '../components/Modal/SharedData';
import { API_BASE_URL } from '../core/utils';
import { createIcons, X, Save, Trash2, ChevronLeft, ChevronRight } from 'lucide';
export class MapEditor {
private container: HTMLElement;
private wrapper: HTMLElement;
private img: HTMLImageElement;
private boxListEl: HTMLElement;
private pathLabel: HTMLElement;
private statusEl: HTMLElement;
private saveBtn: HTMLButtonElement;
private fileSidebar: HTMLElement;
private allMapConfig: Record<string, any[]> = {};
private boxes: any[] = [];
private isDrawing: boolean = false;
private startX: number = 0;
private startY: number = 0;
private currentBox: HTMLElement | null = null;
private currentPath: string = '';
private assetOptions: {id: string, name: string}[] = [];
constructor() {
this.container = document.getElementById('container')!;
this.wrapper = document.getElementById('wrapper')!;
this.img = document.getElementById('target-img') as HTMLImageElement;
this.boxListEl = document.getElementById('box-list')!;
this.pathLabel = document.getElementById('current-path')!;
this.statusEl = document.getElementById('save-status')!;
this.saveBtn = document.getElementById('btn-save-server') as HTMLButtonElement;
this.fileSidebar = document.getElementById('file-sidebar')!;
}
public async init() {
this.renderFileSidebar();
await this.loadConfig();
await this.loadAssets();
this.bindEvents();
this.selectFirstFile();
createIcons({ icons: { X, Save, Trash2, ChevronLeft, ChevronRight } });
}
private async loadAssets() {
try {
const res = await fetch(`http://${location.hostname}:3000/api/assets/master`);
const masterData = await res.json();
const allHw = [
...(masterData.pc || []),
...(masterData.server || []),
...(masterData.storage || []),
...(masterData.network || []),
...(masterData.equipment || []),
...(masterData.survey || []),
...(masterData.officeSupplies || []),
...(masterData.pcParts || [])
];
this.assetOptions = allHw.map(a => ({
id: a.id,
name: `[${a.asset_code}] ${a.asset_purpose || a.model_name || a.category}`
}));
} catch (err) {
console.error('Failed to load assets for mapping', err);
}
}
private renderFileSidebar() {
let html = '';
Object.entries(IMAGE_LOCATIONS).forEach(([bldg, details]) => {
html += `<div class="folder-item">${bldg}</div>`;
Object.entries(details).forEach(([detail, paths]) => {
paths.forEach(path => {
const fileName = path.split('/').pop() || path;
html += `<div class="file-item" data-path="${path}">${fileName}</div>`;
});
});
});
this.fileSidebar.innerHTML = html;
this.fileSidebar.querySelectorAll('.file-item').forEach(item => {
item.addEventListener('click', () => {
this.fileSidebar.querySelectorAll('.file-item').forEach(i => i.classList.remove('active'));
item.classList.add('active');
this.renderCurrentFile();
});
});
}
private selectFirstFile() {
const firstItem = this.fileSidebar.querySelector('.file-item') as HTMLElement;
if (firstItem) {
firstItem.classList.add('active');
this.renderCurrentFile();
}
}
private async loadConfig() {
try {
const res = await fetch(`${API_BASE_URL}/api/maps`);
this.allMapConfig = await res.json();
} catch (err) {
console.error('Failed to load config:', err);
}
}
private renderCurrentFile() {
const activeItem = this.fileSidebar.querySelector('.file-item.active') as HTMLElement;
if (!activeItem) return;
this.currentPath = activeItem.dataset.path || '';
this.boxes = this.allMapConfig[this.currentPath] || [];
this.pathLabel.textContent = this.currentPath;
this.img.src = this.currentPath;
this.render();
}
private bindEvents() {
this.wrapper.addEventListener('mousedown', (e) => {
if (e.button !== 0) return;
this.isDrawing = true;
const rect = this.wrapper.getBoundingClientRect();
this.startX = e.clientX - rect.left;
this.startY = e.clientY - rect.top;
this.currentBox = document.createElement('div');
this.currentBox.className = 'draw-box';
this.currentBox.style.left = this.startX + 'px';
this.currentBox.style.top = this.startY + 'px';
const label = document.createElement('div');
label.className = 'box-label';
label.textContent = '#' + (this.boxes.length + 1);
this.currentBox.appendChild(label);
this.wrapper.appendChild(this.currentBox);
});
window.addEventListener('mousemove', (e) => {
if (!this.isDrawing || !this.currentBox) return;
const rect = this.wrapper.getBoundingClientRect();
const currentX = Math.max(0, Math.min(e.clientX - rect.left, rect.width));
const currentY = Math.max(0, Math.min(e.clientY - rect.top, rect.height));
const width = currentX - this.startX;
const height = currentY - this.startY;
this.currentBox.style.width = Math.abs(width) + 'px';
this.currentBox.style.height = Math.abs(height) + 'px';
this.currentBox.style.left = (width > 0 ? this.startX : currentX) + 'px';
this.currentBox.style.top = (height > 0 ? this.startY : currentY) + 'px';
});
window.addEventListener('mouseup', () => {
if (!this.isDrawing || !this.currentBox) return;
this.isDrawing = false;
const width = parseFloat(this.currentBox.style.width);
const height = parseFloat(this.currentBox.style.height);
if (width > 3 && height > 3) {
const rect = this.wrapper.getBoundingClientRect();
const boxData = {
x: (parseFloat(this.currentBox.style.left) / rect.width * 100).toFixed(2),
y: (parseFloat(this.currentBox.style.top) / rect.height * 100).toFixed(2),
w: (width / rect.width * 100).toFixed(2),
h: (height / rect.height * 100).toFixed(2),
asset_id: null
};
this.boxes.push(boxData);
this.render();
}
this.currentBox.remove();
this.currentBox = null;
});
(window as any).removeBox = (index: number) => {
this.boxes.splice(index, 1);
this.render();
};
document.getElementById('btn-clear-all')?.addEventListener('click', () => {
if(confirm('모든 박스를 삭제할까요?')) {
this.boxes = [];
this.render();
}
});
document.getElementById('btn-save-server')?.addEventListener('click', () => this.saveToServer());
}
private async saveToServer() {
if (!this.currentPath) return;
try {
this.saveBtn.disabled = true;
this.saveBtn.textContent = '저장 중...';
const res = await fetch(`${API_BASE_URL}/api/maps/save`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ path: this.currentPath, boxes: this.boxes })
});
if (res.ok) {
this.allMapConfig[this.currentPath] = [...this.boxes];
this.statusEl.textContent = '✅ 서버 저장 완료 (' + new Date().toLocaleTimeString() + ')';
setTimeout(() => this.statusEl.textContent = '', 3000);
} else {
alert('저장 실패!');
}
} catch (err) {
alert('서버 연결 오류!');
} finally {
this.saveBtn.disabled = false;
this.saveBtn.textContent = '서버에 즉시 저장';
}
}
private render() {
this.boxListEl.innerHTML = '';
const oldBoxes = this.wrapper.querySelectorAll('.placed-box');
oldBoxes.forEach(b => b.remove());
this.boxes.forEach((box, i) => {
const div = document.createElement('div');
div.className = 'placed-box';
div.style.left = box.x + '%';
div.style.top = box.y + '%';
div.style.width = box.w + '%';
div.style.height = box.h + '%';
const label = document.createElement('div');
label.className = 'box-label';
label.textContent = '#' + (i + 1);
div.appendChild(label);
this.wrapper.appendChild(div);
// Create asset options dropdown
let optionsHtml = '<option value="">-- 자산 매핑 안 됨 --</option>';
this.assetOptions.forEach(opt => {
const selected = box.asset_id === opt.id ? 'selected' : '';
optionsHtml += `<option value="${opt.id}" ${selected}>${opt.name}</option>`;
});
const item = document.createElement('div');
item.className = 'box-item';
item.innerHTML = `
<div class="box-header">
<span class="box-index">#${i+1}</span>
<button class="btn-del" onclick="removeBox(${i})">×</button>
</div>
<div class="box-inputs margin-bottom">
<select data-index="${i}" data-prop="asset_id">
${optionsHtml}
</select>
</div>
<div class="box-inputs">
<div class="input-group">
<label>X</label>
<input type="number" step="0.01" value="${box.x}" data-index="${i}" data-prop="x">
</div>
<div class="input-group">
<label>Y</label>
<input type="number" step="0.01" value="${box.y}" data-index="${i}" data-prop="y">
</div>
<div class="input-group">
<label>W</label>
<input type="number" step="0.01" value="${box.w}" data-index="${i}" data-prop="w">
</div>
<div class="input-group">
<label>H</label>
<input type="number" step="0.01" value="${box.h}" data-index="${i}" data-prop="h">
</div>
</div>
`;
this.boxListEl.appendChild(item);
});
// Add events to new inputs and selects
this.boxListEl.querySelectorAll('input, select').forEach(input => {
input.addEventListener('change', (e) => {
const target = e.target as HTMLInputElement | HTMLSelectElement;
const index = parseInt(target.dataset.index!);
const prop = target.dataset.prop!;
if (this.boxes[index]) {
if (prop === 'asset_id') {
this.boxes[index][prop] = target.value || null;
} else {
this.boxes[index][prop] = parseFloat(target.value).toFixed(2);
this.render(); // Re-render to update the map visual size
}
}
});
});
}
}
import { IMAGE_LOCATIONS } from '../components/Modal/SharedData';
import { API_BASE_URL } from '../core/utils';
import { createIcons, X, Save, Trash2, ChevronLeft, ChevronRight } from 'lucide';
export class MapEditor {
private container: HTMLElement;
private wrapper: HTMLElement;
private img: HTMLImageElement;
private boxListEl: HTMLElement;
private pathLabel: HTMLElement;
private statusEl: HTMLElement;
private saveBtn: HTMLButtonElement;
private fileSidebar: HTMLElement;
private allMapConfig: Record<string, any[]> = {};
private boxes: any[] = [];
private isDrawing: boolean = false;
private startX: number = 0;
private startY: number = 0;
private currentBox: HTMLElement | null = null;
private currentPath: string = '';
private assetOptions: {id: string, name: string}[] = [];
constructor() {
this.container = document.getElementById('container')!;
this.wrapper = document.getElementById('wrapper')!;
this.img = document.getElementById('target-img') as HTMLImageElement;
this.boxListEl = document.getElementById('box-list')!;
this.pathLabel = document.getElementById('current-path')!;
this.statusEl = document.getElementById('save-status')!;
this.saveBtn = document.getElementById('btn-save-server') as HTMLButtonElement;
this.fileSidebar = document.getElementById('file-sidebar')!;
}
public async init() {
this.renderFileSidebar();
await this.loadConfig();
await this.loadAssets();
this.bindEvents();
this.selectFirstFile();
createIcons({ icons: { X, Save, Trash2, ChevronLeft, ChevronRight } });
}
private async loadAssets() {
try {
const res = await fetch(`http://${location.hostname}:3000/api/assets/master`);
const masterData = await res.json();
const allHw = [
...(masterData.pc || []),
...(masterData.server || []),
...(masterData.storage || []),
...(masterData.network || []),
...(masterData.equipment || []),
...(masterData.survey || []),
...(masterData.officeSupplies || []),
...(masterData.pcParts || [])
];
this.assetOptions = allHw.map(a => ({
id: a.id,
name: `[${a.asset_code}] ${a.asset_purpose || a.model_name || a.category}`
}));
} catch (err) {
console.error('Failed to load assets for mapping', err);
}
}
private renderFileSidebar() {
let html = '';
Object.entries(IMAGE_LOCATIONS).forEach(([bldg, details]) => {
html += `<div class="folder-item">${bldg}</div>`;
Object.entries(details).forEach(([detail, paths]) => {
paths.forEach(path => {
const fileName = path.split('/').pop() || path;
html += `<div class="file-item" data-path="${path}">${fileName}</div>`;
});
});
});
this.fileSidebar.innerHTML = html;
this.fileSidebar.querySelectorAll('.file-item').forEach(item => {
item.addEventListener('click', () => {
this.fileSidebar.querySelectorAll('.file-item').forEach(i => i.classList.remove('active'));
item.classList.add('active');
this.renderCurrentFile();
});
});
}
private selectFirstFile() {
const firstItem = this.fileSidebar.querySelector('.file-item') as HTMLElement;
if (firstItem) {
firstItem.classList.add('active');
this.renderCurrentFile();
}
}
private async loadConfig() {
try {
const res = await fetch(`${API_BASE_URL}/api/maps`);
this.allMapConfig = await res.json();
} catch (err) {
console.error('Failed to load config:', err);
}
}
private renderCurrentFile() {
const activeItem = this.fileSidebar.querySelector('.file-item.active') as HTMLElement;
if (!activeItem) return;
this.currentPath = activeItem.dataset.path || '';
this.boxes = this.allMapConfig[this.currentPath] || [];
this.pathLabel.textContent = this.currentPath;
this.img.src = this.currentPath;
this.render();
}
private bindEvents() {
this.wrapper.addEventListener('mousedown', (e) => {
if (e.button !== 0) return;
this.isDrawing = true;
const rect = this.wrapper.getBoundingClientRect();
this.startX = e.clientX - rect.left;
this.startY = e.clientY - rect.top;
this.currentBox = document.createElement('div');
this.currentBox.className = 'draw-box';
this.currentBox.style.left = this.startX + 'px';
this.currentBox.style.top = this.startY + 'px';
const label = document.createElement('div');
label.className = 'box-label';
label.textContent = '#' + (this.boxes.length + 1);
this.currentBox.appendChild(label);
this.wrapper.appendChild(this.currentBox);
});
window.addEventListener('mousemove', (e) => {
if (!this.isDrawing || !this.currentBox) return;
const rect = this.wrapper.getBoundingClientRect();
const currentX = Math.max(0, Math.min(e.clientX - rect.left, rect.width));
const currentY = Math.max(0, Math.min(e.clientY - rect.top, rect.height));
const width = currentX - this.startX;
const height = currentY - this.startY;
this.currentBox.style.width = Math.abs(width) + 'px';
this.currentBox.style.height = Math.abs(height) + 'px';
this.currentBox.style.left = (width > 0 ? this.startX : currentX) + 'px';
this.currentBox.style.top = (height > 0 ? this.startY : currentY) + 'px';
});
window.addEventListener('mouseup', () => {
if (!this.isDrawing || !this.currentBox) return;
this.isDrawing = false;
const width = parseFloat(this.currentBox.style.width);
const height = parseFloat(this.currentBox.style.height);
if (width > 3 && height > 3) {
const rect = this.wrapper.getBoundingClientRect();
const boxData = {
x: (parseFloat(this.currentBox.style.left) / rect.width * 100).toFixed(2),
y: (parseFloat(this.currentBox.style.top) / rect.height * 100).toFixed(2),
w: (width / rect.width * 100).toFixed(2),
h: (height / rect.height * 100).toFixed(2),
asset_id: null
};
this.boxes.push(boxData);
this.render();
}
this.currentBox.remove();
this.currentBox = null;
});
(window as any).removeBox = (index: number) => {
this.boxes.splice(index, 1);
this.render();
};
document.getElementById('btn-clear-all')?.addEventListener('click', () => {
if(confirm('모든 박스를 삭제할까요?')) {
this.boxes = [];
this.render();
}
});
document.getElementById('btn-save-server')?.addEventListener('click', () => this.saveToServer());
}
private async saveToServer() {
if (!this.currentPath) return;
try {
this.saveBtn.disabled = true;
this.saveBtn.textContent = '저장 중...';
const res = await fetch(`${API_BASE_URL}/api/maps/save`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ path: this.currentPath, boxes: this.boxes })
});
if (res.ok) {
this.allMapConfig[this.currentPath] = [...this.boxes];
this.statusEl.textContent = '✅ 서버 저장 완료 (' + new Date().toLocaleTimeString() + ')';
setTimeout(() => this.statusEl.textContent = '', 3000);
} else {
alert('저장 실패!');
}
} catch (err) {
alert('서버 연결 오류!');
} finally {
this.saveBtn.disabled = false;
this.saveBtn.textContent = '서버에 즉시 저장';
}
}
private render() {
this.boxListEl.innerHTML = '';
const oldBoxes = this.wrapper.querySelectorAll('.placed-box');
oldBoxes.forEach(b => b.remove());
this.boxes.forEach((box, i) => {
const div = document.createElement('div');
div.className = 'placed-box';
div.style.left = box.x + '%';
div.style.top = box.y + '%';
div.style.width = box.w + '%';
div.style.height = box.h + '%';
const label = document.createElement('div');
label.className = 'box-label';
label.textContent = '#' + (i + 1);
div.appendChild(label);
this.wrapper.appendChild(div);
// Create asset options dropdown
let optionsHtml = '<option value="">-- 자산 매핑 안 됨 --</option>';
this.assetOptions.forEach(opt => {
const selected = box.asset_id === opt.id ? 'selected' : '';
optionsHtml += `<option value="${opt.id}" ${selected}>${opt.name}</option>`;
});
const item = document.createElement('div');
item.className = 'box-item';
item.innerHTML = `
<div class="box-header">
<span class="box-index">#${i+1}</span>
<button class="btn-del" onclick="removeBox(${i})">×</button>
</div>
<div class="box-inputs margin-bottom">
<select data-index="${i}" data-prop="asset_id">
${optionsHtml}
</select>
</div>
<div class="box-inputs">
<div class="input-group">
<label>X</label>
<input type="number" step="0.01" value="${box.x}" data-index="${i}" data-prop="x">
</div>
<div class="input-group">
<label>Y</label>
<input type="number" step="0.01" value="${box.y}" data-index="${i}" data-prop="y">
</div>
<div class="input-group">
<label>W</label>
<input type="number" step="0.01" value="${box.w}" data-index="${i}" data-prop="w">
</div>
<div class="input-group">
<label>H</label>
<input type="number" step="0.01" value="${box.h}" data-index="${i}" data-prop="h">
</div>
</div>
`;
this.boxListEl.appendChild(item);
});
// Add events to new inputs and selects
this.boxListEl.querySelectorAll('input, select').forEach(input => {
input.addEventListener('change', (e) => {
const target = e.target as HTMLInputElement | HTMLSelectElement;
const index = parseInt(target.dataset.index!);
const prop = target.dataset.prop!;
if (this.boxes[index]) {
if (prop === 'asset_id') {
this.boxes[index][prop] = target.value || null;
} else {
this.boxes[index][prop] = parseFloat(target.value).toFixed(2);
this.render(); // Re-render to update the map visual size
}
}
});
});
}
}

View File

@@ -1,84 +1,84 @@
import { state } from '../core/state';
import { renderPcList } from './List/PcListView';
import { renderServerList } from './List/ServerListView';
import { renderStorageList } from './List/StorageListView';
import { renderEquipmentList } from './List/EquipmentListView';
import { renderMobileList } from './List/MobileListView';
import { renderSwList } from './List/SwListView';
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 { renderUserList } from './List/UserListView';
import { createIcons, Plus, X, LayoutDashboard, Monitor, Server, Database, Laptop, CalendarClock, Key, Cpu, Layers, Users, Paperclip, Edit2, RefreshCcw, BookOpen, Settings } from 'lucide';
/**
* 자산 목록 테이블 렌더링 통합 허브 (Vercel Style Normalized)
*/
export function renderSWTable(mainContent: HTMLElement) {
if (!mainContent) return;
console.log(`📂 Rendering Table for: ${state.activeCategory} / ${state.activeSubTab}`);
mainContent.innerHTML = `
<div class="view-content-wrapper">
<div id="list-view-container" style="flex: 1; display: flex; flex-direction: column; overflow: hidden;"></div>
</div>
`;
const container = document.getElementById('list-view-container')!;
try {
const tab = state.activeSubTab;
if (state.activeCategory === 'hw') {
if (tab === 'PC') renderPcList(container);
else if (tab === '서버') renderServerList(container);
else if (tab === '스토리지') renderStorageList(container);
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>`;
}
} else if (state.activeCategory === 'sw') {
if (tab === '외부SW' || tab === '내부SW') {
renderSwList(container);
} else {
container.innerHTML = `<div style="padding:2rem; color:var(--text-muted);">"${tab}" 탭에 대한 소프트웨어 리스트 뷰가 정의되지 않았습니다.</div>`;
}
} else if (state.activeCategory === 'ops') {
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>`;
}
} else if (state.activeCategory === 'vip') {
if (tab === '선물') renderGiftList(container);
else {
container.innerHTML = `<div style="padding:2rem; color:var(--text-muted); text-align:center; margin-top:3rem;">내빈/외빈(${tab}) 관리 기능은 현재 준비 중입니다.</div>`;
}
} else if (state.activeCategory === 'fac') {
if (tab === '사무가구') renderFacilityList(container);
else {
container.innerHTML = `<div style="padding:2rem; color:var(--text-muted); text-align:center; margin-top:3rem;">시설자산(${tab}) 관리 기능은 현재 준비 중입니다.</div>`;
}
}
// 전역 아이콘 초기화
createIcons({
icons: { Plus, X, LayoutDashboard, Monitor, Server, Database, Laptop, CalendarClock, Key, Cpu, Layers, Users, Paperclip, Edit2, RefreshCcw, BookOpen, Settings }
});
} catch (err: any) {
console.error('❌ Error rendering table view:', err);
mainContent.innerHTML = `<div style="padding:2rem; color:var(--danger);">목록을 불러오는 중 오류가 발생했습니다: ${err?.message || err}</div>`;
}
}
import { state } from '../core/state';
import { renderPcList } from './List/PcListView';
import { renderServerList } from './List/ServerListView';
import { renderStorageList } from './List/StorageListView';
import { renderEquipmentList } from './List/EquipmentListView';
import { renderMobileList } from './List/MobileListView';
import { renderSwList } from './List/SwListView';
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 { renderUserList } from './List/UserListView';
import { createIcons, Plus, X, LayoutDashboard, Monitor, Server, Database, Laptop, CalendarClock, Key, Cpu, Layers, Users, Paperclip, Edit2, RefreshCcw, BookOpen, Settings } from 'lucide';
/**
* 자산 목록 테이블 렌더링 통합 허브 (Vercel Style Normalized)
*/
export function renderSWTable(mainContent: HTMLElement) {
if (!mainContent) return;
console.log(`📂 Rendering Table for: ${state.activeCategory} / ${state.activeSubTab}`);
mainContent.innerHTML = `
<div class="view-content-wrapper">
<div id="list-view-container" style="flex: 1; display: flex; flex-direction: column; overflow: hidden;"></div>
</div>
`;
const container = document.getElementById('list-view-container')!;
try {
const tab = state.activeSubTab;
if (state.activeCategory === 'hw') {
if (tab === 'PC') renderPcList(container);
else if (tab === '서버') renderServerList(container);
else if (tab === '스토리지') renderStorageList(container);
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>`;
}
} else if (state.activeCategory === 'sw') {
if (tab === '외부SW' || tab === '내부SW') {
renderSwList(container);
} else {
container.innerHTML = `<div style="padding:2rem; color:var(--text-muted);">"${tab}" 탭에 대한 소프트웨어 리스트 뷰가 정의되지 않았습니다.</div>`;
}
} else if (state.activeCategory === 'ops') {
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>`;
}
} else if (state.activeCategory === 'vip') {
if (tab === '선물') renderGiftList(container);
else {
container.innerHTML = `<div style="padding:2rem; color:var(--text-muted); text-align:center; margin-top:3rem;">내빈/외빈(${tab}) 관리 기능은 현재 준비 중입니다.</div>`;
}
} else if (state.activeCategory === 'fac') {
if (tab === '사무가구') renderFacilityList(container);
else {
container.innerHTML = `<div style="padding:2rem; color:var(--text-muted); text-align:center; margin-top:3rem;">시설자산(${tab}) 관리 기능은 현재 준비 중입니다.</div>`;
}
}
// 전역 아이콘 초기화
createIcons({
icons: { Plus, X, LayoutDashboard, Monitor, Server, Database, Laptop, CalendarClock, Key, Cpu, Layers, Users, Paperclip, Edit2, RefreshCcw, BookOpen, Settings }
});
} catch (err: any) {
console.error('❌ Error rendering table view:', err);
mainContent.innerHTML = `<div style="padding:2rem; color:var(--danger);">목록을 불러오는 중 오류가 발생했습니다: ${err?.message || err}</div>`;
}
}

View File

@@ -1,236 +1,236 @@
/* ITAM Map Coordinate Editor Styles */
.file-sidebar {
width: 260px;
background: var(--white);
border-right: 1px solid var(--border-color);
display: flex;
flex-direction: column;
overflow-y: auto;
}
.folder-item {
padding: 10px 15px;
background: var(--bg-light);
font-weight: 700;
font-size: var(--fs-base);
border-bottom: 1px solid var(--border-color);
color: var(--primary-color);
}
.file-item {
padding: 8px 25px;
cursor: pointer;
font-size: var(--fs-sm);
border-bottom: 1px solid var(--bg-color);
transition: background 0.2s;
}
.file-item:hover { background: var(--bg-light); }
.file-item.active { background: var(--primary-color); color: var(--white); font-weight: 700; }
/* Center: Editor Area */
.editor-container {
flex: 1;
position: relative;
overflow: auto;
padding: 20px;
display: flex;
align-items: center;
justify-content: center;
background: #e0e0e0; /* 전용 배경색 유지 */
}
.img-wrapper {
position: relative;
display: inline-block;
box-shadow: 0 0 30px rgba(0,0,0,0.3);
background: var(--white);
line-height: 0;
}
.img-wrapper img {
display: block;
max-width: calc(100vw - 650px);
max-height: 85vh;
width: auto;
height: auto;
user-select: none;
-webkit-user-drag: none;
}
/* Right Sidebar: Control Panel */
.sidebar {
width: 350px;
background: var(--white);
border-left: 1px solid var(--border-color);
display: flex;
flex-direction: column;
padding: 20px;
box-shadow: -5px 0 15px rgba(0,0,0,0.05);
}
.sidebar h2 { margin-top: 0; color: var(--primary-color); font-size: var(--fs-lg); font-weight: 600; }
.sidebar p { font-size: var(--fs-sm); color: var(--text-muted); line-height: 1.4; margin-bottom: 20px; }
.current-path { font-size: var(--fs-xs); color: var(--text-muted); margin-bottom: 10px; word-break: break-all; font-family: monospace; }
.box-list {
flex: 1;
overflow-y: auto;
margin-bottom: 15px;
border: 1px solid var(--border-color);
border-radius: 4px;
padding: 10px;
background: var(--bg-light);
}
.box-item {
font-family: monospace;
font-size: var(--fs-xs);
padding: 10px 6px;
border-bottom: 1px solid var(--border-color);
display: flex;
flex-direction: column;
gap: 8px;
}
.box-header {
display: flex;
justify-content: space-between;
align-items: center;
}
.box-index {
font-weight: 700;
color: var(--primary-color);
}
.box-inputs {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 6px;
}
.input-group {
display: flex;
align-items: center;
gap: 4px;
}
.input-group label {
color: var(--text-muted);
width: 12px;
}
.input-group input {
width: 100%;
padding: 2px 4px;
border: 1px solid var(--border-color);
border-radius: 2px;
font-size: var(--fs-xs);
outline: none;
}
.input-group input:focus {
border-color: var(--primary-color);
}
.box-item:hover { background: var(--white); }
.btn-del { cursor: pointer; color: var(--danger); border: none; background: none; font-size: var(--fs-md); padding: 0 5px; }
.actions { display: flex; flex-direction: column; gap: 8px; }
/* Drawing Elements */
.draw-box {
position: absolute;
border: 2px solid var(--edit-mode-color);
background: rgba(255, 61, 0, 0.2);
pointer-events: none;
z-index: 100;
}
.placed-box {
position: absolute;
border: 1.5px solid var(--primary-color);
background: rgba(30, 81, 73, 0.15);
cursor: pointer;
z-index: 50;
}
.placed-box:hover {
background: rgba(30, 81, 73, 0.4);
border-color: #000;
}
.placed-box.selected {
border: 2.5px solid var(--edit-mode-color);
z-index: 60;
box-shadow: 0 0 10px rgba(255,61,0,0.5);
}
.box-label {
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
font-size: var(--fs-xs);
font-weight: 700;
color: var(--primary-color);
pointer-events: none;
white-space: nowrap;
background: rgba(255,255,255,0.7);
padding: 0 2px;
border-radius: 2px;
line-height: 1;
}
.draw-box .box-label {
color: var(--edit-mode-color);
background: rgba(255,255,255,0.8);
}
#save-status {
margin-top: 8px;
font-size: var(--fs-xs);
color: var(--success);
text-align: center;
font-weight: 700;
height: 14px;
}
/* Editor Body & Layout Overrides */
.editor-body {
margin: 0;
display: flex;
height: 100vh;
overflow: hidden;
}
.editor-version {
font-size: var(--fs-xs);
color: var(--text-muted);
}
.actions .btn {
height: 38px;
}
/* Box Item Dropdown Inputs */
.box-inputs.margin-bottom {
margin-bottom: 8px;
}
.box-inputs select {
width: 100%;
padding: 4px;
border: 1px solid var(--border-color);
border-radius: 4px;
font-size: var(--fs-xs);
background-color: var(--canvas);
color: var(--text-main);
outline: none;
}
.box-inputs select:focus {
border-color: var(--primary-color);
}
/* ITAM Map Coordinate Editor Styles */
.file-sidebar {
width: 260px;
background: var(--white);
border-right: 1px solid var(--border-color);
display: flex;
flex-direction: column;
overflow-y: auto;
}
.folder-item {
padding: 10px 15px;
background: var(--bg-light);
font-weight: 700;
font-size: var(--fs-base);
border-bottom: 1px solid var(--border-color);
color: var(--primary-color);
}
.file-item {
padding: 8px 25px;
cursor: pointer;
font-size: var(--fs-sm);
border-bottom: 1px solid var(--bg-color);
transition: background 0.2s;
}
.file-item:hover { background: var(--bg-light); }
.file-item.active { background: var(--primary-color); color: var(--white); font-weight: 700; }
/* Center: Editor Area */
.editor-container {
flex: 1;
position: relative;
overflow: auto;
padding: 20px;
display: flex;
align-items: center;
justify-content: center;
background: #e0e0e0; /* 전용 배경색 유지 */
}
.img-wrapper {
position: relative;
display: inline-block;
box-shadow: 0 0 30px rgba(0,0,0,0.3);
background: var(--white);
line-height: 0;
}
.img-wrapper img {
display: block;
max-width: calc(100vw - 650px);
max-height: 85vh;
width: auto;
height: auto;
user-select: none;
-webkit-user-drag: none;
}
/* Right Sidebar: Control Panel */
.sidebar {
width: 350px;
background: var(--white);
border-left: 1px solid var(--border-color);
display: flex;
flex-direction: column;
padding: 20px;
box-shadow: -5px 0 15px rgba(0,0,0,0.05);
}
.sidebar h2 { margin-top: 0; color: var(--primary-color); font-size: var(--fs-lg); font-weight: 600; }
.sidebar p { font-size: var(--fs-sm); color: var(--text-muted); line-height: 1.4; margin-bottom: 20px; }
.current-path { font-size: var(--fs-xs); color: var(--text-muted); margin-bottom: 10px; word-break: break-all; font-family: monospace; }
.box-list {
flex: 1;
overflow-y: auto;
margin-bottom: 15px;
border: 1px solid var(--border-color);
border-radius: 4px;
padding: 10px;
background: var(--bg-light);
}
.box-item {
font-family: monospace;
font-size: var(--fs-xs);
padding: 10px 6px;
border-bottom: 1px solid var(--border-color);
display: flex;
flex-direction: column;
gap: 8px;
}
.box-header {
display: flex;
justify-content: space-between;
align-items: center;
}
.box-index {
font-weight: 700;
color: var(--primary-color);
}
.box-inputs {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 6px;
}
.input-group {
display: flex;
align-items: center;
gap: 4px;
}
.input-group label {
color: var(--text-muted);
width: 12px;
}
.input-group input {
width: 100%;
padding: 2px 4px;
border: 1px solid var(--border-color);
border-radius: 2px;
font-size: var(--fs-xs);
outline: none;
}
.input-group input:focus {
border-color: var(--primary-color);
}
.box-item:hover { background: var(--white); }
.btn-del { cursor: pointer; color: var(--danger); border: none; background: none; font-size: var(--fs-md); padding: 0 5px; }
.actions { display: flex; flex-direction: column; gap: 8px; }
/* Drawing Elements */
.draw-box {
position: absolute;
border: 2px solid var(--edit-mode-color);
background: rgba(255, 61, 0, 0.2);
pointer-events: none;
z-index: 100;
}
.placed-box {
position: absolute;
border: 1.5px solid var(--primary-color);
background: rgba(30, 81, 73, 0.15);
cursor: pointer;
z-index: 50;
}
.placed-box:hover {
background: rgba(30, 81, 73, 0.4);
border-color: #000;
}
.placed-box.selected {
border: 2.5px solid var(--edit-mode-color);
z-index: 60;
box-shadow: 0 0 10px rgba(255,61,0,0.5);
}
.box-label {
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
font-size: var(--fs-xs);
font-weight: 700;
color: var(--primary-color);
pointer-events: none;
white-space: nowrap;
background: rgba(255,255,255,0.7);
padding: 0 2px;
border-radius: 2px;
line-height: 1;
}
.draw-box .box-label {
color: var(--edit-mode-color);
background: rgba(255,255,255,0.8);
}
#save-status {
margin-top: 8px;
font-size: var(--fs-xs);
color: var(--success);
text-align: center;
font-weight: 700;
height: 14px;
}
/* Editor Body & Layout Overrides */
.editor-body {
margin: 0;
display: flex;
height: 100vh;
overflow: hidden;
}
.editor-version {
font-size: var(--fs-xs);
color: var(--text-muted);
}
.actions .btn {
height: 38px;
}
/* Box Item Dropdown Inputs */
.box-inputs.margin-bottom {
margin-bottom: 8px;
}
.box-inputs select {
width: 100%;
padding: 4px;
border: 1px solid var(--border-color);
border-radius: 4px;
font-size: var(--fs-xs);
background-color: var(--canvas);
color: var(--text-main);
outline: none;
}
.box-inputs select:focus {
border-color: var(--primary-color);
}