Initial intranet dashboard app
This commit is contained in:
@@ -0,0 +1,437 @@
|
||||
{% extends "base.html" %}
|
||||
|
||||
{% block title %}연도별 수익 비용 정리{% endblock %}
|
||||
|
||||
{% block head_extra %}
|
||||
<style>
|
||||
.filter-grid {
|
||||
display: grid;
|
||||
grid-template-columns: 220px 220px 1fr;
|
||||
gap: 12px;
|
||||
align-items: end;
|
||||
}
|
||||
|
||||
.legend {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 10px;
|
||||
margin-top: 10px;
|
||||
}
|
||||
|
||||
.legend-item {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
color: #363b44;
|
||||
font-size: 13px;
|
||||
font-weight: 700;
|
||||
background: rgba(255,255,255,0.92);
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 10px;
|
||||
padding: 6px 10px;
|
||||
}
|
||||
|
||||
.legend-swatch {
|
||||
width: 10px;
|
||||
height: 10px;
|
||||
border-radius: 999px;
|
||||
}
|
||||
|
||||
.chart-box {
|
||||
background:
|
||||
linear-gradient(180deg, rgba(255,255,255,0.98), rgba(246,247,249,0.98)),
|
||||
radial-gradient(circle at top left, rgba(17, 17, 17, 0.045), transparent 36%);
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 16px;
|
||||
padding: 16px;
|
||||
box-shadow: inset 0 1px 0 rgba(255,255,255,0.92);
|
||||
}
|
||||
|
||||
.chart-legend {
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
.chart-title {
|
||||
font-size: 18px;
|
||||
font-weight: 700;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.chart-svg {
|
||||
width: 100%;
|
||||
height: auto;
|
||||
aspect-ratio: 1200 / 420;
|
||||
min-height: 320px;
|
||||
display: block;
|
||||
}
|
||||
|
||||
.expense-chart-svg {
|
||||
aspect-ratio: 1600 / 420;
|
||||
}
|
||||
|
||||
.chart-note {
|
||||
margin-top: 10px;
|
||||
color: var(--muted);
|
||||
font-size: 13px;
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
.metric-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
@media (max-width: 1000px) {
|
||||
.filter-grid,
|
||||
.metric-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<section class="panel">
|
||||
<div class="section-title">
|
||||
<h2>연도별 수익 비용 정리</h2>
|
||||
</div>
|
||||
<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>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="panel">
|
||||
<div class="section-title">
|
||||
<h2>선택 구간 요약</h2>
|
||||
</div>
|
||||
<div class="metric-grid" id="metricGrid"></div>
|
||||
</section>
|
||||
|
||||
<section class="panel">
|
||||
<div class="section-title">
|
||||
<h2>비용 구조</h2>
|
||||
</div>
|
||||
<div class="chart-box">
|
||||
<div class="legend chart-legend" id="expenseLegend"></div>
|
||||
<svg id="expenseChart" class="chart-svg expense-chart-svg" viewBox="0 0 1600 420" preserveAspectRatio="xMidYMid meet"></svg>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="panel">
|
||||
<div class="section-title">
|
||||
<h2>수익/비용/영업수지 그래프</h2>
|
||||
</div>
|
||||
<div class="chart-box">
|
||||
<div class="legend chart-legend" id="balanceLegend"></div>
|
||||
<svg id="balanceChart" class="chart-svg" viewBox="0 0 1200 420" preserveAspectRatio="xMidYMid meet"></svg>
|
||||
</div>
|
||||
</section>
|
||||
{% endblock %}
|
||||
|
||||
{% block script %}
|
||||
<script>
|
||||
const yearlySeries = {{ yearly_financial_series | tojson }};
|
||||
const monthlySeries = {{ monthly_financial_series | tojson }};
|
||||
const availableYears = [...new Set(yearlySeries.map((item) => item.year).filter((year) => year !== null && year !== undefined))];
|
||||
|
||||
const palette = {
|
||||
revenue_sum: "#0f766e",
|
||||
project_cost_sum: "#0ea5a4",
|
||||
support_cost_sum: "#67b7dc",
|
||||
support_sga_sum: "#f59e0b",
|
||||
field_sga_sum: "#f97316",
|
||||
labor_sum: "#8b5cf6",
|
||||
outsourcing_sum: "#ec4899",
|
||||
total_expense: "#1d4ed8",
|
||||
operating_balance: "#dc2626",
|
||||
};
|
||||
|
||||
const labels = {
|
||||
revenue_sum: "수익",
|
||||
project_cost_sum: "원가(프로젝트)",
|
||||
support_cost_sum: "원가(지원부서)",
|
||||
support_sga_sum: "판관비(지원부서)",
|
||||
field_sga_sum: "판관비(현업부서)",
|
||||
labor_sum: "원가인건비",
|
||||
outsourcing_sum: "원가외주비",
|
||||
total_expense: "비용합계",
|
||||
operating_balance: "영업수지",
|
||||
};
|
||||
|
||||
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";
|
||||
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 formatNumber(numeric);
|
||||
}
|
||||
|
||||
function pickTickStep(maxValue) {
|
||||
const baseUnit = maxValue < 1000000 ? 1000 : 1000000;
|
||||
const units = [1, 2, 5];
|
||||
const raw = Math.max(maxValue / 5 / 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 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");
|
||||
}
|
||||
|
||||
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 = [
|
||||
"revenue_sum",
|
||||
"project_cost_sum",
|
||||
"support_cost_sum",
|
||||
"support_sga_sum",
|
||||
"field_sga_sum",
|
||||
"labor_sum",
|
||||
"outsourcing_sum",
|
||||
"total_expense",
|
||||
"operating_balance",
|
||||
];
|
||||
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 = pickTickStep(maxValue);
|
||||
const tickMax = Math.ceil(maxValue / 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="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;
|
||||
svg.innerHTML = `
|
||||
<rect x="0" y="0" width="1200" height="420" rx="8" fill="#f7fafb" stroke="#d6e2e8"></rect>
|
||||
<text x="600" y="210" text-anchor="middle" fill="#667887" font-size="18" font-weight="700">${message}</text>
|
||||
`;
|
||||
}
|
||||
|
||||
function renderExpenseChart(series) {
|
||||
const svg = document.getElementById("expenseChart");
|
||||
const keys = ["labor_sum", "outsourcing_sum", "project_cost_sum", "support_cost_sum", "support_sga_sum", "field_sga_sum"];
|
||||
const isMonthlyView = series.some((item) => String(item.label || "").includes("월"));
|
||||
renderLegend("expenseLegend", keys);
|
||||
if (!series.length) {
|
||||
renderEmptyChart("expenseChart", "표시할 비용 구조 데이터가 없습니다.");
|
||||
return;
|
||||
}
|
||||
|
||||
const width = 1600;
|
||||
const height = 420;
|
||||
const margin = { top: 30, right: isMonthlyView ? 72 : 36, bottom: 74, left: 98 };
|
||||
const barWidth = (width - margin.left - margin.right) / series.length * (isMonthlyView ? 0.18 : 0.29);
|
||||
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 } = buildAxis(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="${width - margin.left - margin.right}" height="${height - margin.top - margin.bottom}" 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;
|
||||
const detailX = x + barWidth + 8;
|
||||
const labelEntries = [];
|
||||
keys.forEach((key) => {
|
||||
const value = item[key] || 0;
|
||||
const barHeight = ((height - margin.top - margin.bottom) * value) / tickMax;
|
||||
const y = height - margin.bottom - barHeight - ((height - margin.top - margin.bottom) * 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) {
|
||||
const percent = total ? ((value / total) * 100).toFixed(1) : "0.0";
|
||||
labelEntries.push({
|
||||
key,
|
||||
value,
|
||||
percent,
|
||||
desiredY: y + (barHeight / 2),
|
||||
});
|
||||
}
|
||||
});
|
||||
labelEntries.sort((a, b) => a.desiredY - b.desiredY);
|
||||
const minY = margin.top + 12;
|
||||
const maxY = height - margin.bottom - 12;
|
||||
const gap = isMonthlyView ? 16 : 18;
|
||||
let lastY = minY - gap;
|
||||
labelEntries.forEach((entry) => {
|
||||
const lineY = Math.max(entry.desiredY, lastY + gap, minY);
|
||||
const finalY = Math.min(lineY, maxY);
|
||||
lastY = finalY;
|
||||
markup += `<rect x="${detailX}" y="${finalY - 10}" width="8" height="8" rx="1.5" fill="${palette[entry.key]}"></rect>`;
|
||||
markup += `<text x="${detailX + 14}" y="${finalY + 4}" text-anchor="start" fill="#6b7b88" font-size="9" font-weight="700">${entry.percent}%</text>`;
|
||||
});
|
||||
if (total > 0) {
|
||||
const topY = height - margin.bottom - ((height - margin.top - margin.bottom) * total) / tickMax;
|
||||
markup += `<text x="${x + barWidth / 2}" y="${Math.max(topY - 10, margin.top + 10)}" text-anchor="middle" fill="#314555" font-size="10.5" font-weight="800">${formatAxisLabel(total)}</text>`;
|
||||
}
|
||||
markup += `<text x="${x + barWidth / 2}" y="${height - margin.bottom + 20}" text-anchor="middle" fill="#5a6672" font-size="11.5" font-weight="700">${item.label}</text>`;
|
||||
});
|
||||
svg.innerHTML = markup;
|
||||
}
|
||||
|
||||
function renderBalanceChart(series) {
|
||||
const svg = document.getElementById("balanceChart");
|
||||
const metrics = ["revenue_sum", "total_expense", "operating_balance"];
|
||||
renderLegend("balanceLegend", metrics);
|
||||
if (!series.length) {
|
||||
renderEmptyChart("balanceChart", "표시할 수익/비용 데이터가 없습니다.");
|
||||
return;
|
||||
}
|
||||
|
||||
const width = 1200;
|
||||
const height = 420;
|
||||
const margin = { top: 30, right: 24, bottom: 74, left: 98 };
|
||||
const plotWidth = width - margin.left - margin.right;
|
||||
const plotHeight = height - margin.top - margin.bottom;
|
||||
const maxValue = Math.max(...series.flatMap((item) => [
|
||||
item.revenue_sum || 0,
|
||||
item.total_expense || 0,
|
||||
Math.abs(item.operating_balance || 0),
|
||||
]), 1);
|
||||
const { axis, tickMax } = buildAxis(maxValue, 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, 44);
|
||||
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;
|
||||
metrics.forEach((key, metricIndex) => {
|
||||
const value = key === "operating_balance" ? Math.abs(item[key] || 0) : (item[key] || 0);
|
||||
const barHeight = (plotHeight * value) / tickMax;
|
||||
const x = baseX + groupStartOffset + metricIndex * (barWidth + innerGap);
|
||||
const y = height - margin.bottom - barHeight;
|
||||
markup += `<rect x="${x}" y="${y}" width="${barWidth}" height="${barHeight}" rx="2" fill="${palette[key]}" filter="url(#balanceShadow)" />`;
|
||||
if (value > 0) {
|
||||
markup += `<text x="${x + barWidth / 2}" y="${Math.max(y - 8, margin.top + 12)}" text-anchor="middle" fill="#314555" font-size="9.5" font-weight="800">${formatAxisLabel(value)}</text>`;
|
||||
}
|
||||
});
|
||||
markup += `<text x="${baseX + groupWidth / 2}" y="${height - margin.bottom + 20}" text-anchor="middle" fill="#5a6672" font-size="11.5" font-weight="700">${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);
|
||||
const granularitySelect = document.getElementById("granularity");
|
||||
if (granularitySelect) {
|
||||
granularitySelect.value = "yearly";
|
||||
}
|
||||
const yearFilter = document.getElementById("yearFilter");
|
||||
if (yearFilter) {
|
||||
yearFilter.value = "recent10";
|
||||
}
|
||||
renderAll();
|
||||
</script>
|
||||
{% endblock %}
|
||||
Reference in New Issue
Block a user