feat: 소프트웨어 자산 관리 기능 고도화 및 대시보드 누적 비용 분석 기능 추가

This commit is contained in:
2026-04-23 19:47:07 +09:00
parent d125de1902
commit 9fcecd4bf5
13 changed files with 649 additions and 324 deletions

View File

@@ -1,14 +1,11 @@
import { state } from '../../core/state';
import { openSwModal } from '../../components/Modal/SWModal';
import { sortAssets } from '../../core/utils';
import { openSwUserModal } from '../../components/Modal/SWUserModal';
import { sortAssets, formatPrice } from '../../core/utils';
import { CORP_LIST } from '../../components/Modal/SharedData';
import { generateOptionsHTML } from '../../components/Modal/ModalUtils';
import { ASSET_SCHEMA, UI_TEXT } from '../../core/schema';
import { createIcons, RefreshCcw } from 'lucide';
import { createIcons, Edit2, Users, RefreshCcw } from 'lucide';
/**
* 소프트웨어(구독/영구) 자산 목록 뷰
*/
export function renderSwList(container: HTMLElement) {
const isSub = state.activeSubTab === '구독SW';
const fullList = sortAssets(isSub ? state.masterData.subSw : state.masterData.permSw);
@@ -17,7 +14,7 @@ export function renderSwList(container: HTMLElement) {
filterBar.className = 'search-bar';
filterBar.innerHTML = `
<div class="search-item flex-1">
<label>통합 검색 (${ASSET_SCHEMA.PRODUCT.ui}/부서)</label>
<label>통합 검색 (제품명/부서)</label>
<input type="text" id="filter-keyword" placeholder="검색어를 입력하세요..." autocomplete="off">
</div>
<div class="search-item">
@@ -31,11 +28,11 @@ export function renderSwList(container: HTMLElement) {
</select>
</div>
<div class="search-item">
<label>${ASSET_SCHEMA.CORP.ui}</label>
<label>법인</label>
<select id="filter-corp">${generateOptionsHTML(CORP_LIST, '', true)}</select>
</div>
<button id="btn-reset-filters" class="btn btn-outline btn-reset">
<i data-lucide="refresh-ccw"></i> ${UI_TEXT.ACTION.RESET_FILTER}
<i data-lucide="refresh-ccw"></i> 필터 초기화
</button>
`;
container.appendChild(filterBar);
@@ -49,14 +46,16 @@ export function renderSwList(container: HTMLElement) {
<th style="text-align:center;">No.</th>
<th style="text-align:center;">상태</th>
<th style="text-align:center;">분야</th>
<th style="text-align:center;">${ASSET_SCHEMA.CORP.ui}</th>
<th style="text-align:center;">법인</th>
<th style="text-align:center;">부서</th>
<th style="text-align:center;">${ASSET_SCHEMA.PRODUCT.ui}</th>
<th style="text-align:center;">${ASSET_SCHEMA.PURCHASE_YM.ui}</th>
${isSub ? `<th style="text-align:center;">${ASSET_SCHEMA.EXPIRY.ui}</th>` : ''}
<th style="text-align:center;">${ASSET_SCHEMA.PRICE.ui}</th>
<th style="text-align:center;">${ASSET_SCHEMA.QTY.ui}</th>
<th style="text-align:center;">제품명</th>
<th style="text-align:center;">구매일</th>
<th style="text-align:center;">시작일</th>
<th style="text-align:center;">만료일</th>
<th style="text-align:center;">금액</th>
<th style="text-align:center;">수량</th>
<th style="text-align:center;">사용가능</th>
<th style="text-align:center;">사용자</th>
</tr>
</thead>
<tbody id="dynamic-tbody"></tbody>
@@ -76,38 +75,49 @@ export function renderSwList(container: HTMLElement) {
const corp = corpSelect ? corpSelect.value : '';
const filtered = fullList.filter(asset => {
const matchKeyword = !keyword || ((asset as any)[ASSET_SCHEMA.PRODUCT.key] || '').toLowerCase().includes(keyword) || (asset. || '').toLowerCase().includes(keyword);
const matchKeyword = !keyword || (asset. || '').toLowerCase().includes(keyword) || (asset. || '').toLowerCase().includes(keyword);
const matchField = !field || asset. === field;
const matchCorp = !corp || (asset as any)[ASSET_SCHEMA.CORP.key] === corp;
const matchCorp = !corp || asset. === corp;
return matchKeyword && matchField && matchCorp;
});
tbody.innerHTML = '';
if (filtered.length === 0) {
tbody.innerHTML = `<tr><td colspan="${isSub ? 11 : 10}" style="text-align:center; padding: 3rem; color: var(--text-muted);">${UI_TEXT.MESSAGES.NO_DATA}</td></tr>`;
tbody.innerHTML = `<tr><td colspan="13" style="text-align:center; padding: 3rem; color: var(--text-muted);">검색 결과가 없습니다.</td></tr>`;
return;
}
filtered.forEach((asset, idx) => {
const assigned = state.masterData.swUsers.filter(u => u.sw_id === asset.id).length;
const qty = typeof (asset as any)[ASSET_SCHEMA.QTY.key] === 'number' ? (asset as any)[ASSET_SCHEMA.QTY.key] : parseInt((asset as any)[ASSET_SCHEMA.QTY.key]||'0', 10);
const qty = typeof asset. === 'number' ? asset.수량 : parseInt(asset.||'0', 10);
const avail = qty - assigned;
let statusBadge = '';
let statusHtml = '';
if (isSub) {
let isExpired = false;
if ((asset as any)[ASSET_SCHEMA.EXPIRY.key]) {
const parts = (asset as any)[ASSET_SCHEMA.EXPIRY.key].split('~');
const endDateStr = parts[parts.length - 1].trim().replace(/\./g, '-');
if (asset.) {
const endDateStr = asset..replace(/\./g, '-');
const endDate = new Date(endDateStr);
if (!isNaN(endDate.getTime())) {
endDate.setHours(23, 59, 59, 999);
if (endDate < new Date()) isExpired = true;
}
}
statusBadge = isExpired ? `<span class="badge badge-danger">만료</span>` : `<span class="badge badge-primary">사용중</span>`;
if (isExpired) statusHtml = `<span style="background: var(--danger, #ef4444); color: white; padding: 2px 6px; border-radius: 4px; font-size: 0.75rem; font-weight: bold; white-space: nowrap;">만료</span>`;
else statusHtml = `<span style="background: var(--primary-color, #1E5149); color: white; padding: 2px 6px; border-radius: 4px; font-size: 0.75rem; font-weight: bold; white-space: nowrap;">사용중</span>`;
} else {
statusBadge = asset. ? `<span class="badge badge-success">유효</span>` : `<span class="badge badge-muted">없음</span>`;
let isMaintenance = false;
if (asset. && asset.) {
const startDate = new Date(asset..replace(/\./g, '-'));
const endDate = new Date(asset..replace(/\./g, '-'));
const today = new Date();
if (!isNaN(startDate.getTime()) && !isNaN(endDate.getTime())) {
endDate.setHours(23, 59, 59, 999);
if (today >= startDate && today <= endDate) isMaintenance = true;
}
}
if (isMaintenance) statusHtml = `<span style="background: #3b82f6; color: white; padding: 2px 6px; border-radius: 4px; font-size: 0.75rem; font-weight: bold; white-space: nowrap;">유지보수</span>`;
else statusHtml = `<span style="background: #6b7280; color: white; padding: 2px 6px; border-radius: 4px; font-size: 0.75rem; font-weight: bold; white-space: nowrap;">보유중</span>`;
}
const tr = document.createElement('tr');
@@ -115,22 +125,36 @@ export function renderSwList(container: HTMLElement) {
tr.innerHTML = `
<td style="text-align:center;">${idx+1}</td>
<td style="text-align:center;">${statusBadge}</td>
<td style="text-align:center;">${asset.||''}</td>
<td style="text-align:center;">${(asset as any)[ASSET_SCHEMA.CORP.key]}</td>
<td style="text-align:center;">${asset.||''}</td>
<td>${(asset as any)[ASSET_SCHEMA.PRODUCT.key]}</td>
<td style="text-align:center;">${(asset as any)[ASSET_SCHEMA.PURCHASE_YM.key]||''}</td>
${isSub ? `<td style="text-align:center;">${(asset as any)[ASSET_SCHEMA.EXPIRY.key]||''}</td>` : ''}
<td style="text-align:right;">${Number((asset as any)[ASSET_SCHEMA.PRICE.key]||0).toLocaleString()}</td>
<td style="text-align:center;">${statusHtml}</td>
<td>${asset.||''}</td>
<td>${asset.}</td>
<td>${asset.||''}</td>
<td>${asset.}</td>
<td style="text-align:center;">${asset.||''}</td>
<td style="text-align:center;">${asset.||''}</td>
<td style="text-align:center;">${asset.||''}</td>
<td style="text-align:right;">${formatPrice(asset.)}</td>
<td style="text-align:center;">${qty}</td>
<td style="text-align:center;"><strong style="color: ${avail > 0 ? 'var(--primary-color)' : 'var(--danger)'}">${avail}</strong></td>
<td style="text-align:center;">
<button class="btn-icon btn-user-mgmt" title="사용자 관리" style="margin: 0 auto; color: var(--primary-color);">
<i data-lucide="users" style="width:18px; height:18px;"></i>
</button>
</td>
`;
tr.addEventListener('click', () => openSwModal(asset, 'view'));
const userBtn = tr.querySelector('.btn-user-mgmt');
userBtn?.addEventListener('click', (e) => {
e.stopPropagation();
openSwUserModal(asset);
});
tr.addEventListener('click', (e) => {
openSwModal(asset, 'view');
});
tbody.appendChild(tr);
});
createIcons({ icons: { RefreshCcw } });
createIcons({ icons: { Edit2, Users, RefreshCcw } });
};
document.getElementById('filter-keyword')?.addEventListener('input', updateTable);