Update dashboard and project info workflows
This commit is contained in:
+1
-1
@@ -476,7 +476,7 @@
|
||||
<div class="page">
|
||||
<nav class="nav">
|
||||
<a href="/" class="{% if request.url.path == '/' %}active{% endif %}">대시보드</a>
|
||||
<a href="/projects" class="{% if request.url.path == '/projects' %}active{% endif %}">사업현황</a>
|
||||
<a href="/projects" class="{% if request.url.path == '/projects' %}active{% endif %}">프로젝트 정보</a>
|
||||
<a href="/annual-summary" class="{% if request.url.path == '/annual-summary' %}active{% endif %}">연도별 수익 비용 정리</a>
|
||||
<div class="nav-spacer"></div>
|
||||
<aside
|
||||
|
||||
+275
-304
@@ -4,23 +4,24 @@
|
||||
|
||||
{% block head_extra %}
|
||||
<style>
|
||||
.hero {
|
||||
.dashboard-topbar {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: flex-start;
|
||||
gap: 18px;
|
||||
align-items: center;
|
||||
gap: 14px;
|
||||
flex-wrap: wrap;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.dashboard-topbar-actions {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.hero-copy {
|
||||
display: grid;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.hero-copy p {
|
||||
color: var(--muted);
|
||||
max-width: 920px;
|
||||
line-height: 1.7;
|
||||
.dashboard-year-select {
|
||||
width: 180px;
|
||||
}
|
||||
|
||||
.upload-actions {
|
||||
@@ -69,22 +70,62 @@
|
||||
display: none;
|
||||
}
|
||||
|
||||
.metric-grid {
|
||||
.dashboard-layout {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(280px, 340px) minmax(0, 1fr);
|
||||
gap: 16px;
|
||||
align-items: stretch;
|
||||
}
|
||||
|
||||
.dashboard-status-panel {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(4, minmax(150px, 1fr));
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.dashboard-status-grid {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.dashboard-status-grid .stat-card {
|
||||
min-height: 84px;
|
||||
}
|
||||
|
||||
.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: 16px;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.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, 220px)) auto;
|
||||
gap: 12px;
|
||||
align-items: end;
|
||||
grid-template-columns: repeat(3, minmax(0, 180px));
|
||||
gap: 10px;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.chart-shell {
|
||||
@@ -93,23 +134,10 @@
|
||||
radial-gradient(circle at top left, rgba(24, 24, 27, 0.045), transparent 38%);
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 16px;
|
||||
padding: 16px;
|
||||
padding: 14px 16px 16px;
|
||||
box-shadow: inset 0 1px 0 rgba(255,255,255,0.92);
|
||||
}
|
||||
|
||||
.chart-head {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: flex-start;
|
||||
gap: 14px;
|
||||
margin-bottom: 10px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.chart-head p {
|
||||
color: var(--muted);
|
||||
font-size: 14px;
|
||||
line-height: 1.7;
|
||||
display: grid;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.legend-box {
|
||||
@@ -118,6 +146,10 @@
|
||||
gap: 10px 12px;
|
||||
}
|
||||
|
||||
.legend-box.center {
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.legend-item {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
@@ -131,14 +163,6 @@
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.metric-grid .stat-card {
|
||||
min-height: 118px;
|
||||
}
|
||||
|
||||
.metric-grid .stat-card .value {
|
||||
margin-top: 2px;
|
||||
}
|
||||
|
||||
.legend-swatch {
|
||||
width: 10px;
|
||||
height: 10px;
|
||||
@@ -150,19 +174,21 @@
|
||||
width: 100%;
|
||||
height: auto;
|
||||
aspect-ratio: 1120 / 390;
|
||||
min-height: 320px;
|
||||
min-height: 300px;
|
||||
display: block;
|
||||
}
|
||||
|
||||
@media (max-width: 1200px) {
|
||||
.metric-grid,
|
||||
.filter-row {
|
||||
grid-template-columns: 1fr 1fr;
|
||||
.dashboard-layout {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.dashboard-chart-stack {
|
||||
grid-template-rows: auto;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 780px) {
|
||||
.metric-grid,
|
||||
@media (max-width: 860px) {
|
||||
.filter-row {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
@@ -172,141 +198,161 @@
|
||||
|
||||
{% block content %}
|
||||
<section class="panel">
|
||||
<div class="hero">
|
||||
<div class="hero-copy">
|
||||
<div class="section-title" style="margin-bottom: 0;">
|
||||
<h2>대시보드</h2>
|
||||
<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>
|
||||
<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 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>
|
||||
</section>
|
||||
|
||||
<section class="panel">
|
||||
<div class="section-title">
|
||||
<h2>현황 요약</h2>
|
||||
</div>
|
||||
<form method="get" action="/" class="filter-row" style="margin-bottom: 16px;">
|
||||
<div class="field">
|
||||
<select id="overviewYear" 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>
|
||||
</div>
|
||||
</form>
|
||||
<div class="metric-grid">
|
||||
<div class="stat-card">
|
||||
<div class="label">집계 대상 사업 수</div>
|
||||
<div class="value">{{ overview.business_count or 0 }}</div>
|
||||
</div>
|
||||
<div class="stat-card">
|
||||
<div class="label">수입금액</div>
|
||||
<div class="value">{{ "{:,.0f}".format(overview.total_revenue 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-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>
|
||||
|
||||
<section class="panel chart-panel">
|
||||
<div class="section-title">
|
||||
<h2>통합 비용 집계</h2>
|
||||
</div>
|
||||
<div class="filter-row">
|
||||
<div class="field">
|
||||
<select id="summaryGranularity" aria-label="집계 단위">
|
||||
<option value="yearly">연도별</option>
|
||||
<option value="monthly">월별</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="field">
|
||||
<select id="summaryYear" aria-label="연도 선택">
|
||||
<option value="all">전체연도</option>
|
||||
{% for year in available_years %}
|
||||
<option value="{{ year }}">{{ year }}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
</div>
|
||||
<div class="field">
|
||||
<select id="summaryMetric" aria-label="강조 항목">
|
||||
<option value="all">전체 항목</option>
|
||||
<option value="cost_sum">원가</option>
|
||||
<option value="sga_sum">판관비</option>
|
||||
<option value="labor_sum">원가인건비</option>
|
||||
<option value="outsourcing_sum">원가외주비</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="legend-box" id="summaryLegend"></div>
|
||||
</div>
|
||||
<div class="chart-shell">
|
||||
<svg id="summaryChart" class="chart-svg" viewBox="0 0 1120 390" preserveAspectRatio="xMidYMid meet"></svg>
|
||||
</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>
|
||||
<option value="design_revenue">설계</option>
|
||||
<option value="design_other_revenue">설계 외</option>
|
||||
<option value="supervision_revenue">감리</option>
|
||||
<option value="inspection_revenue">점검</option>
|
||||
</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="panel chart-panel">
|
||||
<div class="section-title">
|
||||
<h2>원가·판관비 추세</h2>
|
||||
</div>
|
||||
<div class="filter-row">
|
||||
<div class="field">
|
||||
<select id="trendYear" aria-label="연도 선택">
|
||||
<option value="recent10">최근 10개년</option>
|
||||
{% for year in available_years %}
|
||||
<option value="{{ year }}">{{ year }}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
<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>
|
||||
<option value="cost_sum">원가</option>
|
||||
<option value="sga_sum">판관비</option>
|
||||
<option value="labor_sum">원가인건비</option>
|
||||
<option value="outsourcing_sum">원가외주비</option>
|
||||
</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 class="field">
|
||||
<select id="trendMetric" aria-label="표시 방식">
|
||||
<option value="cost_sga">원가 + 판관비</option>
|
||||
<option value="all">전체 항목</option>
|
||||
<option value="labor_outsourcing">인건비 + 외주비</option>
|
||||
</select>
|
||||
</div>
|
||||
<div></div>
|
||||
<div class="legend-box" id="trendLegend"></div>
|
||||
</div>
|
||||
<div class="chart-shell">
|
||||
<svg id="trendChart" class="chart-svg" viewBox="0 0 1120 390" preserveAspectRatio="xMidYMid meet"></svg>
|
||||
</div>
|
||||
</section>
|
||||
{% endblock %}
|
||||
|
||||
{% block script %}
|
||||
<script>
|
||||
const availableYears = {{ available_years | tojson }};
|
||||
const yearlySummary = {{ yearly_summary | tojson }};
|
||||
const monthlySummary = {{ monthly_summary | tojson }};
|
||||
const availableYears = {{ available_years | tojson }};
|
||||
const revenueYearly = {{ project_revenue_mix_yearly | tojson }};
|
||||
const revenueMonthly = {{ project_revenue_mix_monthly | tojson }};
|
||||
const pageSelectedYear = {{ overview_selected_year | tojson }};
|
||||
|
||||
const palette = {
|
||||
const revenuePalette = {
|
||||
design_revenue: { label: "설계", color: "#4f7cff" },
|
||||
design_other_revenue: { label: "설계 외", color: "#67c7c9" },
|
||||
supervision_revenue: { label: "감리", color: "#233a5a" },
|
||||
inspection_revenue: { label: "점검", color: "#ffb54a" },
|
||||
};
|
||||
|
||||
const expensePalette = {
|
||||
cost_sum: { label: "원가", color: "#4f7cff" },
|
||||
sga_sum: { label: "판관비", color: "#67c7c9" },
|
||||
labor_sum: { label: "원가인건비", color: "#233a5a" },
|
||||
outsourcing_sum: { label: "원가외주비", color: "#ffb54a" },
|
||||
};
|
||||
|
||||
const summaryMetricsMap = {
|
||||
const revenueMetricMap = {
|
||||
all: ["design_revenue", "design_other_revenue", "supervision_revenue", "inspection_revenue"],
|
||||
design_revenue: ["design_revenue"],
|
||||
design_other_revenue: ["design_other_revenue"],
|
||||
supervision_revenue: ["supervision_revenue"],
|
||||
inspection_revenue: ["inspection_revenue"],
|
||||
};
|
||||
|
||||
const expenseMetricMap = {
|
||||
all: ["cost_sum", "sga_sum", "labor_sum", "outsourcing_sum"],
|
||||
cost_sum: ["cost_sum"],
|
||||
sga_sum: ["sga_sum"],
|
||||
@@ -314,12 +360,6 @@
|
||||
outsourcing_sum: ["outsourcing_sum"],
|
||||
};
|
||||
|
||||
const trendMetricsMap = {
|
||||
cost_sga: ["cost_sum", "sga_sum"],
|
||||
all: ["cost_sum", "sga_sum", "labor_sum", "outsourcing_sum"],
|
||||
labor_outsourcing: ["labor_sum", "outsourcing_sum"],
|
||||
};
|
||||
|
||||
function formatNumber(value) {
|
||||
return new Intl.NumberFormat("ko-KR", { maximumFractionDigits: 0 }).format(value || 0);
|
||||
}
|
||||
@@ -363,13 +403,13 @@
|
||||
return { tickStep, tickMax };
|
||||
}
|
||||
|
||||
function setLegend(containerId, metricKeys) {
|
||||
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:${palette[key].color}"></span>
|
||||
${palette[key].label}
|
||||
<span class="legend-swatch" style="background:${paletteMap[key].color}"></span>
|
||||
${paletteMap[key].label}
|
||||
</span>
|
||||
`).join("");
|
||||
}
|
||||
@@ -383,7 +423,7 @@
|
||||
`;
|
||||
}
|
||||
|
||||
function renderGroupedBarChart(svgId, rows, metricKeys, options = {}) {
|
||||
function renderGroupedBarChart(svgId, rows, metricKeys, paletteMap, options = {}) {
|
||||
const svg = document.getElementById(svgId);
|
||||
if (!svg) return;
|
||||
if (!rows.length || !metricKeys.length) {
|
||||
@@ -394,13 +434,10 @@
|
||||
const granularity = options.granularity || "yearly";
|
||||
const width = 1120;
|
||||
const height = 390;
|
||||
const margin = { top: 42, right: 36, bottom: 78, left: 94 };
|
||||
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 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;
|
||||
@@ -425,7 +462,6 @@
|
||||
</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>
|
||||
<text x="${margin.left}" y="24" fill="#334654" font-size="14" font-weight="700">${granularity === "monthly" ? "월간 비교" : "연간 비교"}</text>
|
||||
`;
|
||||
|
||||
for (let value = 0; value <= tickMax; value += tickStep) {
|
||||
@@ -446,7 +482,7 @@
|
||||
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="${palette[key].color}" filter="url(#${svgId}Shadow)"></rect>`;
|
||||
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>`;
|
||||
}
|
||||
@@ -459,68 +495,44 @@
|
||||
svg.innerHTML = markup;
|
||||
}
|
||||
|
||||
function renderLineChart(svgId, rows, metricKeys) {
|
||||
const svg = document.getElementById(svgId);
|
||||
if (!svg) return;
|
||||
const width = 1120;
|
||||
const height = 390;
|
||||
const margin = { top: 24, right: 24, bottom: 64, left: 86 };
|
||||
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 xStep = rows.length > 1 ? plotWidth / (rows.length - 1) : 0;
|
||||
const axisBaseY = height - margin.bottom;
|
||||
|
||||
let markup = `
|
||||
<defs>
|
||||
<linearGradient id="${svgId}Bg" x1="0" x2="0" y1="0" y2="1">
|
||||
<stop offset="0%" stop-color="#ffffff" stop-opacity="0.9" />
|
||||
<stop offset="100%" stop-color="#f3f8fa" stop-opacity="0.98" />
|
||||
</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.16)" />
|
||||
</filter>
|
||||
</defs>
|
||||
<rect x="0" y="0" width="${width}" height="${height}" rx="24" fill="url(#${svgId}Bg)"></rect>
|
||||
<rect x="${margin.left}" y="${margin.top}" width="${plotWidth}" height="${plotHeight}" rx="18" fill="rgba(255,255,255,0.68)"></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="#d6e2e8" stroke-dasharray="4 7"></line>`;
|
||||
markup += `<text x="${margin.left - 14}" y="${y + 5}" text-anchor="end" fill="#62717f" font-size="12">${formatAxisLabel(value)}</text>`;
|
||||
}
|
||||
markup += `<line x1="${margin.left}" y1="${axisBaseY}" x2="${width - margin.right}" y2="${axisBaseY}" stroke="#7f95a5" stroke-width="1.2"></line>`;
|
||||
|
||||
metricKeys.forEach((key) => {
|
||||
const points = rows.map((row, index) => {
|
||||
const x = margin.left + index * xStep;
|
||||
const y = margin.top + plotHeight - ((Number(row[key] || 0) / tickMax) * plotHeight);
|
||||
return { x, y, value: Number(row[key] || 0), label: row.label };
|
||||
});
|
||||
markup += `<path d="M ${points.map((p) => `${p.x} ${p.y}`).join(" L ")}" fill="none" stroke="${palette[key].color}" stroke-width="4" stroke-linecap="round" stroke-linejoin="round" filter="url(#${svgId}Shadow)"></path>`;
|
||||
points.forEach((point) => {
|
||||
markup += `<circle cx="${point.x}" cy="${point.y}" r="5.5" fill="${palette[key].color}" stroke="#ffffff" stroke-width="2"></circle>`;
|
||||
});
|
||||
});
|
||||
|
||||
rows.forEach((row, index) => {
|
||||
const x = margin.left + index * xStep;
|
||||
if (rows.length <= 18 || index % Math.ceil(rows.length / 12) === 0) {
|
||||
markup += `<text x="${x}" y="${height - 26}" text-anchor="middle" fill="#425464" font-size="13" font-weight="700">${row.label}</text>`;
|
||||
}
|
||||
});
|
||||
|
||||
svg.innerHTML = markup;
|
||||
function getLatestAvailableYear() {
|
||||
return String(availableYears[availableYears.length - 1] || "all");
|
||||
}
|
||||
|
||||
function getSummaryRows() {
|
||||
const granularity = document.getElementById("summaryGranularity").value;
|
||||
const yearFilter = document.getElementById("summaryYear").value;
|
||||
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) => {
|
||||
@@ -534,79 +546,36 @@
|
||||
.slice(granularity === "yearly" ? -10 : 0);
|
||||
}
|
||||
|
||||
function getLatestAvailableYear() {
|
||||
return String(availableYears[availableYears.length - 1] || "all");
|
||||
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 syncSummaryYearSelection(granularity) {
|
||||
const summaryYearSelect = document.getElementById("summaryYear");
|
||||
if (!summaryYearSelect) return;
|
||||
|
||||
if (granularity === "yearly") {
|
||||
summaryYearSelect.value = "all";
|
||||
summaryYearSelect.disabled = true;
|
||||
return;
|
||||
}
|
||||
|
||||
const hasSelectedYear = availableYears.some((year) => String(year) === String(summaryYearSelect.value));
|
||||
if (summaryYearSelect.value === "all" || !hasSelectedYear) {
|
||||
summaryYearSelect.value = getLatestAvailableYear();
|
||||
}
|
||||
summaryYearSelect.disabled = false;
|
||||
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 });
|
||||
}
|
||||
|
||||
function updateSummaryChart() {
|
||||
const granularity = document.getElementById("summaryGranularity").value;
|
||||
const metricMode = document.getElementById("summaryMetric").value;
|
||||
const metricKeys = summaryMetricsMap[metricMode];
|
||||
syncSummaryYearSelection(granularity);
|
||||
const rows = getSummaryRows();
|
||||
setLegend("summaryLegend", metricKeys);
|
||||
renderGroupedBarChart("summaryChart", rows, metricKeys, { 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);
|
||||
|
||||
function updateTrendChart() {
|
||||
const selectedYear = document.getElementById("trendYear").value;
|
||||
const metricMode = document.getElementById("trendMetric").value;
|
||||
const metricKeys = trendMetricsMap[metricMode];
|
||||
const rows = selectedYear === "recent10"
|
||||
? yearlySummary
|
||||
.slice(-10)
|
||||
.map((row) => ({
|
||||
...row,
|
||||
label: String(row.year),
|
||||
}))
|
||||
: monthlySummary
|
||||
.filter((row) => String(row.year) === String(selectedYear))
|
||||
.map((row) => ({ ...row, label: `${row.month}월` }));
|
||||
setLegend("trendLegend", metricKeys);
|
||||
renderLineChart("trendChart", rows, metricKeys);
|
||||
}
|
||||
|
||||
document.getElementById("summaryGranularity")?.addEventListener("change", updateSummaryChart);
|
||||
document.getElementById("summaryYear")?.addEventListener("change", updateSummaryChart);
|
||||
document.getElementById("summaryMetric")?.addEventListener("change", updateSummaryChart);
|
||||
document.getElementById("trendYear")?.addEventListener("change", updateTrendChart);
|
||||
document.getElementById("trendMetric")?.addEventListener("change", updateTrendChart);
|
||||
|
||||
const defaultYear = getLatestAvailableYear();
|
||||
const trendYear = document.getElementById("trendYear");
|
||||
if (trendYear) {
|
||||
trendYear.value = "recent10";
|
||||
}
|
||||
const summaryYear = document.getElementById("summaryYear");
|
||||
if (summaryYear) {
|
||||
syncSummaryYearSelection(document.getElementById("summaryGranularity")?.value || "yearly");
|
||||
}
|
||||
document.getElementById("dashboardYearSelect")?.addEventListener("change", (event) => {
|
||||
event.target.form?.submit();
|
||||
});
|
||||
|
||||
const uploadButton = document.getElementById("uploadButton");
|
||||
const excelInput = document.getElementById("excel_file");
|
||||
const uploadForm = document.getElementById("uploadForm");
|
||||
const overviewYear = document.getElementById("overviewYear");
|
||||
|
||||
overviewYear?.addEventListener("change", (event) => {
|
||||
event.target.form?.submit();
|
||||
});
|
||||
|
||||
uploadButton?.addEventListener("click", () => {
|
||||
excelInput?.click();
|
||||
@@ -618,7 +587,9 @@
|
||||
}
|
||||
});
|
||||
|
||||
updateSummaryChart();
|
||||
updateTrendChart();
|
||||
syncYearSelection("revenueYear", document.getElementById("revenueGranularity")?.value || "yearly");
|
||||
syncYearSelection("expenseYear", document.getElementById("expenseGranularity")?.value || "yearly");
|
||||
updateRevenueChart();
|
||||
updateExpenseChart();
|
||||
</script>
|
||||
{% endblock %}
|
||||
|
||||
+772
-242
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user