Stabilize project flows and reporting

This commit is contained in:
b17301
2026-04-09 21:01:21 +09:00
parent f2d86cd840
commit 9aaae5b820
11 changed files with 5118 additions and 626 deletions
+169
View File
@@ -0,0 +1,169 @@
# DB Health Check 2026-04-09
## Summary
- Current SQLite size and row counts are still within a manageable range for this app.
- Main medium-term risks were:
- concurrent writes causing `database is locked`
- all users sharing one `project_page_state` row
- growing scan cost around billing-link and project-status entry access
- These were addressed without data loss.
## Current Scale
- `transactions`: about 49k rows
- `project_contract_info`: 760 rows
- `project_billing_entries`: 1,774 rows
- `project_related_links`: 1,276 rows
- `project_status`: few rows now, but each row can contain many logical sub-entries
## Improvements Applied
### 1. SQLite concurrency and durability tuning
Applied on every DB connection in [main.py](/home/b17301/my-intranet-app/main.py#L39):
- `PRAGMA journal_mode=WAL`
- `PRAGMA synchronous=NORMAL`
- `PRAGMA foreign_keys=ON`
- `PRAGMA busy_timeout=5000`
- `PRAGMA temp_store=MEMORY`
Effect:
- better concurrent read/write behavior
- lower chance of write collisions during multi-user editing
- safer long-running usage than SQLite defaults
### 2. Added indexes for growth hotspots
Added in [main.py](/home/b17301/my-intranet-app/main.py#L119):
- `transactions`
- `idx_transactions_support_category`
- `idx_transactions_support_account`
- `idx_transactions_source_file`
- `idx_transactions_updated_at`
- `project_billing_entries`
- `idx_project_billing_entries_raw_code`
- `idx_project_billing_entries_round_code`
- `idx_project_billing_entries_source_file`
- `idx_project_billing_entries_updated_at`
- `project_related_links`
- `idx_project_related_links_related`
- `project_page_state`
- `idx_project_page_state_page_session`
Effect:
- faster changed-round grouping
- better support/account based project aggregations
- faster source-file based reimport paths
- safer scaling as billing and transaction history grows
### 3. Page state changed from shared row to session-scoped rows
The old structure used one shared record:
- `project_page_state(page_key PRIMARY KEY, ...)`
This meant different users could overwrite each other's selected project, year, and open/closed detail state.
It now migrates to:
- `project_page_state(page_key, session_id, ...)`
- primary key: `(page_key, session_id)`
Relevant code:
- schema migration: [main.py](/home/b17301/my-intranet-app/main.py#L264)
- load/save logic: [main.py](/home/b17301/my-intranet-app/main.py#L1970)
- API: [main.py](/home/b17301/my-intranet-app/main.py#L3473)
- browser session id: [templates/base.html](/home/b17301/my-intranet-app/templates/base.html#L538)
- client page-state save/load: [templates/projects.html](/home/b17301/my-intranet-app/templates/projects.html#L4880)
Effect:
- browser A and browser B no longer fight over one shared project-search state
### 4. Project status JSON blobs normalized into child tables
Previously, logical row collections were stored only inside JSON columns in `project_status`:
- `collection_entries_json`
- `task_plan_entries_json`
- `exec_budget_entries_json`
- `actual_input_entries_json`
This has now been normalized into child tables:
- `project_collection_entries`
- `project_task_plan_entries`
- `project_exec_budget_entries`
- `project_actual_input_entries`
Relevant code:
- row extraction and migration helpers: [main.py](/home/b17301/my-intranet-app/main.py#L987)
- child-table schema: [main.py](/home/b17301/my-intranet-app/main.py#L273)
- migration from legacy JSON: [main.py](/home/b17301/my-intranet-app/main.py#L1201)
- project status read paths: [main.py](/home/b17301/my-intranet-app/main.py#L2143)
- project status save path: [main.py](/home/b17301/my-intranet-app/main.py#L3323)
Effect:
- less dependence on large JSON blobs for active reads
- cleaner future path for per-section editing
- safer long-term maintainability
## Backward Compatibility
Legacy JSON columns are still kept in `project_status` for compatibility and rollback safety.
Current behavior:
- reads prefer normalized child rows
- if child rows do not exist, legacy JSON/scalar fallback still works
- writes update both:
- scalar summary fields
- legacy JSON cache
- normalized child rows
This avoids data loss during migration.
## Remaining Structural Risk
The biggest remaining architectural limitation is:
- `project_status` still acts as one large parent record for many independently editable sections
So while row collections are normalized now, parent-level fields such as:
- project type
- expected rates
- contract amount
- dates
- notes
still live together in one row and one update flow.
This is acceptable for now, but if many users edit the same project simultaneously, the next best improvement would be:
1. split edit APIs by section
2. add per-section revision tracking
3. optionally move more parent fields into section-specific tables
## Recommendation
Current DB can continue operating efficiently with the applied changes.
Recommended next step if the app keeps expanding:
- introduce section-level save endpoints for
- collection
- task plan
- exec budget
- actual input
- project metadata
That would reduce cross-section write conflicts even more.
+50
View File
@@ -0,0 +1,50 @@
# 작업 요약 2026-04-09
## 이번 반영 범위
- 프로젝트 정보 페이지 안정화
- 저장 후 프로젝트 정보 화면이 비거나 모달이 갑자기 닫히는 문제 구조 개선
- 프로젝트 검색/상세/미계약 비용 발생 현황의 데이터 흐름 및 자동 최신화 충돌 완화
- 바로가기, 연관 프로젝트, 세부 내역 정렬/표시 개선
- 사업현황 추가/수정 저장 구조 보강
- 프로젝트 저장을 DB 기준으로 즉시 반영되도록 보강
- 수금정보 분류값 정리
- 기성구분: `선급금 / 기성금 / 준공금`
- 청구구분: `계약분 / 기타`
- 실행예산/실투입/예상 배분 설정 연계 보강
- 계약/청구/변경계약 데이터 반영
- 계약현황, 기성청구현황, 변경계약금액현황(총괄/차수) 파일을 DB에 반영
- 변경차수/보완/연계 프로젝트 자동 연결 로직 보강
- 본계약과 연결 가능한 건은 상세 페이지와 연관 프로젝트 태그/집행내역에 합산 반영
- DB 구조 및 안정성 개선
- 설정성 하드코딩 일부를 DB 설정 테이블로 이동
- 프로젝트 입력 데이터의 섹션 분리 구조 확장
- 건강 점검 문서 추가: `DB_HEALTH_CHECK_20260409.md`
- 대시보드 / 연도별 수익·비용 UI 개선
- 대시보드 카드/그래프 구조 정리
- 연도별 수익/비용 그래프 크기, 라벨, 축, 카드 활용도 개선
- 페이지 공통 여백 구조 정리
## 주요 수정 파일
- `main.py`
- `templates/projects.html`
- `templates/annual_summary.html`
- `templates/base.html`
- `templates/dashboard.html`
- `templates/index.html`
- `data.db`
## 참고 데이터 파일
- `변경계약금액현황(회계)_총괄_20210101_20260409_260409.xlsx`
- `변경계약금액현황(회계)_차수_20210101_20260409_260409.xlsx`
## 비고
- SQLite 기반 운영은 현재 데이터 규모에서는 가능하지만, 동시 작업과 화면 상태 저장은 계속 점검이 필요함
- `data.db-wal`, `data.db-shm` 같은 런타임 임시 파일은 커밋 대상에서 제외함
BIN
View File
Binary file not shown.
+2959 -208
View File
File diff suppressed because it is too large Load Diff
+162 -140
View File
@@ -8,12 +8,14 @@
display: grid;
grid-template-columns: 320px minmax(0, 1fr);
gap: 16px;
align-items: start;
align-items: stretch;
}
.summary-stack {
.summary-panel {
display: grid;
grid-template-columns: 320px minmax(0, 1fr);
gap: 16px;
align-items: stretch;
}
.filter-grid {
@@ -26,7 +28,7 @@
.legend {
display: flex;
flex-wrap: wrap;
gap: 10px;
gap: 12px;
margin-top: 0;
justify-content: center;
}
@@ -35,13 +37,14 @@
display: inline-flex;
align-items: center;
gap: 8px;
color: #363b44;
color: #2f3c49;
font-size: 13px;
font-weight: 700;
font-weight: 800;
letter-spacing: -0.01em;
background: rgba(255,255,255,0.92);
border: 1px solid var(--line);
border-radius: 10px;
padding: 6px 10px;
border-radius: 12px;
padding: 8px 12px;
}
.legend-swatch {
@@ -56,24 +59,25 @@
radial-gradient(circle at top left, rgba(17, 17, 17, 0.045), transparent 36%);
border: 1px solid var(--line);
border-radius: 16px;
padding: 14px;
padding: 18px 20px 20px;
box-shadow: inset 0 1px 0 rgba(255,255,255,0.92);
}
.chart-legend {
margin-bottom: 10px;
margin-bottom: 12px;
}
.chart-svg {
width: 100%;
height: auto;
aspect-ratio: 1200 / 420;
min-height: 320px;
aspect-ratio: 1560 / 620;
min-height: 460px;
display: block;
}
.expense-chart-svg {
aspect-ratio: 1600 / 420;
aspect-ratio: 1560 / 660;
min-height: 510px;
}
.chart-note {
@@ -83,49 +87,60 @@
line-height: 1.6;
}
.summary-overview {
display: grid;
grid-template-rows: auto auto 1fr;
gap: 14px;
min-height: 100%;
}
.metric-grid {
display: grid;
grid-template-columns: 1fr;
gap: 8px;
gap: 10px;
align-content: stretch;
}
.chart-stack {
display: grid;
gap: 16px;
min-height: 100%;
}
.summary-stack .panel {
padding: 14px;
gap: 12px;
.summary-overview .stat-card,
.chart-stack .stat-card {
min-height: 112px;
}
.summary-stack .section-title h2,
.summary-overview .section-title h2,
.chart-stack .section-title h2 {
font-size: 17px;
}
.summary-stack .field select {
.summary-overview .field select {
min-width: 0;
padding: 10px 12px;
font-size: 13px;
}
.summary-stack .stat-card {
padding: 10px 12px;
.summary-overview .stat-card {
padding: 14px 14px 15px;
border-radius: 12px;
gap: 2px;
gap: 4px;
}
.summary-stack .stat-card .label {
font-size: 11px;
.summary-overview .stat-card .label {
font-size: 12px;
}
.summary-stack .stat-card .value {
font-size: 20px;
.summary-overview .stat-card .value {
font-size: 22px;
line-height: 1.18;
}
@media (max-width: 1000px) {
.summary-layout,
.summary-panel,
.filter-grid,
.metric-grid {
grid-template-columns: 1fr;
@@ -136,12 +151,11 @@
{% block content %}
<section class="panel">
<div class="summary-layout">
<div class="summary-stack">
<section class="panel">
<div class="section-title">
<div class="section-title" style="margin-bottom: 14px;">
<h2>수익/비용 현황</h2>
</div>
<div class="summary-panel">
<div class="summary-overview">
<div class="filter-grid">
<div class="field">
<select id="granularity" aria-label="보기 기준">
@@ -159,27 +173,22 @@
</div>
</div>
<div class="metric-grid" id="metricGrid"></div>
</section>
</div>
<div class="chart-stack">
<section class="panel">
<div class="section-title">
<div class="chart-box">
<div class="section-title" style="margin-bottom: 10px;">
<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>
<svg id="expenseChart" class="chart-svg expense-chart-svg" viewBox="0 0 1560 660" preserveAspectRatio="xMidYMid meet"></svg>
</div>
</section>
<section class="panel">
<div class="section-title">
<div class="chart-box">
<div class="section-title" style="margin-bottom: 10px;">
<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>
<svg id="balanceChart" class="chart-svg" viewBox="0 0 1560 620" preserveAspectRatio="xMidYMid meet"></svg>
</div>
</section>
</div>
</div>
</section>
@@ -190,30 +199,20 @@
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 annualMetricCards = {{ annual_metric_cards | tojson }};
const annualExpenseChartMetrics = {{ annual_expense_chart_metrics | tojson }};
const annualBalanceChartMetrics = {{ annual_balance_chart_metrics | tojson }};
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 palette = Object.fromEntries(
[...annualExpenseChartMetrics, ...annualBalanceChartMetrics].map((option) => [
option.item_key,
option.value,
]),
);
const labels = {
revenue_sum: "수금",
project_cost_sum: "원가(프로젝트)",
support_cost_sum: "원가(지원부서)",
support_sga_sum: "판관비(지원부서)",
field_sga_sum: "판관비(현업부서)",
labor_sum: "원가인건비",
outsourcing_sum: "원가외주비",
total_expense: "비용합계",
operating_balance: "영업수지",
};
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);
@@ -251,6 +250,43 @@
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;
@@ -298,17 +334,7 @@
}
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 keys = annualMetricCards.map((option) => option.item_key);
const totals = {};
keys.forEach((key) => {
totals[key] = series.reduce((sum, item) => sum + (item[key] || 0), 0);
@@ -334,35 +360,21 @@
return { axis, tickMax };
}
function buildSignedAxis(maxPositiveValue, minNegativeValue, width, height, margin) {
const forcedNegativeFloor = -20000000000;
const normalizedNegative = Math.min(Number(minNegativeValue || 0), forcedNegativeFloor);
const rangeMax = Math.max(Math.abs(maxPositiveValue || 0), Math.abs(normalizedNegative || 0), 1);
const { tickStep, tickMax } = buildPositiveAxisScale(rangeMax, 4);
const plotHeight = height - margin.top - margin.bottom;
const zeroY = margin.top + (plotHeight * tickMax) / (tickMax * 2);
let axis = "";
for (let value = -tickMax; value <= tickMax; value += tickStep) {
const y = zeroY - (plotHeight * value) / (tickMax * 2);
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="${zeroY}" x2="${width - margin.right}" y2="${zeroY}" stroke="#8ba0ae" stroke-width="1.2" />`;
return { axis, tickMax, zeroY };
}
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="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>
<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 = ["labor_sum", "outsourcing_sum", "project_cost_sum", "support_cost_sum", "support_sga_sum", "field_sga_sum"];
const keys = annualExpenseChartMetrics.map((option) => option.item_key);
const isMonthlyView = series.some((item) => String(item.label || "").includes("월"));
renderLegend("expenseLegend", keys);
if (!series.length) {
@@ -370,77 +382,62 @@
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 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 } = buildAxis(maxValue, width, height, margin);
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="${width - margin.left - margin.right}" height="${height - margin.top - margin.bottom}" rx="6" fill="rgba(255,255,255,0.7)" stroke="#dde7ec"></rect>
<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;
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;
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) {
const percent = total ? ((value / total) * 100).toFixed(1) : "0.0";
labelEntries.push({
key,
value,
percent,
desiredY: y + (barHeight / 2),
});
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>`;
}
});
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>`;
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 + 20}" text-anchor="middle" fill="#5a6672" font-size="11.5" font-weight="700">${item.label}</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 = ["revenue_sum", "total_expense", "operating_balance"];
const metrics = annualBalanceChartMetrics.map((option) => option.item_key);
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 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) => [
@@ -449,11 +446,11 @@
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, tickMax, zeroY } = buildSignedAxis(maxPositiveValue, minNegativeValue, width, height, margin);
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, 44);
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 = `
@@ -467,21 +464,46 @@
`;
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 barHeight = (plotHeight * Math.abs(value)) / (tickMax * 2);
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 = value < 0 ? zeroY : zeroY - barHeight;
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 (value !== 0) {
const labelY = value < 0
? Math.min(y + barHeight + 14, height - margin.bottom + 6)
: Math.max(y - 8, margin.top + 12);
markup += `<text x="${x + barWidth / 2}" y="${labelY}" text-anchor="middle" fill="#314555" font-size="9.5" font-weight="800">${formatAxisLabel(value)}</text>`;
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);
}
}
});
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>`;
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;
+35 -19
View File
@@ -17,6 +17,9 @@
--warn: #fff2cb;
--table-alt: #f5f6f8;
--white: #ffffff;
--page-gutter: clamp(14px, 1.8vw, 24px);
--panel-pad: clamp(16px, 1.4vw, 20px);
--page-frame-width: min(1520px, calc(100vw - (var(--page-gutter) * 2)));
}
* {
@@ -32,14 +35,18 @@
radial-gradient(circle at top left, rgba(255, 255, 255, 0.92), transparent 24%),
linear-gradient(180deg, var(--bg-a), var(--bg-b));
min-height: 100vh;
padding: 14px;
padding: var(--page-gutter);
}
.page {
max-width: 1520px;
width: min(100%, var(--page-frame-width));
margin: 0 auto;
display: grid;
gap: 14px;
gap: var(--page-gutter);
}
.page > * {
width: 100%;
}
.nav {
@@ -82,7 +89,7 @@
background: var(--panel);
border: 1px solid var(--line);
border-radius: 18px;
padding: 18px;
padding: var(--panel-pad);
box-shadow: 0 10px 24px rgba(21, 24, 29, 0.045);
backdrop-filter: blur(6px);
}
@@ -450,10 +457,6 @@
}
@media (max-width: 720px) {
body {
padding: 14px;
}
.stats,
.form-grid {
grid-template-columns: 1fr;
@@ -484,6 +487,7 @@
id="syncStatusWidget"
data-data-version="{{ data_version or '' }}"
data-refresh-url="{{ request.url.path }}{% if request.url.query %}?{{ request.url.query }}{% endif %}"
data-refresh-mode="{{ 'disabled' if request.url.path == '/projects' else 'auto' }}"
>
<div class="sync-status-head">
<div class="sync-status-title">
@@ -521,6 +525,7 @@
const dataVersion = document.getElementById("syncDataVersion");
let pageVersion = widget.dataset.dataVersion || "";
const refreshUrl = widget.dataset.refreshUrl || window.location.href;
const refreshMode = widget.dataset.refreshMode || "auto";
let refreshInFlight = false;
let pendingVersion = "";
let isFormDirty = false;
@@ -536,6 +541,7 @@
}
const clientSessionId = getSessionId();
window.clientSessionId = clientSessionId;
sessionPill.textContent = clientSessionId;
function updateWidgetTitle() {
@@ -591,17 +597,36 @@
updateWidgetTitle();
}
function setPageDataVersion(nextVersion) {
pageVersion = nextVersion || "";
pendingVersion = "";
widget.dataset.dataVersion = pageVersion;
dataVersion.textContent = pageVersion || "-";
updateWidgetTitle();
}
window.__setPageDataVersion = setPageDataVersion;
function hasActiveEditor() {
const active = document.activeElement;
return Boolean(active && active.closest && active.closest("form[data-collab-form]"));
}
function hasOpenModal() {
return Boolean(document.querySelector(".modal-backdrop.open"));
}
function shouldDelayRefresh() {
return isFormDirty || hasActiveEditor();
return isFormDirty || hasActiveEditor() || hasOpenModal() || window.__suspendAutoRefresh === true;
}
async function refreshPageWhenSafe(nextVersion) {
if (refreshInFlight) return;
if (refreshMode === "disabled") {
setPageDataVersion(nextVersion || pageVersion);
setStatus("online", "서버 정상 연결");
return;
}
if (shouldDelayRefresh()) {
pendingVersion = nextVersion || pendingVersion || pageVersion;
setStatus("online", "새 데이터 대기 중");
@@ -613,16 +638,7 @@
setStatus("online", "새 데이터 반영 중");
try {
const response = await fetch(refreshUrl, {
cache: "no-store",
credentials: "same-origin",
headers: { "X-Requested-With": "XMLHttpRequest" },
});
if (!response.ok) throw new Error(`HTTP ${response.status}`);
const html = await response.text();
document.open();
document.write(html);
document.close();
window.location.replace(refreshUrl);
} catch (error) {
refreshInFlight = false;
setStatus("error", "업데이트 재시도 중");
+24 -40
View File
@@ -228,16 +228,6 @@
</div>
</div>
<div class="chart-shell" style="margin-bottom: 16px;">
<div class="legend-box" style="justify-content:flex-start;">
<span class="legend-item"><span class="legend-swatch" style="background:#111827;"></span>계약 프로젝트 {{ import_sync_summary.contract_project_count or 0 }}건</span>
<span class="legend-item"><span class="legend-swatch" style="background:#0f766e;"></span>청구 프로젝트 {{ import_sync_summary.billing_project_count or 0 }}건</span>
<span class="legend-item"><span class="legend-swatch" style="background:#f59e0b;"></span>변경계약 검토 {{ import_sync_summary.review_needed_count or 0 }}건</span>
<span class="legend-item"><span class="legend-swatch" style="background:#1d4ed8;"></span>한맥 계약합계 {{ "{:,.0f}".format(import_sync_summary.total_hanmac_contract_amount or 0) }}</span>
<span class="legend-item"><span class="legend-swatch" style="background:#7c3aed;"></span>청구 수금합계 {{ "{:,.0f}".format(import_sync_summary.total_collected_amount or 0) }}</span>
</div>
</div>
<div class="dashboard-layout">
<section class="dashboard-status-panel">
<div class="dashboard-status-grid">
@@ -285,10 +275,9 @@
</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>
{% for option in dashboard_revenue_metric_options %}
<option value="{{ option.item_key }}">{{ option.label }}</option>
{% endfor %}
</select>
</div>
</div>
@@ -314,10 +303,9 @@
</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>
{% for option in dashboard_expense_metric_options %}
<option value="{{ option.item_key }}">{{ option.label }}</option>
{% endfor %}
</select>
</div>
</div>
@@ -339,35 +327,31 @@
const revenueYearly = {{ project_revenue_mix_yearly | tojson }};
const revenueMonthly = {{ project_revenue_mix_monthly | tojson }};
const pageSelectedYear = {{ overview_selected_year | tojson }};
const dashboardRevenueMetricOptions = {{ dashboard_revenue_metric_options | tojson }};
const dashboardExpenseMetricOptions = {{ dashboard_expense_metric_options | tojson }};
const revenuePalette = {
design_revenue: { label: "설계", color: "#4f7cff" },
design_other_revenue: { label: "설계 외", color: "#67c7c9" },
supervision_revenue: { label: "감리", color: "#233a5a" },
inspection_revenue: { label: "점검", color: "#ffb54a" },
};
const revenuePalette = Object.fromEntries(
dashboardRevenueMetricOptions.map((option) => [
option.item_key,
{ label: option.label, color: option.value },
]),
);
const expensePalette = {
cost_sum: { label: "원가", color: "#4f7cff" },
sga_sum: { label: "판관비", color: "#67c7c9" },
labor_sum: { label: "원가인건비", color: "#233a5a" },
outsourcing_sum: { label: "원가외주비", color: "#ffb54a" },
};
const expensePalette = Object.fromEntries(
dashboardExpenseMetricOptions.map((option) => [
option.item_key,
{ label: option.label, color: option.value },
]),
);
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"],
all: dashboardRevenueMetricOptions.map((option) => option.item_key),
...Object.fromEntries(dashboardRevenueMetricOptions.map((option) => [option.item_key, [option.item_key]])),
};
const expenseMetricMap = {
all: ["cost_sum", "sga_sum", "labor_sum", "outsourcing_sum"],
cost_sum: ["cost_sum"],
sga_sum: ["sga_sum"],
labor_sum: ["labor_sum"],
outsourcing_sum: ["outsourcing_sum"],
all: dashboardExpenseMetricOptions.map((option) => option.item_key),
...Object.fromEntries(dashboardExpenseMetricOptions.map((option) => [option.item_key, [option.item_key]])),
};
function formatNumber(value) {
+11 -4
View File
@@ -18,6 +18,9 @@
--warn: #fff0c9;
--table-alt: #f9fbfc;
--white: #ffffff;
--page-gutter: clamp(14px, 1.8vw, 24px);
--panel-pad: clamp(16px, 1.4vw, 20px);
--page-frame-width: min(1520px, calc(100vw - (var(--page-gutter) * 2)));
}
* {
@@ -33,21 +36,25 @@
radial-gradient(circle at top left, rgba(255, 255, 255, 0.9), transparent 28%),
linear-gradient(155deg, var(--bg-a), var(--bg-b));
min-height: 100vh;
padding: 24px;
padding: var(--page-gutter);
}
.page {
max-width: 1480px;
width: min(100%, var(--page-frame-width));
margin: 0 auto;
display: grid;
gap: 20px;
gap: var(--page-gutter);
}
.page > * {
width: 100%;
}
.panel {
background: var(--panel);
border: 1px solid rgba(255, 255, 255, 0.6);
border-radius: 24px;
padding: 24px;
padding: var(--panel-pad);
box-shadow: 0 20px 45px rgba(51, 76, 92, 0.12);
backdrop-filter: blur(10px);
}
+1631 -138
View File
File diff suppressed because it is too large Load Diff