Compare commits
2
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
080f7cf112 | ||
|
|
fb11139da4 |
@@ -1,3 +1,5 @@
|
|||||||
.venv/
|
.venv/
|
||||||
__pycache__/
|
__pycache__/
|
||||||
*.pyc
|
*.pyc
|
||||||
|
data.db-shm
|
||||||
|
data.db-wal
|
||||||
|
|||||||
@@ -0,0 +1,13 @@
|
|||||||
|
Backup created: 2026-04-10 09:00 KST
|
||||||
|
Files:
|
||||||
|
- main.py
|
||||||
|
- data.db
|
||||||
|
- templates/base.html
|
||||||
|
- templates/dashboard.html
|
||||||
|
- templates/index.html
|
||||||
|
- templates/projects.html
|
||||||
|
- templates/annual_summary.html
|
||||||
|
- README.md (if present)
|
||||||
|
- WORK_SUMMARY_20260408.md
|
||||||
|
- WORK_SUMMARY_20260409.md
|
||||||
|
- DB_HEALTH_CHECK_20260409.md
|
||||||
@@ -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,23 @@
|
|||||||
|
# 작업 요약
|
||||||
|
|
||||||
|
작업일: 2026-04-08
|
||||||
|
|
||||||
|
## 핵심 변경
|
||||||
|
|
||||||
|
- 프로젝트 검색 성능 저하 구간을 줄이기 위해 검색 목록 렌더 흐름을 정리하고 표시 개수를 제한했습니다.
|
||||||
|
- 프로젝트 정보 페이지의 상세 레이아웃을 재구성해 상단 정보 카드의 중첩 박스를 제거하고 주요 지표 배치를 정리했습니다.
|
||||||
|
- 계획 대비 실제 비교에서 인건비, 외주비, 제경비, A/S비, 판관비 세부 로직과 실제 집행 합산 기준을 여러 차례 보정했습니다.
|
||||||
|
- 실투입 관리, 실행예산계획, 과업수행계획 입력 UI와 저장 구조를 정리했습니다.
|
||||||
|
- 대시보드 상단을 재구성해 사업현황 요약과 수금/지출 구성 그래프를 다시 배치했습니다.
|
||||||
|
- 프로젝트 페이지 상태 저장은 버튼 클릭 시 DB에 저장되도록 연결했습니다.
|
||||||
|
|
||||||
|
## 주요 파일
|
||||||
|
|
||||||
|
- `main.py`
|
||||||
|
- `templates/base.html`
|
||||||
|
- `templates/dashboard.html`
|
||||||
|
- `templates/projects.html`
|
||||||
|
|
||||||
|
## 참고
|
||||||
|
|
||||||
|
- 템플릿 백업은 `template_backups/20260408_ko/`에 생성했습니다.
|
||||||
@@ -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` 같은 런타임 임시 파일은 커밋 대상에서 제외함
|
||||||
Binary file not shown.
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,532 @@
|
|||||||
|
{% extends "base.html" %}
|
||||||
|
|
||||||
|
{% block title %}연도별 수익/비용{% endblock %}
|
||||||
|
|
||||||
|
{% block head_extra %}
|
||||||
|
<style>
|
||||||
|
.summary-layout {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 320px minmax(0, 1fr);
|
||||||
|
gap: 16px;
|
||||||
|
align-items: stretch;
|
||||||
|
}
|
||||||
|
|
||||||
|
.summary-panel {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 320px minmax(0, 1fr);
|
||||||
|
gap: 16px;
|
||||||
|
align-items: stretch;
|
||||||
|
}
|
||||||
|
|
||||||
|
.filter-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||||
|
gap: 8px;
|
||||||
|
align-items: end;
|
||||||
|
}
|
||||||
|
|
||||||
|
.legend {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 12px;
|
||||||
|
margin-top: 0;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.legend-item {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
color: #2f3c49;
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 800;
|
||||||
|
letter-spacing: -0.01em;
|
||||||
|
background: rgba(255,255,255,0.92);
|
||||||
|
border: 1px solid var(--line);
|
||||||
|
border-radius: 12px;
|
||||||
|
padding: 8px 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.legend-swatch {
|
||||||
|
width: 10px;
|
||||||
|
height: 10px;
|
||||||
|
border-radius: 999px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chart-box {
|
||||||
|
background:
|
||||||
|
linear-gradient(180deg, rgba(255,255,255,0.98), rgba(246,247,249,0.98)),
|
||||||
|
radial-gradient(circle at top left, rgba(17, 17, 17, 0.045), transparent 36%);
|
||||||
|
border: 1px solid var(--line);
|
||||||
|
border-radius: 16px;
|
||||||
|
padding: 18px 20px 20px;
|
||||||
|
box-shadow: inset 0 1px 0 rgba(255,255,255,0.92);
|
||||||
|
}
|
||||||
|
|
||||||
|
.chart-legend {
|
||||||
|
margin-bottom: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chart-svg {
|
||||||
|
width: 100%;
|
||||||
|
height: auto;
|
||||||
|
aspect-ratio: 1560 / 620;
|
||||||
|
min-height: 460px;
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
|
||||||
|
.expense-chart-svg {
|
||||||
|
aspect-ratio: 1560 / 660;
|
||||||
|
min-height: 510px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chart-note {
|
||||||
|
margin-top: 10px;
|
||||||
|
color: var(--muted);
|
||||||
|
font-size: 13px;
|
||||||
|
line-height: 1.6;
|
||||||
|
}
|
||||||
|
|
||||||
|
.summary-overview {
|
||||||
|
display: grid;
|
||||||
|
grid-template-rows: auto auto 1fr;
|
||||||
|
gap: 14px;
|
||||||
|
min-height: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.metric-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
gap: 10px;
|
||||||
|
align-content: stretch;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chart-stack {
|
||||||
|
display: grid;
|
||||||
|
gap: 16px;
|
||||||
|
min-height: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.summary-overview .stat-card,
|
||||||
|
.chart-stack .stat-card {
|
||||||
|
min-height: 112px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.summary-overview .section-title h2,
|
||||||
|
.chart-stack .section-title h2 {
|
||||||
|
font-size: 17px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.summary-overview .field select {
|
||||||
|
min-width: 0;
|
||||||
|
padding: 10px 12px;
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.summary-overview .stat-card {
|
||||||
|
padding: 14px 14px 15px;
|
||||||
|
border-radius: 12px;
|
||||||
|
gap: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.summary-overview .stat-card .label {
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
{% endblock %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
<section class="panel">
|
||||||
|
<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="보기 기준">
|
||||||
|
<option value="yearly">연간</option>
|
||||||
|
<option value="monthly">월간</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div class="field">
|
||||||
|
<select id="yearFilter" aria-label="연도 선택">
|
||||||
|
<option value="recent10">최근 10개년</option>
|
||||||
|
{% for year in available_years %}
|
||||||
|
<option value="{{ year }}">{{ year }}</option>
|
||||||
|
{% endfor %}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="metric-grid" id="metricGrid"></div>
|
||||||
|
</div>
|
||||||
|
<div class="chart-stack">
|
||||||
|
<div class="chart-box">
|
||||||
|
<div class="section-title" style="margin-bottom: 10px;">
|
||||||
|
<h2>비용 구조</h2>
|
||||||
|
</div>
|
||||||
|
<div class="legend chart-legend" id="expenseLegend"></div>
|
||||||
|
<svg id="expenseChart" class="chart-svg expense-chart-svg" viewBox="0 0 1560 660" preserveAspectRatio="xMidYMid meet"></svg>
|
||||||
|
</div>
|
||||||
|
<div class="chart-box">
|
||||||
|
<div class="section-title" style="margin-bottom: 10px;">
|
||||||
|
<h2>수금/비용/영업수지 그래프</h2>
|
||||||
|
</div>
|
||||||
|
<div class="legend chart-legend" id="balanceLegend"></div>
|
||||||
|
<svg id="balanceChart" class="chart-svg" viewBox="0 0 1560 620" preserveAspectRatio="xMidYMid meet"></svg>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
{% endblock %}
|
||||||
|
|
||||||
|
{% block script %}
|
||||||
|
<script>
|
||||||
|
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 = Object.fromEntries(
|
||||||
|
[...annualExpenseChartMetrics, ...annualBalanceChartMetrics].map((option) => [
|
||||||
|
option.item_key,
|
||||||
|
option.value,
|
||||||
|
]),
|
||||||
|
);
|
||||||
|
|
||||||
|
const labels = Object.fromEntries(
|
||||||
|
annualMetricCards.map((option) => [option.item_key, option.label]),
|
||||||
|
);
|
||||||
|
|
||||||
|
function formatNumber(value) {
|
||||||
|
return new Intl.NumberFormat("ko-KR", { maximumFractionDigits: 0 }).format(value || 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatAxisLabel(value) {
|
||||||
|
const numeric = Number(value || 0);
|
||||||
|
if (!numeric) return "0.0";
|
||||||
|
const sign = numeric < 0 ? "-" : "";
|
||||||
|
const absolute = Math.abs(numeric);
|
||||||
|
if (absolute >= 100000000) return `${sign}${(absolute / 100000000).toFixed(1)}억`;
|
||||||
|
if (absolute >= 1000000) return `${sign}${(absolute / 1000000).toFixed(1)}백만`;
|
||||||
|
if (absolute >= 1000) return `${sign}${(absolute / 1000).toFixed(1)}천`;
|
||||||
|
return formatNumber(numeric);
|
||||||
|
}
|
||||||
|
|
||||||
|
function pickTickStep(maxValue) {
|
||||||
|
const baseUnit = maxValue < 1000000 ? 1000 : 1000000;
|
||||||
|
const units = [1, 2, 5];
|
||||||
|
const raw = Math.max(maxValue / baseUnit, 1);
|
||||||
|
let power = 1;
|
||||||
|
while (power * 10 <= raw) power *= 10;
|
||||||
|
for (const unit of units) {
|
||||||
|
const candidate = unit * power;
|
||||||
|
if (candidate >= raw) return candidate * baseUnit;
|
||||||
|
}
|
||||||
|
return power * 10 * baseUnit;
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildPositiveAxisScale(maxValue, tickCount = 4) {
|
||||||
|
const safeMax = Math.max(Number(maxValue || 0), 1);
|
||||||
|
const paddedMax = safeMax * (safeMax < 1000 ? 1.12 : 1.08);
|
||||||
|
const tickStep = pickTickStep(paddedMax / tickCount);
|
||||||
|
const tickMax = Math.max(tickStep, Math.ceil(paddedMax / tickStep) * tickStep);
|
||||||
|
return { tickStep, tickMax };
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildDynamicPositiveAxis(maxValue, width, height, margin) {
|
||||||
|
const safeMax = Math.max(Number(maxValue || 0), 1);
|
||||||
|
const paddedMax = safeMax * 1.08;
|
||||||
|
const tickStep = pickTickStep(paddedMax / 5);
|
||||||
|
const tickMax = Math.max(tickStep, Math.ceil(paddedMax / tickStep) * tickStep);
|
||||||
|
let axis = "";
|
||||||
|
for (let value = 0; value <= tickMax; value += tickStep) {
|
||||||
|
const y = height - margin.bottom - ((height - margin.top - margin.bottom) * value) / tickMax;
|
||||||
|
axis += `<line x1="${margin.left}" y1="${y}" x2="${width - margin.right}" y2="${y}" stroke="#d8dee5" stroke-dasharray="4 8" />`;
|
||||||
|
axis += `<text x="${margin.left - 14}" y="${y + 5}" text-anchor="end" fill="#52606d" font-size="14" font-weight="800">${formatAxisLabel(value)}</text>`;
|
||||||
|
}
|
||||||
|
axis += `<line x1="${margin.left}" y1="${height - margin.bottom}" x2="${width - margin.right}" y2="${height - margin.bottom}" stroke="#8ba0ae" stroke-width="1.4" />`;
|
||||||
|
return { axis, tickStep, tickMax };
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildDynamicBalanceAxis(maxPositiveValue, minNegativeValue, width, height, margin) {
|
||||||
|
const safePositive = Math.max(Number(maxPositiveValue || 0), 0);
|
||||||
|
const safeNegative = Math.min(Number(minNegativeValue || 0), 0);
|
||||||
|
const paddedPositive = safePositive > 0 ? safePositive * 1.08 : 1000;
|
||||||
|
const paddedNegative = safeNegative < 0 ? safeNegative * 1.08 : 0;
|
||||||
|
const rangeAbs = Math.max(Math.abs(paddedPositive), Math.abs(paddedNegative), 1);
|
||||||
|
const tickStep = pickTickStep(rangeAbs / 4);
|
||||||
|
const positiveMax = Math.max(tickStep, Math.ceil(paddedPositive / tickStep) * tickStep);
|
||||||
|
const negativeMin = safeNegative < 0 ? Math.min(-tickStep, Math.floor(paddedNegative / tickStep) * tickStep) : 0;
|
||||||
|
const plotHeight = height - margin.top - margin.bottom;
|
||||||
|
const totalRange = positiveMax - negativeMin;
|
||||||
|
const zeroY = margin.top + (plotHeight * positiveMax) / totalRange;
|
||||||
|
let axis = "";
|
||||||
|
for (let value = negativeMin; value <= positiveMax; value += tickStep) {
|
||||||
|
const y = margin.top + ((positiveMax - value) / totalRange) * plotHeight;
|
||||||
|
axis += `<line x1="${margin.left}" y1="${y}" x2="${width - margin.right}" y2="${y}" stroke="#d8dee5" stroke-dasharray="4 8" />`;
|
||||||
|
axis += `<text x="${margin.left - 14}" y="${y + 5}" text-anchor="end" fill="#52606d" font-size="14" font-weight="800">${formatAxisLabel(value)}</text>`;
|
||||||
|
}
|
||||||
|
axis += `<line x1="${margin.left}" y1="${zeroY}" x2="${width - margin.right}" y2="${zeroY}" stroke="#7c8d9a" stroke-width="1.5" />`;
|
||||||
|
return { axis, tickStep, positiveMax, negativeMin, zeroY, totalRange };
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderLegend(targetId, keys) {
|
||||||
|
const target = document.getElementById(targetId);
|
||||||
|
if (!target) return;
|
||||||
|
target.innerHTML = keys.map((key) => `
|
||||||
|
<span class="legend-item">
|
||||||
|
<span class="legend-swatch" style="background:${palette[key]};"></span>
|
||||||
|
${labels[key]}
|
||||||
|
</span>
|
||||||
|
`).join("");
|
||||||
|
}
|
||||||
|
|
||||||
|
function getLatestAvailableYear() {
|
||||||
|
return String(availableYears[availableYears.length - 1] || "recent10");
|
||||||
|
}
|
||||||
|
|
||||||
|
function syncYearFilter() {
|
||||||
|
const granularity = document.getElementById("granularity").value;
|
||||||
|
const yearFilterEl = document.getElementById("yearFilter");
|
||||||
|
if (!yearFilterEl) return;
|
||||||
|
|
||||||
|
if (granularity === "yearly") {
|
||||||
|
yearFilterEl.value = "recent10";
|
||||||
|
yearFilterEl.disabled = true;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const hasSelectedYear = availableYears.some((year) => String(year) === String(yearFilterEl.value));
|
||||||
|
if (yearFilterEl.value === "recent10" || !hasSelectedYear) {
|
||||||
|
yearFilterEl.value = getLatestAvailableYear();
|
||||||
|
}
|
||||||
|
yearFilterEl.disabled = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
function getFilteredSeries() {
|
||||||
|
const granularity = document.getElementById("granularity").value;
|
||||||
|
const selectedYear = document.getElementById("yearFilter").value;
|
||||||
|
if (granularity === "yearly" || selectedYear === "recent10") {
|
||||||
|
return yearlySeries
|
||||||
|
.slice(-10)
|
||||||
|
.map((item) => ({ ...item, label: String(item.year) }));
|
||||||
|
}
|
||||||
|
return monthlySeries
|
||||||
|
.filter((item) => String(item.year) === String(selectedYear))
|
||||||
|
.map((item) => ({ ...item, label: `${item.month}월` }));
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderMetrics(series) {
|
||||||
|
const keys = annualMetricCards.map((option) => option.item_key);
|
||||||
|
const totals = {};
|
||||||
|
keys.forEach((key) => {
|
||||||
|
totals[key] = series.reduce((sum, item) => sum + (item[key] || 0), 0);
|
||||||
|
});
|
||||||
|
const grid = document.getElementById("metricGrid");
|
||||||
|
grid.innerHTML = keys.map((key) => `
|
||||||
|
<div class="stat-card">
|
||||||
|
<div class="label">${labels[key]}</div>
|
||||||
|
<div class="value">${formatNumber(totals[key])}</div>
|
||||||
|
</div>
|
||||||
|
`).join("");
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildAxis(maxValue, width, height, margin) {
|
||||||
|
const { tickStep, tickMax } = buildPositiveAxisScale(maxValue, 4);
|
||||||
|
let axis = "";
|
||||||
|
for (let value = 0; value <= tickMax; value += tickStep) {
|
||||||
|
const y = height - margin.bottom - ((height - margin.top - margin.bottom) * value) / tickMax;
|
||||||
|
axis += `<line x1="${margin.left}" y1="${y}" x2="${width - margin.right}" y2="${y}" stroke="#d8dee5" stroke-dasharray="3 7" />`;
|
||||||
|
axis += `<text x="${margin.left - 12}" y="${y + 4}" text-anchor="end" fill="#5a6672" font-size="11" font-weight="700">${formatAxisLabel(value)}</text>`;
|
||||||
|
}
|
||||||
|
axis += `<line x1="${margin.left}" y1="${height - margin.bottom}" x2="${width - margin.right}" y2="${height - margin.bottom}" stroke="#8ba0ae" stroke-width="1.2" />`;
|
||||||
|
return { axis, tickMax };
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderEmptyChart(svgId, message) {
|
||||||
|
const svg = document.getElementById(svgId);
|
||||||
|
if (!svg) return;
|
||||||
|
const viewBox = (svg.getAttribute("viewBox") || "0 0 1400 560").split(/\s+/).map(Number);
|
||||||
|
const width = viewBox[2] || 1400;
|
||||||
|
const height = viewBox[3] || 560;
|
||||||
|
svg.innerHTML = `
|
||||||
|
<rect x="0" y="0" width="${width}" height="${height}" rx="8" fill="#f7fafb" stroke="#d6e2e8"></rect>
|
||||||
|
<text x="${width / 2}" y="${height / 2}" text-anchor="middle" fill="#667887" font-size="24" font-weight="800">${message}</text>
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderExpenseChart(series) {
|
||||||
|
const svg = document.getElementById("expenseChart");
|
||||||
|
const keys = annualExpenseChartMetrics.map((option) => option.item_key);
|
||||||
|
const isMonthlyView = series.some((item) => String(item.label || "").includes("월"));
|
||||||
|
renderLegend("expenseLegend", keys);
|
||||||
|
if (!series.length) {
|
||||||
|
renderEmptyChart("expenseChart", "표시할 비용 구조 데이터가 없습니다.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const width = 1560;
|
||||||
|
const height = 660;
|
||||||
|
svg.setAttribute("viewBox", `0 0 ${width} ${height}`);
|
||||||
|
const margin = { top: 54, right: 36, bottom: 96, left: 124 };
|
||||||
|
const plotWidth = width - margin.left - margin.right;
|
||||||
|
const plotHeight = height - margin.top - margin.bottom;
|
||||||
|
const barWidth = (plotWidth / series.length) * (isMonthlyView ? 0.36 : 0.42);
|
||||||
|
const step = (width - margin.left - margin.right) / series.length;
|
||||||
|
const maxValue = Math.max(...series.map((item) => keys.reduce((sum, key) => sum + (item[key] || 0), 0)), 1);
|
||||||
|
const { axis, tickMax } = buildDynamicPositiveAxis(maxValue, width, height, margin);
|
||||||
|
let markup = `
|
||||||
|
<defs>
|
||||||
|
<filter id="expenseShadow" x="-20%" y="-20%" width="140%" height="160%">
|
||||||
|
<feDropShadow dx="0" dy="8" stdDeviation="8" flood-color="rgba(44, 68, 89, 0.12)" />
|
||||||
|
</filter>
|
||||||
|
</defs>
|
||||||
|
<rect x="${margin.left}" y="${margin.top}" width="${plotWidth}" height="${plotHeight}" rx="6" fill="rgba(255,255,255,0.7)" stroke="#dde7ec"></rect>
|
||||||
|
${axis}
|
||||||
|
`;
|
||||||
|
series.forEach((item, index) => {
|
||||||
|
let cumulative = 0;
|
||||||
|
const total = keys.reduce((sum, key) => sum + (item[key] || 0), 0);
|
||||||
|
const x = margin.left + index * step + (step - barWidth) / 2;
|
||||||
|
keys.forEach((key) => {
|
||||||
|
const value = item[key] || 0;
|
||||||
|
const barHeight = (plotHeight * value) / tickMax;
|
||||||
|
const y = height - margin.bottom - barHeight - (plotHeight * cumulative) / tickMax;
|
||||||
|
cumulative += value;
|
||||||
|
markup += `<rect x="${x}" y="${y}" width="${barWidth}" height="${barHeight}" rx="2" fill="${palette[key]}" filter="url(#expenseShadow)" />`;
|
||||||
|
if (value > 0 && barHeight >= 34) {
|
||||||
|
const percent = total ? `${((value / total) * 100).toFixed(1)}%` : "0.0%";
|
||||||
|
markup += `<text x="${x + barWidth / 2}" y="${y + Math.min(barHeight / 2, 22)}" text-anchor="middle" fill="#ffffff" font-size="${isMonthlyView ? 12.5 : 13.5}" font-weight="900">${percent}</text>`;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
if (total > 0) {
|
||||||
|
const topY = height - margin.bottom - (plotHeight * total) / tickMax;
|
||||||
|
markup += `<text x="${x + barWidth / 2}" y="${Math.max(topY - 14, margin.top + 18)}" text-anchor="middle" fill="#2d3c4a" font-size="${isMonthlyView ? 14 : 15.5}" font-weight="900">${formatAxisLabel(total)}</text>`;
|
||||||
|
}
|
||||||
|
markup += `<text x="${x + barWidth / 2}" y="${height - margin.bottom + 30}" text-anchor="middle" fill="#53606c" font-size="${isMonthlyView ? 14 : 15}" font-weight="800">${item.label}</text>`;
|
||||||
|
});
|
||||||
|
svg.innerHTML = markup;
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderBalanceChart(series) {
|
||||||
|
const svg = document.getElementById("balanceChart");
|
||||||
|
const metrics = annualBalanceChartMetrics.map((option) => option.item_key);
|
||||||
|
renderLegend("balanceLegend", metrics);
|
||||||
|
if (!series.length) {
|
||||||
|
renderEmptyChart("balanceChart", "표시할 수익/비용 데이터가 없습니다.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const width = 1560;
|
||||||
|
const height = 620;
|
||||||
|
svg.setAttribute("viewBox", `0 0 ${width} ${height}`);
|
||||||
|
const margin = { top: 56, right: 34, bottom: 100, left: 124 };
|
||||||
|
const plotWidth = width - margin.left - margin.right;
|
||||||
|
const plotHeight = height - margin.top - margin.bottom;
|
||||||
|
const maxPositiveValue = Math.max(...series.flatMap((item) => [
|
||||||
|
item.revenue_sum || 0,
|
||||||
|
item.total_expense || 0,
|
||||||
|
Math.max(item.operating_balance || 0, 0),
|
||||||
|
]), 1);
|
||||||
|
const minNegativeValue = Math.min(...series.map((item) => Math.min(item.operating_balance || 0, 0)), 0);
|
||||||
|
const { axis, positiveMax, negativeMin, zeroY, totalRange } = buildDynamicBalanceAxis(maxPositiveValue, minNegativeValue, width, height, margin);
|
||||||
|
const groupWidth = plotWidth / Math.max(series.length, 1);
|
||||||
|
const groupGap = groupWidth * 0.22;
|
||||||
|
const innerGap = 0;
|
||||||
|
const barWidth = Math.min((groupWidth - groupGap * 2) / metrics.length, 72);
|
||||||
|
const actualGroupWidth = barWidth * metrics.length + innerGap * (metrics.length - 1);
|
||||||
|
const groupStartOffset = (groupWidth - actualGroupWidth) / 2;
|
||||||
|
let markup = `
|
||||||
|
<defs>
|
||||||
|
<filter id="balanceShadow" x="-20%" y="-20%" width="140%" height="160%">
|
||||||
|
<feDropShadow dx="0" dy="8" stdDeviation="8" flood-color="rgba(44, 68, 89, 0.12)" />
|
||||||
|
</filter>
|
||||||
|
</defs>
|
||||||
|
<rect x="${margin.left}" y="${margin.top}" width="${plotWidth}" height="${plotHeight}" rx="6" fill="rgba(255,255,255,0.7)" stroke="#dde7ec"></rect>
|
||||||
|
${axis}
|
||||||
|
`;
|
||||||
|
series.forEach((item, index) => {
|
||||||
|
const baseX = margin.left + index * groupWidth;
|
||||||
|
const positiveLabels = [];
|
||||||
|
const negativeLabels = [];
|
||||||
|
metrics.forEach((key, metricIndex) => {
|
||||||
|
const rawValue = Number(item[key] || 0);
|
||||||
|
const value = key === "operating_balance" ? rawValue : Math.max(rawValue, 0);
|
||||||
|
const clampedValue = Math.max(negativeMin, Math.min(positiveMax, value));
|
||||||
|
const barHeight = (plotHeight * Math.abs(clampedValue)) / totalRange;
|
||||||
|
const x = baseX + groupStartOffset + metricIndex * (barWidth + innerGap);
|
||||||
|
const y = clampedValue < 0 ? zeroY : zeroY - barHeight;
|
||||||
|
markup += `<rect x="${x}" y="${y}" width="${barWidth}" height="${barHeight}" rx="2" fill="${palette[key]}" filter="url(#balanceShadow)" />`;
|
||||||
|
if (clampedValue !== 0) {
|
||||||
|
const label = {
|
||||||
|
x: x + barWidth / 2,
|
||||||
|
desiredY: clampedValue < 0
|
||||||
|
? Math.min(y + barHeight + 22, height - margin.bottom + 12)
|
||||||
|
: Math.max(y - 14, margin.top + 18),
|
||||||
|
text: formatAxisLabel(value),
|
||||||
|
};
|
||||||
|
if (clampedValue < 0) {
|
||||||
|
negativeLabels.push(label);
|
||||||
|
} else {
|
||||||
|
positiveLabels.push(label);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
positiveLabels.sort((a, b) => a.desiredY - b.desiredY);
|
||||||
|
let lastPositiveY = margin.top - 24;
|
||||||
|
positiveLabels.forEach((label) => {
|
||||||
|
const y = Math.max(label.desiredY, lastPositiveY + 18);
|
||||||
|
lastPositiveY = y;
|
||||||
|
markup += `<text x="${label.x}" y="${y}" text-anchor="middle" fill="#2d3c4a" font-size="13.5" font-weight="900">${label.text}</text>`;
|
||||||
|
});
|
||||||
|
negativeLabels.sort((a, b) => a.desiredY - b.desiredY);
|
||||||
|
let lastNegativeY = zeroY + 18;
|
||||||
|
negativeLabels.forEach((label) => {
|
||||||
|
const y = Math.max(label.desiredY, lastNegativeY + 18);
|
||||||
|
lastNegativeY = y;
|
||||||
|
markup += `<text x="${label.x}" y="${Math.min(y, height - margin.bottom + 34)}" text-anchor="middle" fill="#2d3c4a" font-size="13.5" font-weight="900">${label.text}</text>`;
|
||||||
|
});
|
||||||
|
markup += `<text x="${baseX + groupWidth / 2}" y="${height - margin.bottom + 34}" text-anchor="middle" fill="#53606c" font-size="15" font-weight="800">${item.label}</text>`;
|
||||||
|
});
|
||||||
|
|
||||||
|
svg.innerHTML = markup;
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderAll() {
|
||||||
|
syncYearFilter();
|
||||||
|
const series = getFilteredSeries();
|
||||||
|
renderMetrics(series);
|
||||||
|
renderExpenseChart(series);
|
||||||
|
renderBalanceChart(series);
|
||||||
|
}
|
||||||
|
|
||||||
|
document.getElementById("granularity").addEventListener("change", renderAll);
|
||||||
|
document.getElementById("yearFilter").addEventListener("change", renderAll);
|
||||||
|
const granularitySelect = document.getElementById("granularity");
|
||||||
|
if (granularitySelect) {
|
||||||
|
granularitySelect.value = "yearly";
|
||||||
|
}
|
||||||
|
const yearFilter = document.getElementById("yearFilter");
|
||||||
|
if (yearFilter) {
|
||||||
|
yearFilter.value = "recent10";
|
||||||
|
}
|
||||||
|
renderAll();
|
||||||
|
</script>
|
||||||
|
{% endblock %}
|
||||||
@@ -0,0 +1,682 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="ko">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title>{% block title %}인트라넷 회계 시스템{% endblock %}</title>
|
||||||
|
<style>
|
||||||
|
:root {
|
||||||
|
--bg-a: #f6f6f7;
|
||||||
|
--bg-b: #ececef;
|
||||||
|
--panel: rgba(255, 255, 255, 0.94);
|
||||||
|
--ink: #161616;
|
||||||
|
--muted: #73777f;
|
||||||
|
--line: #d9dde3;
|
||||||
|
--accent: #111111;
|
||||||
|
--accent-strong: #000000;
|
||||||
|
--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)));
|
||||||
|
}
|
||||||
|
|
||||||
|
* {
|
||||||
|
box-sizing: border-box;
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
body {
|
||||||
|
font-family: "SUIT", "Noto Sans KR", "Malgun Gothic", sans-serif;
|
||||||
|
color: var(--ink);
|
||||||
|
background:
|
||||||
|
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: var(--page-gutter);
|
||||||
|
}
|
||||||
|
|
||||||
|
.page {
|
||||||
|
width: min(100%, var(--page-frame-width));
|
||||||
|
margin: 0 auto;
|
||||||
|
display: grid;
|
||||||
|
gap: var(--page-gutter);
|
||||||
|
}
|
||||||
|
|
||||||
|
.page > * {
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav {
|
||||||
|
display: flex;
|
||||||
|
gap: 10px;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
align-items: center;
|
||||||
|
background: rgba(255, 255, 255, 0.9);
|
||||||
|
border: 1px solid var(--line);
|
||||||
|
border-radius: 18px;
|
||||||
|
padding: 8px;
|
||||||
|
box-shadow: 0 10px 24px rgba(21, 24, 29, 0.06);
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav-spacer {
|
||||||
|
flex: 1 1 auto;
|
||||||
|
min-width: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav a {
|
||||||
|
text-decoration: none;
|
||||||
|
color: var(--ink);
|
||||||
|
padding: 9px 13px;
|
||||||
|
border-radius: 10px;
|
||||||
|
font-weight: 700;
|
||||||
|
font-size: 14px;
|
||||||
|
transition: background 0.18s ease, color 0.18s ease, border-color 0.18s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav a.active {
|
||||||
|
background: var(--accent);
|
||||||
|
color: var(--white);
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav a:hover {
|
||||||
|
background: #f3f4f6;
|
||||||
|
}
|
||||||
|
|
||||||
|
.panel {
|
||||||
|
background: var(--panel);
|
||||||
|
border: 1px solid var(--line);
|
||||||
|
border-radius: 18px;
|
||||||
|
padding: var(--panel-pad);
|
||||||
|
box-shadow: 0 10px 24px rgba(21, 24, 29, 0.045);
|
||||||
|
backdrop-filter: blur(6px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.section-title {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 12px;
|
||||||
|
margin-bottom: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.section-title h2 {
|
||||||
|
font-size: 21px;
|
||||||
|
letter-spacing: -0.03em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.section-title p {
|
||||||
|
color: var(--muted);
|
||||||
|
font-size: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.message {
|
||||||
|
background: var(--warn);
|
||||||
|
border: 1px solid #ead98a;
|
||||||
|
color: #624c0b;
|
||||||
|
border-radius: 14px;
|
||||||
|
padding: 14px 16px;
|
||||||
|
line-height: 1.6;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stats {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(5, minmax(0, 1fr));
|
||||||
|
gap: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.summary-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 1fr 1fr;
|
||||||
|
gap: 18px;
|
||||||
|
margin-top: 18px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stat-card {
|
||||||
|
background: var(--white);
|
||||||
|
border: 1px solid var(--line);
|
||||||
|
border-radius: 14px;
|
||||||
|
padding: 14px 15px 13px;
|
||||||
|
box-shadow: inset 0 1px 0 rgba(255,255,255,0.7);
|
||||||
|
}
|
||||||
|
|
||||||
|
.stat-card .label {
|
||||||
|
color: var(--muted);
|
||||||
|
font-size: 13px;
|
||||||
|
margin-bottom: 6px;
|
||||||
|
letter-spacing: 0.01em;
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stat-card .value {
|
||||||
|
font-size: clamp(23px, 2vw, 38px);
|
||||||
|
font-weight: 800;
|
||||||
|
line-height: 1.1;
|
||||||
|
letter-spacing: -0.04em;
|
||||||
|
word-break: break-word;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stat-card .meta {
|
||||||
|
margin-top: 6px;
|
||||||
|
color: var(--muted);
|
||||||
|
font-size: 12px;
|
||||||
|
line-height: 1.45;
|
||||||
|
}
|
||||||
|
|
||||||
|
.two-col {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 0.92fr 1.08fr;
|
||||||
|
gap: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stack {
|
||||||
|
display: grid;
|
||||||
|
gap: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
form {
|
||||||
|
display: grid;
|
||||||
|
gap: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.upload-box {
|
||||||
|
background: linear-gradient(180deg, #fbfbfc, #f1f3f6);
|
||||||
|
border: 1px dashed #c5ccd6;
|
||||||
|
border-radius: 14px;
|
||||||
|
padding: 18px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.upload-box p {
|
||||||
|
color: var(--muted);
|
||||||
|
line-height: 1.65;
|
||||||
|
margin-bottom: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(4, minmax(0, 1fr));
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.field {
|
||||||
|
display: grid;
|
||||||
|
gap: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.field-wide {
|
||||||
|
grid-column: span 2;
|
||||||
|
}
|
||||||
|
|
||||||
|
.field-full {
|
||||||
|
grid-column: 1 / -1;
|
||||||
|
}
|
||||||
|
|
||||||
|
label {
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 700;
|
||||||
|
color: var(--muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
input,
|
||||||
|
select,
|
||||||
|
textarea,
|
||||||
|
button {
|
||||||
|
font: inherit;
|
||||||
|
}
|
||||||
|
|
||||||
|
input[type="text"],
|
||||||
|
input[type="number"],
|
||||||
|
input[type="date"],
|
||||||
|
input[type="file"],
|
||||||
|
select,
|
||||||
|
textarea {
|
||||||
|
width: 100%;
|
||||||
|
border: 1px solid var(--line);
|
||||||
|
background: #fcfcfd;
|
||||||
|
border-radius: 10px;
|
||||||
|
padding: 10px 12px;
|
||||||
|
color: var(--ink);
|
||||||
|
box-shadow: inset 0 1px 2px rgba(16, 24, 40, 0.03);
|
||||||
|
}
|
||||||
|
|
||||||
|
textarea {
|
||||||
|
min-height: 96px;
|
||||||
|
resize: vertical;
|
||||||
|
}
|
||||||
|
|
||||||
|
input:focus,
|
||||||
|
select:focus,
|
||||||
|
textarea:focus {
|
||||||
|
outline: none;
|
||||||
|
border-color: var(--accent);
|
||||||
|
box-shadow: 0 0 0 3px rgba(17, 17, 17, 0.08);
|
||||||
|
}
|
||||||
|
|
||||||
|
.actions {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 8px;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
button,
|
||||||
|
.button-link {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 8px;
|
||||||
|
border-radius: 10px;
|
||||||
|
border: 1px solid transparent;
|
||||||
|
padding: 9px 14px;
|
||||||
|
background: var(--accent);
|
||||||
|
color: var(--white);
|
||||||
|
font-weight: 700;
|
||||||
|
font-size: 14px;
|
||||||
|
cursor: pointer;
|
||||||
|
text-decoration: none;
|
||||||
|
transition: transform 0.16s ease, box-shadow 0.16s ease, background 0.16s ease, border-color 0.16s ease;
|
||||||
|
box-shadow: 0 8px 18px rgba(17, 17, 17, 0.14);
|
||||||
|
}
|
||||||
|
|
||||||
|
button:hover,
|
||||||
|
.button-link:hover {
|
||||||
|
background: var(--accent-strong);
|
||||||
|
transform: translateY(-1px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.button-secondary {
|
||||||
|
background: #ffffff;
|
||||||
|
color: #1b1d21;
|
||||||
|
border-color: var(--line);
|
||||||
|
box-shadow: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.button-icon {
|
||||||
|
width: 38px;
|
||||||
|
height: 38px;
|
||||||
|
min-width: 38px;
|
||||||
|
padding: 0;
|
||||||
|
border-radius: 10px;
|
||||||
|
box-shadow: none;
|
||||||
|
flex: 0 0 auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.button-icon svg {
|
||||||
|
width: 16px;
|
||||||
|
height: 16px;
|
||||||
|
stroke: currentColor;
|
||||||
|
fill: none;
|
||||||
|
stroke-width: 1.8;
|
||||||
|
stroke-linecap: round;
|
||||||
|
stroke-linejoin: round;
|
||||||
|
}
|
||||||
|
|
||||||
|
.button-icon.button-secondary svg {
|
||||||
|
stroke: #1b1d21;
|
||||||
|
}
|
||||||
|
|
||||||
|
.button-icon.danger-lite svg {
|
||||||
|
stroke: #a93a3a;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sr-only {
|
||||||
|
position: absolute;
|
||||||
|
width: 1px;
|
||||||
|
height: 1px;
|
||||||
|
padding: 0;
|
||||||
|
margin: -1px;
|
||||||
|
overflow: hidden;
|
||||||
|
clip: rect(0, 0, 0, 0);
|
||||||
|
white-space: nowrap;
|
||||||
|
border: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.table-wrap {
|
||||||
|
overflow: auto;
|
||||||
|
border: 1px solid var(--line);
|
||||||
|
border-radius: 14px;
|
||||||
|
background: var(--white);
|
||||||
|
}
|
||||||
|
|
||||||
|
table {
|
||||||
|
width: 100%;
|
||||||
|
border-collapse: collapse;
|
||||||
|
min-width: 780px;
|
||||||
|
}
|
||||||
|
|
||||||
|
th,
|
||||||
|
td {
|
||||||
|
padding: 12px 14px;
|
||||||
|
border-bottom: 1px solid var(--line);
|
||||||
|
text-align: left;
|
||||||
|
vertical-align: top;
|
||||||
|
font-size: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
th {
|
||||||
|
position: sticky;
|
||||||
|
top: 0;
|
||||||
|
background: #eff5f7;
|
||||||
|
color: #345061;
|
||||||
|
z-index: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
tbody tr:nth-child(even) td {
|
||||||
|
background: var(--table-alt);
|
||||||
|
}
|
||||||
|
|
||||||
|
.empty {
|
||||||
|
padding: 24px;
|
||||||
|
color: var(--muted);
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.search-box {
|
||||||
|
margin: 16px 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mono {
|
||||||
|
font-family: "Consolas", "Courier New", monospace;
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sync-status {
|
||||||
|
margin-left: auto;
|
||||||
|
max-width: 240px;
|
||||||
|
border: 1px solid var(--line);
|
||||||
|
border-radius: 999px;
|
||||||
|
background: rgba(248, 250, 252, 0.92);
|
||||||
|
box-shadow: none;
|
||||||
|
padding: 6px 10px;
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
backdrop-filter: blur(6px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.sync-status-head {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sync-status-title {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 800;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sync-dot {
|
||||||
|
width: 8px;
|
||||||
|
height: 8px;
|
||||||
|
border-radius: 999px;
|
||||||
|
background: #9aa3af;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sync-dot.online {
|
||||||
|
background: #16a34a;
|
||||||
|
box-shadow: 0 0 0 4px rgba(22, 163, 74, 0.12);
|
||||||
|
}
|
||||||
|
|
||||||
|
.sync-dot.error {
|
||||||
|
background: #dc2626;
|
||||||
|
box-shadow: 0 0 0 4px rgba(220, 38, 38, 0.12);
|
||||||
|
}
|
||||||
|
|
||||||
|
.sync-pill {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sync-meta {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sync-meta strong {
|
||||||
|
color: var(--ink);
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 1200px) {
|
||||||
|
.stats {
|
||||||
|
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||||
|
}
|
||||||
|
|
||||||
|
.summary-grid,
|
||||||
|
.two-col {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-grid {
|
||||||
|
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 720px) {
|
||||||
|
.stats,
|
||||||
|
.form-grid {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
|
||||||
|
.field-wide,
|
||||||
|
.field-full {
|
||||||
|
grid-column: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sync-status {
|
||||||
|
width: 100%;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
{% block head_extra %}{% endblock %}
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<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="/annual-summary" class="{% if request.url.path == '/annual-summary' %}active{% endif %}">연도별 수익/비용</a>
|
||||||
|
<div class="nav-spacer"></div>
|
||||||
|
<aside
|
||||||
|
class="sync-status"
|
||||||
|
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">
|
||||||
|
<span class="sync-dot" id="syncStatusDot"></span>
|
||||||
|
<span id="syncStatusLabel">연결 확인 중</span>
|
||||||
|
</div>
|
||||||
|
<span class="sync-pill" id="syncSessionPill">세션 준비 중</span>
|
||||||
|
</div>
|
||||||
|
<div class="sync-meta">
|
||||||
|
<div>마지막 확인: <strong id="syncLastChecked">-</strong></div>
|
||||||
|
<div>서버 시간: <strong id="syncServerTime">{{ server_time or '-' }}</strong></div>
|
||||||
|
<div>데이터 버전: <strong id="syncDataVersion">{{ data_version or '-' }}</strong></div>
|
||||||
|
<div>계약/청구 동기화: <strong>{{ import_sync_summary.contract_project_count or 0 }}</strong> / <strong>{{ import_sync_summary.billing_project_count or 0 }}</strong></div>
|
||||||
|
</div>
|
||||||
|
</aside>
|
||||||
|
</nav>
|
||||||
|
|
||||||
|
{% if message %}
|
||||||
|
<div class="message">{{ message }}</div>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
{% block content %}{% endblock %}
|
||||||
|
</div>
|
||||||
|
{% block script %}{% endblock %}
|
||||||
|
<script>
|
||||||
|
(() => {
|
||||||
|
const widget = document.getElementById("syncStatusWidget");
|
||||||
|
if (!widget) return;
|
||||||
|
|
||||||
|
const dot = document.getElementById("syncStatusDot");
|
||||||
|
const label = document.getElementById("syncStatusLabel");
|
||||||
|
const sessionPill = document.getElementById("syncSessionPill");
|
||||||
|
const lastChecked = document.getElementById("syncLastChecked");
|
||||||
|
const serverTime = document.getElementById("syncServerTime");
|
||||||
|
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;
|
||||||
|
|
||||||
|
function getSessionId() {
|
||||||
|
const key = "intranet-client-session-id";
|
||||||
|
let sessionId = window.localStorage.getItem(key);
|
||||||
|
if (!sessionId) {
|
||||||
|
sessionId = `session-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 8)}`;
|
||||||
|
window.localStorage.setItem(key, sessionId);
|
||||||
|
}
|
||||||
|
return sessionId;
|
||||||
|
}
|
||||||
|
|
||||||
|
const clientSessionId = getSessionId();
|
||||||
|
window.clientSessionId = clientSessionId;
|
||||||
|
sessionPill.textContent = clientSessionId;
|
||||||
|
|
||||||
|
function updateWidgetTitle() {
|
||||||
|
widget.title = [
|
||||||
|
`상태: ${label.textContent}`,
|
||||||
|
`세션: ${sessionPill.textContent}`,
|
||||||
|
`마지막 확인: ${lastChecked.textContent}`,
|
||||||
|
`서버 시간: ${serverTime.textContent}`,
|
||||||
|
`데이터 버전: ${dataVersion.textContent}`,
|
||||||
|
].join("\n");
|
||||||
|
}
|
||||||
|
|
||||||
|
function prepareCollabForms() {
|
||||||
|
document.querySelectorAll("form[data-collab-form]").forEach((form) => {
|
||||||
|
let sessionInput = form.querySelector('input[name="client_session_id"]');
|
||||||
|
if (!sessionInput) {
|
||||||
|
sessionInput = document.createElement("input");
|
||||||
|
sessionInput.type = "hidden";
|
||||||
|
sessionInput.name = "client_session_id";
|
||||||
|
form.appendChild(sessionInput);
|
||||||
|
}
|
||||||
|
sessionInput.value = clientSessionId;
|
||||||
|
|
||||||
|
let submittedInput = form.querySelector('input[name="client_submitted_at"]');
|
||||||
|
if (!submittedInput) {
|
||||||
|
submittedInput = document.createElement("input");
|
||||||
|
submittedInput.type = "hidden";
|
||||||
|
submittedInput.name = "client_submitted_at";
|
||||||
|
form.appendChild(submittedInput);
|
||||||
|
}
|
||||||
|
|
||||||
|
const markDirty = () => {
|
||||||
|
isFormDirty = true;
|
||||||
|
};
|
||||||
|
|
||||||
|
form.addEventListener("input", markDirty);
|
||||||
|
form.addEventListener("change", markDirty);
|
||||||
|
form.addEventListener("submit", () => {
|
||||||
|
isFormDirty = false;
|
||||||
|
submittedInput.value = new Date().toISOString();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
prepareCollabForms();
|
||||||
|
|
||||||
|
function setStatus(kind, text) {
|
||||||
|
dot.classList.remove("online", "error");
|
||||||
|
if (kind === "online") dot.classList.add("online");
|
||||||
|
if (kind === "error") dot.classList.add("error");
|
||||||
|
label.textContent = text;
|
||||||
|
lastChecked.textContent = new Date().toLocaleTimeString("ko-KR", { hour12: false });
|
||||||
|
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() || 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", "새 데이터 대기 중");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
refreshInFlight = true;
|
||||||
|
pendingVersion = nextVersion || pendingVersion || "";
|
||||||
|
setStatus("online", "새 데이터 반영 중");
|
||||||
|
|
||||||
|
try {
|
||||||
|
window.location.replace(refreshUrl);
|
||||||
|
} catch (error) {
|
||||||
|
refreshInFlight = false;
|
||||||
|
setStatus("error", "업데이트 재시도 중");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function pollHealth() {
|
||||||
|
try {
|
||||||
|
const response = await fetch(`/health?ts=${Date.now()}`, { cache: "no-store" });
|
||||||
|
if (!response.ok) throw new Error(`HTTP ${response.status}`);
|
||||||
|
const payload = await response.json();
|
||||||
|
serverTime.textContent = payload.server_time || "-";
|
||||||
|
dataVersion.textContent = payload.data_version || "-";
|
||||||
|
updateWidgetTitle();
|
||||||
|
if (payload.data_version && payload.data_version !== pageVersion) {
|
||||||
|
pendingVersion = payload.data_version;
|
||||||
|
await refreshPageWhenSafe(payload.data_version);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (pendingVersion && pendingVersion !== pageVersion) {
|
||||||
|
await refreshPageWhenSafe(pendingVersion);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setStatus("online", "서버 정상 연결");
|
||||||
|
} catch (error) {
|
||||||
|
setStatus("error", "연결 오류");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pollHealth();
|
||||||
|
updateWidgetTitle();
|
||||||
|
document.addEventListener("visibilitychange", () => {
|
||||||
|
if (!document.hidden) {
|
||||||
|
pollHealth();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
window.setInterval(pollHealth, 15000);
|
||||||
|
})();
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -0,0 +1,589 @@
|
|||||||
|
{% extends "base.html" %}
|
||||||
|
|
||||||
|
{% block title %}대시보드{% endblock %}
|
||||||
|
|
||||||
|
{% block head_extra %}
|
||||||
|
<style>
|
||||||
|
.dashboard-topbar {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dashboard-year-select {
|
||||||
|
width: 180px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.upload-actions {
|
||||||
|
position: relative;
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.upload-actions:hover .upload-tooltip,
|
||||||
|
.upload-actions:focus-within .upload-tooltip {
|
||||||
|
opacity: 1;
|
||||||
|
transform: translateY(0);
|
||||||
|
pointer-events: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.upload-tooltip {
|
||||||
|
position: absolute;
|
||||||
|
top: calc(100% + 12px);
|
||||||
|
right: 0;
|
||||||
|
width: 280px;
|
||||||
|
background: rgba(20, 20, 20, 0.96);
|
||||||
|
color: #f8fbfd;
|
||||||
|
border-radius: 14px;
|
||||||
|
padding: 14px 16px;
|
||||||
|
font-size: 13px;
|
||||||
|
line-height: 1.7;
|
||||||
|
box-shadow: 0 18px 35px rgba(20, 20, 20, 0.18);
|
||||||
|
opacity: 0;
|
||||||
|
transform: translateY(-6px);
|
||||||
|
pointer-events: none;
|
||||||
|
transition: opacity 0.18s ease, transform 0.18s ease;
|
||||||
|
z-index: 10;
|
||||||
|
}
|
||||||
|
|
||||||
|
.upload-tooltip::before {
|
||||||
|
content: "";
|
||||||
|
position: absolute;
|
||||||
|
top: -8px;
|
||||||
|
right: 22px;
|
||||||
|
border-left: 8px solid transparent;
|
||||||
|
border-right: 8px solid transparent;
|
||||||
|
border-bottom: 8px solid rgba(20, 20, 20, 0.96);
|
||||||
|
}
|
||||||
|
|
||||||
|
.hidden-file-input {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dashboard-layout {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: minmax(280px, 340px) minmax(0, 1fr);
|
||||||
|
gap: 16px;
|
||||||
|
align-items: stretch;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dashboard-status-panel {
|
||||||
|
display: grid;
|
||||||
|
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: 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, 180px));
|
||||||
|
gap: 10px;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chart-shell {
|
||||||
|
background:
|
||||||
|
linear-gradient(180deg, rgba(255,255,255,0.98), rgba(246,247,249,0.98)),
|
||||||
|
radial-gradient(circle at top left, rgba(24, 24, 27, 0.045), transparent 38%);
|
||||||
|
border: 1px solid var(--line);
|
||||||
|
border-radius: 16px;
|
||||||
|
padding: 14px 16px 16px;
|
||||||
|
box-shadow: inset 0 1px 0 rgba(255,255,255,0.92);
|
||||||
|
display: grid;
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.legend-box {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 10px 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.legend-box.center {
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.legend-item {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
border-radius: 10px;
|
||||||
|
padding: 6px 10px;
|
||||||
|
background: rgba(255,255,255,0.92);
|
||||||
|
border: 1px solid var(--line);
|
||||||
|
color: #363b44;
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
|
||||||
|
.legend-swatch {
|
||||||
|
width: 10px;
|
||||||
|
height: 10px;
|
||||||
|
border-radius: 999px;
|
||||||
|
display: inline-block;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chart-svg {
|
||||||
|
width: 100%;
|
||||||
|
height: auto;
|
||||||
|
aspect-ratio: 1120 / 390;
|
||||||
|
min-height: 300px;
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 1200px) {
|
||||||
|
.dashboard-layout {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dashboard-chart-stack {
|
||||||
|
grid-template-rows: auto;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 860px) {
|
||||||
|
.filter-row {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
{% endblock %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
<section class="panel">
|
||||||
|
<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>
|
||||||
|
|
||||||
|
<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>
|
||||||
|
|
||||||
|
<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>
|
||||||
|
{% for option in dashboard_revenue_metric_options %}
|
||||||
|
<option value="{{ option.item_key }}">{{ option.label }}</option>
|
||||||
|
{% endfor %}
|
||||||
|
</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="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>
|
||||||
|
{% for option in dashboard_expense_metric_options %}
|
||||||
|
<option value="{{ option.item_key }}">{{ option.label }}</option>
|
||||||
|
{% endfor %}
|
||||||
|
</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>
|
||||||
|
</section>
|
||||||
|
{% endblock %}
|
||||||
|
|
||||||
|
{% block script %}
|
||||||
|
<script>
|
||||||
|
const availableYears = {{ available_years | tojson }};
|
||||||
|
const yearlySummary = {{ yearly_summary | tojson }};
|
||||||
|
const monthlySummary = {{ monthly_summary | tojson }};
|
||||||
|
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 = Object.fromEntries(
|
||||||
|
dashboardRevenueMetricOptions.map((option) => [
|
||||||
|
option.item_key,
|
||||||
|
{ label: option.label, color: option.value },
|
||||||
|
]),
|
||||||
|
);
|
||||||
|
|
||||||
|
const expensePalette = Object.fromEntries(
|
||||||
|
dashboardExpenseMetricOptions.map((option) => [
|
||||||
|
option.item_key,
|
||||||
|
{ label: option.label, color: option.value },
|
||||||
|
]),
|
||||||
|
);
|
||||||
|
|
||||||
|
const revenueMetricMap = {
|
||||||
|
all: dashboardRevenueMetricOptions.map((option) => option.item_key),
|
||||||
|
...Object.fromEntries(dashboardRevenueMetricOptions.map((option) => [option.item_key, [option.item_key]])),
|
||||||
|
};
|
||||||
|
|
||||||
|
const expenseMetricMap = {
|
||||||
|
all: dashboardExpenseMetricOptions.map((option) => option.item_key),
|
||||||
|
...Object.fromEntries(dashboardExpenseMetricOptions.map((option) => [option.item_key, [option.item_key]])),
|
||||||
|
};
|
||||||
|
|
||||||
|
function formatNumber(value) {
|
||||||
|
return new Intl.NumberFormat("ko-KR", { maximumFractionDigits: 0 }).format(value || 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatValueLabel(value) {
|
||||||
|
const numeric = Number(value || 0);
|
||||||
|
if (!numeric) return "0.0";
|
||||||
|
if (numeric >= 100000000) return `${(numeric / 100000000).toFixed(1)}억`;
|
||||||
|
if (numeric >= 1000000) return `${(numeric / 1000000).toFixed(1)}백만`;
|
||||||
|
if (numeric >= 1000) return `${(numeric / 1000).toFixed(1)}천`;
|
||||||
|
return numeric.toFixed(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatAxisLabel(value) {
|
||||||
|
const numeric = Number(value || 0);
|
||||||
|
if (!numeric) return "0";
|
||||||
|
if (numeric >= 100000000) return `${numeric / 100000000}억`;
|
||||||
|
if (numeric >= 1000000) return `${numeric / 1000000}백만`;
|
||||||
|
if (numeric >= 1000) return `${numeric / 1000}천`;
|
||||||
|
return formatNumber(numeric);
|
||||||
|
}
|
||||||
|
|
||||||
|
function pickTickStep(maxValue) {
|
||||||
|
const baseUnit = maxValue < 1000000 ? 1000 : 1000000;
|
||||||
|
const units = [1, 2, 5];
|
||||||
|
const raw = Math.max(maxValue / baseUnit, 1);
|
||||||
|
let power = 1;
|
||||||
|
while (power * 10 <= raw) power *= 10;
|
||||||
|
for (const unit of units) {
|
||||||
|
const candidate = unit * power;
|
||||||
|
if (candidate >= raw) return candidate * baseUnit;
|
||||||
|
}
|
||||||
|
return power * 10 * baseUnit;
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildPositiveAxis(maxValue, tickCount = 4) {
|
||||||
|
const safeMax = Math.max(Number(maxValue || 0), 1);
|
||||||
|
const paddedMax = safeMax * (safeMax < 1000 ? 1.12 : 1.08);
|
||||||
|
const tickStep = pickTickStep(paddedMax / tickCount);
|
||||||
|
const tickMax = Math.max(tickStep, Math.ceil(paddedMax / tickStep) * tickStep);
|
||||||
|
return { tickStep, tickMax };
|
||||||
|
}
|
||||||
|
|
||||||
|
function 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:${paletteMap[key].color}"></span>
|
||||||
|
${paletteMap[key].label}
|
||||||
|
</span>
|
||||||
|
`).join("");
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderEmptyChart(svgId, message) {
|
||||||
|
const svg = document.getElementById(svgId);
|
||||||
|
if (!svg) return;
|
||||||
|
svg.innerHTML = `
|
||||||
|
<rect x="0" y="0" width="1120" height="390" rx="8" fill="#f7fafb" stroke="#d6e2e8"></rect>
|
||||||
|
<text x="560" y="195" text-anchor="middle" fill="#667887" font-size="18" font-weight="700">${message}</text>
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderGroupedBarChart(svgId, rows, metricKeys, paletteMap, options = {}) {
|
||||||
|
const svg = document.getElementById(svgId);
|
||||||
|
if (!svg) return;
|
||||||
|
if (!rows.length || !metricKeys.length) {
|
||||||
|
renderEmptyChart(svgId, "표시할 집계 데이터가 없습니다.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const granularity = options.granularity || "yearly";
|
||||||
|
const width = 1120;
|
||||||
|
const height = 390;
|
||||||
|
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 { tickStep, tickMax } = buildPositiveAxis(maxValue, 4);
|
||||||
|
const groupWidth = plotWidth / Math.max(rows.length, 1);
|
||||||
|
const axisBaseY = height - margin.bottom;
|
||||||
|
const groupGapRatio = granularity === "monthly" ? 0.28 : 0.18;
|
||||||
|
const innerGap = granularity === "monthly" ? 7 : 10;
|
||||||
|
const groupInset = Math.max(groupWidth * groupGapRatio, granularity === "monthly" ? 8 : 12);
|
||||||
|
const usableGroupWidth = Math.max(groupWidth - groupInset * 2, metricKeys.length * 12);
|
||||||
|
const barWidth = Math.min((usableGroupWidth - innerGap * Math.max(metricKeys.length - 1, 0)) / Math.max(metricKeys.length, 1), granularity === "monthly" ? 18 : 34);
|
||||||
|
const actualGroupWidth = barWidth * metricKeys.length + innerGap * Math.max(metricKeys.length - 1, 0);
|
||||||
|
const groupStartOffset = (groupWidth - actualGroupWidth) / 2;
|
||||||
|
const xLabelStep = granularity === "monthly" ? 1 : Math.max(1, Math.ceil(rows.length / 10));
|
||||||
|
|
||||||
|
let markup = `
|
||||||
|
<defs>
|
||||||
|
<linearGradient id="${svgId}Bg" x1="0" x2="1" y1="0" y2="1">
|
||||||
|
<stop offset="0%" stop-color="#fcfefe" />
|
||||||
|
<stop offset="100%" stop-color="#edf4f7" />
|
||||||
|
</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.14)" />
|
||||||
|
</filter>
|
||||||
|
</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>
|
||||||
|
`;
|
||||||
|
|
||||||
|
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="#d8e3e8" stroke-dasharray="3 7"></line>`;
|
||||||
|
markup += `<text x="${margin.left - 16}" y="${y + 4}" text-anchor="end" fill="#60717d" font-size="11.5" font-weight="700">${formatAxisLabel(value)}</text>`;
|
||||||
|
}
|
||||||
|
markup += `<line x1="${margin.left}" y1="${axisBaseY}" x2="${width - margin.right}" y2="${axisBaseY}" stroke="#8ea0ac" stroke-width="1.2"></line>`;
|
||||||
|
|
||||||
|
rows.forEach((row, rowIndex) => {
|
||||||
|
const baseX = margin.left + rowIndex * groupWidth;
|
||||||
|
const showXAxisLabel = rows.length <= 14 || rowIndex % xLabelStep === 0 || rowIndex === rows.length - 1;
|
||||||
|
metricKeys.forEach((key, metricIndex) => {
|
||||||
|
const value = Number(row[key] || 0);
|
||||||
|
const x = baseX + groupStartOffset + metricIndex * (barWidth + innerGap);
|
||||||
|
const drawWidth = Math.max(barWidth, 10);
|
||||||
|
const barHeight = tickMax ? (value / tickMax) * plotHeight : 0;
|
||||||
|
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="${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>`;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
if (showXAxisLabel) {
|
||||||
|
markup += `<text x="${baseX + groupWidth / 2}" y="${height - 28}" text-anchor="middle" fill="#405362" font-size="12.5" font-weight="700">${row.label}</text>`;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
svg.innerHTML = markup;
|
||||||
|
}
|
||||||
|
|
||||||
|
function getLatestAvailableYear() {
|
||||||
|
return String(availableYears[availableYears.length - 1] || "all");
|
||||||
|
}
|
||||||
|
|
||||||
|
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) => {
|
||||||
|
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 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 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 });
|
||||||
|
}
|
||||||
|
|
||||||
|
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);
|
||||||
|
|
||||||
|
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");
|
||||||
|
|
||||||
|
uploadButton?.addEventListener("click", () => {
|
||||||
|
excelInput?.click();
|
||||||
|
});
|
||||||
|
|
||||||
|
excelInput?.addEventListener("change", () => {
|
||||||
|
if (excelInput.files && excelInput.files.length > 0) {
|
||||||
|
uploadForm.submit();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
syncYearSelection("revenueYear", document.getElementById("revenueGranularity")?.value || "yearly");
|
||||||
|
syncYearSelection("expenseYear", document.getElementById("expenseGranularity")?.value || "yearly");
|
||||||
|
updateRevenueChart();
|
||||||
|
updateExpenseChart();
|
||||||
|
</script>
|
||||||
|
{% endblock %}
|
||||||
@@ -0,0 +1,627 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="ko">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title>회계 데이터 인트라넷 대시보드</title>
|
||||||
|
<style>
|
||||||
|
:root {
|
||||||
|
--bg-a: #f3efe7;
|
||||||
|
--bg-b: #d7e5eb;
|
||||||
|
--panel: rgba(255, 252, 247, 0.92);
|
||||||
|
--ink: #14212f;
|
||||||
|
--muted: #5a6672;
|
||||||
|
--line: #d8dee5;
|
||||||
|
--accent: #0b6b63;
|
||||||
|
--accent-strong: #074b49;
|
||||||
|
--accent-soft: #e6f5f2;
|
||||||
|
--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)));
|
||||||
|
}
|
||||||
|
|
||||||
|
* {
|
||||||
|
box-sizing: border-box;
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
body {
|
||||||
|
font-family: "Noto Sans KR", "Malgun Gothic", sans-serif;
|
||||||
|
color: var(--ink);
|
||||||
|
background:
|
||||||
|
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: var(--page-gutter);
|
||||||
|
}
|
||||||
|
|
||||||
|
.page {
|
||||||
|
width: min(100%, var(--page-frame-width));
|
||||||
|
margin: 0 auto;
|
||||||
|
display: grid;
|
||||||
|
gap: var(--page-gutter);
|
||||||
|
}
|
||||||
|
|
||||||
|
.page > * {
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.panel {
|
||||||
|
background: var(--panel);
|
||||||
|
border: 1px solid rgba(255, 255, 255, 0.6);
|
||||||
|
border-radius: 24px;
|
||||||
|
padding: var(--panel-pad);
|
||||||
|
box-shadow: 0 20px 45px rgba(51, 76, 92, 0.12);
|
||||||
|
backdrop-filter: blur(10px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.section-title {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 16px;
|
||||||
|
margin-bottom: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.section-title h2 {
|
||||||
|
font-size: 24px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.section-title p {
|
||||||
|
color: var(--muted);
|
||||||
|
font-size: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.message {
|
||||||
|
background: var(--warn);
|
||||||
|
border: 1px solid #efd486;
|
||||||
|
color: #624c0b;
|
||||||
|
border-radius: 16px;
|
||||||
|
padding: 14px 16px;
|
||||||
|
line-height: 1.6;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stats {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(5, minmax(0, 1fr));
|
||||||
|
gap: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.summary-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 1fr 1fr;
|
||||||
|
gap: 18px;
|
||||||
|
margin-top: 18px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stat-card {
|
||||||
|
background: var(--white);
|
||||||
|
border: 1px solid var(--line);
|
||||||
|
border-radius: 18px;
|
||||||
|
padding: 18px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stat-card .label {
|
||||||
|
color: var(--muted);
|
||||||
|
font-size: 13px;
|
||||||
|
margin-bottom: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stat-card .value {
|
||||||
|
font-size: 28px;
|
||||||
|
font-weight: 800;
|
||||||
|
line-height: 1.1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.two-col {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 0.92fr 1.08fr;
|
||||||
|
gap: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stack {
|
||||||
|
display: grid;
|
||||||
|
gap: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
form {
|
||||||
|
display: grid;
|
||||||
|
gap: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.upload-box {
|
||||||
|
background: linear-gradient(180deg, #f8fffd, #eef8f6);
|
||||||
|
border: 1px dashed #a7d1c8;
|
||||||
|
border-radius: 18px;
|
||||||
|
padding: 18px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.upload-box p {
|
||||||
|
color: var(--muted);
|
||||||
|
line-height: 1.65;
|
||||||
|
margin-bottom: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(4, minmax(0, 1fr));
|
||||||
|
gap: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.field {
|
||||||
|
display: grid;
|
||||||
|
gap: 7px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.field-wide {
|
||||||
|
grid-column: span 2;
|
||||||
|
}
|
||||||
|
|
||||||
|
.field-full {
|
||||||
|
grid-column: 1 / -1;
|
||||||
|
}
|
||||||
|
|
||||||
|
label {
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 700;
|
||||||
|
color: var(--muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
input,
|
||||||
|
select,
|
||||||
|
textarea,
|
||||||
|
button {
|
||||||
|
font: inherit;
|
||||||
|
}
|
||||||
|
|
||||||
|
input[type="text"],
|
||||||
|
input[type="number"],
|
||||||
|
input[type="date"],
|
||||||
|
input[type="file"],
|
||||||
|
textarea {
|
||||||
|
width: 100%;
|
||||||
|
border: 1px solid var(--line);
|
||||||
|
background: var(--white);
|
||||||
|
border-radius: 14px;
|
||||||
|
padding: 12px 14px;
|
||||||
|
color: var(--ink);
|
||||||
|
}
|
||||||
|
|
||||||
|
textarea {
|
||||||
|
min-height: 96px;
|
||||||
|
resize: vertical;
|
||||||
|
}
|
||||||
|
|
||||||
|
input:focus,
|
||||||
|
textarea:focus {
|
||||||
|
outline: none;
|
||||||
|
border-color: var(--accent);
|
||||||
|
box-shadow: 0 0 0 4px rgba(11, 107, 99, 0.12);
|
||||||
|
}
|
||||||
|
|
||||||
|
.actions {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 10px;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
button,
|
||||||
|
.button-link {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 8px;
|
||||||
|
border-radius: 999px;
|
||||||
|
border: none;
|
||||||
|
padding: 12px 18px;
|
||||||
|
background: var(--accent);
|
||||||
|
color: var(--white);
|
||||||
|
font-weight: 700;
|
||||||
|
cursor: pointer;
|
||||||
|
text-decoration: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
button:hover,
|
||||||
|
.button-link:hover {
|
||||||
|
background: var(--accent-strong);
|
||||||
|
}
|
||||||
|
|
||||||
|
.button-secondary {
|
||||||
|
background: #e7f0f5;
|
||||||
|
color: #204257;
|
||||||
|
}
|
||||||
|
|
||||||
|
.table-wrap {
|
||||||
|
overflow: auto;
|
||||||
|
border: 1px solid var(--line);
|
||||||
|
border-radius: 18px;
|
||||||
|
background: var(--white);
|
||||||
|
}
|
||||||
|
|
||||||
|
table {
|
||||||
|
width: 100%;
|
||||||
|
border-collapse: collapse;
|
||||||
|
min-width: 780px;
|
||||||
|
}
|
||||||
|
|
||||||
|
th,
|
||||||
|
td {
|
||||||
|
padding: 12px 14px;
|
||||||
|
border-bottom: 1px solid var(--line);
|
||||||
|
text-align: left;
|
||||||
|
vertical-align: top;
|
||||||
|
font-size: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
th {
|
||||||
|
position: sticky;
|
||||||
|
top: 0;
|
||||||
|
background: #eff5f7;
|
||||||
|
color: #345061;
|
||||||
|
z-index: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
tbody tr:nth-child(even) td {
|
||||||
|
background: var(--table-alt);
|
||||||
|
}
|
||||||
|
|
||||||
|
.empty {
|
||||||
|
padding: 24px;
|
||||||
|
color: var(--muted);
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.note-list {
|
||||||
|
display: grid;
|
||||||
|
gap: 8px;
|
||||||
|
color: var(--muted);
|
||||||
|
line-height: 1.7;
|
||||||
|
font-size: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
details.panel summary {
|
||||||
|
list-style: none;
|
||||||
|
cursor: pointer;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 16px;
|
||||||
|
font-size: 24px;
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
|
||||||
|
details.panel summary::-webkit-details-marker {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.search-box {
|
||||||
|
margin: 16px 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mono {
|
||||||
|
font-family: "Consolas", "Courier New", monospace;
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 1200px) {
|
||||||
|
.stats {
|
||||||
|
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||||
|
}
|
||||||
|
|
||||||
|
.summary-grid {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
|
||||||
|
.two-col {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-grid {
|
||||||
|
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 720px) {
|
||||||
|
body {
|
||||||
|
padding: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stats,
|
||||||
|
.form-grid {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
|
||||||
|
.field-wide,
|
||||||
|
.field-full {
|
||||||
|
grid-column: auto;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div class="page">
|
||||||
|
{% if message %}
|
||||||
|
<div class="message">{{ message }}</div>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
<section class="panel">
|
||||||
|
<div class="section-title">
|
||||||
|
<h2>현황 요약</h2>
|
||||||
|
<p>DB에 저장된 전체 자료 기준</p>
|
||||||
|
</div>
|
||||||
|
<div class="stats">
|
||||||
|
<div class="stat-card">
|
||||||
|
<div class="label">전체 데이터 건수</div>
|
||||||
|
<div class="value">{{ overview.total_rows or 0 }}</div>
|
||||||
|
</div>
|
||||||
|
<div class="stat-card">
|
||||||
|
<div class="label">업로드 파일 수</div>
|
||||||
|
<div class="value">{{ overview.source_files or 0 }}</div>
|
||||||
|
</div>
|
||||||
|
<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_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>
|
||||||
|
<div class="summary-grid">
|
||||||
|
<div class="table-wrap">
|
||||||
|
{% if yearly_summary %}
|
||||||
|
<table>
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>연도</th>
|
||||||
|
<th>원가 합계</th>
|
||||||
|
<th>판관비 합계</th>
|
||||||
|
<th>원가인건비</th>
|
||||||
|
<th>원가외주비</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{% for item in yearly_summary %}
|
||||||
|
<tr>
|
||||||
|
<td>{{ item.year }}</td>
|
||||||
|
<td>{{ "{:,.0f}".format(item.cost_sum or 0) }}</td>
|
||||||
|
<td>{{ "{:,.0f}".format(item.sga_sum or 0) }}</td>
|
||||||
|
<td>{{ "{:,.0f}".format(item.labor_sum or 0) }}</td>
|
||||||
|
<td>{{ "{:,.0f}".format(item.outsourcing_sum or 0) }}</td>
|
||||||
|
</tr>
|
||||||
|
{% endfor %}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
{% else %}
|
||||||
|
<div class="empty">연간 집계 데이터가 없습니다.</div>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
<div class="table-wrap">
|
||||||
|
{% if monthly_summary %}
|
||||||
|
<table>
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>연도</th>
|
||||||
|
<th>월</th>
|
||||||
|
<th>원가 합계</th>
|
||||||
|
<th>판관비 합계</th>
|
||||||
|
<th>원가인건비</th>
|
||||||
|
<th>원가외주비</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{% for item in monthly_summary %}
|
||||||
|
<tr>
|
||||||
|
<td>{{ item.year }}</td>
|
||||||
|
<td>{{ item.month }}</td>
|
||||||
|
<td>{{ "{:,.0f}".format(item.cost_sum or 0) }}</td>
|
||||||
|
<td>{{ "{:,.0f}".format(item.sga_sum or 0) }}</td>
|
||||||
|
<td>{{ "{:,.0f}".format(item.labor_sum or 0) }}</td>
|
||||||
|
<td>{{ "{:,.0f}".format(item.outsourcing_sum or 0) }}</td>
|
||||||
|
</tr>
|
||||||
|
{% endfor %}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
{% else %}
|
||||||
|
<div class="empty">월별 집계 데이터가 없습니다.</div>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="panel">
|
||||||
|
<div class="section-title">
|
||||||
|
<h2>계약·청구 동기화 현황</h2>
|
||||||
|
<p>계약현황 / 기성청구현황 파일 기준</p>
|
||||||
|
</div>
|
||||||
|
<div class="stats">
|
||||||
|
<div class="stat-card">
|
||||||
|
<div class="label">계약 프로젝트</div>
|
||||||
|
<div class="value">{{ import_sync_summary.contract_project_count or 0 }}</div>
|
||||||
|
</div>
|
||||||
|
<div class="stat-card">
|
||||||
|
<div class="label">청구 프로젝트</div>
|
||||||
|
<div class="value">{{ import_sync_summary.billing_project_count or 0 }}</div>
|
||||||
|
</div>
|
||||||
|
<div class="stat-card">
|
||||||
|
<div class="label">청구 행 수</div>
|
||||||
|
<div class="value">{{ import_sync_summary.billing_entry_count or 0 }}</div>
|
||||||
|
</div>
|
||||||
|
<div class="stat-card">
|
||||||
|
<div class="label">변경계약 검토</div>
|
||||||
|
<div class="value">{{ import_sync_summary.review_needed_count or 0 }}</div>
|
||||||
|
</div>
|
||||||
|
<div class="stat-card">
|
||||||
|
<div class="label">청구 수금합계</div>
|
||||||
|
<div class="value">{{ "{:,.0f}".format(import_sync_summary.total_collected_amount or 0) }}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="two-col">
|
||||||
|
<div class="stack">
|
||||||
|
<section class="panel">
|
||||||
|
<div class="section-title">
|
||||||
|
<h2>엑셀 업로드</h2>
|
||||||
|
<p>업로드 즉시 DB 저장</p>
|
||||||
|
</div>
|
||||||
|
<div class="upload-box">
|
||||||
|
<p>
|
||||||
|
업로드 파일은 이미지에 보인 열 형식 기준으로 읽습니다.
|
||||||
|
예: 결재상태, 가전표번호, 계정코드, 계정명칭, 차변공급가, 대변공급가, 지원부서코드,
|
||||||
|
지원부서명, 원가부서코드, 원가부서명, 적요1, 관리항목 등
|
||||||
|
</p>
|
||||||
|
<p>
|
||||||
|
현재 프로젝트 폴더에 있는 엑셀 파일은 서버 시작 시 DB가 비어 있으면 자동으로 적재됩니다.
|
||||||
|
</p>
|
||||||
|
<form action="/upload" method="post" enctype="multipart/form-data">
|
||||||
|
<div class="field">
|
||||||
|
<label for="excel_file">엑셀 파일 선택</label>
|
||||||
|
<input id="excel_file" type="file" name="excel_file" accept=".xlsx,.xlsm,.xltx,.xltm" required>
|
||||||
|
</div>
|
||||||
|
<div class="actions">
|
||||||
|
<button type="submit">엑셀을 DB에 저장</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="panel">
|
||||||
|
<div class="section-title">
|
||||||
|
<h2>{{ "데이터 수정" if edit_record.id else "DB 직접 입력" }}</h2>
|
||||||
|
<p>엑셀 없이도 직접 등록/수정 가능</p>
|
||||||
|
</div>
|
||||||
|
<form action="/records/save" method="post">
|
||||||
|
<input type="hidden" name="id" value="{{ edit_record.id }}">
|
||||||
|
<div class="form-grid">
|
||||||
|
{% for field_name, field_label in field_labels.items() %}
|
||||||
|
<div class="field {% if field_name in ['memo1', 'memo2', 'management_item'] %}field-wide{% endif %}">
|
||||||
|
<label for="{{ field_name }}">{{ field_label }}</label>
|
||||||
|
{% if field_name in ['memo1', 'memo2', 'management_item'] %}
|
||||||
|
<textarea id="{{ field_name }}" name="{{ field_name }}">{{ edit_record[field_name] }}</textarea>
|
||||||
|
{% elif field_name == 'posting_date' %}
|
||||||
|
<input id="{{ field_name }}" type="date" name="{{ field_name }}" value="{{ edit_record[field_name] }}">
|
||||||
|
{% elif field_name in ['debit_supply', 'debit_vat', 'credit_supply', 'credit_vat'] %}
|
||||||
|
<input id="{{ field_name }}" type="number" step="0.01" name="{{ field_name }}" value="{{ edit_record[field_name] }}">
|
||||||
|
{% else %}
|
||||||
|
<input id="{{ field_name }}" type="text" name="{{ field_name }}" value="{{ edit_record[field_name] }}">
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
<div class="actions">
|
||||||
|
<button type="submit">{{ "수정 내용을 저장" if edit_record.id else "새 데이터 저장" }}</button>
|
||||||
|
{% if edit_record.id %}
|
||||||
|
<a class="button-link button-secondary" href="/">수정 취소</a>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="stack">
|
||||||
|
<details class="panel">
|
||||||
|
<summary>
|
||||||
|
<span>집계 대상 사업</span>
|
||||||
|
<span style="font-size:14px;color:var(--muted);">검색해서 펼쳐보기</span>
|
||||||
|
</summary>
|
||||||
|
<div class="search-box">
|
||||||
|
<input type="text" id="support-business-search" placeholder="지원부서코드 또는 사업명을 입력하세요.">
|
||||||
|
</div>
|
||||||
|
<div class="table-wrap">
|
||||||
|
{% if support_businesses %}
|
||||||
|
<table id="support-business-table">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>지원부서코드</th>
|
||||||
|
<th>사업명</th>
|
||||||
|
<th>행 수</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{% for item in support_businesses %}
|
||||||
|
<tr data-search="{{ item.support_dept_code }} {{ item.support_dept_name }}">
|
||||||
|
<td class="mono">{{ item.support_dept_code }}</td>
|
||||||
|
<td>{{ item.support_dept_name }}</td>
|
||||||
|
<td>{{ item.row_count }}</td>
|
||||||
|
</tr>
|
||||||
|
{% endfor %}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
{% else %}
|
||||||
|
<div class="empty">아직 표시할 사업 데이터가 없습니다. 엑셀 업로드 또는 수동 입력을 먼저 진행해주세요.</div>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
</details>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<details class="panel">
|
||||||
|
<summary>
|
||||||
|
<span>사업별 연도/월 사용 비용</span>
|
||||||
|
<span style="font-size:14px;color:var(--muted);">검색해서 펼쳐보기</span>
|
||||||
|
</summary>
|
||||||
|
<div class="search-box">
|
||||||
|
<input type="text" id="business-cost-search" placeholder="연도, 월, 지원부서코드, 사업명으로 검색하세요.">
|
||||||
|
</div>
|
||||||
|
<div class="table-wrap">
|
||||||
|
{% if business_monthly_summary %}
|
||||||
|
<table id="business-cost-table">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>연도</th>
|
||||||
|
<th>월</th>
|
||||||
|
<th>지원부서코드</th>
|
||||||
|
<th>사업명</th>
|
||||||
|
<th>원가</th>
|
||||||
|
<th>판관비</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{% for item in business_monthly_summary %}
|
||||||
|
<tr data-search="{{ item.year }} {{ item.month }} {{ item.support_dept_code }} {{ item.support_dept_name }}">
|
||||||
|
<td>{{ item.year }}</td>
|
||||||
|
<td>{{ item.month }}</td>
|
||||||
|
<td class="mono">{{ item.support_dept_code }}</td>
|
||||||
|
<td>{{ item.support_dept_name }}</td>
|
||||||
|
<td>{{ "{:,.0f}".format(item.cost_sum or 0) }}</td>
|
||||||
|
<td>{{ "{:,.0f}".format(item.sga_sum or 0) }}</td>
|
||||||
|
</tr>
|
||||||
|
{% endfor %}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
{% else %}
|
||||||
|
<div class="empty">사업별 월 집계 데이터가 없습니다.</div>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
</details>
|
||||||
|
</div>
|
||||||
|
<script>
|
||||||
|
function bindTableSearch(inputId, tableId) {
|
||||||
|
const input = document.getElementById(inputId);
|
||||||
|
const table = document.getElementById(tableId);
|
||||||
|
if (!input || !table) return;
|
||||||
|
const rows = Array.from(table.querySelectorAll("tbody tr"));
|
||||||
|
input.addEventListener("input", () => {
|
||||||
|
const keyword = input.value.trim().toLowerCase();
|
||||||
|
rows.forEach((row) => {
|
||||||
|
const haystack = (row.dataset.search || row.textContent || "").toLowerCase();
|
||||||
|
row.style.display = !keyword || haystack.includes(keyword) ? "" : "none";
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
bindTableSearch("support-business-search", "support-business-table");
|
||||||
|
bindTableSearch("business-cost-search", "business-cost-table");
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -6713,4 +6713,5 @@ async def save_project_json(request: Request):
|
|||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
auto_reload = os.getenv("INTRANET_AUTO_RELOAD", "1").lower() not in {"0", "false", "no"}
|
auto_reload = os.getenv("INTRANET_AUTO_RELOAD", "1").lower() not in {"0", "false", "no"}
|
||||||
uvicorn.run("main:app", host="0.0.0.0", port=8010, reload=auto_reload, reload_dirs=[str(BASE_DIR)])
|
port = int(os.getenv("INTRANET_PORT", "8010"))
|
||||||
|
uvicorn.run("main:app", host="0.0.0.0", port=port, reload=auto_reload, reload_dirs=[str(BASE_DIR)])
|
||||||
|
|||||||
@@ -1,12 +1,32 @@
|
|||||||
param(
|
param(
|
||||||
[string]$ListenAddress = "0.0.0.0",
|
[string]$ListenAddress = "0.0.0.0",
|
||||||
[int]$Port = 8010,
|
[int]$Port = 8010,
|
||||||
[string]$RuleName = "MyIntranetApp-8010"
|
[string]$RuleName = ""
|
||||||
)
|
)
|
||||||
|
|
||||||
$ErrorActionPreference = "Stop"
|
$ErrorActionPreference = "Stop"
|
||||||
|
|
||||||
netsh interface portproxy delete v4tov4 listenport=$Port listenaddress=$ListenAddress | Out-Null
|
if ([string]::IsNullOrWhiteSpace($RuleName)) {
|
||||||
|
$RuleName = "MyIntranetApp-$Port"
|
||||||
|
}
|
||||||
|
|
||||||
|
$windowsIps = Get-NetIPAddress -AddressFamily IPv4 |
|
||||||
|
Where-Object {
|
||||||
|
$_.IPAddress -notlike '127.*' `
|
||||||
|
-and $_.IPAddress -notlike '169.254*' `
|
||||||
|
-and $_.InterfaceAlias -notlike '*WSL*'
|
||||||
|
} |
|
||||||
|
Select-Object -ExpandProperty IPAddress
|
||||||
|
|
||||||
|
$listenAddresses = @($ListenAddress)
|
||||||
|
if ($ListenAddress -eq "0.0.0.0") {
|
||||||
|
$listenAddresses += $windowsIps
|
||||||
|
}
|
||||||
|
$listenAddresses = $listenAddresses | Where-Object { -not [string]::IsNullOrWhiteSpace($_) } | Select-Object -Unique
|
||||||
|
|
||||||
|
foreach ($address in $listenAddresses) {
|
||||||
|
netsh interface portproxy delete v4tov4 listenport=$Port listenaddress=$address | Out-Null
|
||||||
|
}
|
||||||
|
|
||||||
$existingRule = Get-NetFirewallRule -DisplayName $RuleName -ErrorAction SilentlyContinue
|
$existingRule = Get-NetFirewallRule -DisplayName $RuleName -ErrorAction SilentlyContinue
|
||||||
if ($existingRule) {
|
if ($existingRule) {
|
||||||
|
|||||||
@@ -3,6 +3,8 @@ set -euo pipefail
|
|||||||
|
|
||||||
cd "$(dirname "$0")/.."
|
cd "$(dirname "$0")/.."
|
||||||
|
|
||||||
|
PORT="${1:-${INTRANET_PORT:-8010}}"
|
||||||
|
|
||||||
if [ ! -x ".venv/bin/python" ]; then
|
if [ ! -x ".venv/bin/python" ]; then
|
||||||
echo "가상환경이 없습니다. 먼저 아래를 실행하세요."
|
echo "가상환경이 없습니다. 먼저 아래를 실행하세요."
|
||||||
echo "python3 -m venv .venv"
|
echo "python3 -m venv .venv"
|
||||||
@@ -12,5 +14,6 @@ if [ ! -x ".venv/bin/python" ]; then
|
|||||||
fi
|
fi
|
||||||
|
|
||||||
export INTRANET_AUTO_RELOAD="${INTRANET_AUTO_RELOAD:-1}"
|
export INTRANET_AUTO_RELOAD="${INTRANET_AUTO_RELOAD:-1}"
|
||||||
|
export INTRANET_PORT="$PORT"
|
||||||
|
|
||||||
exec ./.venv/bin/python main.py
|
exec ./.venv/bin/python main.py
|
||||||
|
|||||||
@@ -0,0 +1,27 @@
|
|||||||
|
@echo off
|
||||||
|
setlocal
|
||||||
|
|
||||||
|
net session >nul 2>&1
|
||||||
|
if not "%errorlevel%"=="0" (
|
||||||
|
echo This script must be run as Administrator.
|
||||||
|
echo.
|
||||||
|
echo 1. Open Windows Start menu.
|
||||||
|
echo 2. Type cmd.
|
||||||
|
echo 3. Right-click Command Prompt and choose "Run as administrator".
|
||||||
|
echo 4. Run this command:
|
||||||
|
echo %~f0 %*
|
||||||
|
echo.
|
||||||
|
pause
|
||||||
|
exit /b 1
|
||||||
|
)
|
||||||
|
|
||||||
|
set SCRIPT_DIR=%~dp0
|
||||||
|
set WSL_IP=%~1
|
||||||
|
|
||||||
|
if "%WSL_IP%"=="" (
|
||||||
|
powershell -NoProfile -ExecutionPolicy Bypass -File "%SCRIPT_DIR%setup_windows_all_portproxies.ps1"
|
||||||
|
) else (
|
||||||
|
powershell -NoProfile -ExecutionPolicy Bypass -File "%SCRIPT_DIR%setup_windows_all_portproxies.ps1" -WslIp %WSL_IP%
|
||||||
|
)
|
||||||
|
|
||||||
|
endlocal
|
||||||
@@ -0,0 +1,83 @@
|
|||||||
|
param(
|
||||||
|
[string]$ListenAddress = "0.0.0.0",
|
||||||
|
[string]$WslIp = ""
|
||||||
|
)
|
||||||
|
|
||||||
|
$ErrorActionPreference = "Stop"
|
||||||
|
|
||||||
|
$apps = @(
|
||||||
|
@{ Name = "hm-biz-process"; Port = 8000; RuleName = "HM-BIZ-PROCESS-8000" },
|
||||||
|
@{ Name = "my-intranet-app"; Port = 8010; RuleName = "MyIntranetApp-8010" },
|
||||||
|
@{ Name = "b17301"; Port = 8020; RuleName = "B17301-8020" }
|
||||||
|
)
|
||||||
|
|
||||||
|
if ([string]::IsNullOrWhiteSpace($WslIp)) {
|
||||||
|
$detected = wsl.exe hostname -I 2>$null
|
||||||
|
if (-not $detected) {
|
||||||
|
throw "WSL IP를 자동으로 찾지 못했습니다. -WslIp 옵션으로 직접 지정해 주세요."
|
||||||
|
}
|
||||||
|
|
||||||
|
$WslIp = (($detected -split "\s+") | Where-Object { $_ -match '^\d+\.\d+\.\d+\.\d+$' } | Select-Object -First 1)
|
||||||
|
if (-not $WslIp) {
|
||||||
|
throw "WSL IP를 자동으로 파싱하지 못했습니다. -WslIp 옵션으로 직접 지정해 주세요."
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
$windowsIps = Get-NetIPAddress -AddressFamily IPv4 |
|
||||||
|
Where-Object {
|
||||||
|
$_.IPAddress -notlike '127.*' `
|
||||||
|
-and $_.IPAddress -notlike '169.254*' `
|
||||||
|
-and $_.InterfaceAlias -notlike '*WSL*'
|
||||||
|
} |
|
||||||
|
Select-Object -ExpandProperty IPAddress
|
||||||
|
|
||||||
|
$listenAddresses = @($ListenAddress)
|
||||||
|
if ($ListenAddress -eq "0.0.0.0") {
|
||||||
|
$listenAddresses += $windowsIps
|
||||||
|
}
|
||||||
|
$listenAddresses = $listenAddresses | Where-Object { -not [string]::IsNullOrWhiteSpace($_) } | Select-Object -Unique
|
||||||
|
|
||||||
|
Write-Host "Setting Windows portproxy for all WSL apps..." -ForegroundColor Cyan
|
||||||
|
Write-Host "WSL IP: $WslIp"
|
||||||
|
|
||||||
|
foreach ($app in $apps) {
|
||||||
|
$port = [int]$app.Port
|
||||||
|
$ruleName = [string]$app.RuleName
|
||||||
|
|
||||||
|
Write-Host ""
|
||||||
|
Write-Host "$($app.Name): $port" -ForegroundColor Cyan
|
||||||
|
|
||||||
|
foreach ($address in $listenAddresses) {
|
||||||
|
netsh interface portproxy delete v4tov4 listenport=$port listenaddress=$address | Out-Null
|
||||||
|
netsh interface portproxy add v4tov4 listenport=$port listenaddress=$address connectport=$port connectaddress=$WslIp
|
||||||
|
}
|
||||||
|
|
||||||
|
$existingRule = Get-NetFirewallRule -DisplayName $ruleName -ErrorAction SilentlyContinue
|
||||||
|
if ($existingRule) {
|
||||||
|
Remove-NetFirewallRule -DisplayName $ruleName | Out-Null
|
||||||
|
}
|
||||||
|
|
||||||
|
New-NetFirewallRule `
|
||||||
|
-DisplayName $ruleName `
|
||||||
|
-Direction Inbound `
|
||||||
|
-Action Allow `
|
||||||
|
-Protocol TCP `
|
||||||
|
-LocalPort $port | Out-Null
|
||||||
|
}
|
||||||
|
|
||||||
|
Write-Host ""
|
||||||
|
Write-Host "Completed." -ForegroundColor Green
|
||||||
|
Write-Host "Use these URLs from another PC on the intranet:"
|
||||||
|
if ($windowsIps) {
|
||||||
|
foreach ($ip in $windowsIps) {
|
||||||
|
Write-Host "hm-biz-process : http://$ip`:8000"
|
||||||
|
Write-Host "my-intranet-app: http://$ip`:8010"
|
||||||
|
Write-Host "b17301 : http://$ip`:8020"
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
Write-Host "hm-biz-process : http://<Windows-IP>:8000"
|
||||||
|
Write-Host "my-intranet-app: http://<Windows-IP>:8010"
|
||||||
|
Write-Host "b17301 : http://<Windows-IP>:8020"
|
||||||
|
}
|
||||||
|
Write-Host ""
|
||||||
|
Write-Host "Tip: if WSL restarts and its IP changes, run this script again."
|
||||||
@@ -1,6 +1,20 @@
|
|||||||
@echo off
|
@echo off
|
||||||
setlocal
|
setlocal
|
||||||
|
|
||||||
|
net session >nul 2>&1
|
||||||
|
if not "%errorlevel%"=="0" (
|
||||||
|
echo This script must be run as Administrator.
|
||||||
|
echo.
|
||||||
|
echo 1. Open Windows Start menu.
|
||||||
|
echo 2. Type cmd.
|
||||||
|
echo 3. Right-click Command Prompt and choose "Run as administrator".
|
||||||
|
echo 4. Run this command:
|
||||||
|
echo %~f0 %*
|
||||||
|
echo.
|
||||||
|
pause
|
||||||
|
exit /b 1
|
||||||
|
)
|
||||||
|
|
||||||
set SCRIPT_DIR=%~dp0
|
set SCRIPT_DIR=%~dp0
|
||||||
set WSL_IP=%~1
|
set WSL_IP=%~1
|
||||||
set PORT=%~2
|
set PORT=%~2
|
||||||
|
|||||||
@@ -2,11 +2,15 @@ param(
|
|||||||
[string]$ListenAddress = "0.0.0.0",
|
[string]$ListenAddress = "0.0.0.0",
|
||||||
[int]$Port = 8010,
|
[int]$Port = 8010,
|
||||||
[string]$WslIp = "",
|
[string]$WslIp = "",
|
||||||
[string]$RuleName = "MyIntranetApp-8010"
|
[string]$RuleName = ""
|
||||||
)
|
)
|
||||||
|
|
||||||
$ErrorActionPreference = "Stop"
|
$ErrorActionPreference = "Stop"
|
||||||
|
|
||||||
|
if ([string]::IsNullOrWhiteSpace($RuleName)) {
|
||||||
|
$RuleName = "MyIntranetApp-$Port"
|
||||||
|
}
|
||||||
|
|
||||||
if ([string]::IsNullOrWhiteSpace($WslIp)) {
|
if ([string]::IsNullOrWhiteSpace($WslIp)) {
|
||||||
$detected = wsl.exe hostname -I 2>$null
|
$detected = wsl.exe hostname -I 2>$null
|
||||||
if (-not $detected) {
|
if (-not $detected) {
|
||||||
@@ -24,8 +28,24 @@ Write-Host "ListenAddress: $ListenAddress"
|
|||||||
Write-Host "Port: $Port"
|
Write-Host "Port: $Port"
|
||||||
Write-Host "WSL IP: $WslIp"
|
Write-Host "WSL IP: $WslIp"
|
||||||
|
|
||||||
netsh interface portproxy delete v4tov4 listenport=$Port listenaddress=$ListenAddress | Out-Null
|
$windowsIps = Get-NetIPAddress -AddressFamily IPv4 |
|
||||||
netsh interface portproxy add v4tov4 listenport=$Port listenaddress=$ListenAddress connectport=$Port connectaddress=$WslIp
|
Where-Object {
|
||||||
|
$_.IPAddress -notlike '127.*' `
|
||||||
|
-and $_.IPAddress -notlike '169.254*' `
|
||||||
|
-and $_.InterfaceAlias -notlike '*WSL*'
|
||||||
|
} |
|
||||||
|
Select-Object -ExpandProperty IPAddress
|
||||||
|
|
||||||
|
$listenAddresses = @($ListenAddress)
|
||||||
|
if ($ListenAddress -eq "0.0.0.0") {
|
||||||
|
$listenAddresses += $windowsIps
|
||||||
|
}
|
||||||
|
$listenAddresses = $listenAddresses | Where-Object { -not [string]::IsNullOrWhiteSpace($_) } | Select-Object -Unique
|
||||||
|
|
||||||
|
foreach ($address in $listenAddresses) {
|
||||||
|
netsh interface portproxy delete v4tov4 listenport=$Port listenaddress=$address | Out-Null
|
||||||
|
netsh interface portproxy add v4tov4 listenport=$Port listenaddress=$address connectport=$Port connectaddress=$WslIp
|
||||||
|
}
|
||||||
|
|
||||||
$existingRule = Get-NetFirewallRule -DisplayName $RuleName -ErrorAction SilentlyContinue
|
$existingRule = Get-NetFirewallRule -DisplayName $RuleName -ErrorAction SilentlyContinue
|
||||||
if ($existingRule) {
|
if ($existingRule) {
|
||||||
@@ -39,16 +59,15 @@ New-NetFirewallRule `
|
|||||||
-Protocol TCP `
|
-Protocol TCP `
|
||||||
-LocalPort $Port | Out-Null
|
-LocalPort $Port | Out-Null
|
||||||
|
|
||||||
$windowsIps = Get-NetIPAddress -AddressFamily IPv4 |
|
|
||||||
Where-Object { $_.IPAddress -notlike '127.*' -and $_.IPAddress -notlike '169.254*' -and $_.IPAddress -notlike '172.*' } |
|
|
||||||
Select-Object -ExpandProperty IPAddress
|
|
||||||
|
|
||||||
Write-Host ""
|
Write-Host ""
|
||||||
Write-Host "Completed." -ForegroundColor Green
|
Write-Host "Completed." -ForegroundColor Green
|
||||||
Write-Host "Now open this from another PC on the intranet:"
|
Write-Host "Now open this from another PC on the intranet:"
|
||||||
Write-Host "http://<Windows-IP>:$Port"
|
|
||||||
if ($windowsIps) {
|
if ($windowsIps) {
|
||||||
Write-Host "Windows IPv4 candidates: $($windowsIps -join ', ')"
|
foreach ($ip in $windowsIps) {
|
||||||
|
Write-Host "http://$ip`:$Port"
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
Write-Host "http://<Windows-IP>:$Port"
|
||||||
}
|
}
|
||||||
Write-Host ""
|
Write-Host ""
|
||||||
Write-Host "Tip: if WSL restarts and its IP changes, run this script again. If you omit -WslIp, the script will detect it automatically."
|
Write-Host "Tip: if WSL restarts and its IP changes, run this script again. If you omit -WslIp, the script will detect it automatically."
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ set -euo pipefail
|
|||||||
WSL_IP="$(hostname -I | awk '{print $1}')"
|
WSL_IP="$(hostname -I | awk '{print $1}')"
|
||||||
WSL_HOST_IP="$(awk '/nameserver/ {print $2; exit}' /etc/resolv.conf)"
|
WSL_HOST_IP="$(awk '/nameserver/ {print $2; exit}' /etc/resolv.conf)"
|
||||||
WINDOWS_IPS="$(
|
WINDOWS_IPS="$(
|
||||||
powershell.exe -NoProfile -Command "(Get-NetIPAddress -AddressFamily IPv4 | Where-Object { \$_.IPAddress -notlike '127.*' -and \$_.IPAddress -notlike '169.254*' } | Select-Object -ExpandProperty IPAddress) -join ','" \
|
powershell.exe -NoProfile -Command "(Get-NetIPAddress -AddressFamily IPv4 | Where-Object { \$_.IPAddress -notlike '127.*' -and \$_.IPAddress -notlike '169.254*' -and \$_.InterfaceAlias -notlike '*WSL*' } | Select-Object -ExpandProperty IPAddress) -join ','" \
|
||||||
2>/dev/null | tr -d '\r'
|
2>/dev/null | tr -d '\r'
|
||||||
)"
|
)"
|
||||||
|
|
||||||
@@ -13,7 +13,10 @@ WSL 앱 정보
|
|||||||
- WSL 내부 IP: ${WSL_IP}
|
- WSL 내부 IP: ${WSL_IP}
|
||||||
- Windows 호스트와 연결된 주소 추정값: ${WSL_HOST_IP}
|
- Windows 호스트와 연결된 주소 추정값: ${WSL_HOST_IP}
|
||||||
- Windows IPv4 후보: ${WINDOWS_IPS:-확인 실패}
|
- Windows IPv4 후보: ${WINDOWS_IPS:-확인 실패}
|
||||||
- 앱 포트: 8010
|
- 포트 배치:
|
||||||
|
- hm-biz-process: 8000
|
||||||
|
- my-intranet-app: 8010
|
||||||
|
- b17301: 8020
|
||||||
|
|
||||||
중요
|
중요
|
||||||
- WSL2에서는 다른 PC가 WSL 내부 IP(${WSL_IP})로 직접 접속하지 못하는 경우가 많습니다.
|
- WSL2에서는 다른 PC가 WSL 내부 IP(${WSL_IP})로 직접 접속하지 못하는 경우가 많습니다.
|
||||||
@@ -21,7 +24,7 @@ WSL 앱 정보
|
|||||||
- 따라서 Windows 관리자 PowerShell에서 portproxy + 방화벽 규칙을 설정해야 합니다.
|
- 따라서 Windows 관리자 PowerShell에서 portproxy + 방화벽 규칙을 설정해야 합니다.
|
||||||
|
|
||||||
다음 단계
|
다음 단계
|
||||||
1. WSL에서 서버 실행: ./scripts/run_server.sh
|
1. WSL에서 my-intranet-app 실행: ./scripts/run_server.sh 8010
|
||||||
2. Windows 관리자 PowerShell 또는 CMD에서 scripts/setup_windows_portproxy 실행
|
2. Windows 관리자 PowerShell 또는 CMD에서 scripts/setup_windows_all_portproxies 실행
|
||||||
3. 다른 PC 브라우저에서 http://Windows호스트IP:8010 접속
|
3. 다른 PC 브라우저에서 http://Windows호스트IP:8010 접속
|
||||||
EOF
|
EOF
|
||||||
|
|||||||
+110
-40
@@ -155,6 +155,9 @@
|
|||||||
.search-layout {
|
.search-layout {
|
||||||
display: grid;
|
display: grid;
|
||||||
gap: 16px;
|
gap: 16px;
|
||||||
|
overflow: visible;
|
||||||
|
position: relative;
|
||||||
|
z-index: 90;
|
||||||
}
|
}
|
||||||
|
|
||||||
.analysis-shell {
|
.analysis-shell {
|
||||||
@@ -168,7 +171,7 @@
|
|||||||
gap: 12px;
|
gap: 12px;
|
||||||
align-items: start;
|
align-items: start;
|
||||||
position: relative;
|
position: relative;
|
||||||
z-index: 5200;
|
z-index: 120;
|
||||||
}
|
}
|
||||||
|
|
||||||
.analysis-main {
|
.analysis-main {
|
||||||
@@ -181,16 +184,6 @@
|
|||||||
display: none;
|
display: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
.toolbar-field {
|
|
||||||
background: rgba(255, 255, 255, 0.95);
|
|
||||||
border: 1px solid var(--line);
|
|
||||||
border-radius: 16px;
|
|
||||||
padding: 14px;
|
|
||||||
position: relative;
|
|
||||||
overflow: visible;
|
|
||||||
z-index: 5300;
|
|
||||||
}
|
|
||||||
|
|
||||||
.toolbar-head {
|
.toolbar-head {
|
||||||
display: flex;
|
display: flex;
|
||||||
justify-content: space-between;
|
justify-content: space-between;
|
||||||
@@ -208,7 +201,7 @@
|
|||||||
flex: 1;
|
flex: 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
.toolbar-field h3 {
|
.analysis-toolbar h3 {
|
||||||
font-size: 16px;
|
font-size: 16px;
|
||||||
margin-bottom: 0;
|
margin-bottom: 0;
|
||||||
}
|
}
|
||||||
@@ -299,7 +292,7 @@
|
|||||||
position: relative;
|
position: relative;
|
||||||
display: grid;
|
display: grid;
|
||||||
gap: 8px;
|
gap: 8px;
|
||||||
z-index: 5400;
|
z-index: 180;
|
||||||
}
|
}
|
||||||
|
|
||||||
.project-search-dropdown {
|
.project-search-dropdown {
|
||||||
@@ -308,7 +301,7 @@
|
|||||||
top: calc(100% + 8px);
|
top: calc(100% + 8px);
|
||||||
left: 0;
|
left: 0;
|
||||||
right: 0;
|
right: 0;
|
||||||
z-index: 9999;
|
z-index: 4000;
|
||||||
border: 1px solid var(--line);
|
border: 1px solid var(--line);
|
||||||
border-radius: 14px;
|
border-radius: 14px;
|
||||||
background: rgba(255, 255, 255, 0.98);
|
background: rgba(255, 255, 255, 0.98);
|
||||||
@@ -917,7 +910,7 @@
|
|||||||
border-radius: 10px;
|
border-radius: 10px;
|
||||||
background: #fff;
|
background: #fff;
|
||||||
font: inherit;
|
font: inherit;
|
||||||
color: #111827;
|
color: var(--ink);
|
||||||
resize: vertical;
|
resize: vertical;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -2315,7 +2308,6 @@
|
|||||||
<div class="analysis-shell">
|
<div class="analysis-shell">
|
||||||
<div class="analysis-main">
|
<div class="analysis-main">
|
||||||
<section class="analysis-toolbar">
|
<section class="analysis-toolbar">
|
||||||
<div class="toolbar-field">
|
|
||||||
<div class="toolbar-head">
|
<div class="toolbar-head">
|
||||||
<div class="toolbar-title-group">
|
<div class="toolbar-title-group">
|
||||||
<h3>프로젝트 검색</h3>
|
<h3>프로젝트 검색</h3>
|
||||||
@@ -2378,7 +2370,6 @@
|
|||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
</section>
|
</section>
|
||||||
<div class="analysis-content is-hidden" id="projectAnalysisContent">
|
<div class="analysis-content is-hidden" id="projectAnalysisContent">
|
||||||
<section class="analysis-panel analysis-related-bar" id="projectRelatedBar"></section>
|
<section class="analysis-panel analysis-related-bar" id="projectRelatedBar"></section>
|
||||||
@@ -5674,6 +5665,9 @@
|
|||||||
if (group === "labor_joint") {
|
if (group === "labor_joint") {
|
||||||
return entryGroup === "labor_joint";
|
return entryGroup === "labor_joint";
|
||||||
}
|
}
|
||||||
|
if (group === "overhead") {
|
||||||
|
return entryGroup === "overhead" || entryGroup === "cost_plan";
|
||||||
|
}
|
||||||
return entryGroup === group;
|
return entryGroup === group;
|
||||||
})
|
})
|
||||||
: [];
|
: [];
|
||||||
@@ -5853,6 +5847,8 @@
|
|||||||
const overheadPlanned = getExecBudgetGroupEntries(item, "cost_plan", "계정별비용계획");
|
const overheadPlanned = getExecBudgetGroupEntries(item, "cost_plan", "계정별비용계획");
|
||||||
const laborActual = getActualGroupEntries(item, "labor", "실투입 인건비");
|
const laborActual = getActualGroupEntries(item, "labor", "실투입 인건비");
|
||||||
const laborJointActual = getActualGroupEntries(item, "labor_joint", "실투입 인건비(합사)");
|
const laborJointActual = getActualGroupEntries(item, "labor_joint", "실투입 인건비(합사)");
|
||||||
|
const outsourceInputActual = getActualGroupEntries(item, "outsource", "실투입 외주비");
|
||||||
|
const overheadInputActual = getActualGroupEntries(item, "overhead", "실투입 제경비");
|
||||||
const asActual = getActualGroupEntries(item, "as", "실투입 A/S비");
|
const asActual = getActualGroupEntries(item, "as", "실투입 A/S비");
|
||||||
const sgaInputActual = getActualGroupEntries(item, "sga", "실투입 판관비");
|
const sgaInputActual = getActualGroupEntries(item, "sga", "실투입 판관비");
|
||||||
const costEntries = aggregateCodes.flatMap((code) => getCostBreakdownEntries(code).map((entry) => ({
|
const costEntries = aggregateCodes.flatMap((code) => getCostBreakdownEntries(code).map((entry) => ({
|
||||||
@@ -5863,7 +5859,9 @@
|
|||||||
account_name: String(entry.account_name || "").trim(),
|
account_name: String(entry.account_name || "").trim(),
|
||||||
support_dept_code: code,
|
support_dept_code: code,
|
||||||
})));
|
})));
|
||||||
const outsourceActual = costEntries
|
const outsourceActual = [
|
||||||
|
...outsourceInputActual,
|
||||||
|
...costEntries
|
||||||
.filter(isDesignOutsourceEntry)
|
.filter(isDesignOutsourceEntry)
|
||||||
.map((entry) => ({
|
.map((entry) => ({
|
||||||
...entry,
|
...entry,
|
||||||
@@ -5875,8 +5873,11 @@
|
|||||||
project_code: item.support_dept_code,
|
project_code: item.support_dept_code,
|
||||||
project_name: item.support_dept_name,
|
project_name: item.support_dept_name,
|
||||||
},
|
},
|
||||||
}));
|
})),
|
||||||
const overheadActual = costEntries
|
];
|
||||||
|
const overheadActual = [
|
||||||
|
...overheadInputActual,
|
||||||
|
...costEntries
|
||||||
.filter((entry) => !isDesignOutsourceEntry(entry))
|
.filter((entry) => !isDesignOutsourceEntry(entry))
|
||||||
.map((entry) => ({
|
.map((entry) => ({
|
||||||
...entry,
|
...entry,
|
||||||
@@ -5888,7 +5889,8 @@
|
|||||||
project_code: item.support_dept_code,
|
project_code: item.support_dept_code,
|
||||||
project_name: item.support_dept_name,
|
project_name: item.support_dept_name,
|
||||||
},
|
},
|
||||||
}));
|
})),
|
||||||
|
];
|
||||||
return {
|
return {
|
||||||
collection: {
|
collection: {
|
||||||
planned: revenuePlanned,
|
planned: revenuePlanned,
|
||||||
@@ -5918,6 +5920,18 @@
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function getActualExecutionCostFromDetails(details) {
|
||||||
|
if (!details) return 0;
|
||||||
|
return (
|
||||||
|
sumEntryAmounts(details.labor?.actual)
|
||||||
|
+ sumEntryAmounts(details.labor?.actual_joint)
|
||||||
|
+ sumEntryAmounts(details.outsource?.actual)
|
||||||
|
+ sumEntryAmounts(details.overhead?.actual)
|
||||||
|
+ sumEntryAmounts(details.as?.actual)
|
||||||
|
+ sumEntryAmounts(details.sga?.actual)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
function renderAnalysisHero(item) {
|
function renderAnalysisHero(item) {
|
||||||
const editUrl = getProjectEditUrl(item.support_dept_code);
|
const editUrl = getProjectEditUrl(item.support_dept_code);
|
||||||
return `
|
return `
|
||||||
@@ -6462,16 +6476,8 @@
|
|||||||
|
|
||||||
function renderAnalysisMetrics(item) {
|
function renderAnalysisMetrics(item) {
|
||||||
const details = buildComparisonDetails(item);
|
const details = buildComparisonDetails(item);
|
||||||
const actualExecutionCost =
|
const actualExecutionCost = getActualExecutionCostFromDetails(details);
|
||||||
Number(item.total_expense || 0)
|
const operatingBalance = Number(item.collection_amount || 0) - actualExecutionCost;
|
||||||
|| (
|
|
||||||
sumEntryAmounts(details.labor.actual)
|
|
||||||
+ sumEntryAmounts(details.labor.actual_joint)
|
|
||||||
+ sumEntryAmounts(details.outsource.actual)
|
|
||||||
+ sumEntryAmounts(details.overhead.actual)
|
|
||||||
+ sumEntryAmounts(details.as.actual)
|
|
||||||
+ sumEntryAmounts(details.sga.actual)
|
|
||||||
);
|
|
||||||
return `
|
return `
|
||||||
<h4>핵심 비교 지표</h4>
|
<h4>핵심 비교 지표</h4>
|
||||||
<div class="analysis-kpis">
|
<div class="analysis-kpis">
|
||||||
@@ -6489,7 +6495,7 @@
|
|||||||
</div>
|
</div>
|
||||||
<div class="analysis-kpi">
|
<div class="analysis-kpi">
|
||||||
<span>영업수지</span>
|
<span>영업수지</span>
|
||||||
<strong>${formatDisplayAmount(item.operating_balance)}</strong>
|
<strong>${formatDisplayAmount(operatingBalance)}</strong>
|
||||||
</div>
|
</div>
|
||||||
<div class="analysis-kpi">
|
<div class="analysis-kpi">
|
||||||
<span>과업수행계획비용</span>
|
<span>과업수행계획비용</span>
|
||||||
@@ -6706,11 +6712,76 @@
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
const aggregatedProjectCostDatasetCache = new Map();
|
function getProjectStatusYearRange(item) {
|
||||||
const aggregatedAllProjectCostRows = aggregateProjectCostRows(projectCostRows).map((item) => ({
|
const years = [
|
||||||
|
Number(item?.latest_year || 0),
|
||||||
|
Number(String(item?.project_start_date || "").slice(0, 4) || 0),
|
||||||
|
Number(String(item?.project_end_date || "").slice(0, 4) || 0),
|
||||||
|
getProjectCodeFullYear(item?.support_dept_code),
|
||||||
|
].filter(Boolean);
|
||||||
|
if (!years.length) {
|
||||||
|
return { min_year: 0, max_year: 0 };
|
||||||
|
}
|
||||||
|
return { min_year: Math.min(...years), max_year: Math.max(...years) };
|
||||||
|
}
|
||||||
|
|
||||||
|
function projectStatusMatchesYear(item, yearFilterValue) {
|
||||||
|
if (!yearFilterValue) return true;
|
||||||
|
const targetYear = Number(yearFilterValue || 0);
|
||||||
|
if (!targetYear) return true;
|
||||||
|
const range = getProjectStatusYearRange(item);
|
||||||
|
if (range.min_year && range.max_year) {
|
||||||
|
return range.min_year <= targetYear && targetYear <= range.max_year;
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
function mergeProjectExplorerRows(costRows, statusRows, yearFilterValue = "") {
|
||||||
|
const merged = new Map();
|
||||||
|
aggregateProjectCostRows(costRows).forEach((item) => {
|
||||||
|
merged.set(item.support_dept_code, { ...item });
|
||||||
|
});
|
||||||
|
statusRows.forEach((statusItem) => {
|
||||||
|
const code = String(statusItem?.support_dept_code || "").trim();
|
||||||
|
if (!code || !projectStatusMatchesYear(statusItem, yearFilterValue)) return;
|
||||||
|
const yearRange = getProjectStatusYearRange(statusItem);
|
||||||
|
const current = merged.get(code) || {
|
||||||
|
support_dept_code: code,
|
||||||
|
support_dept_name: statusItem.support_dept_name || code,
|
||||||
|
revenue_amount: 0,
|
||||||
|
expense_amount: 0,
|
||||||
|
min_year: yearRange.min_year,
|
||||||
|
max_year: yearRange.max_year,
|
||||||
|
matched_years: [],
|
||||||
|
};
|
||||||
|
const statusRevenue = Number(statusItem.total_revenue || statusItem.collection_amount || 0);
|
||||||
|
const statusExpense = Number(statusItem.total_cost || 0) + Number(statusItem.total_sga || 0);
|
||||||
|
current.support_dept_name = statusItem.support_dept_name || current.support_dept_name || code;
|
||||||
|
current.revenue_amount = Number(current.revenue_amount || 0) || statusRevenue;
|
||||||
|
current.expense_amount = Number(current.expense_amount || 0) || statusExpense;
|
||||||
|
current.min_year = Math.min(
|
||||||
|
...[current.min_year, yearRange.min_year].map(Number).filter(Boolean),
|
||||||
|
) || current.min_year || yearRange.min_year || 0;
|
||||||
|
current.max_year = Math.max(
|
||||||
|
...[current.max_year, yearRange.max_year].map(Number).filter(Boolean),
|
||||||
|
) || current.max_year || yearRange.max_year || 0;
|
||||||
|
current._hasProjectStatus = true;
|
||||||
|
current._searchText = `${code} ${current.support_dept_name || ""}`.toLowerCase();
|
||||||
|
merged.set(code, current);
|
||||||
|
});
|
||||||
|
return [...merged.values()]
|
||||||
|
.map((item) => ({
|
||||||
...item,
|
...item,
|
||||||
_searchText: `${item.support_dept_code || ""} ${item.support_dept_name || ""}`.toLowerCase(),
|
_searchText: `${item.support_dept_code || ""} ${item.support_dept_name || ""}`.toLowerCase(),
|
||||||
}));
|
}))
|
||||||
|
.sort((a, b) => {
|
||||||
|
if ((b.max_year || 0) !== (a.max_year || 0)) return (b.max_year || 0) - (a.max_year || 0);
|
||||||
|
return String(a.support_dept_code).localeCompare(String(b.support_dept_code));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const aggregatedProjectCostDatasetCache = new Map();
|
||||||
|
const aggregatedAllProjectCostRows = mergeProjectExplorerRows(projectCostRows, projectStatusRows);
|
||||||
const aggregatedAllProjectCostMap = new Map(
|
const aggregatedAllProjectCostMap = new Map(
|
||||||
aggregatedAllProjectCostRows.map((item) => [item.support_dept_code, item]),
|
aggregatedAllProjectCostRows.map((item) => [item.support_dept_code, item]),
|
||||||
);
|
);
|
||||||
@@ -6722,12 +6793,11 @@
|
|||||||
}
|
}
|
||||||
const dataset = !yearFilterValue
|
const dataset = !yearFilterValue
|
||||||
? aggregatedAllProjectCostRows
|
? aggregatedAllProjectCostRows
|
||||||
: aggregateProjectCostRows(
|
: mergeProjectExplorerRows(
|
||||||
projectCostRows.filter((item) => String(item.year) === String(yearFilterValue)),
|
projectCostRows.filter((item) => String(item.year) === String(yearFilterValue)),
|
||||||
).map((item) => ({
|
projectStatusRows,
|
||||||
...item,
|
yearFilterValue,
|
||||||
_searchText: `${item.support_dept_code || ""} ${item.support_dept_name || ""}`.toLowerCase(),
|
);
|
||||||
}));
|
|
||||||
aggregatedProjectCostDatasetCache.set(cacheKey, dataset);
|
aggregatedProjectCostDatasetCache.set(cacheKey, dataset);
|
||||||
return dataset;
|
return dataset;
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user