Files

829 lines
33 KiB
HTML

{% extends "base.html" %}
{% block title %}대시보드{% endblock %}
{% block head_extra %}
<style>
.dashboard-topbar {
display: flex;
justify-content: space-between;
align-items: center;
gap: 14px;
flex-wrap: wrap;
margin-bottom: 20px;
padding-bottom: 14px;
border-bottom: 1px solid var(--line);
}
.dashboard-topbar-actions {
display: inline-flex;
align-items: center;
gap: 10px;
flex-wrap: wrap;
}
.dashboard-year-select {
width: 180px;
}
.upload-actions {
position: relative;
display: inline-flex;
align-items: center;
}
.page-job-status {
display: inline-flex;
align-items: center;
min-height: 32px;
max-width: 320px;
padding: 0 10px;
border: 1px solid #d8e2ec;
border-radius: 999px;
background: #f8fafc;
color: #475569;
font-size: 12px;
font-weight: 800;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.page-job-status[data-state="queued"],
.page-job-status[data-state="running"] {
border-color: #bae6fd;
background: #f0f9ff;
color: #075985;
}
.page-job-status[data-state="done"] {
border-color: #bbf7d0;
background: #f0fdf4;
color: #166534;
}
.page-job-status[data-state="failed"] {
border-color: #fecaca;
background: #fef2f2;
color: #991b1b;
}
.upload-actions:hover .upload-tooltip,
.upload-actions:focus-within .upload-tooltip {
opacity: 1;
transform: translateY(0);
pointer-events: auto;
}
.upload-tooltip {
position: absolute;
top: calc(100% + 12px);
right: 0;
width: 280px;
background: rgba(20, 20, 20, 0.96);
color: #f8fbfd;
border-radius: 14px;
padding: 14px 16px;
font-size: 13px;
line-height: 1.7;
box-shadow: 0 18px 35px rgba(20, 20, 20, 0.18);
opacity: 0;
transform: translateY(-6px);
pointer-events: none;
transition: opacity 0.18s ease, transform 0.18s ease;
z-index: 10;
}
.upload-tooltip::before {
content: "";
position: absolute;
top: -8px;
right: 22px;
border-left: 8px solid transparent;
border-right: 8px solid transparent;
border-bottom: 8px solid rgba(20, 20, 20, 0.96);
}
.hidden-file-input {
display: none;
}
.dashboard-layout {
display: grid;
grid-template-columns: minmax(280px, 340px) minmax(0, 1fr);
gap: 16px;
align-items: stretch;
}
.dashboard-status-panel {
display: grid;
gap: 12px;
padding-right: 12px;
border-right: 1px solid rgba(217, 221, 227, 0.9);
}
.dashboard-status-grid {
display: grid;
grid-template-columns: 1fr;
gap: 12px;
}
.dashboard-status-grid .stat-card {
min-height: 84px;
padding-right: 10px;
}
.dashboard-status-grid .stat-card .value {
font-size: clamp(18px, 1.5vw, 28px);
line-height: 1.12;
}
.dashboard-chart-stack {
display: grid;
grid-template-rows: minmax(0, 1fr) minmax(0, 1fr);
gap: 16px;
}
.chart-panel {
display: grid;
gap: 12px;
content-visibility: auto;
contain-intrinsic-size: 480px;
}
.chart-panel-header {
display: flex;
justify-content: space-between;
align-items: center;
gap: 12px;
flex-wrap: wrap;
}
.chart-panel-header h3 {
font-size: 18px;
letter-spacing: -0.03em;
}
.filter-row {
display: grid;
grid-template-columns: repeat(3, minmax(0, 180px));
gap: 10px;
align-items: center;
}
.chart-shell {
background: transparent;
border: 0;
border-top: 1px solid var(--line);
border-radius: 0;
padding: 14px 0 0;
box-shadow: none;
display: grid;
gap: 12px;
content-visibility: auto;
contain-intrinsic-size: 420px;
}
.legend-box {
display: flex;
flex-wrap: wrap;
gap: 10px 12px;
}
.legend-box.center {
justify-content: center;
}
.legend-item {
display: inline-flex;
align-items: center;
gap: 8px;
border-radius: 0;
padding: 0;
background: transparent;
border: 0;
color: #363b44;
font-size: 13px;
font-weight: 700;
}
.legend-swatch {
width: 10px;
height: 10px;
border-radius: 999px;
display: inline-block;
}
.chart-svg {
width: 100%;
height: auto;
aspect-ratio: 1120 / 390;
min-height: 300px;
display: block;
}
body[data-view-mode="dual"] .dashboard-layout {
grid-template-columns: minmax(250px, 300px) minmax(0, 1fr);
gap: 12px;
min-height: calc(100vh - 150px);
}
body[data-view-mode="dual"] .dashboard-status-grid {
grid-template-columns: 1fr;
gap: 8px;
}
body[data-view-mode="dual"] .dashboard-status-grid .stat-card {
min-height: 64px;
padding: 10px 11px;
}
body[data-view-mode="dual"] .dashboard-status-grid .stat-card .value {
font-size: clamp(16px, 1.15vw, 24px);
}
body[data-view-mode="dual"] .dashboard-chart-stack {
grid-template-columns: repeat(2, minmax(0, 1fr));
grid-template-rows: minmax(0, 1fr);
gap: 12px;
min-height: 100%;
}
body[data-view-mode="dual"] .chart-panel {
gap: 8px;
}
body[data-view-mode="dual"] .chart-shell {
padding: 10px 0 0;
min-height: 100%;
}
body[data-view-mode="dual"] .chart-svg {
min-height: 320px;
}
@media (max-width: 1200px) {
.dashboard-layout {
grid-template-columns: 1fr;
}
.dashboard-status-panel {
padding-right: 0;
padding-bottom: 12px;
border-right: 0;
border-bottom: 1px solid rgba(217, 221, 227, 0.9);
}
.dashboard-chart-stack {
grid-template-rows: auto;
}
}
@media (max-width: 860px) {
.filter-row {
grid-template-columns: 1fr;
}
}
</style>
{% endblock %}
{% block content %}
<section class="panel">
<div class="dashboard-topbar">
<div class="section-title" style="margin-bottom: 0;">
<h2>사업현황</h2>
</div>
<div class="dashboard-topbar-actions">
<form method="get" action="/" id="dashboardYearForm">
<select id="dashboardYearSelect" class="dashboard-year-select" name="overview_year" aria-label="사업현황 연도 선택">
<option value="" {% if not overview_selected_year %}selected{% endif %}>최근 10개년</option>
{% for year in available_years %}
<option value="{{ year }}" {% if overview_selected_year == year %}selected{% endif %}>{{ year }}</option>
{% endfor %}
</select>
</form>
<button type="button" class="button-secondary" id="dashboardRebuildCacheBtn">캐시 재계산</button>
<span class="page-job-status" id="dashboardJobStatus" data-state="">작업 상태 확인 전</span>
<div class="upload-actions">
<form action="/upload" method="post" enctype="multipart/form-data" id="uploadForm">
<input id="excel_file" class="hidden-file-input" type="file" name="excel_file" accept=".xlsx,.xlsm,.xltx,.xltm" required>
<button type="button" id="uploadButton" class="button-icon" title="업로드 저장" aria-label="업로드 저장">
<svg viewBox="0 0 24 24" aria-hidden="true">
<path d="M6 4h9l3 3v13H6z"></path>
<path d="M9 4v6h6V4"></path>
<path d="M9 17h6"></path>
</svg>
<span class="sr-only">업로드 저장</span>
</button>
</form>
<div class="upload-tooltip">엑셀 파일을 선택하면 회계 데이터를 DB에 바로 저장합니다. 프로젝트 폴더에 둔 파일 외에 추가 파일을 수동 반영할 때 사용하세요.</div>
</div>
</div>
</div>
<div class="dashboard-layout">
<section class="dashboard-status-panel">
<div class="dashboard-status-grid">
<div class="stat-card">
<div class="label">수행 프로젝트</div>
<div class="value">{{ ((project_dashboard.related_projects or 0) - (project_dashboard.completed_projects or 0)) if ((project_dashboard.related_projects or 0) - (project_dashboard.completed_projects or 0)) > 0 else 0 }}</div>
</div>
<div class="stat-card">
<div class="label">종료 프로젝트</div>
<div class="value">{{ project_dashboard.completed_projects or 0 }}</div>
</div>
<div class="stat-card">
<div class="label">수금액</div>
<div class="value">{{ "{:,.0f}".format(project_dashboard.collection_amount or 0) }}</div>
</div>
<div class="stat-card">
<div class="label">비용</div>
<div class="value">{{ "{:,.0f}".format((overview.total_cost or 0) + (overview.total_sga or 0)) }}</div>
</div>
<div class="stat-card">
<div class="label">원가</div>
<div class="value">{{ "{:,.0f}".format(overview.total_cost or 0) }}</div>
</div>
<div class="stat-card">
<div class="label">판관비</div>
<div class="value">{{ "{:,.0f}".format(overview.total_sga or 0) }}</div>
</div>
</div>
</section>
<div class="dashboard-chart-stack">
<section class="chart-panel">
<div class="chart-panel-header">
<h3>수금 구성</h3>
<div class="filter-row">
<select id="revenueGranularity" aria-label="수금 구성 집계 단위">
<option value="yearly" {% if not overview_selected_year %}selected{% endif %}>연도별</option>
<option value="monthly" {% if overview_selected_year %}selected{% endif %}>월별</option>
</select>
<select id="revenueYear" aria-label="수금 구성 연도 선택">
<option value="all">전체연도</option>
{% for year in available_years %}
<option value="{{ year }}" {% if overview_selected_year == year %}selected{% endif %}>{{ year }}</option>
{% endfor %}
</select>
<select id="revenueMetric" aria-label="수금 구성 항목 선택">
<option value="all">전체 항목</option>
{% for option in dashboard_revenue_metric_options %}
<option value="{{ option.item_key }}">{{ option.label }}</option>
{% endfor %}
</select>
</div>
</div>
<div class="chart-shell">
<div class="legend-box center" id="revenueLegend"></div>
<svg id="revenueChart" class="chart-svg" viewBox="0 0 1120 390" preserveAspectRatio="xMidYMid meet"></svg>
</div>
</section>
<section class="chart-panel">
<div class="chart-panel-header">
<h3>지출 구성</h3>
<div class="filter-row">
<select id="expenseGranularity" aria-label="지출 구성 집계 단위">
<option value="yearly" {% if not overview_selected_year %}selected{% endif %}>연도별</option>
<option value="monthly" {% if overview_selected_year %}selected{% endif %}>월별</option>
</select>
<select id="expenseYear" aria-label="지출 구성 연도 선택">
<option value="all">전체연도</option>
{% for year in available_years %}
<option value="{{ year }}" {% if overview_selected_year == year %}selected{% endif %}>{{ year }}</option>
{% endfor %}
</select>
<select id="expenseMetric" aria-label="지출 구성 항목 선택">
<option value="all">전체 항목</option>
{% for option in dashboard_expense_metric_options %}
<option value="{{ option.item_key }}">{{ option.label }}</option>
{% endfor %}
</select>
</div>
</div>
<div class="chart-shell">
<div class="legend-box center" id="expenseLegend"></div>
<svg id="expenseChart" class="chart-svg" viewBox="0 0 1120 390" preserveAspectRatio="xMidYMid meet"></svg>
</div>
</section>
</div>
</div>
</section>
{% endblock %}
{% block script %}
<script>
const availableYears = {{ available_years | tojson }};
const pageSelectedYear = {{ overview_selected_year | tojson }};
let yearlySummary = [];
let monthlySummary = [];
let revenueYearly = [];
let revenueMonthly = [];
let dashboardJobPollTimer = null;
const dashboardRevenueMetricOptions = {{ dashboard_revenue_metric_options | tojson }};
const dashboardExpenseMetricOptions = {{ dashboard_expense_metric_options | tojson }};
const revenuePalette = Object.fromEntries(
dashboardRevenueMetricOptions.map((option) => [
option.item_key,
{ label: option.label, color: option.value },
]),
);
const expensePalette = Object.fromEntries(
dashboardExpenseMetricOptions.map((option) => [
option.item_key,
{ label: option.label, color: option.value },
]),
);
const revenueMetricMap = {
all: dashboardRevenueMetricOptions.map((option) => option.item_key),
...Object.fromEntries(dashboardRevenueMetricOptions.map((option) => [option.item_key, [option.item_key]])),
};
const expenseMetricMap = {
all: dashboardExpenseMetricOptions.map((option) => option.item_key),
...Object.fromEntries(dashboardExpenseMetricOptions.map((option) => [option.item_key, [option.item_key]])),
};
function formatNumber(value) {
return new Intl.NumberFormat("ko-KR", { maximumFractionDigits: 0 }).format(value || 0);
}
function formatValueLabel(value) {
const numeric = Number(value || 0);
if (!numeric) return "0.0";
if (numeric >= 100000000) return `${(numeric / 100000000).toFixed(1)}억`;
if (numeric >= 1000000) return `${(numeric / 1000000).toFixed(1)}백만`;
if (numeric >= 1000) return `${(numeric / 1000).toFixed(1)}천`;
return numeric.toFixed(1);
}
function formatAxisLabel(value) {
const numeric = Number(value || 0);
if (!numeric) return "0";
if (numeric >= 100000000) return `${numeric / 100000000}억`;
if (numeric >= 1000000) return `${numeric / 1000000}백만`;
if (numeric >= 1000) return `${numeric / 1000}천`;
return formatNumber(numeric);
}
function pickTickStep(maxValue) {
const baseUnit = maxValue < 1000000 ? 1000 : 1000000;
const units = [1, 2, 5];
const raw = Math.max(maxValue / baseUnit, 1);
let power = 1;
while (power * 10 <= raw) power *= 10;
for (const unit of units) {
const candidate = unit * power;
if (candidate >= raw) return candidate * baseUnit;
}
return power * 10 * baseUnit;
}
function buildPositiveAxis(maxValue, tickCount = 4) {
const safeMax = Math.max(Number(maxValue || 0), 1);
const paddedMax = safeMax * (safeMax < 1000 ? 1.12 : 1.08);
const tickStep = pickTickStep(paddedMax / tickCount);
const tickMax = Math.max(tickStep, Math.ceil(paddedMax / tickStep) * tickStep);
return { tickStep, tickMax };
}
function setLegend(containerId, metricKeys, paletteMap) {
const target = document.getElementById(containerId);
if (!target) return;
target.innerHTML = metricKeys.map((key) => `
<span class="legend-item">
<span class="legend-swatch" style="background:${paletteMap[key].color}"></span>
${paletteMap[key].label}
</span>
`).join("");
}
function renderEmptyChart(svgId, message) {
const svg = document.getElementById(svgId);
if (!svg) return;
svg.innerHTML = `
<rect x="0" y="0" width="1120" height="390" rx="8" fill="#f7fafb" stroke="#d6e2e8"></rect>
<text x="560" y="195" text-anchor="middle" fill="#667887" font-size="18" font-weight="700">${message}</text>
`;
}
function renderGroupedBarChart(svgId, rows, metricKeys, paletteMap, options = {}) {
const svg = document.getElementById(svgId);
if (!svg) return;
if (!rows.length || !metricKeys.length) {
renderEmptyChart(svgId, "표시할 집계 데이터가 없습니다.");
return;
}
const granularity = options.granularity || "yearly";
const width = 1120;
const height = 390;
const margin = { top: 34, right: 26, bottom: 74, left: 94 };
const plotWidth = width - margin.left - margin.right;
const plotHeight = height - margin.top - margin.bottom;
const maxValue = Math.max(1, ...rows.flatMap((row) => metricKeys.map((key) => Number(row[key] || 0))));
const { tickStep, tickMax } = buildPositiveAxis(maxValue, 4);
const groupWidth = plotWidth / Math.max(rows.length, 1);
const axisBaseY = height - margin.bottom;
const groupGapRatio = granularity === "monthly" ? 0.28 : 0.18;
const innerGap = granularity === "monthly" ? 7 : 10;
const groupInset = Math.max(groupWidth * groupGapRatio, granularity === "monthly" ? 8 : 12);
const usableGroupWidth = Math.max(groupWidth - groupInset * 2, metricKeys.length * 12);
const barWidth = Math.min((usableGroupWidth - innerGap * Math.max(metricKeys.length - 1, 0)) / Math.max(metricKeys.length, 1), granularity === "monthly" ? 18 : 34);
const actualGroupWidth = barWidth * metricKeys.length + innerGap * Math.max(metricKeys.length - 1, 0);
const groupStartOffset = (groupWidth - actualGroupWidth) / 2;
const xLabelStep = granularity === "monthly" ? 1 : Math.max(1, Math.ceil(rows.length / 10));
let markup = `
<defs>
<linearGradient id="${svgId}Bg" x1="0" x2="1" y1="0" y2="1">
<stop offset="0%" stop-color="#fcfefe" />
<stop offset="100%" stop-color="#edf4f7" />
</linearGradient>
<filter id="${svgId}Shadow" x="-20%" y="-20%" width="140%" height="160%">
<feDropShadow dx="0" dy="8" stdDeviation="8" flood-color="rgba(44, 68, 89, 0.14)" />
</filter>
</defs>
<rect x="0" y="0" width="${width}" height="${height}" rx="8" fill="url(#${svgId}Bg)"></rect>
<rect x="${margin.left}" y="${margin.top}" width="${plotWidth}" height="${plotHeight}" rx="4" fill="rgba(255,255,255,0.72)" stroke="#dde7ec"></rect>
`;
for (let value = 0; value <= tickMax; value += tickStep) {
const y = margin.top + plotHeight - (value / tickMax) * plotHeight;
markup += `<line x1="${margin.left}" y1="${y}" x2="${width - margin.right}" y2="${y}" stroke="#d8e3e8" stroke-dasharray="3 7"></line>`;
markup += `<text x="${margin.left - 16}" y="${y + 4}" text-anchor="end" fill="#60717d" font-size="11.5" font-weight="700">${formatAxisLabel(value)}</text>`;
}
markup += `<line x1="${margin.left}" y1="${axisBaseY}" x2="${width - margin.right}" y2="${axisBaseY}" stroke="#8ea0ac" stroke-width="1.2"></line>`;
rows.forEach((row, rowIndex) => {
const baseX = margin.left + rowIndex * groupWidth;
const showXAxisLabel = rows.length <= 14 || rowIndex % xLabelStep === 0 || rowIndex === rows.length - 1;
metricKeys.forEach((key, metricIndex) => {
const value = Number(row[key] || 0);
const x = baseX + groupStartOffset + metricIndex * (barWidth + innerGap);
const drawWidth = Math.max(barWidth, 10);
const barHeight = tickMax ? (value / tickMax) * plotHeight : 0;
const y = margin.top + plotHeight - barHeight;
const labelY = Math.max(y - 12, margin.top - 8);
const showValueLabel = barHeight > 24 && (granularity === "yearly" || metricKeys.length <= 2 || drawWidth >= 16);
markup += `<rect x="${x}" y="${y}" width="${drawWidth}" height="${barHeight}" rx="3" fill="${paletteMap[key].color}" filter="url(#${svgId}Shadow)"></rect>`;
if (showValueLabel) {
markup += `<text x="${x + drawWidth / 2}" y="${labelY}" text-anchor="middle" fill="#35505f" font-size="10" font-weight="700">${formatValueLabel(value)}</text>`;
}
});
if (showXAxisLabel) {
markup += `<text x="${baseX + groupWidth / 2}" y="${height - 28}" text-anchor="middle" fill="#405362" font-size="12.5" font-weight="700">${row.label}</text>`;
}
});
svg.innerHTML = markup;
}
function getLatestAvailableYear() {
return String(availableYears[availableYears.length - 1] || "all");
}
async function fetchDashboardJson(url, options = {}) {
const response = await fetch(url, {
...options,
cache: "no-store",
credentials: "same-origin",
headers: {
"Accept": "application/json",
"Cache-Control": "no-cache",
"X-Requested-With": "XMLHttpRequest",
...(options.headers || {}),
},
});
const payload = await response.json().catch(() => ({}));
if (!response.ok || payload?.error || payload?.ok === false) {
throw new Error(payload?.error || `HTTP ${response.status}`);
}
return payload;
}
function describeDashboardJob(job) {
if (!job) return { text: "최근 작업 없음", state: "" };
const status = String(job.status || "");
const range = job.start_year ? String(job.start_year) : "전체";
const message = job.error_message || job.message || "";
if (status === "queued") return { text: `${range} 대기 중`, state: status };
if (status === "running") return { text: `${range} 계산 중... ${message}`.trim(), state: status };
if (status === "done") return { text: `${range} 계산 완료`, state: status };
if (status === "failed") return { text: `${range} 실패: ${message || "오류"}`, state: status };
return { text: `${range} ${message || status || "작업 상태 확인 전"}`.trim(), state: status };
}
function renderDashboardJob(job) {
const jobStatus = document.getElementById("dashboardJobStatus");
const rebuildButton = document.getElementById("dashboardRebuildCacheBtn");
if (!jobStatus) return;
const view = describeDashboardJob(job);
jobStatus.textContent = view.text;
jobStatus.dataset.state = view.state || "";
if (rebuildButton) {
rebuildButton.disabled = view.state === "queued" || view.state === "running";
}
}
async function loadLatestDashboardJob() {
const params = new URLSearchParams({
page_key: "dashboard",
job_type: "dashboard_bootstrap",
});
const payload = await fetchDashboardJson(`/api/system-jobs/latest?${params.toString()}`);
renderDashboardJob(payload.job || null);
return payload.job || null;
}
function pollDashboardJob(jobId) {
if (!jobId) return;
if (dashboardJobPollTimer) window.clearInterval(dashboardJobPollTimer);
dashboardJobPollTimer = window.setInterval(async () => {
try {
const payload = await fetchDashboardJson(`/api/system-jobs/${encodeURIComponent(jobId)}`);
const job = payload.job || null;
renderDashboardJob(job);
if (!job || ["done", "failed", "cancelled"].includes(String(job.status || ""))) {
window.clearInterval(dashboardJobPollTimer);
dashboardJobPollTimer = null;
if (job && job.status === "done") {
window.setTimeout(() => window.location.reload(), 600);
}
}
} catch (_error) {
// Transient DB locks should not block the dashboard controls.
}
}, 2500);
}
async function requestDashboardCacheRebuild() {
const rebuildButton = document.getElementById("dashboardRebuildCacheBtn");
if (rebuildButton) rebuildButton.disabled = true;
const payload = await fetchDashboardJson("/dashboard/api/rebuild-cache", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ overview_year: pageSelectedYear || null }),
});
renderDashboardJob(payload.job || null);
pollDashboardJob(payload.job?.id);
}
function syncYearSelection(selectId, granularity) {
const select = document.getElementById(selectId);
if (!select) return;
if (granularity === "yearly") {
select.value = "all";
select.disabled = true;
return;
}
const hasSelectedYear = availableYears.some((year) => String(year) === String(select.value));
if (select.value === "all" || !hasSelectedYear) {
select.value = pageSelectedYear ? String(pageSelectedYear) : getLatestAvailableYear();
}
select.disabled = false;
}
function getRevenueRows() {
const granularity = document.getElementById("revenueGranularity").value;
const yearFilter = document.getElementById("revenueYear").value;
const source = granularity === "yearly" ? revenueYearly : revenueMonthly;
return source
.filter((row) => {
if (granularity === "yearly") return true;
return yearFilter === "all" ? true : String(row.year) === yearFilter;
})
.map((row) => ({
...row,
label: granularity === "yearly" ? String(row.year) : `${row.month}월`,
}))
.slice(granularity === "yearly" ? -10 : 0);
}
function getExpenseRows() {
const granularity = document.getElementById("expenseGranularity").value;
const yearFilter = document.getElementById("expenseYear").value;
const source = granularity === "yearly" ? yearlySummary : monthlySummary;
return source
.filter((row) => {
if (granularity === "yearly") return true;
return yearFilter === "all" ? true : String(row.year) === yearFilter;
})
.map((row) => ({
...row,
label: granularity === "yearly" ? String(row.year) : `${row.month}월`,
}))
.slice(granularity === "yearly" ? -10 : 0);
}
async function loadDashboardBootstrapData() {
const queryParams = new URLSearchParams();
if (pageSelectedYear) {
queryParams.set("overview_year", String(pageSelectedYear));
}
const query = queryParams.toString() ? `?${queryParams.toString()}` : "";
const response = await fetch(`/bootstrap-data${query}`, {
method: "GET",
credentials: "same-origin",
headers: {
"Accept": "application/json",
},
});
const payload = await response.json().catch(() => ({}));
if (!response.ok || payload?.error) {
throw new Error(payload?.error || `HTTP ${response.status}`);
}
yearlySummary = Array.isArray(payload?.yearly_summary) ? payload.yearly_summary : [];
monthlySummary = Array.isArray(payload?.monthly_summary) ? payload.monthly_summary : [];
revenueYearly = Array.isArray(payload?.project_revenue_mix_yearly) ? payload.project_revenue_mix_yearly : [];
revenueMonthly = Array.isArray(payload?.project_revenue_mix_monthly) ? payload.project_revenue_mix_monthly : [];
}
function updateRevenueChart() {
const granularity = document.getElementById("revenueGranularity").value;
syncYearSelection("revenueYear", granularity);
const metricKeys = revenueMetricMap[document.getElementById("revenueMetric").value];
setLegend("revenueLegend", metricKeys, revenuePalette);
renderGroupedBarChart("revenueChart", getRevenueRows(), metricKeys, revenuePalette, { granularity });
}
function updateExpenseChart() {
const granularity = document.getElementById("expenseGranularity").value;
syncYearSelection("expenseYear", granularity);
const metricKeys = expenseMetricMap[document.getElementById("expenseMetric").value];
setLegend("expenseLegend", metricKeys, expensePalette);
renderGroupedBarChart("expenseChart", getExpenseRows(), metricKeys, expensePalette, { granularity });
}
document.getElementById("revenueGranularity")?.addEventListener("change", updateRevenueChart);
document.getElementById("revenueYear")?.addEventListener("change", updateRevenueChart);
document.getElementById("revenueMetric")?.addEventListener("change", updateRevenueChart);
document.getElementById("expenseGranularity")?.addEventListener("change", updateExpenseChart);
document.getElementById("expenseYear")?.addEventListener("change", updateExpenseChart);
document.getElementById("expenseMetric")?.addEventListener("change", updateExpenseChart);
document.getElementById("dashboardYearSelect")?.addEventListener("change", (event) => {
event.target.form?.submit();
});
document.getElementById("dashboardRebuildCacheBtn")?.addEventListener("click", async () => {
const confirmed = window.confirm("현재 대시보드 조건의 데이터를 서버에서 다시 계산할까요? 계산 중에도 다른 화면을 사용할 수 있습니다.");
if (!confirmed) return;
try {
await requestDashboardCacheRebuild();
} catch (error) {
const jobStatus = document.getElementById("dashboardJobStatus");
if (jobStatus) {
jobStatus.textContent = error.message || "작업 등록 실패";
jobStatus.dataset.state = "failed";
}
const rebuildButton = document.getElementById("dashboardRebuildCacheBtn");
if (rebuildButton) rebuildButton.disabled = false;
}
});
const uploadButton = document.getElementById("uploadButton");
const excelInput = document.getElementById("excel_file");
const uploadForm = document.getElementById("uploadForm");
uploadButton?.addEventListener("click", () => {
excelInput?.click();
});
excelInput?.addEventListener("change", () => {
if (excelInput.files && excelInput.files.length > 0) {
uploadForm.submit();
}
});
(async () => {
try {
await loadDashboardBootstrapData();
} catch (error) {
console.error("대시보드 부트스트랩 데이터 조회 에러", error);
}
syncYearSelection("revenueYear", document.getElementById("revenueGranularity")?.value || "yearly");
syncYearSelection("expenseYear", document.getElementById("expenseGranularity")?.value || "yearly");
updateRevenueChart();
updateExpenseChart();
loadLatestDashboardJob()
.then((job) => {
if (job && ["queued", "running"].includes(String(job.status || ""))) {
pollDashboardJob(job.id);
}
})
.catch(() => {
const jobStatus = document.getElementById("dashboardJobStatus");
if (jobStatus) {
jobStatus.textContent = "작업 상태 조회 실패";
jobStatus.dataset.state = "failed";
}
});
})();
</script>
{% endblock %}