Compare commits

...
20 Commits
Author SHA1 Message Date
b17301 da073ad6ef Update wehago matching logic and exclude reports 2026-07-03 09:07:04 +09:00
b17301 a09190ecd3 Fix cost analysis table scroll behavior 2026-06-25 09:51:22 +09:00
b17301 886192ae58 Preserve latest comparison functionality 2026-06-08 14:25:47 +09:00
b17301 b93289bfa8 Update intranet tools and voucher comparison 2026-06-04 09:00:51 +09:00
b17301 2ab74bac88 Refactor data loading and query projections 2026-05-18 21:01:46 +09:00
b17301 49a7a45070 Add data refresh assets and local access scripts 2026-05-14 20:44:17 +09:00
b17301 542dd7d536 Improve hanmac browser and wehago compare performance 2026-05-14 20:38:22 +09:00
b17301 7ceee2f897 Update WEHAGO comparison data and tools 2026-05-09 02:49:34 +09:00
b17301 b0b4647bb4 Update project pages and add DB browser 2026-05-08 23:51:20 +09:00
b17301 99137e5f97 Fix wehago compare loading and matching workflow 2026-05-08 23:38:16 +09:00
b17301 37f422b493 Adjust related project pills in project info 2026-04-30 09:35:47 +09:00
b17301 9466f9a6ab Refine project navigation and balance calculation behavior 2026-04-29 20:45:55 +09:00
b17301 7afd2680d3 Fix project comparison consistency and labor input handling 2026-04-29 20:30:33 +09:00
b17301 3bf48c42db Refine process cost navigation and unify section layouts 2026-04-28 18:11:14 +09:00
b17301 1d6470c770 Refine process cost metrics and project search dropdown behavior 2026-04-28 12:22:20 +09:00
b17301 21901fe8fb Add process cost page with Hanmac/WEHAGO views and tab 2026-04-27 18:10:01 +09:00
b17301 b2a3802dff Keep view mode across tabs and speed up recommendation pipeline 2026-04-27 17:29:24 +09:00
b17301 354d149245 Refine voucher compare UI/layout and fix suggestion behavior 2026-04-27 16:22:23 +09:00
b17301 080f7cf112 chore: stop tracking sqlite wal artifacts 2026-04-24 09:15:07 +09:00
b17301 fb11139da4 chore: snapshot current intranet work 2026-04-24 09:09:39 +09:00
152 changed files with 128058 additions and 850 deletions
+15
View File
@@ -0,0 +1,15 @@
.git
.env
.venv
__pycache__
*.pyc
data.db
data.db-wal
data.db-shm
backups
runtime_cache
static/exports
scripts/.chrome-wehago-profile
scripts/data_download
reports
dump.sql
+19
View File
@@ -0,0 +1,19 @@
INTRANET_PORT=8010
# Windows Docker Desktop에서 compose를 실행할 때는 아래 UNC 경로를 사용합니다.
INTRANET_RUNTIME_ROOT=\\wsl.localhost\Ubuntu\home\b17301\intranet-runtime
WEHAGO_HOST_SOURCE_ROOT=\\wsl.localhost\Ubuntu\home\b17301\WEHAGO_DB
# WSL 내부 docker CLI를 정상 사용할 수 있는 환경이면 아래 Linux 경로도 사용할 수 있습니다.
# INTRANET_RUNTIME_ROOT=/home/b17301/intranet-runtime
# WEHAGO_HOST_SOURCE_ROOT=/home/b17301/WEHAGO_DB
APP_UID=1000
APP_GID=1000
INTRANET_DB_PATH=/home/b17301/intranet-runtime/db/data.db
INTRANET_BACKUP_DIR=/home/b17301/intranet-runtime/backups
INTRANET_CACHE_ROOT=/home/b17301/intranet-runtime/cache
INTRANET_COMPARE_EXPORT_DIR=/home/b17301/intranet-runtime/exports/wehago_compare
INTRANET_HANMAC_EXPORT_DIR=/home/b17301/intranet-runtime/exports/hanmac
WEHAGO_SOURCE_ROOT=/home/b17301/WEHAGO_DB
INTRANET_REQUIRE_SAFE_SQLITE=0
INTRANET_WAL_WARN_BYTES=268435456
INTRANET_WAL_BLOCK_HEAVY_BYTES=536870912
HM_APP_MAINTENANCE_ENABLED=0
+23
View File
@@ -1,3 +1,26 @@
.venv/
.env
__pycache__/
*.pyc
data.db-shm
data.db-wal
backups/
tmp_*.py
static/exports/
scripts/data_download/
scripts/.chrome-wehago-profile/
runtime_cache/
intranet-runtime/
.dev-state/
reports/
static/reports/
runtime_diagnostics/
.local-tools/
*.pdf
*.xls
*.xlsx
WORK_SUMMARY_*.md
DB_HEALTH_CHECK_*.md
backup_snapshots/**/*.md
reports/hanmac_related_party_loans_*.xlsx
reports/wehago_account_fixes/
-169
View File
@@ -1,169 +0,0 @@
# 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.
+54
View File
@@ -0,0 +1,54 @@
FROM python:3.12-slim-bookworm
ARG SQLITE_ARCHIVE=sqlite-autoconf-3530100.tar.gz
ARG SQLITE_URL=https://www.sqlite.org/2026/sqlite-autoconf-3530100.tar.gz
ARG SQLITE_SHA3_256=36ca143645cf76997d07b66e9244c636b8ccdec64a1d50558259c4e415e6558b
ARG APP_UID=1000
ARG APP_GID=1000
ENV PYTHONDONTWRITEBYTECODE=1 \
PYTHONUNBUFFERED=1 \
LD_LIBRARY_PATH=/usr/local/lib \
INTRANET_DB_PATH=/runtime/db/data.db \
INTRANET_BACKUP_DIR=/runtime/backups \
INTRANET_CACHE_ROOT=/runtime/cache \
INTRANET_COMPARE_EXPORT_DIR=/runtime/exports/wehago_compare \
INTRANET_HANMAC_EXPORT_DIR=/runtime/exports/hanmac \
WEHAGO_SOURCE_ROOT=/source/wehago \
INTRANET_REQUIRE_SAFE_SQLITE=1
RUN apt-get update \
&& apt-get install -y --no-install-recommends build-essential ca-certificates curl \
&& curl -fsSL "${SQLITE_URL}" -o "/tmp/${SQLITE_ARCHIVE}" \
&& python -c "import hashlib, pathlib; p=pathlib.Path('/tmp/${SQLITE_ARCHIVE}'); actual=hashlib.sha3_256(p.read_bytes()).hexdigest(); expected='${SQLITE_SHA3_256}'; assert actual == expected, (actual, expected)" \
&& mkdir -p /tmp/sqlite-src \
&& tar -xzf "/tmp/${SQLITE_ARCHIVE}" -C /tmp/sqlite-src --strip-components=1 \
&& cd /tmp/sqlite-src \
&& ./configure --prefix=/usr/local --enable-shared --disable-static \
&& make -j2 \
&& make install \
&& ldconfig \
&& python -c "import sqlite3; assert sqlite3.sqlite_version_info >= (3, 51, 3), sqlite3.sqlite_version" \
&& rm -rf /tmp/sqlite-src "/tmp/${SQLITE_ARCHIVE}" \
&& apt-get purge -y --auto-remove build-essential curl \
&& rm -rf /var/lib/apt/lists/*
WORKDIR /app
COPY requirements.txt /app/requirements.txt
RUN pip install --no-cache-dir -r /app/requirements.txt
COPY . /app
RUN mkdir -p /runtime/db /runtime/backups /runtime/cache /runtime/exports/wehago_compare /runtime/exports/hanmac \
&& groupadd --gid "${APP_GID}" intranet \
&& useradd --create-home --uid "${APP_UID}" --gid "${APP_GID}" intranet \
&& chown -R intranet:intranet /app /runtime
USER intranet
EXPOSE 8010
HEALTHCHECK --interval=30s --timeout=5s --start-period=20s --retries=3 \
CMD python -c "import urllib.request; urllib.request.urlopen('http://127.0.0.1:8010/health', timeout=3).read()"
CMD ["python", "main.py"]
-23
View File
@@ -1,23 +0,0 @@
# 작업 요약
작업일: 2026-04-08
## 핵심 변경
- 프로젝트 검색 성능 저하 구간을 줄이기 위해 검색 목록 렌더 흐름을 정리하고 표시 개수를 제한했습니다.
- 프로젝트 정보 페이지의 상세 레이아웃을 재구성해 상단 정보 카드의 중첩 박스를 제거하고 주요 지표 배치를 정리했습니다.
- 계획 대비 실제 비교에서 인건비, 외주비, 제경비, A/S비, 판관비 세부 로직과 실제 집행 합산 기준을 여러 차례 보정했습니다.
- 실투입 관리, 실행예산계획, 과업수행계획 입력 UI와 저장 구조를 정리했습니다.
- 대시보드 상단을 재구성해 사업현황 요약과 수금/지출 구성 그래프를 다시 배치했습니다.
- 프로젝트 페이지 상태 저장은 버튼 클릭 시 DB에 저장되도록 연결했습니다.
## 주요 파일
- `main.py`
- `templates/base.html`
- `templates/dashboard.html`
- `templates/projects.html`
## 참고
- 템플릿 백업은 `template_backups/20260408_ko/`에 생성했습니다.
-50
View File
@@ -1,50 +0,0 @@
# 작업 요약 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` 같은 런타임 임시 파일은 커밋 대상에서 제외함
@@ -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
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
+6
View File
@@ -0,0 +1,6 @@
services:
intranet-app:
environment:
INTRANET_AUTO_RELOAD: "1"
volumes:
- ./:/app:ro
+25
View File
@@ -0,0 +1,33 @@
services:
intranet-app:
build:
context: .
dockerfile: Dockerfile
args:
APP_UID: "${APP_UID:-1000}"
APP_GID: "${APP_GID:-1000}"
image: my-intranet-app:sqlite-3.53.1
container_name: my-intranet-app
restart: unless-stopped
environment:
INTRANET_PORT: "8010"
INTRANET_AUTO_RELOAD: "0"
INTRANET_REQUIRE_SAFE_SQLITE: "1"
INTRANET_WAL_WARN_BYTES: "268435456"
INTRANET_WAL_BLOCK_HEAVY_BYTES: "536870912"
HM_APP_MAINTENANCE_ENABLED: "0"
INTRANET_DB_PATH: /runtime/db/data.db
INTRANET_BACKUP_DIR: /runtime/backups
INTRANET_CACHE_ROOT: /runtime/cache
INTRANET_COMPARE_EXPORT_DIR: /runtime/exports/wehago_compare
INTRANET_HANMAC_EXPORT_DIR: /runtime/exports/hanmac
HMBIZ_PROCESS_DB_PATH: /runtime/db/hmbiz-process-flow.db
WEHAGO_SOURCE_ROOT: /source/wehago