Files

771 lines
30 KiB
HTML

{% extends "base.html" %}
{% block title %}연도별 수익/비용{% endblock %}
{% block head_extra %}
<style>
.summary-layout {
display: grid;
grid-template-columns: 320px minmax(0, 1fr);
gap: 16px;
align-items: stretch;
}
.summary-panel {
display: grid;
grid-template-columns: 320px minmax(0, 1fr);
gap: 16px;
align-items: stretch;
}
.filter-grid {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 8px;
align-items: end;
}
.summary-actions {
display: flex;
align-items: center;
gap: 8px;
flex-wrap: wrap;
}
.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;
}
.legend {
display: flex;
flex-wrap: wrap;
gap: 12px;
margin-top: 0;
justify-content: center;
}
.legend-item {
display: inline-flex;
align-items: center;
gap: 8px;
color: #2f3c49;
font-size: 13px;
font-weight: 800;
letter-spacing: -0.01em;
background: transparent;
border: 0;
border-radius: 0;
padding: 0;
}
.legend-swatch {
width: 10px;
height: 10px;
border-radius: 999px;
}
.chart-box {
background: transparent;
border: 0;
border-top: 1px solid var(--line);
border-radius: 0;
padding: 18px 0 0;
box-shadow: none;
content-visibility: auto;
contain-intrinsic-size: 620px;
}
.chart-legend {
margin-bottom: 12px;
}
.chart-svg {
width: 100%;
height: auto;
aspect-ratio: 1560 / 620;
min-height: 460px;
display: block;
}
.expense-chart-svg {
aspect-ratio: 1560 / 660;
min-height: 510px;
}
.chart-note {
margin-top: 10px;
color: var(--muted);
font-size: 13px;
line-height: 1.6;
}
.summary-overview {
display: grid;
grid-template-rows: auto auto 1fr;
gap: 14px;
min-height: 100%;
content-visibility: auto;
contain-intrinsic-size: 760px;
padding-right: 14px;
border-right: 1px solid rgba(217, 221, 227, 0.9);
}
.metric-grid {
display: grid;
grid-template-columns: 1fr;
gap: 10px;
align-content: stretch;
}
.chart-stack {
display: grid;
gap: 16px;
min-height: 100%;
}
.summary-overview .stat-card,
.chart-stack .stat-card {
min-height: 112px;
}
.summary-overview .section-title h2,
.chart-stack .section-title h2 {
font-size: 17px;
}
.summary-overview .field select {
min-width: 0;
padding: 10px 12px;
font-size: 13px;
}
.summary-overview .stat-card {
padding: 0 8px 12px 0;
border-radius: 0;
gap: 4px;
}
.summary-overview .stat-card .label {
font-size: 12px;
}
.summary-overview .stat-card .value {
font-size: 22px;
line-height: 1.18;
}
body[data-view-mode="dual"] .summary-panel {
grid-template-columns: minmax(250px, 300px) minmax(0, 1fr);
gap: 12px;
min-height: calc(100vh - 150px);
}
body[data-view-mode="dual"] .summary-overview {
gap: 10px;
}
body[data-view-mode="dual"] .metric-grid {
grid-template-columns: 1fr;
gap: 8px;
}
body[data-view-mode="dual"] .summary-overview .stat-card {
min-height: 64px;
padding: 10px 11px;
}
body[data-view-mode="dual"] .summary-overview .stat-card .value {
font-size: 18px;
}
body[data-view-mode="dual"] .chart-stack {
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 12px;
align-items: stretch;
min-height: 100%;
}
body[data-view-mode="dual"] .chart-box {
padding: 12px 0 0;
min-height: 100%;
}
body[data-view-mode="dual"] .chart-svg,
body[data-view-mode="dual"] .expense-chart-svg {
aspect-ratio: 1560 / 620;
min-height: 360px;
}
@media (max-width: 1000px) {
.summary-layout,
.summary-panel,
.filter-grid,
.metric-grid {
grid-template-columns: 1fr;
}
.summary-overview {
padding-right: 0;
padding-bottom: 12px;
border-right: 0;
border-bottom: 1px solid rgba(217, 221, 227, 0.9);
}
}
</style>
{% endblock %}
{% block content %}
<section class="panel">
<div class="section-title" style="margin-bottom: 14px;">
<h2>수익/비용 현황</h2>
<div class="summary-actions">
<a class="button-link button-secondary" href="/annual-summary/gap-analysis">차이분석</a>
<button type="button" class="button-secondary" id="annualRebuildCacheBtn">캐시 재계산</button>
<span class="page-job-status" id="annualJobStatus" data-state="">작업 상태 확인 전</span>
</div>
</div>
<div class="summary-panel">
<div class="summary-overview">
<div class="filter-grid">
<div class="field">
<select id="granularity" aria-label="보기 기준">
<option value="yearly">연간</option>
<option value="monthly">월간</option>
</select>
</div>
<div class="field">
<select id="yearFilter" aria-label="연도 선택">
<option value="recent10">최근 10개년</option>
{% for year in available_years %}
<option value="{{ year }}">{{ year }}</option>
{% endfor %}
</select>
</div>
</div>
<div class="metric-grid" id="metricGrid"></div>
</div>
<div class="chart-stack">
<div class="chart-box">
<div class="section-title" style="margin-bottom: 10px;">
<h2>비용 구조</h2>
</div>
<div class="legend chart-legend" id="expenseLegend"></div>
<svg id="expenseChart" class="chart-svg expense-chart-svg" viewBox="0 0 1560 660" preserveAspectRatio="xMidYMid meet"></svg>
</div>
<div class="chart-box">
<div class="section-title" style="margin-bottom: 10px;">
<h2>수금/비용/영업수지 그래프</h2>
</div>
<div class="legend chart-legend" id="balanceLegend"></div>
<svg id="balanceChart" class="chart-svg" viewBox="0 0 1560 620" preserveAspectRatio="xMidYMid meet"></svg>
</div>
</div>
</div>
</section>
{% endblock %}
{% block script %}
<script>
let yearlySeries = [];
let monthlySeries = [];
let availableYears = [];
let annualJobPollTimer = null;
const annualMetricCards = {{ annual_metric_cards | tojson }};
const annualExpenseChartMetrics = {{ annual_expense_chart_metrics | tojson }};
const annualBalanceChartMetrics = {{ annual_balance_chart_metrics | tojson }};
const palette = Object.fromEntries(
[...annualExpenseChartMetrics, ...annualBalanceChartMetrics].map((option) => [
option.item_key,
option.value,
]),
);
const labels = Object.fromEntries(
annualMetricCards.map((option) => [option.item_key, option.label]),
);
function formatNumber(value) {
return new Intl.NumberFormat("ko-KR", { maximumFractionDigits: 0 }).format(value || 0);
}
function formatAxisLabel(value) {
const numeric = Number(value || 0);
if (!numeric) return "0.0";
const sign = numeric < 0 ? "-" : "";
const absolute = Math.abs(numeric);
if (absolute >= 100000000) return `${sign}${(absolute / 100000000).toFixed(1)}억`;
if (absolute >= 1000000) return `${sign}${(absolute / 1000000).toFixed(1)}백만`;
if (absolute >= 1000) return `${sign}${(absolute / 1000).toFixed(1)}천`;
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 buildPositiveAxisScale(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 buildDynamicPositiveAxis(maxValue, width, height, margin) {
const safeMax = Math.max(Number(maxValue || 0), 1);
const paddedMax = safeMax * 1.08;
const tickStep = pickTickStep(paddedMax / 5);
const tickMax = Math.max(tickStep, Math.ceil(paddedMax / tickStep) * tickStep);
let axis = "";
for (let value = 0; value <= tickMax; value += tickStep) {
const y = height - margin.bottom - ((height - margin.top - margin.bottom) * value) / tickMax;
axis += `<line x1="${margin.left}" y1="${y}" x2="${width - margin.right}" y2="${y}" stroke="#d8dee5" stroke-dasharray="4 8" />`;
axis += `<text x="${margin.left - 14}" y="${y + 5}" text-anchor="end" fill="#52606d" font-size="14" font-weight="800">${formatAxisLabel(value)}</text>`;
}
axis += `<line x1="${margin.left}" y1="${height - margin.bottom}" x2="${width - margin.right}" y2="${height - margin.bottom}" stroke="#8ba0ae" stroke-width="1.4" />`;
return { axis, tickStep, tickMax };
}
function buildDynamicBalanceAxis(maxPositiveValue, minNegativeValue, width, height, margin) {
const safePositive = Math.max(Number(maxPositiveValue || 0), 0);
const safeNegative = Math.min(Number(minNegativeValue || 0), 0);
const paddedPositive = safePositive > 0 ? safePositive * 1.08 : 1000;
const paddedNegative = safeNegative < 0 ? safeNegative * 1.08 : 0;
const rangeAbs = Math.max(Math.abs(paddedPositive), Math.abs(paddedNegative), 1);
const tickStep = pickTickStep(rangeAbs / 4);
const positiveMax = Math.max(tickStep, Math.ceil(paddedPositive / tickStep) * tickStep);
const negativeMin = safeNegative < 0 ? Math.min(-tickStep, Math.floor(paddedNegative / tickStep) * tickStep) : 0;
const plotHeight = height - margin.top - margin.bottom;
const totalRange = positiveMax - negativeMin;
const zeroY = margin.top + (plotHeight * positiveMax) / totalRange;
let axis = "";
for (let value = negativeMin; value <= positiveMax; value += tickStep) {
const y = margin.top + ((positiveMax - value) / totalRange) * plotHeight;
axis += `<line x1="${margin.left}" y1="${y}" x2="${width - margin.right}" y2="${y}" stroke="#d8dee5" stroke-dasharray="4 8" />`;
axis += `<text x="${margin.left - 14}" y="${y + 5}" text-anchor="end" fill="#52606d" font-size="14" font-weight="800">${formatAxisLabel(value)}</text>`;
}
axis += `<line x1="${margin.left}" y1="${zeroY}" x2="${width - margin.right}" y2="${zeroY}" stroke="#7c8d9a" stroke-width="1.5" />`;
return { axis, tickStep, positiveMax, negativeMin, zeroY, totalRange };
}
function renderLegend(targetId, keys) {
const target = document.getElementById(targetId);
if (!target) return;
target.innerHTML = keys.map((key) => `
<span class="legend-item">
<span class="legend-swatch" style="background:${palette[key]};"></span>
${labels[key]}
</span>
`).join("");
}
function getLatestAvailableYear() {
return String(availableYears[availableYears.length - 1] || "recent10");
}
async function fetchAnnualJson(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 describeAnnualJob(job) {
if (!job) return { text: "최근 작업 없음", state: "" };
const status = String(job.status || "");
const message = job.error_message || job.message || "";
if (status === "queued") return { text: "작업 대기 중", state: status };
if (status === "running") return { text: `계산 중... ${message}`.trim(), state: status };
if (status === "done") return { text: "계산 완료", state: status };
if (status === "failed") return { text: `실패: ${message || "오류"}`, state: status };
return { text: message || status || "작업 상태 확인 전", state: status };
}
function renderAnnualJob(job) {
const jobStatus = document.getElementById("annualJobStatus");
const rebuildButton = document.getElementById("annualRebuildCacheBtn");
if (!jobStatus) return;
const view = describeAnnualJob(job);
jobStatus.textContent = view.text;
jobStatus.dataset.state = view.state || "";
if (rebuildButton) {
rebuildButton.disabled = view.state === "queued" || view.state === "running";
}
}
async function loadLatestAnnualJob() {
const params = new URLSearchParams({
page_key: "annual_summary",
job_type: "annual_summary_bootstrap",
});
const payload = await fetchAnnualJson(`/api/system-jobs/latest?${params.toString()}`);
renderAnnualJob(payload.job || null);
return payload.job || null;
}
function pollAnnualJob(jobId) {
if (!jobId) return;
if (annualJobPollTimer) window.clearInterval(annualJobPollTimer);
annualJobPollTimer = window.setInterval(async () => {
try {
const payload = await fetchAnnualJson(`/api/system-jobs/${encodeURIComponent(jobId)}`);
const job = payload.job || null;
renderAnnualJob(job);
if (!job || ["done", "failed", "cancelled"].includes(String(job.status || ""))) {
window.clearInterval(annualJobPollTimer);
annualJobPollTimer = null;
if (job && job.status === "done") {
window.setTimeout(() => window.location.reload(), 600);
}
}
} catch (_error) {
// Keep existing charts visible during transient lock or network delays.
}
}, 2500);
}
async function requestAnnualCacheRebuild() {
const rebuildButton = document.getElementById("annualRebuildCacheBtn");
if (rebuildButton) rebuildButton.disabled = true;
const payload = await fetchAnnualJson("/annual-summary/api/rebuild-cache", {
method: "POST",
});
renderAnnualJob(payload.job || null);
pollAnnualJob(payload.job?.id);
}
async function loadAnnualSummaryBootstrapData() {
const response = await fetch("/annual-summary/bootstrap-data", {
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}`);
}
yearlySeries = Array.isArray(payload?.yearly_financial_series) ? payload.yearly_financial_series : [];
monthlySeries = Array.isArray(payload?.monthly_financial_series) ? payload.monthly_financial_series : [];
availableYears = [...new Set(yearlySeries.map((item) => item.year).filter((year) => year !== null && year !== undefined))];
}
function syncYearFilter() {
const granularity = document.getElementById("granularity").value;
const yearFilterEl = document.getElementById("yearFilter");
if (!yearFilterEl) return;
if (granularity === "yearly") {
yearFilterEl.value = "recent10";
yearFilterEl.disabled = true;
return;
}
const hasSelectedYear = availableYears.some((year) => String(year) === String(yearFilterEl.value));
if (yearFilterEl.value === "recent10" || !hasSelectedYear) {
yearFilterEl.value = getLatestAvailableYear();
}
yearFilterEl.disabled = false;
}
function getFilteredSeries() {
const granularity = document.getElementById("granularity").value;
const selectedYear = document.getElementById("yearFilter").value;
if (granularity === "yearly" || selectedYear === "recent10") {
return yearlySeries
.slice(-10)
.map((item) => ({ ...item, label: String(item.year) }));
}
return monthlySeries
.filter((item) => String(item.year) === String(selectedYear))
.map((item) => ({ ...item, label: `${item.month}월` }));
}
function renderMetrics(series) {
const keys = annualMetricCards.map((option) => option.item_key);
const totals = {};
keys.forEach((key) => {
totals[key] = series.reduce((sum, item) => sum + (item[key] || 0), 0);
});
const grid = document.getElementById("metricGrid");
grid.innerHTML = keys.map((key) => `
<div class="stat-card">
<div class="label">${labels[key]}</div>
<div class="value">${formatNumber(totals[key])}</div>
</div>
`).join("");
}
function buildAxis(maxValue, width, height, margin) {
const { tickStep, tickMax } = buildPositiveAxisScale(maxValue, 4);
let axis = "";
for (let value = 0; value <= tickMax; value += tickStep) {
const y = height - margin.bottom - ((height - margin.top - margin.bottom) * value) / tickMax;
axis += `<line x1="${margin.left}" y1="${y}" x2="${width - margin.right}" y2="${y}" stroke="#d8dee5" stroke-dasharray="3 7" />`;
axis += `<text x="${margin.left - 12}" y="${y + 4}" text-anchor="end" fill="#5a6672" font-size="11" font-weight="700">${formatAxisLabel(value)}</text>`;
}
axis += `<line x1="${margin.left}" y1="${height - margin.bottom}" x2="${width - margin.right}" y2="${height - margin.bottom}" stroke="#8ba0ae" stroke-width="1.2" />`;
return { axis, tickMax };
}
function renderEmptyChart(svgId, message) {
const svg = document.getElementById(svgId);
if (!svg) return;
const viewBox = (svg.getAttribute("viewBox") || "0 0 1400 560").split(/\s+/).map(Number);
const width = viewBox[2] || 1400;
const height = viewBox[3] || 560;
svg.innerHTML = `
<rect x="0" y="0" width="${width}" height="${height}" rx="8" fill="#f7fafb" stroke="#d6e2e8"></rect>
<text x="${width / 2}" y="${height / 2}" text-anchor="middle" fill="#667887" font-size="24" font-weight="800">${message}</text>
`;
}
function renderExpenseChart(series) {
const svg = document.getElementById("expenseChart");
const keys = annualExpenseChartMetrics.map((option) => option.item_key);
const isMonthlyView = series.some((item) => String(item.label || "").includes("월"));
renderLegend("expenseLegend", keys);
if (!series.length) {
renderEmptyChart("expenseChart", "표시할 비용 구조 데이터가 없습니다.");
return;
}
const width = 1560;
const height = 660;
svg.setAttribute("viewBox", `0 0 ${width} ${height}`);
const margin = { top: 54, right: 36, bottom: 96, left: 124 };
const plotWidth = width - margin.left - margin.right;
const plotHeight = height - margin.top - margin.bottom;
const barWidth = (plotWidth / series.length) * (isMonthlyView ? 0.36 : 0.42);
const step = (width - margin.left - margin.right) / series.length;
const maxValue = Math.max(...series.map((item) => keys.reduce((sum, key) => sum + (item[key] || 0), 0)), 1);
const { axis, tickMax } = buildDynamicPositiveAxis(maxValue, width, height, margin);
let markup = `
<defs>
<filter id="expenseShadow" x="-20%" y="-20%" width="140%" height="160%">
<feDropShadow dx="0" dy="8" stdDeviation="8" flood-color="rgba(44, 68, 89, 0.12)" />
</filter>
</defs>
<rect x="${margin.left}" y="${margin.top}" width="${plotWidth}" height="${plotHeight}" rx="6" fill="rgba(255,255,255,0.7)" stroke="#dde7ec"></rect>
${axis}
`;
series.forEach((item, index) => {
let cumulative = 0;
const total = keys.reduce((sum, key) => sum + (item[key] || 0), 0);
const x = margin.left + index * step + (step - barWidth) / 2;
keys.forEach((key) => {
const value = item[key] || 0;
const barHeight = (plotHeight * value) / tickMax;
const y = height - margin.bottom - barHeight - (plotHeight * cumulative) / tickMax;
cumulative += value;
markup += `<rect x="${x}" y="${y}" width="${barWidth}" height="${barHeight}" rx="2" fill="${palette[key]}" filter="url(#expenseShadow)" />`;
if (value > 0 && barHeight >= 34) {
const percent = total ? `${((value / total) * 100).toFixed(1)}%` : "0.0%";
markup += `<text x="${x + barWidth / 2}" y="${y + Math.min(barHeight / 2, 22)}" text-anchor="middle" fill="#ffffff" font-size="${isMonthlyView ? 12.5 : 13.5}" font-weight="900">${percent}</text>`;
}
});
if (total > 0) {
const topY = height - margin.bottom - (plotHeight * total) / tickMax;
markup += `<text x="${x + barWidth / 2}" y="${Math.max(topY - 14, margin.top + 18)}" text-anchor="middle" fill="#2d3c4a" font-size="${isMonthlyView ? 14 : 15.5}" font-weight="900">${formatAxisLabel(total)}</text>`;
}
markup += `<text x="${x + barWidth / 2}" y="${height - margin.bottom + 30}" text-anchor="middle" fill="#53606c" font-size="${isMonthlyView ? 14 : 15}" font-weight="800">${item.label}</text>`;
});
svg.innerHTML = markup;
}
function renderBalanceChart(series) {
const svg = document.getElementById("balanceChart");
const metrics = annualBalanceChartMetrics.map((option) => option.item_key);
renderLegend("balanceLegend", metrics);
if (!series.length) {
renderEmptyChart("balanceChart", "표시할 수익/비용 데이터가 없습니다.");
return;
}
const width = 1560;
const height = 620;
svg.setAttribute("viewBox", `0 0 ${width} ${height}`);
const margin = { top: 56, right: 34, bottom: 100, left: 124 };
const plotWidth = width - margin.left - margin.right;
const plotHeight = height - margin.top - margin.bottom;
const maxPositiveValue = Math.max(...series.flatMap((item) => [
item.revenue_sum || 0,
item.total_expense || 0,
Math.max(item.operating_balance || 0, 0),
]), 1);
const minNegativeValue = Math.min(...series.map((item) => Math.min(item.operating_balance || 0, 0)), 0);
const { axis, positiveMax, negativeMin, zeroY, totalRange } = buildDynamicBalanceAxis(maxPositiveValue, minNegativeValue, width, height, margin);
const groupWidth = plotWidth / Math.max(series.length, 1);
const groupGap = groupWidth * 0.22;
const innerGap = 0;
const barWidth = Math.min((groupWidth - groupGap * 2) / metrics.length, 72);
const actualGroupWidth = barWidth * metrics.length + innerGap * (metrics.length - 1);
const groupStartOffset = (groupWidth - actualGroupWidth) / 2;
let markup = `
<defs>
<filter id="balanceShadow" x="-20%" y="-20%" width="140%" height="160%">
<feDropShadow dx="0" dy="8" stdDeviation="8" flood-color="rgba(44, 68, 89, 0.12)" />
</filter>
</defs>
<rect x="${margin.left}" y="${margin.top}" width="${plotWidth}" height="${plotHeight}" rx="6" fill="rgba(255,255,255,0.7)" stroke="#dde7ec"></rect>
${axis}
`;
series.forEach((item, index) => {
const baseX = margin.left + index * groupWidth;
const positiveLabels = [];
const negativeLabels = [];
metrics.forEach((key, metricIndex) => {
const rawValue = Number(item[key] || 0);
const value = key === "operating_balance" ? rawValue : Math.max(rawValue, 0);
const clampedValue = Math.max(negativeMin, Math.min(positiveMax, value));
const barHeight = (plotHeight * Math.abs(clampedValue)) / totalRange;
const x = baseX + groupStartOffset + metricIndex * (barWidth + innerGap);
const y = clampedValue < 0 ? zeroY : zeroY - barHeight;
markup += `<rect x="${x}" y="${y}" width="${barWidth}" height="${barHeight}" rx="2" fill="${palette[key]}" filter="url(#balanceShadow)" />`;
if (clampedValue !== 0) {
const label = {
x: x + barWidth / 2,
desiredY: clampedValue < 0
? Math.min(y + barHeight + 22, height - margin.bottom + 12)
: Math.max(y - 14, margin.top + 18),
text: formatAxisLabel(value),
};
if (clampedValue < 0) {
negativeLabels.push(label);
} else {
positiveLabels.push(label);
}
}
});
positiveLabels.sort((a, b) => a.desiredY - b.desiredY);
let lastPositiveY = margin.top - 24;
positiveLabels.forEach((label) => {
const y = Math.max(label.desiredY, lastPositiveY + 18);
lastPositiveY = y;
markup += `<text x="${label.x}" y="${y}" text-anchor="middle" fill="#2d3c4a" font-size="13.5" font-weight="900">${label.text}</text>`;
});
negativeLabels.sort((a, b) => a.desiredY - b.desiredY);
let lastNegativeY = zeroY + 18;
negativeLabels.forEach((label) => {
const y = Math.max(label.desiredY, lastNegativeY + 18);
lastNegativeY = y;
markup += `<text x="${label.x}" y="${Math.min(y, height - margin.bottom + 34)}" text-anchor="middle" fill="#2d3c4a" font-size="13.5" font-weight="900">${label.text}</text>`;
});
markup += `<text x="${baseX + groupWidth / 2}" y="${height - margin.bottom + 34}" text-anchor="middle" fill="#53606c" font-size="15" font-weight="800">${item.label}</text>`;
});
svg.innerHTML = markup;
}
function renderAll() {
syncYearFilter();
const series = getFilteredSeries();
renderMetrics(series);
renderExpenseChart(series);
renderBalanceChart(series);
}
document.getElementById("granularity").addEventListener("change", renderAll);
document.getElementById("yearFilter").addEventListener("change", renderAll);
document.getElementById("annualRebuildCacheBtn")?.addEventListener("click", async () => {
const confirmed = window.confirm("연도별 수익/비용 데이터를 서버에서 다시 계산할까요? 계산 중에도 다른 화면을 사용할 수 있습니다.");
if (!confirmed) return;
try {
await requestAnnualCacheRebuild();
} catch (error) {
const jobStatus = document.getElementById("annualJobStatus");
if (jobStatus) {
jobStatus.textContent = error.message || "작업 등록 실패";
jobStatus.dataset.state = "failed";
}
const rebuildButton = document.getElementById("annualRebuildCacheBtn");
if (rebuildButton) rebuildButton.disabled = false;
}
});
const granularitySelect = document.getElementById("granularity");
if (granularitySelect) {
granularitySelect.value = "yearly";
}
const yearFilter = document.getElementById("yearFilter");
if (yearFilter) {
yearFilter.value = "recent10";
}
(async () => {
try {
await loadAnnualSummaryBootstrapData();
} catch (error) {
console.error("연도별 수익/비용 부트스트랩 데이터 조회 에러", error);
}
renderAll();
loadLatestAnnualJob()
.then((job) => {
if (job && ["queued", "running"].includes(String(job.status || ""))) {
pollAnnualJob(job.id);
}
})
.catch(() => {
const jobStatus = document.getElementById("annualJobStatus");
if (jobStatus) {
jobStatus.textContent = "작업 상태 조회 실패";
jobStatus.dataset.state = "failed";
}
});
})();
</script>
{% endblock %}