Stabilize project flows and reporting
This commit is contained in:
@@ -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.
|
||||||
@@ -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` 같은 런타임 임시 파일은 커밋 대상에서 제외함
|
||||||
+162
-140
@@ -8,12 +8,14 @@
|
|||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: 320px minmax(0, 1fr);
|
grid-template-columns: 320px minmax(0, 1fr);
|
||||||
gap: 16px;
|
gap: 16px;
|
||||||
align-items: start;
|
align-items: stretch;
|
||||||
}
|
}
|
||||||
|
|
||||||
.summary-stack {
|
.summary-panel {
|
||||||
display: grid;
|
display: grid;
|
||||||
|
grid-template-columns: 320px minmax(0, 1fr);
|
||||||
gap: 16px;
|
gap: 16px;
|
||||||
|
align-items: stretch;
|
||||||
}
|
}
|
||||||
|
|
||||||
.filter-grid {
|
.filter-grid {
|
||||||
@@ -26,7 +28,7 @@
|
|||||||
.legend {
|
.legend {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-wrap: wrap;
|
flex-wrap: wrap;
|
||||||
gap: 10px;
|
gap: 12px;
|
||||||
margin-top: 0;
|
margin-top: 0;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
}
|
}
|
||||||
@@ -35,13 +37,14 @@
|
|||||||
display: inline-flex;
|
display: inline-flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 8px;
|
gap: 8px;
|
||||||
color: #363b44;
|
color: #2f3c49;
|
||||||
font-size: 13px;
|
font-size: 13px;
|
||||||
font-weight: 700;
|
font-weight: 800;
|
||||||
|
letter-spacing: -0.01em;
|
||||||
background: rgba(255,255,255,0.92);
|
background: rgba(255,255,255,0.92);
|
||||||
border: 1px solid var(--line);
|
border: 1px solid var(--line);
|
||||||
border-radius: 10px;
|
border-radius: 12px;
|
||||||
padding: 6px 10px;
|
padding: 8px 12px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.legend-swatch {
|
.legend-swatch {
|
||||||
@@ -56,24 +59,25 @@
|
|||||||
radial-gradient(circle at top left, rgba(17, 17, 17, 0.045), transparent 36%);
|
radial-gradient(circle at top left, rgba(17, 17, 17, 0.045), transparent 36%);
|
||||||
border: 1px solid var(--line);
|
border: 1px solid var(--line);
|
||||||
border-radius: 16px;
|
border-radius: 16px;
|
||||||
padding: 14px;
|
padding: 18px 20px 20px;
|
||||||
box-shadow: inset 0 1px 0 rgba(255,255,255,0.92);
|
box-shadow: inset 0 1px 0 rgba(255,255,255,0.92);
|
||||||
}
|
}
|
||||||
|
|
||||||
.chart-legend {
|
.chart-legend {
|
||||||
margin-bottom: 10px;
|
margin-bottom: 12px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.chart-svg {
|
.chart-svg {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
height: auto;
|
height: auto;
|
||||||
aspect-ratio: 1200 / 420;
|
aspect-ratio: 1560 / 620;
|
||||||
min-height: 320px;
|
min-height: 460px;
|
||||||
display: block;
|
display: block;
|
||||||
}
|
}
|
||||||
|
|
||||||
.expense-chart-svg {
|
.expense-chart-svg {
|
||||||
aspect-ratio: 1600 / 420;
|
aspect-ratio: 1560 / 660;
|
||||||
|
min-height: 510px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.chart-note {
|
.chart-note {
|
||||||
@@ -83,49 +87,60 @@
|
|||||||
line-height: 1.6;
|
line-height: 1.6;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.summary-overview {
|
||||||
|
display: grid;
|
||||||
|
grid-template-rows: auto auto 1fr;
|
||||||
|
gap: 14px;
|
||||||
|
min-height: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
.metric-grid {
|
.metric-grid {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: 1fr;
|
grid-template-columns: 1fr;
|
||||||
gap: 8px;
|
gap: 10px;
|
||||||
|
align-content: stretch;
|
||||||
}
|
}
|
||||||
|
|
||||||
.chart-stack {
|
.chart-stack {
|
||||||
display: grid;
|
display: grid;
|
||||||
gap: 16px;
|
gap: 16px;
|
||||||
|
min-height: 100%;
|
||||||
}
|
}
|
||||||
|
|
||||||
.summary-stack .panel {
|
.summary-overview .stat-card,
|
||||||
padding: 14px;
|
.chart-stack .stat-card {
|
||||||
gap: 12px;
|
min-height: 112px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.summary-stack .section-title h2,
|
.summary-overview .section-title h2,
|
||||||
.chart-stack .section-title h2 {
|
.chart-stack .section-title h2 {
|
||||||
font-size: 17px;
|
font-size: 17px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.summary-stack .field select {
|
.summary-overview .field select {
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
padding: 10px 12px;
|
padding: 10px 12px;
|
||||||
font-size: 13px;
|
font-size: 13px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.summary-stack .stat-card {
|
.summary-overview .stat-card {
|
||||||
padding: 10px 12px;
|
padding: 14px 14px 15px;
|
||||||
border-radius: 12px;
|
border-radius: 12px;
|
||||||
gap: 2px;
|
gap: 4px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.summary-stack .stat-card .label {
|
.summary-overview .stat-card .label {
|
||||||
font-size: 11px;
|
font-size: 12px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.summary-stack .stat-card .value {
|
.summary-overview .stat-card .value {
|
||||||
font-size: 20px;
|
font-size: 22px;
|
||||||
|
line-height: 1.18;
|
||||||
}
|
}
|
||||||
|
|
||||||
@media (max-width: 1000px) {
|
@media (max-width: 1000px) {
|
||||||
.summary-layout,
|
.summary-layout,
|
||||||
|
.summary-panel,
|
||||||
.filter-grid,
|
.filter-grid,
|
||||||
.metric-grid {
|
.metric-grid {
|
||||||
grid-template-columns: 1fr;
|
grid-template-columns: 1fr;
|
||||||
@@ -136,12 +151,11 @@
|
|||||||
|
|
||||||
{% block content %}
|
{% block content %}
|
||||||
<section class="panel">
|
<section class="panel">
|
||||||
<div class="summary-layout">
|
<div class="section-title" style="margin-bottom: 14px;">
|
||||||
<div class="summary-stack">
|
|
||||||
<section class="panel">
|
|
||||||
<div class="section-title">
|
|
||||||
<h2>수익/비용 현황</h2>
|
<h2>수익/비용 현황</h2>
|
||||||
</div>
|
</div>
|
||||||
|
<div class="summary-panel">
|
||||||
|
<div class="summary-overview">
|
||||||
<div class="filter-grid">
|
<div class="filter-grid">
|
||||||
<div class="field">
|
<div class="field">
|
||||||
<select id="granularity" aria-label="보기 기준">
|
<select id="granularity" aria-label="보기 기준">
|
||||||
@@ -159,27 +173,22 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="metric-grid" id="metricGrid"></div>
|
<div class="metric-grid" id="metricGrid"></div>
|
||||||
</section>
|
|
||||||
</div>
|
</div>
|
||||||
<div class="chart-stack">
|
<div class="chart-stack">
|
||||||
<section class="panel">
|
<div class="chart-box">
|
||||||
<div class="section-title">
|
<div class="section-title" style="margin-bottom: 10px;">
|
||||||
<h2>비용 구조</h2>
|
<h2>비용 구조</h2>
|
||||||
</div>
|
</div>
|
||||||
<div class="chart-box">
|
|
||||||
<div class="legend chart-legend" id="expenseLegend"></div>
|
<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>
|
</div>
|
||||||
</section>
|
<div class="chart-box">
|
||||||
<section class="panel">
|
<div class="section-title" style="margin-bottom: 10px;">
|
||||||
<div class="section-title">
|
|
||||||
<h2>수금/비용/영업수지 그래프</h2>
|
<h2>수금/비용/영업수지 그래프</h2>
|
||||||
</div>
|
</div>
|
||||||
<div class="chart-box">
|
|
||||||
<div class="legend chart-legend" id="balanceLegend"></div>
|
<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>
|
</div>
|
||||||
</section>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
@@ -190,30 +199,20 @@
|
|||||||
const yearlySeries = {{ yearly_financial_series | tojson }};
|
const yearlySeries = {{ yearly_financial_series | tojson }};
|
||||||
const monthlySeries = {{ monthly_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 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 = {
|
const palette = Object.fromEntries(
|
||||||
revenue_sum: "#0f766e",
|
[...annualExpenseChartMetrics, ...annualBalanceChartMetrics].map((option) => [
|
||||||
project_cost_sum: "#0ea5a4",
|
option.item_key,
|
||||||
support_cost_sum: "#67b7dc",
|
option.value,
|
||||||
support_sga_sum: "#f59e0b",
|
]),
|
||||||
field_sga_sum: "#f97316",
|
);
|
||||||
labor_sum: "#8b5cf6",
|
|
||||||
outsourcing_sum: "#ec4899",
|
|
||||||
total_expense: "#1d4ed8",
|
|
||||||
operating_balance: "#dc2626",
|
|
||||||
};
|
|
||||||
|
|
||||||
const labels = {
|
const labels = Object.fromEntries(
|
||||||
revenue_sum: "수금",
|
annualMetricCards.map((option) => [option.item_key, option.label]),
|
||||||
project_cost_sum: "원가(프로젝트)",
|
);
|
||||||
support_cost_sum: "원가(지원부서)",
|
|
||||||
support_sga_sum: "판관비(지원부서)",
|
|
||||||
field_sga_sum: "판관비(현업부서)",
|
|
||||||
labor_sum: "원가인건비",
|
|
||||||
outsourcing_sum: "원가외주비",
|
|
||||||
total_expense: "비용합계",
|
|
||||||
operating_balance: "영업수지",
|
|
||||||
};
|
|
||||||
|
|
||||||
function formatNumber(value) {
|
function formatNumber(value) {
|
||||||
return new Intl.NumberFormat("ko-KR", { maximumFractionDigits: 0 }).format(value || 0);
|
return new Intl.NumberFormat("ko-KR", { maximumFractionDigits: 0 }).format(value || 0);
|
||||||
@@ -251,6 +250,43 @@
|
|||||||
return { tickStep, tickMax };
|
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) {
|
function renderLegend(targetId, keys) {
|
||||||
const target = document.getElementById(targetId);
|
const target = document.getElementById(targetId);
|
||||||
if (!target) return;
|
if (!target) return;
|
||||||
@@ -298,17 +334,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
function renderMetrics(series) {
|
function renderMetrics(series) {
|
||||||
const keys = [
|
const keys = annualMetricCards.map((option) => option.item_key);
|
||||||
"revenue_sum",
|
|
||||||
"project_cost_sum",
|
|
||||||
"support_cost_sum",
|
|
||||||
"support_sga_sum",
|
|
||||||
"field_sga_sum",
|
|
||||||
"labor_sum",
|
|
||||||
"outsourcing_sum",
|
|
||||||
"total_expense",
|
|
||||||
"operating_balance",
|
|
||||||
];
|
|
||||||
const totals = {};
|
const totals = {};
|
||||||
keys.forEach((key) => {
|
keys.forEach((key) => {
|
||||||
totals[key] = series.reduce((sum, item) => sum + (item[key] || 0), 0);
|
totals[key] = series.reduce((sum, item) => sum + (item[key] || 0), 0);
|
||||||
@@ -334,35 +360,21 @@
|
|||||||
return { axis, tickMax };
|
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) {
|
function renderEmptyChart(svgId, message) {
|
||||||
const svg = document.getElementById(svgId);
|
const svg = document.getElementById(svgId);
|
||||||
if (!svg) return;
|
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 = `
|
svg.innerHTML = `
|
||||||
<rect x="0" y="0" width="1200" height="420" rx="8" fill="#f7fafb" stroke="#d6e2e8"></rect>
|
<rect x="0" y="0" width="${width}" height="${height}" 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>
|
<text x="${width / 2}" y="${height / 2}" text-anchor="middle" fill="#667887" font-size="24" font-weight="800">${message}</text>
|
||||||
`;
|
`;
|
||||||
}
|
}
|
||||||
|
|
||||||
function renderExpenseChart(series) {
|
function renderExpenseChart(series) {
|
||||||
const svg = document.getElementById("expenseChart");
|
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("월"));
|
const isMonthlyView = series.some((item) => String(item.label || "").includes("월"));
|
||||||
renderLegend("expenseLegend", keys);
|
renderLegend("expenseLegend", keys);
|
||||||
if (!series.length) {
|
if (!series.length) {
|
||||||
@@ -370,77 +382,62 @@
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const width = 1600;
|
const width = 1560;
|
||||||
const height = 420;
|
const height = 660;
|
||||||
const margin = { top: 30, right: isMonthlyView ? 72 : 36, bottom: 74, left: 98 };
|
svg.setAttribute("viewBox", `0 0 ${width} ${height}`);
|
||||||
const barWidth = (width - margin.left - margin.right) / series.length * (isMonthlyView ? 0.18 : 0.29);
|
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 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 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 = `
|
let markup = `
|
||||||
<defs>
|
<defs>
|
||||||
<filter id="expenseShadow" x="-20%" y="-20%" width="140%" height="160%">
|
<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)" />
|
<feDropShadow dx="0" dy="8" stdDeviation="8" flood-color="rgba(44, 68, 89, 0.12)" />
|
||||||
</filter>
|
</filter>
|
||||||
</defs>
|
</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}
|
${axis}
|
||||||
`;
|
`;
|
||||||
series.forEach((item, index) => {
|
series.forEach((item, index) => {
|
||||||
let cumulative = 0;
|
let cumulative = 0;
|
||||||
const total = keys.reduce((sum, key) => sum + (item[key] || 0), 0);
|
const total = keys.reduce((sum, key) => sum + (item[key] || 0), 0);
|
||||||
const x = margin.left + index * step + (step - barWidth) / 2;
|
const x = margin.left + index * step + (step - barWidth) / 2;
|
||||||
const detailX = x + barWidth + 8;
|
|
||||||
const labelEntries = [];
|
|
||||||
keys.forEach((key) => {
|
keys.forEach((key) => {
|
||||||
const value = item[key] || 0;
|
const value = item[key] || 0;
|
||||||
const barHeight = ((height - margin.top - margin.bottom) * value) / tickMax;
|
const barHeight = (plotHeight * value) / tickMax;
|
||||||
const y = height - margin.bottom - barHeight - ((height - margin.top - margin.bottom) * cumulative) / tickMax;
|
const y = height - margin.bottom - barHeight - (plotHeight * cumulative) / tickMax;
|
||||||
cumulative += value;
|
cumulative += value;
|
||||||
markup += `<rect x="${x}" y="${y}" width="${barWidth}" height="${barHeight}" rx="2" fill="${palette[key]}" filter="url(#expenseShadow)" />`;
|
markup += `<rect x="${x}" y="${y}" width="${barWidth}" height="${barHeight}" rx="2" fill="${palette[key]}" filter="url(#expenseShadow)" />`;
|
||||||
if (value > 0) {
|
if (value > 0 && barHeight >= 34) {
|
||||||
const percent = total ? ((value / total) * 100).toFixed(1) : "0.0";
|
const percent = total ? `${((value / total) * 100).toFixed(1)}%` : "0.0%";
|
||||||
labelEntries.push({
|
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>`;
|
||||||
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) {
|
if (total > 0) {
|
||||||
const topY = height - margin.bottom - ((height - margin.top - margin.bottom) * total) / tickMax;
|
const topY = height - margin.bottom - (plotHeight * 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="${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;
|
svg.innerHTML = markup;
|
||||||
}
|
}
|
||||||
|
|
||||||
function renderBalanceChart(series) {
|
function renderBalanceChart(series) {
|
||||||
const svg = document.getElementById("balanceChart");
|
const svg = document.getElementById("balanceChart");
|
||||||
const metrics = ["revenue_sum", "total_expense", "operating_balance"];
|
const metrics = annualBalanceChartMetrics.map((option) => option.item_key);
|
||||||
renderLegend("balanceLegend", metrics);
|
renderLegend("balanceLegend", metrics);
|
||||||
if (!series.length) {
|
if (!series.length) {
|
||||||
renderEmptyChart("balanceChart", "표시할 수익/비용 데이터가 없습니다.");
|
renderEmptyChart("balanceChart", "표시할 수익/비용 데이터가 없습니다.");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const width = 1200;
|
const width = 1560;
|
||||||
const height = 420;
|
const height = 620;
|
||||||
const margin = { top: 30, right: 24, bottom: 74, left: 98 };
|
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 plotWidth = width - margin.left - margin.right;
|
||||||
const plotHeight = height - margin.top - margin.bottom;
|
const plotHeight = height - margin.top - margin.bottom;
|
||||||
const maxPositiveValue = Math.max(...series.flatMap((item) => [
|
const maxPositiveValue = Math.max(...series.flatMap((item) => [
|
||||||
@@ -449,11 +446,11 @@
|
|||||||
Math.max(item.operating_balance || 0, 0),
|
Math.max(item.operating_balance || 0, 0),
|
||||||
]), 1);
|
]), 1);
|
||||||
const minNegativeValue = Math.min(...series.map((item) => Math.min(item.operating_balance || 0, 0)), 0);
|
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 groupWidth = plotWidth / Math.max(series.length, 1);
|
||||||
const groupGap = groupWidth * 0.22;
|
const groupGap = groupWidth * 0.22;
|
||||||
const innerGap = 0;
|
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 actualGroupWidth = barWidth * metrics.length + innerGap * (metrics.length - 1);
|
||||||
const groupStartOffset = (groupWidth - actualGroupWidth) / 2;
|
const groupStartOffset = (groupWidth - actualGroupWidth) / 2;
|
||||||
let markup = `
|
let markup = `
|
||||||
@@ -467,21 +464,46 @@
|
|||||||
`;
|
`;
|
||||||
series.forEach((item, index) => {
|
series.forEach((item, index) => {
|
||||||
const baseX = margin.left + index * groupWidth;
|
const baseX = margin.left + index * groupWidth;
|
||||||
|
const positiveLabels = [];
|
||||||
|
const negativeLabels = [];
|
||||||
metrics.forEach((key, metricIndex) => {
|
metrics.forEach((key, metricIndex) => {
|
||||||
const rawValue = Number(item[key] || 0);
|
const rawValue = Number(item[key] || 0);
|
||||||
const value = key === "operating_balance" ? rawValue : Math.max(rawValue, 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 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)" />`;
|
markup += `<rect x="${x}" y="${y}" width="${barWidth}" height="${barHeight}" rx="2" fill="${palette[key]}" filter="url(#balanceShadow)" />`;
|
||||||
if (value !== 0) {
|
if (clampedValue !== 0) {
|
||||||
const labelY = value < 0
|
const label = {
|
||||||
? Math.min(y + barHeight + 14, height - margin.bottom + 6)
|
x: x + barWidth / 2,
|
||||||
: Math.max(y - 8, margin.top + 12);
|
desiredY: clampedValue < 0
|
||||||
markup += `<text x="${x + barWidth / 2}" y="${labelY}" text-anchor="middle" fill="#314555" font-size="9.5" font-weight="800">${formatAxisLabel(value)}</text>`;
|
? 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;
|
svg.innerHTML = markup;
|
||||||
|
|||||||
+35
-19
@@ -17,6 +17,9 @@
|
|||||||
--warn: #fff2cb;
|
--warn: #fff2cb;
|
||||||
--table-alt: #f5f6f8;
|
--table-alt: #f5f6f8;
|
||||||
--white: #ffffff;
|
--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%),
|
radial-gradient(circle at top left, rgba(255, 255, 255, 0.92), transparent 24%),
|
||||||
linear-gradient(180deg, var(--bg-a), var(--bg-b));
|
linear-gradient(180deg, var(--bg-a), var(--bg-b));
|
||||||
min-height: 100vh;
|
min-height: 100vh;
|
||||||
padding: 14px;
|
padding: var(--page-gutter);
|
||||||
}
|
}
|
||||||
|
|
||||||
.page {
|
.page {
|
||||||
max-width: 1520px;
|
width: min(100%, var(--page-frame-width));
|
||||||
margin: 0 auto;
|
margin: 0 auto;
|
||||||
display: grid;
|
display: grid;
|
||||||
gap: 14px;
|
gap: var(--page-gutter);
|
||||||
|
}
|
||||||
|
|
||||||
|
.page > * {
|
||||||
|
width: 100%;
|
||||||
}
|
}
|
||||||
|
|
||||||
.nav {
|
.nav {
|
||||||
@@ -82,7 +89,7 @@
|
|||||||
background: var(--panel);
|
background: var(--panel);
|
||||||
border: 1px solid var(--line);
|
border: 1px solid var(--line);
|
||||||
border-radius: 18px;
|
border-radius: 18px;
|
||||||
padding: 18px;
|
padding: var(--panel-pad);
|
||||||
box-shadow: 0 10px 24px rgba(21, 24, 29, 0.045);
|
box-shadow: 0 10px 24px rgba(21, 24, 29, 0.045);
|
||||||
backdrop-filter: blur(6px);
|
backdrop-filter: blur(6px);
|
||||||
}
|
}
|
||||||
@@ -450,10 +457,6 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
@media (max-width: 720px) {
|
@media (max-width: 720px) {
|
||||||
body {
|
|
||||||
padding: 14px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.stats,
|
.stats,
|
||||||
.form-grid {
|
.form-grid {
|
||||||
grid-template-columns: 1fr;
|
grid-template-columns: 1fr;
|
||||||
@@ -484,6 +487,7 @@
|
|||||||
id="syncStatusWidget"
|
id="syncStatusWidget"
|
||||||
data-data-version="{{ data_version or '' }}"
|
data-data-version="{{ data_version or '' }}"
|
||||||
data-refresh-url="{{ request.url.path }}{% if request.url.query %}?{{ request.url.query }}{% endif %}"
|
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-head">
|
||||||
<div class="sync-status-title">
|
<div class="sync-status-title">
|
||||||
@@ -521,6 +525,7 @@
|
|||||||
const dataVersion = document.getElementById("syncDataVersion");
|
const dataVersion = document.getElementById("syncDataVersion");
|
||||||
let pageVersion = widget.dataset.dataVersion || "";
|
let pageVersion = widget.dataset.dataVersion || "";
|
||||||
const refreshUrl = widget.dataset.refreshUrl || window.location.href;
|
const refreshUrl = widget.dataset.refreshUrl || window.location.href;
|
||||||
|
const refreshMode = widget.dataset.refreshMode || "auto";
|
||||||
let refreshInFlight = false;
|
let refreshInFlight = false;
|
||||||
let pendingVersion = "";
|
let pendingVersion = "";
|
||||||
let isFormDirty = false;
|
let isFormDirty = false;
|
||||||
@@ -536,6 +541,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
const clientSessionId = getSessionId();
|
const clientSessionId = getSessionId();
|
||||||
|
window.clientSessionId = clientSessionId;
|
||||||
sessionPill.textContent = clientSessionId;
|
sessionPill.textContent = clientSessionId;
|
||||||
|
|
||||||
function updateWidgetTitle() {
|
function updateWidgetTitle() {
|
||||||
@@ -591,17 +597,36 @@
|
|||||||
updateWidgetTitle();
|
updateWidgetTitle();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function setPageDataVersion(nextVersion) {
|
||||||
|
pageVersion = nextVersion || "";
|
||||||
|
pendingVersion = "";
|
||||||
|
widget.dataset.dataVersion = pageVersion;
|
||||||
|
dataVersion.textContent = pageVersion || "-";
|
||||||
|
updateWidgetTitle();
|
||||||
|
}
|
||||||
|
|
||||||
|
window.__setPageDataVersion = setPageDataVersion;
|
||||||
|
|
||||||
function hasActiveEditor() {
|
function hasActiveEditor() {
|
||||||
const active = document.activeElement;
|
const active = document.activeElement;
|
||||||
return Boolean(active && active.closest && active.closest("form[data-collab-form]"));
|
return Boolean(active && active.closest && active.closest("form[data-collab-form]"));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function hasOpenModal() {
|
||||||
|
return Boolean(document.querySelector(".modal-backdrop.open"));
|
||||||
|
}
|
||||||
|
|
||||||
function shouldDelayRefresh() {
|
function shouldDelayRefresh() {
|
||||||
return isFormDirty || hasActiveEditor();
|
return isFormDirty || hasActiveEditor() || hasOpenModal() || window.__suspendAutoRefresh === true;
|
||||||
}
|
}
|
||||||
|
|
||||||
async function refreshPageWhenSafe(nextVersion) {
|
async function refreshPageWhenSafe(nextVersion) {
|
||||||
if (refreshInFlight) return;
|
if (refreshInFlight) return;
|
||||||
|
if (refreshMode === "disabled") {
|
||||||
|
setPageDataVersion(nextVersion || pageVersion);
|
||||||
|
setStatus("online", "서버 정상 연결");
|
||||||
|
return;
|
||||||
|
}
|
||||||
if (shouldDelayRefresh()) {
|
if (shouldDelayRefresh()) {
|
||||||
pendingVersion = nextVersion || pendingVersion || pageVersion;
|
pendingVersion = nextVersion || pendingVersion || pageVersion;
|
||||||
setStatus("online", "새 데이터 대기 중");
|
setStatus("online", "새 데이터 대기 중");
|
||||||
@@ -613,16 +638,7 @@
|
|||||||
setStatus("online", "새 데이터 반영 중");
|
setStatus("online", "새 데이터 반영 중");
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const response = await fetch(refreshUrl, {
|
window.location.replace(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();
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
refreshInFlight = false;
|
refreshInFlight = false;
|
||||||
setStatus("error", "업데이트 재시도 중");
|
setStatus("error", "업데이트 재시도 중");
|
||||||
|
|||||||
+24
-40
@@ -228,16 +228,6 @@
|
|||||||
</div>
|
</div>
|
||||||
</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">
|
<div class="dashboard-layout">
|
||||||
<section class="dashboard-status-panel">
|
<section class="dashboard-status-panel">
|
||||||
<div class="dashboard-status-grid">
|
<div class="dashboard-status-grid">
|
||||||
@@ -285,10 +275,9 @@
|
|||||||
</select>
|
</select>
|
||||||
<select id="revenueMetric" aria-label="수금 구성 항목 선택">
|
<select id="revenueMetric" aria-label="수금 구성 항목 선택">
|
||||||
<option value="all">전체 항목</option>
|
<option value="all">전체 항목</option>
|
||||||
<option value="design_revenue">설계</option>
|
{% for option in dashboard_revenue_metric_options %}
|
||||||
<option value="design_other_revenue">설계 외</option>
|
<option value="{{ option.item_key }}">{{ option.label }}</option>
|
||||||
<option value="supervision_revenue">감리</option>
|
{% endfor %}
|
||||||
<option value="inspection_revenue">점검</option>
|
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -314,10 +303,9 @@
|
|||||||
</select>
|
</select>
|
||||||
<select id="expenseMetric" aria-label="지출 구성 항목 선택">
|
<select id="expenseMetric" aria-label="지출 구성 항목 선택">
|
||||||
<option value="all">전체 항목</option>
|
<option value="all">전체 항목</option>
|
||||||
<option value="cost_sum">원가</option>
|
{% for option in dashboard_expense_metric_options %}
|
||||||
<option value="sga_sum">판관비</option>
|
<option value="{{ option.item_key }}">{{ option.label }}</option>
|
||||||
<option value="labor_sum">원가인건비</option>
|
{% endfor %}
|
||||||
<option value="outsourcing_sum">원가외주비</option>
|
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -339,35 +327,31 @@
|
|||||||
const revenueYearly = {{ project_revenue_mix_yearly | tojson }};
|
const revenueYearly = {{ project_revenue_mix_yearly | tojson }};
|
||||||
const revenueMonthly = {{ project_revenue_mix_monthly | tojson }};
|
const revenueMonthly = {{ project_revenue_mix_monthly | tojson }};
|
||||||
const pageSelectedYear = {{ overview_selected_year | tojson }};
|
const pageSelectedYear = {{ overview_selected_year | tojson }};
|
||||||
|
const dashboardRevenueMetricOptions = {{ dashboard_revenue_metric_options | tojson }};
|
||||||
|
const dashboardExpenseMetricOptions = {{ dashboard_expense_metric_options | tojson }};
|
||||||
|
|
||||||
const revenuePalette = {
|
const revenuePalette = Object.fromEntries(
|
||||||
design_revenue: { label: "설계", color: "#4f7cff" },
|
dashboardRevenueMetricOptions.map((option) => [
|
||||||
design_other_revenue: { label: "설계 외", color: "#67c7c9" },
|
option.item_key,
|
||||||
supervision_revenue: { label: "감리", color: "#233a5a" },
|
{ label: option.label, color: option.value },
|
||||||
inspection_revenue: { label: "점검", color: "#ffb54a" },
|
]),
|
||||||
};
|
);
|
||||||
|
|
||||||
const expensePalette = {
|
const expensePalette = Object.fromEntries(
|
||||||
cost_sum: { label: "원가", color: "#4f7cff" },
|
dashboardExpenseMetricOptions.map((option) => [
|
||||||
sga_sum: { label: "판관비", color: "#67c7c9" },
|
option.item_key,
|
||||||
labor_sum: { label: "원가인건비", color: "#233a5a" },
|
{ label: option.label, color: option.value },
|
||||||
outsourcing_sum: { label: "원가외주비", color: "#ffb54a" },
|
]),
|
||||||
};
|
);
|
||||||
|
|
||||||
const revenueMetricMap = {
|
const revenueMetricMap = {
|
||||||
all: ["design_revenue", "design_other_revenue", "supervision_revenue", "inspection_revenue"],
|
all: dashboardRevenueMetricOptions.map((option) => option.item_key),
|
||||||
design_revenue: ["design_revenue"],
|
...Object.fromEntries(dashboardRevenueMetricOptions.map((option) => [option.item_key, [option.item_key]])),
|
||||||
design_other_revenue: ["design_other_revenue"],
|
|
||||||
supervision_revenue: ["supervision_revenue"],
|
|
||||||
inspection_revenue: ["inspection_revenue"],
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const expenseMetricMap = {
|
const expenseMetricMap = {
|
||||||
all: ["cost_sum", "sga_sum", "labor_sum", "outsourcing_sum"],
|
all: dashboardExpenseMetricOptions.map((option) => option.item_key),
|
||||||
cost_sum: ["cost_sum"],
|
...Object.fromEntries(dashboardExpenseMetricOptions.map((option) => [option.item_key, [option.item_key]])),
|
||||||
sga_sum: ["sga_sum"],
|
|
||||||
labor_sum: ["labor_sum"],
|
|
||||||
outsourcing_sum: ["outsourcing_sum"],
|
|
||||||
};
|
};
|
||||||
|
|
||||||
function formatNumber(value) {
|
function formatNumber(value) {
|
||||||
|
|||||||
+11
-4
@@ -18,6 +18,9 @@
|
|||||||
--warn: #fff0c9;
|
--warn: #fff0c9;
|
||||||
--table-alt: #f9fbfc;
|
--table-alt: #f9fbfc;
|
||||||
--white: #ffffff;
|
--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%),
|
radial-gradient(circle at top left, rgba(255, 255, 255, 0.9), transparent 28%),
|
||||||
linear-gradient(155deg, var(--bg-a), var(--bg-b));
|
linear-gradient(155deg, var(--bg-a), var(--bg-b));
|
||||||
min-height: 100vh;
|
min-height: 100vh;
|
||||||
padding: 24px;
|
padding: var(--page-gutter);
|
||||||
}
|
}
|
||||||
|
|
||||||
.page {
|
.page {
|
||||||
max-width: 1480px;
|
width: min(100%, var(--page-frame-width));
|
||||||
margin: 0 auto;
|
margin: 0 auto;
|
||||||
display: grid;
|
display: grid;
|
||||||
gap: 20px;
|
gap: var(--page-gutter);
|
||||||
|
}
|
||||||
|
|
||||||
|
.page > * {
|
||||||
|
width: 100%;
|
||||||
}
|
}
|
||||||
|
|
||||||
.panel {
|
.panel {
|
||||||
background: var(--panel);
|
background: var(--panel);
|
||||||
border: 1px solid rgba(255, 255, 255, 0.6);
|
border: 1px solid rgba(255, 255, 255, 0.6);
|
||||||
border-radius: 24px;
|
border-radius: 24px;
|
||||||
padding: 24px;
|
padding: var(--panel-pad);
|
||||||
box-shadow: 0 20px 45px rgba(51, 76, 92, 0.12);
|
box-shadow: 0 20px 45px rgba(51, 76, 92, 0.12);
|
||||||
backdrop-filter: blur(10px);
|
backdrop-filter: blur(10px);
|
||||||
}
|
}
|
||||||
|
|||||||
+1631
-138
File diff suppressed because it is too large
Load Diff
Binary file not shown.
Binary file not shown.
Reference in New Issue
Block a user