Compare commits

...
27 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
b17301 a2affa2d45 Refine project modal and search interactions 2026-04-10 19:31:46 +09:00
b17301 60168eb1d2 Polish project detail modals and data flows 2026-04-10 19:11:15 +09:00
b17301 9aaae5b820 Stabilize project flows and reporting 2026-04-09 21:01:21 +09:00
YJKIM f2d86cd840 Delete README.md 2026-04-09 10:18:51 +09:00
b17301 ae658a332b Fix contract linkage and persist related project groups 2026-04-09 08:50:17 +09:00
b17301 8f6f6533dc Update dashboard and project info workflows 2026-04-08 18:20:46 +09:00
b17301 d72a8377fb Refine project status workflows and dashboards 2026-04-07 20:31:34 +09:00
160 changed files with 146082 additions and 1632 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/ .venv/
.env
__pycache__/ __pycache__/
*.pyc *.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/
+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"]
-205
View File
@@ -1,205 +0,0 @@
# 인트라넷 게시판 초보자 가이드
이 프로젝트는 `WSL2 Ubuntu + VS Code` 환경에서 실행하는 사내 게시판입니다.
중요한 점은, `WSL2`에서는 앱을 `0.0.0.0:8010`으로 띄워도 다른 PC가 곧바로 WSL 주소로 접속되지 않을 수 있다는 점입니다.
즉, 내부망 공유를 하려면 아래 2가지가 모두 필요합니다.
1. WSL 안에서 FastAPI 서버 실행
2. Windows가 `8010` 요청을 받아 WSL의 `8010`으로 전달하도록 설정
## 1. 이번에 정리된 내용
- 앱은 [main.py](/home/b17301/my-intranet-app/main.py) 에서 `0.0.0.0:8010`으로 실행됨
- 데이터는 `data.db`에 저장됨
- WSL2 내부망 공유용 보조 스크립트를 `scripts/` 폴더에 추가함
- Windows `portproxy`와 방화벽 규칙 설정용 PowerShell 스크립트를 추가함
## 2. 폴더 구조
```text
my-intranet-app/
├── main.py
├── requirements.txt
├── data.db
├── static/
├── templates/
│ └── index.html
└── scripts/
├── run_server.sh
├── show_access_info.sh
├── setup_windows_portproxy.ps1
└── remove_windows_portproxy.ps1
```
## 3. 왜 다른 PC에서 접속이 안 되었는가
당신의 현재 환경은 `WSL2`입니다.
WSL2는 Windows 안에 있는 별도 가상 네트워크를 사용합니다.
그래서 아래처럼 동작할 수 있습니다.
- 내 PC 브라우저에서 `http://127.0.0.1:8010` 접속: 됨
- 다른 PC에서 `http://WSL-IP:8010` 접속: 안 될 수 있음
이럴 때는 다른 PC가 `Windows 호스트 IP:8010` 으로 접속하고,
Windows가 그 요청을 WSL 앱으로 넘겨줘야 합니다.
## 4. 처음 한 번만 할 설정
프로젝트 폴더로 이동:
```bash
cd /home/b17301/my-intranet-app
```
가상환경 생성:
```bash
python3 -m venv .venv
```
가상환경 활성화:
```bash
source .venv/bin/activate
```
라이브러리 설치:
```bash
pip install -r requirements.txt
```
## 5. WSL에서 서버 실행
아래 명령으로 실행:
```bash
./scripts/run_server.sh
```
또는 기존처럼:
```bash
source .venv/bin/activate
python main.py
```
내 PC 테스트 주소:
```text
http://127.0.0.1:8010
```
중요:
- 이제 서버는 `reload=False`로 실행되므로, 예전처럼 자동 재시작 로그가 반복해서 나오지 않습니다.
- 코드를 수정한 뒤 반영하려면 서버를 `Ctrl + C`로 끄고 다시 `./scripts/run_server.sh`를 실행하면 됩니다.
## 6. 현재 WSL 쪽 주소 확인
아래 명령 실행:
```bash
./scripts/show_access_info.sh
```
현재 값은 실행 시점마다 달라질 수 있습니다.
- WSL 내부 IP: `./scripts/show_access_info.sh` 실행 결과를 확인
- Windows와 연결된 주소 추정값: `./scripts/show_access_info.sh` 실행 결과를 확인
하지만 다른 PC가 접속할 때는 보통 `WSL 내부 IP`가 아니라 `Windows 호스트 IP`를 사용해야 합니다.
## 7. 가장 중요한 설정: Windows 포트 전달
다음 작업은 `Windows PowerShell을 관리자 권한`으로 열어서 실행해야 합니다.
가장 쉬운 방법은 `PowerShell`에서 아래 명령을 실행하는 것입니다.
```powershell
& "\\wsl$\Ubuntu\home\b17301\my-intranet-app\scripts\setup_windows_portproxy.ps1" -Port 8010
```
만약 `CMD` 창만 사용하고 있다면 아래 명령을 그대로 실행하면 됩니다.
```cmd
"\\wsl$\Ubuntu\home\b17301\my-intranet-app\scripts\setup_windows_portproxy.cmd"
```
이 스크립트가 하는 일:
- Windows의 `0.0.0.0:8010` 수신
- 들어온 요청을 `현재 WSL IP:8010` 으로 전달
- Windows 방화벽에서 TCP `8010` 허용
## 8. 다른 PC에서 접속할 주소
다른 PC 브라우저에서는 아래처럼 접속합니다.
```text
http://Windows호스트IP:8010
```
예를 들어 Windows PC의 실제 사내 IP가 `192.168.0.25` 라면:
```text
http://192.168.0.25:8010
```
여기서 중요한 점:
- WSL 주소를 다른 PC에 알려주면 안 될 수 있음
- 다른 PC에는 반드시 `Windows IP`를 알려주는 쪽이 안전함
## 9. WSL IP가 바뀌면 어떻게 하나
WSL을 재시작하면 내부 IP가 바뀔 수 있습니다.
그럴 때는 아래 순서로 다시 맞추면 됩니다.
1. WSL에서 `./scripts/show_access_info.sh` 실행
2. 새 WSL IP 확인
3. 관리자 PowerShell에서 다시 실행
```powershell
.\setup_windows_portproxy.ps1 -Port 8010
```
## 10. 접속 확인 순서
1. WSL에서 `./scripts/run_server.sh` 실행
2. 내 PC 브라우저에서 `http://127.0.0.1:8010` 확인
3. Windows 관리자 PowerShell에서 `setup_windows_portproxy.ps1` 실행
4. 다른 PC에서 `http://Windows호스트IP:8010` 접속
## 11. 안 되면 확인할 것
1. 서버가 실제로 실행 중인지 확인
```bash
lsof -i :8010
```
2. 다른 프로그램이 `8010`을 이미 사용 중인지 확인
3. Windows 방화벽 규칙이 생겼는지 확인
4. 회사 네트워크에서 사설 포트 접근이 막히지 않았는지 확인
5. WSL IP가 바뀌었는지 확인
## 12. 제거가 필요할 때
관리자 PowerShell에서:
```powershell
& "\\wsl$\Ubuntu\home\b17301\my-intranet-app\scripts\remove_windows_portproxy.ps1" -Port 8010
```
또는 `CMD`에서는:
```cmd
"\\wsl$\Ubuntu\home\b17301\my-intranet-app\scripts\remove_windows_portproxy.cmd" 8010
```
## 13. 초보자용 한 줄 요약
이 프로젝트는 이제 코드 문제는 해결되어 있고, 내부망 공유의 핵심은 `Windows IP:8010 -> WSL 8010` 연결을 Windows에서 열어주는 것입니다.
@@ -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