merge: integrate collaborator features and synchronize with shared DB infrastructure

This commit is contained in:
2026-04-21 10:00:57 +09:00
25 changed files with 5706 additions and 1906 deletions

View File

@@ -14,6 +14,7 @@ export function renderSwDashboard(container: HTMLElement) {
let cloudExp = 0;
const currentYear = new Date().getFullYear();
const today = new Date();
const corps = ['한맥', '삼안', '바론'];
const categories = ['업무공통', '개발S/W', '디자인', '설계S/W'];
@@ -22,9 +23,10 @@ export function renderSwDashboard(container: HTMLElement) {
const costByCat: Record<string, number> = {};
categories.forEach(c => costByCat[c] = 0);
const today = new Date();
// 통합 SW 데이터 (호환용)
const allSw = [...state.masterData.subSw, ...state.masterData.permSw];
state.masterData.sw.forEach(sw => {
allSw.forEach(sw => {
const assigned = state.masterData.swUsers.filter(u => u.swId === sw.id).length;
const qty = typeof sw. === 'number' ? sw.수량 : parseInt(sw.||'0', 10);
const priceStr = sw. ? String(sw.).replace(/,/g, '') : '0';
@@ -36,22 +38,6 @@ export function renderSwDashboard(container: HTMLElement) {
} else if (sw.type === '영구SW') {
permQty += qty; permUsed += assigned; permTotal++;
if (isSWExpiring(sw)) permExp++;
} else if (sw.type === '클라우드') {
if (sw.) {
thisMonthCloudCost += parseInt(String(sw.).replace(/,/g, ''), 10) || 0;
}
if (sw.) {
const payDay = parseInt(sw., 10);
if (!isNaN(payDay)) {
const nextPayMs = new Date(today.getFullYear(), today.getMonth(), payDay).getTime();
let diff = (nextPayMs - today.getTime()) / (1000 * 60 * 60 * 24);
if (diff < 0) {
const nextMonthMs = new Date(today.getFullYear(), today.getMonth() + 1, payDay).getTime();
diff = (nextMonthMs - today.getTime()) / (1000 * 60 * 60 * 24);
}
if (diff <= 14) cloudExp++;
}
}
}
if (sw. && sw..startsWith(String(currentYear))) {
@@ -60,14 +46,14 @@ export function renderSwDashboard(container: HTMLElement) {
}
});
// 클라우드 데이터 처리 (필요시 추가)
// ...
const subPer = subQty > 0 ? Math.round((subUsed/subQty)*100) : 0;
const permPer = permQty > 0 ? Math.round((permUsed/permQty)*100) : 0;
const subExpPer = subTotal > 0 ? Math.round((subExp/subTotal)*100) : 0;
const permExpPer = permTotal > 0 ? Math.round((permExp/permTotal)*100) : 0;
const cloudExpTotal = state.masterData.sw.filter(s => s.type === '클라우드').length;
const cloudExpPer = cloudExpTotal > 0 ? Math.round((cloudExp/cloudExpTotal)*100) : 0;
// Cloud trend & Last month cost logic
const cloudCostTrend = [0, 0, 0, 0];
const trendLabels: string[] = [];
for(let i=3; i>=0; i--) {
@@ -75,25 +61,6 @@ export function renderSwDashboard(container: HTMLElement) {
d.setMonth(d.getMonth() - i);
trendLabels.push(`${d.getMonth()+1}`);
}
if (state.masterData.logs) {
state.masterData.logs.forEach(log => {
const match = log.details.match(/[^\d]*([\d,]+)/);
if (match && (log.details.includes('청구액') || log.details.includes('비용'))) {
const cost = parseInt(match[1].replace(/,/g, ''), 10);
const logDate = new Date(log.date);
const monthDiff = (today.getFullYear() - logDate.getFullYear())*12 + (today.getMonth() - logDate.getMonth());
if (monthDiff === 1) lastMonthCloudCost += cost;
if (monthDiff >= 0 && monthDiff < 4) cloudCostTrend[3 - monthDiff] += cost;
}
});
}
const costDiff = thisMonthCloudCost - lastMonthCloudCost;
const costDiffText = lastMonthCloudCost > 0
? `${costDiff >= 0 ? '+' : ''}${((costDiff/lastMonthCloudCost)*100).toFixed(1)}%`
: 'New';
container.innerHTML = `
<div class="view-container">
@@ -143,53 +110,16 @@ export function renderSwDashboard(container: HTMLElement) {
</div>
</div>
<!-- 클라우드 전월/당월 통합 및 결제 임박 (1:1 비율) -->
<div class="dashboard-layout-2col" style="margin-bottom: 2.5rem;">
<div class="dashboard-card" data-action="cloud-billing" style="flex-direction:row; cursor:pointer; min-height:auto; align-items:center; gap: 1.5rem;">
<div style="flex:1;">
<span style="font-size:1rem; font-weight:700; color:var(--text-main);">☁️ 클라우드 청구 현황 (통합)</span>
<div style="margin-top: 0.5rem; display:flex; align-items:baseline; gap: 0.75rem;">
<span style="font-size: 2rem; font-weight:800; color:var(--primary-color);">₩ ${thisMonthCloudCost.toLocaleString()}</span>
<span style="font-size: 0.85rem; font-weight:600; color:${costDiff >= 0 ? 'var(--dash-danger)' : 'var(--dash-primary)'};">
${costDiff >= 0 ? '▲' : '▼'} ${costDiffText}
</span>
</div>
<div style="font-size: 0.8125rem; color:var(--text-muted); margin-top:0.25rem;">전월 실적: ₩ ${lastMonthCloudCost.toLocaleString()}</div>
</div>
<div style="width: 50px; height: 50px; border-radius: 50%; background: conic-gradient(var(--primary-color) 100%, var(--border-color) 0); display:flex; justify-content:center; align-items:center; opacity:0.8;">
<div style="width: 40px; height: 40px; border-radius: 50%; background: var(--white); display:flex; justify-content:center; align-items:center;">
<span style="font-size: 0.75rem; color:var(--text-muted); font-weight:600;">₩</span>
</div>
</div>
</div>
<div class="dashboard-card" data-action="cloud-exp" style="flex-direction:row; justify-content:space-between; align-items:center; cursor:pointer; min-height:auto;">
<div style="flex:1;">
<span style="font-size:1rem; font-weight:700; color:var(--text-main);">클라우드 결제 임박<br><span style="font-size:0.8rem;font-weight:400;color:var(--text-muted);">(14일 이내)</span></span>
<div style="font-size: 1.5rem; font-weight:700; color:${cloudExp > 0 ? 'var(--dash-danger)' : 'var(--text-main)'}; margin-top:0.5rem;">${cloudExp}건 청구</div>
</div>
<div style="width: 50px; height: 50px; border-radius: 50%; background: conic-gradient(var(--dash-danger) ${cloudExpPer}%, var(--border-color) 0); display:flex; justify-content:center; align-items:center;">
<div style="width: 40px; height: 40px; border-radius: 50%; background: var(--white); display:flex; justify-content:center; align-items:center;">
<span style="font-size: 0.75rem; color:var(--text-muted); font-weight:600;">${cloudExpPer}%</span>
</div>
</div>
</div>
</div>
<h3 class="dashboard-section-title">비용 분석 현황</h3>
<div style="display:grid; grid-template-columns: repeat(3, 1fr); gap: 1rem; margin-bottom: 2rem;">
<div class="dashboard-card" style="grid-column: span 1;">
<h4 style="margin-bottom:1rem; font-size:0.9rem; color:var(--text-muted);">${currentYear}년 법인별 도입 금액 (원)</h4>
<h3 class="dashboard-section-title">${currentYear}년 도입 비용 분석</h3>
<div class="dashboard-layout-2col">
<div class="dashboard-card">
<h4 style="margin-bottom:1rem; font-size:0.9rem; color:var(--text-muted);">구매법인별 도입 금액 (원)</h4>
<canvas id="chart-sw-corp"></canvas>
</div>
<div class="dashboard-card" style="grid-column: span 1;">
<h4 style="margin-bottom:1rem; font-size:0.9rem; color:var(--text-muted);">${currentYear}분야별 도입 금액 (원)</h4>
<div class="dashboard-card">
<h4 style="margin-bottom:1rem; font-size:0.9rem; color:var(--text-muted);">분야별 도입 금액 (원)</h4>
<canvas id="chart-sw-cat"></canvas>
</div>
<div class="dashboard-card" style="grid-column: span 1;">
<h4 style="margin-bottom:1rem; font-size:0.9rem; color:var(--text-muted);">직전 4개월 클라우드 결제 추이 (원)</h4>
<canvas id="chart-cloud-trend"></canvas>
</div>
</div>
</div>
`;
@@ -199,71 +129,27 @@ export function renderSwDashboard(container: HTMLElement) {
const ctxCorp = (document.getElementById('chart-sw-corp') as HTMLCanvasElement)?.getContext('2d');
if (ctxCorp) {
const chart = new Chart(ctxCorp, {
new Chart(ctxCorp, {
type: 'bar',
data: { labels: corps, datasets: [{ data: corps.map(c => costByCorp[c]), backgroundColor: 'rgba(30, 81, 73, 0.8)', borderRadius: 4 }] },
options: { responsive: true, maintainAspectRatio: false, plugins: { legend: { display: false } } }
});
state.activeCharts.push(chart);
}
const ctxTrend = (document.getElementById('chart-cloud-trend') as HTMLCanvasElement)?.getContext('2d');
if (ctxTrend) {
const chart = new Chart(ctxTrend, {
type: 'line',
data: {
labels: trendLabels,
datasets: [{
data: cloudCostTrend,
borderColor: '#6366f1',
backgroundColor: 'rgba(99, 102, 241, 0.05)',
borderWidth: 2,
fill: true,
tension: 0.4
}]
},
options: {
responsive: true,
maintainAspectRatio: false,
plugins: { legend: { display: false } },
scales: { y: { beginAtZero: true } }
}
});
state.activeCharts.push(chart);
}
const ctxCat = (document.getElementById('chart-sw-cat') as HTMLCanvasElement)?.getContext('2d');
if (ctxCat) {
const chart = new Chart(ctxCat, {
new Chart(ctxCat, {
type: 'bar',
data: { labels: categories, datasets: [{ data: categories.map(c => costByCat[c]), backgroundColor: 'rgba(59, 130, 246, 0.8)', borderRadius: 4 }] },
options: { responsive: true, maintainAspectRatio: false, plugins: { legend: { display: false } } }
});
state.activeCharts.push(chart);
}
}, 100);
container.querySelector('[data-action="sub-usage"]')?.addEventListener('click', () => openSwUsageDetail('구독 소프트웨어 사용 목록', state.masterData.sw.filter(sw => sw.type === '구독SW')));
container.querySelector('[data-action="perm-usage"]')?.addEventListener('click', () => openSwUsageDetail('영구 소프트웨어 사용 목록', state.masterData.sw.filter(sw => sw.type === '영구SW')));
container.querySelector('[data-action="sub-exp"]')?.addEventListener('click', () => openSwDashboardDetail('구독 SW 만료 예정 목록', state.masterData.sw.filter(sw => sw.type === '구독SW' && isSWExpiring(sw))));
container.querySelector('[data-action="perm-exp"]')?.addEventListener('click', () => openSwDashboardDetail('유지보수 만료 예정 목록', state.masterData.sw.filter(sw => sw.type === '영구SW' && isSWExpiring(sw))));
container.querySelector('[data-action="cloud-billing"]')?.addEventListener('click', () => openCloudDashboardDetail('클라우드 청구 현황 (전체)', state.masterData.sw.filter(sw => sw.type === '클라우드')));
container.querySelector('[data-action="cloud-exp"]')?.addEventListener('click', () => {
const expiringClouds = state.masterData.sw.filter(sw => {
if (sw.type !== '클라우드' || !sw.) return false;
const payDay = parseInt(sw., 10);
if (isNaN(payDay)) return false;
const nextPayMs = new Date(today.getFullYear(), today.getMonth(), payDay).getTime();
let diff = (nextPayMs - today.getTime()) / (1000 * 60 * 60 * 24);
if (diff < 0) {
const nextMonthMs = new Date(today.getFullYear(), today.getMonth() + 1, payDay).getTime();
diff = (nextMonthMs - today.getTime()) / (1000 * 60 * 60 * 24);
}
return diff <= 14;
});
openCloudDashboardDetail('결제 임박 클라우드 목록 (14일 이내)', expiringClouds);
});
container.querySelector('[data-action="sub-usage"]')?.addEventListener('click', () => openSwUsageDetail('구독 소프트웨어 사용 목록', state.masterData.subSw));
container.querySelector('[data-action="perm-usage"]')?.addEventListener('click', () => openSwUsageDetail('영구 소프트웨어 사용 목록', state.masterData.permSw));
container.querySelector('[data-action="sub-exp"]')?.addEventListener('click', () => openSwDashboardDetail('구독 SW 만료 예정 목록', state.masterData.subSw.filter(sw => isSWExpiring(sw))));
container.querySelector('[data-action="perm-exp"]')?.addEventListener('click', () => openSwDashboardDetail('유지보수 만료 예정 목록', state.masterData.permSw.filter(sw => isSWExpiring(sw))));
}
function isSWExpiring(sw: SoftwareAsset) {
@@ -274,11 +160,15 @@ function isSWExpiring(sw: SoftwareAsset) {
const diffDays = (endMs - Date.now()) / (1000 * 60 * 60 * 24);
return diffDays >= 0 && diffDays <= 30;
}
} else if (sw.type === '영구SW' && sw. && sw..includes('유지보수: ~')) {
} else if (sw.type === '영구SW' && sw. && sw..includes('~')) {
// 임시 로직: 비고란에 날짜가 포함된 경우
try {
const endMs = new Date(normalizeDate(sw..split('~')[1])).getTime();
const diffDays = (endMs - Date.now()) / (1000 * 60 * 60 * 24);
return diffDays >= 0 && diffDays <= 30;
const dateMatch = sw..match(/\\d{4}-\\d{2}-\\d{2}/);
if (dateMatch) {
const endMs = new Date(normalizeDate(dateMatch[0])).getTime();
const diffDays = (endMs - Date.now()) / (1000 * 60 * 60 * 24);
return diffDays >= 0 && diffDays <= 30;
}
} catch { return false; }
}
return false;