Compare commits
2
Commits
27a29e8e4c
...
8f6f6533dc
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8f6f6533dc | ||
|
|
d72a8377fb |
@@ -0,0 +1,23 @@
|
||||
# 작업 요약
|
||||
|
||||
작업일: 2026-04-08
|
||||
|
||||
## 핵심 변경
|
||||
|
||||
- 프로젝트 검색 성능 저하 구간을 줄이기 위해 검색 목록 렌더 흐름을 정리하고 표시 개수를 제한했습니다.
|
||||
- 프로젝트 정보 페이지의 상세 레이아웃을 재구성해 상단 정보 카드의 중첩 박스를 제거하고 주요 지표 배치를 정리했습니다.
|
||||
- 계획 대비 실제 비교에서 인건비, 외주비, 제경비, A/S비, 판관비 세부 로직과 실제 집행 합산 기준을 여러 차례 보정했습니다.
|
||||
- 실투입 관리, 실행예산계획, 과업수행계획 입력 UI와 저장 구조를 정리했습니다.
|
||||
- 대시보드 상단을 재구성해 사업현황 요약과 수금/지출 구성 그래프를 다시 배치했습니다.
|
||||
- 프로젝트 페이지 상태 저장은 버튼 클릭 시 DB에 저장되도록 연결했습니다.
|
||||
|
||||
## 주요 파일
|
||||
|
||||
- `main.py`
|
||||
- `templates/base.html`
|
||||
- `templates/dashboard.html`
|
||||
- `templates/projects.html`
|
||||
|
||||
## 참고
|
||||
|
||||
- 템플릿 백업은 `template_backups/20260408_ko/`에 생성했습니다.
|
||||
@@ -1,3 +1,4 @@
|
||||
import os
|
||||
import logging
|
||||
import json
|
||||
import re
|
||||
@@ -8,7 +9,8 @@ from urllib.parse import parse_qs, quote_plus
|
||||
|
||||
import uvicorn
|
||||
from fastapi import FastAPI, File, Request, UploadFile
|
||||
from fastapi.responses import HTMLResponse, RedirectResponse
|
||||
from fastapi.encoders import jsonable_encoder
|
||||
from fastapi.responses import HTMLResponse, JSONResponse, RedirectResponse
|
||||
from fastapi.staticfiles import StaticFiles
|
||||
from fastapi.templating import Jinja2Templates
|
||||
from openpyxl import load_workbook
|
||||
@@ -192,6 +194,7 @@ def init_db() -> None:
|
||||
task_plan_joint_operating_cost REAL DEFAULT 0,
|
||||
task_plan_entries_json TEXT DEFAULT '[]',
|
||||
exec_budget_labor_by_grade REAL DEFAULT 0,
|
||||
exec_labor_rates_json TEXT DEFAULT '{}',
|
||||
exec_budget_outsource REAL DEFAULT 0,
|
||||
exec_budget_cost_plan REAL DEFAULT 0,
|
||||
exec_budget_entries_json TEXT DEFAULT '[]',
|
||||
@@ -212,6 +215,20 @@ def init_db() -> None:
|
||||
"""
|
||||
)
|
||||
)
|
||||
conn.execute(
|
||||
text(
|
||||
"""
|
||||
CREATE TABLE IF NOT EXISTS project_page_state (
|
||||
page_key TEXT PRIMARY KEY,
|
||||
selected_code TEXT DEFAULT '',
|
||||
selected_year TEXT DEFAULT '',
|
||||
analysis_open INTEGER DEFAULT 0,
|
||||
related_project_selections_json TEXT DEFAULT '{}',
|
||||
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
||||
)
|
||||
"""
|
||||
)
|
||||
)
|
||||
existing_columns = {
|
||||
row[1]
|
||||
for row in conn.execute(text("PRAGMA table_info(project_status)")).fetchall()
|
||||
@@ -227,6 +244,7 @@ def init_db() -> None:
|
||||
"task_plan_joint_operating_cost": "REAL DEFAULT 0",
|
||||
"task_plan_entries_json": "TEXT DEFAULT '[]'",
|
||||
"exec_budget_labor_by_grade": "REAL DEFAULT 0",
|
||||
"exec_labor_rates_json": "TEXT DEFAULT '{}'",
|
||||
"exec_budget_outsource": "REAL DEFAULT 0",
|
||||
"exec_budget_cost_plan": "REAL DEFAULT 0",
|
||||
"exec_budget_entries_json": "TEXT DEFAULT '[]'",
|
||||
@@ -243,6 +261,19 @@ def init_db() -> None:
|
||||
for column_name, column_type in required_columns.items():
|
||||
if column_name not in existing_columns:
|
||||
conn.execute(text(f"ALTER TABLE project_status ADD COLUMN {column_name} {column_type}"))
|
||||
page_state_columns = {
|
||||
row[1]
|
||||
for row in conn.execute(text("PRAGMA table_info(project_page_state)")).fetchall()
|
||||
}
|
||||
required_page_state_columns = {
|
||||
"selected_code": "TEXT DEFAULT ''",
|
||||
"selected_year": "TEXT DEFAULT ''",
|
||||
"analysis_open": "INTEGER DEFAULT 0",
|
||||
"related_project_selections_json": "TEXT DEFAULT '{}'",
|
||||
}
|
||||
for column_name, column_type in required_page_state_columns.items():
|
||||
if column_name not in page_state_columns:
|
||||
conn.execute(text(f"ALTER TABLE project_page_state ADD COLUMN {column_name} {column_type}"))
|
||||
|
||||
|
||||
def count_transactions() -> int:
|
||||
@@ -368,6 +399,64 @@ def get_support_department_options() -> list[dict[str, str]]:
|
||||
]
|
||||
|
||||
|
||||
def get_cost_department_options() -> list[dict[str, str]]:
|
||||
with engine.begin() as conn:
|
||||
rows = conn.execute(
|
||||
text(
|
||||
"""
|
||||
SELECT DISTINCT cost_dept_name
|
||||
FROM transactions
|
||||
WHERE COALESCE(cost_dept_name, '') <> ''
|
||||
ORDER BY cost_dept_name
|
||||
"""
|
||||
)
|
||||
).mappings().all()
|
||||
return [
|
||||
{"cost_dept_name": normalize_text(row["cost_dept_name"])}
|
||||
for row in rows
|
||||
if normalize_text(row["cost_dept_name"])
|
||||
]
|
||||
|
||||
|
||||
def get_cost_account_options() -> list[dict[str, str]]:
|
||||
with engine.begin() as conn:
|
||||
rows = conn.execute(
|
||||
text(
|
||||
"""
|
||||
SELECT DISTINCT account_code, account_name
|
||||
FROM transactions
|
||||
WHERE accounting_category = '원가'
|
||||
AND COALESCE(account_code, '') <> ''
|
||||
AND COALESCE(account_name, '') <> ''
|
||||
ORDER BY account_code, account_name
|
||||
"""
|
||||
)
|
||||
).mappings().all()
|
||||
deduped: dict[tuple[str, str], dict[str, str]] = {}
|
||||
for row in rows:
|
||||
account_code = normalize_text(row["account_code"])
|
||||
account_name = normalize_text(row["account_name"])
|
||||
if not account_code or not account_name:
|
||||
continue
|
||||
normalized_code, normalized_name, _ = normalize_account_display(account_code, account_name)
|
||||
key = (normalized_code, normalized_name)
|
||||
deduped[key] = {
|
||||
"account_code": normalized_code,
|
||||
"account_name": normalized_name,
|
||||
}
|
||||
return sorted(deduped.values(), key=lambda item: (item["account_code"], item["account_name"]))
|
||||
|
||||
|
||||
def normalize_account_display(account_code: Any, account_name: Any) -> tuple[str, str, str]:
|
||||
normalized_code = normalize_text(account_code)[:6]
|
||||
normalized_name = re.sub(r"\s*\(.*$", "", normalize_text(account_name)).strip()
|
||||
if normalized_code and normalized_name:
|
||||
label = f"{normalized_code} · {normalized_name}"
|
||||
else:
|
||||
label = normalized_name or normalized_code or "미분류"
|
||||
return normalized_code, normalized_name, label
|
||||
|
||||
|
||||
def get_data_version() -> str:
|
||||
with engine.begin() as conn:
|
||||
transaction_updated = conn.execute(text("SELECT MAX(updated_at) FROM transactions")).scalar()
|
||||
@@ -753,16 +842,21 @@ def get_project_status_rows() -> list[dict[str, Any]]:
|
||||
b.support_dept_name,
|
||||
b.row_count,
|
||||
COALESCE(ps.progress_rate, 0) AS progress_rate,
|
||||
COALESCE(ps.contract_amount, 0) AS contract_amount,
|
||||
COALESCE(ps.collection_amount, 0) AS collection_amount,
|
||||
COALESCE(ps.collection_entries_json, '[]') AS collection_entries_json,
|
||||
COALESCE(ps.change_round, '') AS change_round,
|
||||
COALESCE(ps.item_investment, 0) AS item_investment,
|
||||
COALESCE(ps.task_plan_department_budget, 0) AS task_plan_department_budget,
|
||||
COALESCE(ps.task_plan_outsource_budget, 0) AS task_plan_outsource_budget,
|
||||
COALESCE(ps.task_plan_outsource_detail, '') AS task_plan_outsource_detail,
|
||||
COALESCE(ps.task_plan_joint_operating_cost, 0) AS task_plan_joint_operating_cost,
|
||||
COALESCE(ps.task_plan_entries_json, '[]') AS task_plan_entries_json,
|
||||
COALESCE(ps.exec_budget_labor_by_grade, 0) AS exec_budget_labor_by_grade,
|
||||
COALESCE(ps.exec_budget_outsource, 0) AS exec_budget_outsource,
|
||||
COALESCE(ps.exec_budget_cost_plan, 0) AS exec_budget_cost_plan,
|
||||
COALESCE(ps.exec_budget_entries_json, '[]') AS exec_budget_entries_json,
|
||||
COALESCE(ps.actual_input_entries_json, '[]') AS actual_input_entries_json,
|
||||
COALESCE(ps.expected_as_cost, 0) AS expected_as_cost,
|
||||
COALESCE(ps.expected_sga_budget, 0) AS expected_sga_budget,
|
||||
COALESCE(ps.project_start_date, '') AS project_start_date,
|
||||
@@ -807,7 +901,15 @@ def get_project_status_rows() -> list[dict[str, Any]]:
|
||||
"""
|
||||
)
|
||||
).mappings().all()
|
||||
return [dict(row) for row in rows]
|
||||
result = []
|
||||
for row in rows:
|
||||
item = dict(row)
|
||||
item["collection_entries"] = decode_json_rows(item.pop("collection_entries_json", "[]"))
|
||||
item["task_plan_entries"] = decode_json_rows(item.pop("task_plan_entries_json", "[]"))
|
||||
item["exec_budget_entries"] = decode_json_rows(item.pop("exec_budget_entries_json", "[]"))
|
||||
item["actual_input_entries"] = decode_json_rows(item.pop("actual_input_entries_json", "[]"))
|
||||
result.append(item)
|
||||
return result
|
||||
|
||||
|
||||
def get_project_status_for_edit(support_dept_code: str | None) -> dict[str, Any]:
|
||||
@@ -827,6 +929,7 @@ def get_project_status_for_edit(support_dept_code: str | None) -> dict[str, Any]
|
||||
"task_plan_joint_operating_cost": "",
|
||||
"task_plan_entries": [],
|
||||
"exec_budget_labor_by_grade": "",
|
||||
"exec_labor_rates": {},
|
||||
"exec_budget_outsource": "",
|
||||
"exec_budget_cost_plan": "",
|
||||
"exec_budget_entries": [],
|
||||
@@ -861,6 +964,7 @@ def get_project_status_for_edit(support_dept_code: str | None) -> dict[str, Any]
|
||||
COALESCE(ps.task_plan_joint_operating_cost, '') AS task_plan_joint_operating_cost,
|
||||
COALESCE(ps.task_plan_entries_json, '[]') AS task_plan_entries_json,
|
||||
COALESCE(ps.exec_budget_labor_by_grade, '') AS exec_budget_labor_by_grade,
|
||||
COALESCE(ps.exec_labor_rates_json, '{}') AS exec_labor_rates_json,
|
||||
COALESCE(ps.exec_budget_outsource, '') AS exec_budget_outsource,
|
||||
COALESCE(ps.exec_budget_cost_plan, '') AS exec_budget_cost_plan,
|
||||
COALESCE(ps.exec_budget_entries_json, '[]') AS exec_budget_entries_json,
|
||||
@@ -904,6 +1008,7 @@ def get_project_status_for_edit(support_dept_code: str | None) -> dict[str, Any]
|
||||
"task_plan_joint_operating_cost": "",
|
||||
"task_plan_entries": [],
|
||||
"exec_budget_labor_by_grade": "",
|
||||
"exec_labor_rates": {},
|
||||
"exec_budget_outsource": "",
|
||||
"exec_budget_cost_plan": "",
|
||||
"exec_budget_entries": [],
|
||||
@@ -924,6 +1029,10 @@ def get_project_status_for_edit(support_dept_code: str | None) -> dict[str, Any]
|
||||
result["task_plan_entries"] = decode_json_rows(result.pop("task_plan_entries_json", "[]"))
|
||||
result["exec_budget_entries"] = decode_json_rows(result.pop("exec_budget_entries_json", "[]"))
|
||||
result["actual_input_entries"] = decode_json_rows(result.pop("actual_input_entries_json", "[]"))
|
||||
try:
|
||||
result["exec_labor_rates"] = json.loads(normalize_text(result.pop("exec_labor_rates_json", "{}")) or "{}")
|
||||
except json.JSONDecodeError:
|
||||
result["exec_labor_rates"] = {}
|
||||
if not result["collection_entries"] and normalize_amount(result.get("collection_amount")):
|
||||
result["collection_entries"] = [
|
||||
{
|
||||
@@ -938,20 +1047,62 @@ def get_project_status_for_edit(support_dept_code: str | None) -> dict[str, Any]
|
||||
if not result["task_plan_entries"]:
|
||||
fallback_task_rows = []
|
||||
if normalize_amount(result.get("task_plan_department_budget")):
|
||||
fallback_task_rows.append({"group": "department", "label": "기존 부서별 배분액", "amount": result.get("task_plan_department_budget", "")})
|
||||
fallback_task_rows.append(
|
||||
{
|
||||
"group": "department",
|
||||
"dept_name": "기존 부서별 배분",
|
||||
"work_name": "",
|
||||
"amount": result.get("task_plan_department_budget", ""),
|
||||
}
|
||||
)
|
||||
if normalize_amount(result.get("task_plan_outsource_budget")):
|
||||
fallback_task_rows.append({"group": "outsource", "label": "기존 외주비", "amount": result.get("task_plan_outsource_budget", ""), "note": result.get("task_plan_outsource_detail", "")})
|
||||
fallback_task_rows.append(
|
||||
{
|
||||
"group": "outsource",
|
||||
"dept_name": "기존 외주비",
|
||||
"work_name": result.get("task_plan_outsource_detail", ""),
|
||||
"amount": result.get("task_plan_outsource_budget", ""),
|
||||
}
|
||||
)
|
||||
if normalize_amount(result.get("task_plan_joint_operating_cost")):
|
||||
fallback_task_rows.append({"group": "joint", "label": "기존 합사운영비", "amount": result.get("task_plan_joint_operating_cost", "")})
|
||||
fallback_task_rows.append(
|
||||
{
|
||||
"group": "joint",
|
||||
"dept_name": "기존 합사운영비",
|
||||
"work_name": "",
|
||||
"amount": result.get("task_plan_joint_operating_cost", ""),
|
||||
}
|
||||
)
|
||||
result["task_plan_entries"] = fallback_task_rows
|
||||
if not result["exec_budget_entries"]:
|
||||
fallback_exec_rows = []
|
||||
if normalize_amount(result.get("exec_budget_labor_by_grade")):
|
||||
fallback_exec_rows.append({"group": "labor", "label": "기존 직급별 인건비", "amount": result.get("exec_budget_labor_by_grade", "")})
|
||||
fallback_exec_rows.append(
|
||||
{
|
||||
"group": "labor",
|
||||
"grade": "기존 인건비",
|
||||
"hours": "",
|
||||
"amount": result.get("exec_budget_labor_by_grade", ""),
|
||||
}
|
||||
)
|
||||
if normalize_amount(result.get("exec_budget_outsource")):
|
||||
fallback_exec_rows.append({"group": "outsource", "label": "기존 외주비", "amount": result.get("exec_budget_outsource", "")})
|
||||
fallback_exec_rows.append(
|
||||
{
|
||||
"group": "outsource",
|
||||
"dept_name": "기존 외주비",
|
||||
"work_name": "",
|
||||
"amount": result.get("exec_budget_outsource", ""),
|
||||
}
|
||||
)
|
||||
if normalize_amount(result.get("exec_budget_cost_plan")):
|
||||
fallback_exec_rows.append({"group": "cost_plan", "label": "기존 비용계획", "amount": result.get("exec_budget_cost_plan", "")})
|
||||
fallback_exec_rows.append(
|
||||
{
|
||||
"group": "cost_plan",
|
||||
"account_code": "기존",
|
||||
"account_name": "비용계획",
|
||||
"amount": result.get("exec_budget_cost_plan", ""),
|
||||
}
|
||||
)
|
||||
result["exec_budget_entries"] = fallback_exec_rows
|
||||
if not result["actual_input_entries"] and normalize_amount(result.get("item_investment")):
|
||||
result["actual_input_entries"] = [
|
||||
@@ -964,6 +1115,102 @@ def get_project_status_for_edit(support_dept_code: str | None) -> dict[str, Any]
|
||||
return result
|
||||
|
||||
|
||||
def get_project_page_state() -> dict[str, Any]:
|
||||
with engine.begin() as conn:
|
||||
row = conn.execute(
|
||||
text(
|
||||
"""
|
||||
SELECT COALESCE(selected_code, '') AS selected_code,
|
||||
COALESCE(selected_year, '') AS selected_year,
|
||||
COALESCE(analysis_open, 0) AS analysis_open,
|
||||
COALESCE(related_project_selections_json, '{}') AS related_project_selections_json
|
||||
FROM project_page_state
|
||||
WHERE page_key = 'projects'
|
||||
"""
|
||||
)
|
||||
).mappings().first()
|
||||
if not row:
|
||||
return {
|
||||
"selected_code": "",
|
||||
"selected_year": "",
|
||||
"analysis_open": False,
|
||||
"related_project_selections": {},
|
||||
}
|
||||
try:
|
||||
related_project_selections_raw = json.loads(normalize_text(row["related_project_selections_json"]) or "{}")
|
||||
except json.JSONDecodeError:
|
||||
related_project_selections_raw = {}
|
||||
related_project_selections = {}
|
||||
if isinstance(related_project_selections_raw, dict):
|
||||
related_project_selections = {
|
||||
normalize_text(key): [
|
||||
normalize_text(value)
|
||||
for value in values
|
||||
if normalize_text(value)
|
||||
]
|
||||
for key, values in related_project_selections_raw.items()
|
||||
if normalize_text(key) and isinstance(values, list)
|
||||
}
|
||||
return {
|
||||
"selected_code": normalize_text(row["selected_code"]),
|
||||
"selected_year": normalize_text(row["selected_year"]),
|
||||
"analysis_open": bool(row["analysis_open"]),
|
||||
"related_project_selections": related_project_selections,
|
||||
}
|
||||
|
||||
|
||||
def save_project_page_state(payload: dict[str, Any]) -> None:
|
||||
selected_code = normalize_text(payload.get("selected_code"))
|
||||
selected_year = normalize_text(payload.get("selected_year"))
|
||||
analysis_open = 1 if payload.get("analysis_open") else 0
|
||||
raw_related = payload.get("related_project_selections") or {}
|
||||
related_project_selections = {}
|
||||
if isinstance(raw_related, dict):
|
||||
related_project_selections = {
|
||||
normalize_text(key): [
|
||||
normalize_text(value)
|
||||
for value in values
|
||||
if normalize_text(value)
|
||||
]
|
||||
for key, values in raw_related.items()
|
||||
if normalize_text(key) and isinstance(values, list)
|
||||
}
|
||||
with engine.begin() as conn:
|
||||
conn.execute(
|
||||
text(
|
||||
"""
|
||||
INSERT INTO project_page_state (
|
||||
page_key,
|
||||
selected_code,
|
||||
selected_year,
|
||||
analysis_open,
|
||||
related_project_selections_json,
|
||||
updated_at
|
||||
) VALUES (
|
||||
'projects',
|
||||
:selected_code,
|
||||
:selected_year,
|
||||
:analysis_open,
|
||||
:related_project_selections_json,
|
||||
CURRENT_TIMESTAMP
|
||||
)
|
||||
ON CONFLICT(page_key) DO UPDATE SET
|
||||
selected_code = excluded.selected_code,
|
||||
selected_year = excluded.selected_year,
|
||||
analysis_open = excluded.analysis_open,
|
||||
related_project_selections_json = excluded.related_project_selections_json,
|
||||
updated_at = CURRENT_TIMESTAMP
|
||||
"""
|
||||
),
|
||||
{
|
||||
"selected_code": selected_code,
|
||||
"selected_year": selected_year,
|
||||
"analysis_open": analysis_open,
|
||||
"related_project_selections_json": json.dumps(related_project_selections, ensure_ascii=False),
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
def get_project_year_options() -> list[int]:
|
||||
return get_available_years()
|
||||
|
||||
@@ -1116,6 +1363,40 @@ def get_project_revenue_mix(selected_year: int | None = None) -> list[dict[str,
|
||||
return result[-10:]
|
||||
|
||||
|
||||
def get_project_revenue_mix_monthly() -> list[dict[str, Any]]:
|
||||
recent_10_start_year = get_recent_10_start_year()
|
||||
params: dict[str, Any] = {}
|
||||
year_clause = ""
|
||||
if recent_10_start_year is not None:
|
||||
year_clause = "AND year >= :recent_10_start_year"
|
||||
params["recent_10_start_year"] = recent_10_start_year
|
||||
|
||||
with engine.begin() as conn:
|
||||
rows = conn.execute(
|
||||
text(
|
||||
f"""
|
||||
SELECT
|
||||
year,
|
||||
month,
|
||||
SUM(CASE WHEN account_code LIKE '40110101%' AND memo1 LIKE '%설계%' THEN amount ELSE 0 END) AS design_revenue,
|
||||
SUM(CASE WHEN account_code LIKE '40110101%' AND (memo1 NOT LIKE '%설계%' OR COALESCE(memo1, '') = '') THEN amount ELSE 0 END) AS design_other_revenue,
|
||||
SUM(CASE WHEN account_code LIKE '40110102%' THEN amount ELSE 0 END) AS supervision_revenue,
|
||||
SUM(CASE WHEN account_code LIKE '40110103%' THEN amount ELSE 0 END) AS inspection_revenue
|
||||
FROM transactions
|
||||
WHERE month IS NOT NULL
|
||||
{year_clause}
|
||||
GROUP BY year, month
|
||||
ORDER BY year, month
|
||||
"""
|
||||
),
|
||||
params,
|
||||
).mappings().all()
|
||||
result = [dict(row) for row in rows]
|
||||
for item in result:
|
||||
item["label"] = f"{int(item['month'])}월" if item.get("month") is not None else str(item.get("year", ""))
|
||||
return result
|
||||
|
||||
|
||||
def get_project_cost_by_year(selected_year: int | None) -> list[dict[str, Any]]:
|
||||
year_clause = ""
|
||||
params: dict[str, Any] = {}
|
||||
@@ -1175,7 +1456,8 @@ def get_project_account_breakdowns(selected_year: int | None) -> dict[str, dict[
|
||||
WHEN accounting_category = '판관비' THEN 'sga'
|
||||
ELSE 'other'
|
||||
END AS breakdown_kind,
|
||||
COALESCE(account_name, account_code, '미분류') AS account_label,
|
||||
COALESCE(account_code, '') AS account_code,
|
||||
COALESCE(account_name, '') AS account_name,
|
||||
SUM(COALESCE(amount, 0)) AS total_amount
|
||||
FROM transactions
|
||||
WHERE COALESCE(support_dept_code, '') <> ''
|
||||
@@ -1184,27 +1466,32 @@ def get_project_account_breakdowns(selected_year: int | None) -> dict[str, dict[
|
||||
AND support_dept_name NOT IN ('공통', '경영지원부', '기술개발센터', '임원실', '기술개발부', '총괄기획실')
|
||||
AND ({REVENUE_SQL} OR accounting_category IN ('원가', '판관비'))
|
||||
{year_clause}
|
||||
GROUP BY support_dept_code, breakdown_kind, account_label
|
||||
ORDER BY support_dept_code, breakdown_kind, total_amount DESC, account_label
|
||||
GROUP BY support_dept_code, breakdown_kind, account_code, account_name
|
||||
ORDER BY support_dept_code, breakdown_kind, total_amount DESC, account_code, account_name
|
||||
"""
|
||||
),
|
||||
params,
|
||||
).mappings().all()
|
||||
|
||||
result: dict[str, dict[str, list[dict[str, Any]]]] = {}
|
||||
result: dict[str, dict[str, dict[str, float]]] = {}
|
||||
for row in rows:
|
||||
code = row["support_dept_code"]
|
||||
kind = row["breakdown_kind"]
|
||||
if kind == "other":
|
||||
continue
|
||||
result.setdefault(code, {"revenue": [], "cost": [], "sga": []})
|
||||
result[code][kind].append(
|
||||
{
|
||||
"label": row["account_label"],
|
||||
"amount": row["total_amount"] or 0,
|
||||
}
|
||||
)
|
||||
return result
|
||||
_, _, label = normalize_account_display(row["account_code"], row["account_name"])
|
||||
result.setdefault(code, {"revenue": {}, "cost": {}, "sga": {}})
|
||||
result[code][kind][label] = result[code][kind].get(label, 0.0) + float(row["total_amount"] or 0)
|
||||
|
||||
normalized_result: dict[str, dict[str, list[dict[str, Any]]]] = {}
|
||||
for code, buckets in result.items():
|
||||
normalized_result[code] = {}
|
||||
for kind, entries in buckets.items():
|
||||
normalized_result[code][kind] = [
|
||||
{"label": label, "amount": amount}
|
||||
for label, amount in sorted(entries.items(), key=lambda item: item[1], reverse=True)
|
||||
]
|
||||
return normalized_result
|
||||
|
||||
|
||||
def get_recent_transactions(limit: int = 50) -> list[dict[str, Any]]:
|
||||
@@ -1454,6 +1741,28 @@ def build_named_amount_rows(
|
||||
return filter_amount_rows(rows, amount_key=amount_key)
|
||||
|
||||
|
||||
def build_triplet_amount_rows(
|
||||
first_values: list[Any],
|
||||
second_values: list[Any],
|
||||
amounts: list[Any],
|
||||
*,
|
||||
first_key: str,
|
||||
second_key: str,
|
||||
amount_key: str = "amount",
|
||||
) -> list[dict[str, Any]]:
|
||||
max_length = max(len(first_values), len(second_values), len(amounts))
|
||||
rows = []
|
||||
for index in range(max_length):
|
||||
rows.append(
|
||||
{
|
||||
first_key: first_values[index] if index < len(first_values) else "",
|
||||
second_key: second_values[index] if index < len(second_values) else "",
|
||||
amount_key: amounts[index] if index < len(amounts) else "",
|
||||
}
|
||||
)
|
||||
return filter_amount_rows(rows, amount_key=amount_key)
|
||||
|
||||
|
||||
def build_collection_rows(payload: dict[str, Any]) -> list[dict[str, Any]]:
|
||||
progress_types = payload.get("collection_progress_type[]", [])
|
||||
billing_rounds = payload.get("collection_billing_round[]", [])
|
||||
@@ -1513,72 +1822,140 @@ def build_project_status_payload(payload: dict[str, Any]) -> dict[str, Any]:
|
||||
collection_amount = sum_row_amounts(collection_rows)
|
||||
progress_rate = (collection_amount / contract_amount * 100) if contract_amount else 0.0
|
||||
|
||||
task_plan_department_rows = build_named_amount_rows(
|
||||
payload.get("task_plan_department_label[]", []),
|
||||
task_plan_department_rows = build_triplet_amount_rows(
|
||||
payload.get("task_plan_department_dept[]", []),
|
||||
payload.get("task_plan_department_work[]", []),
|
||||
payload.get("task_plan_department_amount[]", []),
|
||||
)
|
||||
task_plan_outsource_rows = []
|
||||
outsource_names = payload.get("task_plan_outsource_vendor[]", [])
|
||||
outsource_amounts = payload.get("task_plan_outsource_amount[]", [])
|
||||
outsource_notes = payload.get("task_plan_outsource_note[]", [])
|
||||
for index, vendor in enumerate(outsource_names):
|
||||
task_plan_outsource_rows.append(
|
||||
{
|
||||
"label": vendor,
|
||||
"amount": outsource_amounts[index] if index < len(outsource_amounts) else "",
|
||||
"note": outsource_notes[index] if index < len(outsource_notes) else "",
|
||||
"group": "outsource",
|
||||
}
|
||||
)
|
||||
task_plan_outsource_rows = filter_amount_rows(task_plan_outsource_rows, amount_key="amount")
|
||||
task_plan_joint_rows = build_named_amount_rows(
|
||||
payload.get("task_plan_joint_label[]", []),
|
||||
payload.get("task_plan_joint_amount[]", []),
|
||||
first_key="dept_name",
|
||||
second_key="work_name",
|
||||
)
|
||||
for row in task_plan_department_rows:
|
||||
row["group"] = "department"
|
||||
task_plan_outsource_rows = build_triplet_amount_rows(
|
||||
payload.get("task_plan_outsource_dept[]", []),
|
||||
payload.get("task_plan_outsource_work[]", []),
|
||||
payload.get("task_plan_outsource_amount[]", []),
|
||||
first_key="dept_name",
|
||||
second_key="work_name",
|
||||
)
|
||||
for row in task_plan_outsource_rows:
|
||||
row["group"] = "outsource"
|
||||
task_plan_joint_rows = build_triplet_amount_rows(
|
||||
payload.get("task_plan_joint_dept[]", []),
|
||||
payload.get("task_plan_joint_work[]", []),
|
||||
payload.get("task_plan_joint_amount[]", []),
|
||||
first_key="dept_name",
|
||||
second_key="work_name",
|
||||
)
|
||||
for row in task_plan_joint_rows:
|
||||
row["group"] = "joint"
|
||||
task_plan_rows = task_plan_department_rows + task_plan_outsource_rows + task_plan_joint_rows
|
||||
|
||||
exec_labor_rows = build_named_amount_rows(
|
||||
payload.get("exec_labor_role[]", []),
|
||||
exec_labor_rows = build_triplet_amount_rows(
|
||||
payload.get("exec_labor_grade[]", []),
|
||||
payload.get("exec_labor_hours[]", []),
|
||||
payload.get("exec_labor_amount[]", []),
|
||||
first_key="grade",
|
||||
second_key="hours",
|
||||
)
|
||||
for row in exec_labor_rows:
|
||||
row["group"] = "labor"
|
||||
exec_outsource_rows = []
|
||||
exec_outsource_names = payload.get("exec_outsource_vendor[]", [])
|
||||
exec_outsource_amounts = payload.get("exec_outsource_amount[]", [])
|
||||
exec_outsource_notes = payload.get("exec_outsource_note[]", [])
|
||||
for index, vendor in enumerate(exec_outsource_names):
|
||||
exec_outsource_rows.append(
|
||||
{
|
||||
"label": vendor,
|
||||
"amount": exec_outsource_amounts[index] if index < len(exec_outsource_amounts) else "",
|
||||
"note": exec_outsource_notes[index] if index < len(exec_outsource_notes) else "",
|
||||
"group": "outsource",
|
||||
}
|
||||
exec_outsource_rows = build_triplet_amount_rows(
|
||||
payload.get("exec_outsource_dept[]", []),
|
||||
payload.get("exec_outsource_work[]", []),
|
||||
payload.get("exec_outsource_amount[]", []),
|
||||
first_key="dept_name",
|
||||
second_key="work_name",
|
||||
)
|
||||
exec_outsource_rows = filter_amount_rows(exec_outsource_rows, amount_key="amount")
|
||||
exec_cost_plan_rows = build_named_amount_rows(
|
||||
payload.get("exec_cost_plan_label[]", []),
|
||||
for row in exec_outsource_rows:
|
||||
row["group"] = "outsource"
|
||||
exec_cost_plan_rows = build_triplet_amount_rows(
|
||||
payload.get("exec_cost_plan_code[]", []),
|
||||
payload.get("exec_cost_plan_name[]", []),
|
||||
payload.get("exec_cost_plan_amount[]", []),
|
||||
first_key="account_code",
|
||||
second_key="account_name",
|
||||
)
|
||||
for row in exec_cost_plan_rows:
|
||||
row["group"] = "cost_plan"
|
||||
exec_budget_rows = exec_labor_rows + exec_outsource_rows + exec_cost_plan_rows
|
||||
|
||||
actual_input_rows = []
|
||||
actual_refs = payload.get("actual_input_ref[]", [])
|
||||
actual_amounts = payload.get("actual_input_amount[]", [])
|
||||
actual_notes = payload.get("actual_input_note[]", [])
|
||||
for index, ref in enumerate(actual_refs):
|
||||
actual_labor_grades = payload.get("actual_labor_grade[]", [])
|
||||
actual_labor_minutes = payload.get("actual_labor_minutes[]", [])
|
||||
actual_labor_amounts = payload.get("actual_labor_amount[]", [])
|
||||
actual_labor_rows: list[dict[str, Any]] = []
|
||||
actual_labor_max_length = max(
|
||||
len(actual_labor_grades),
|
||||
len(actual_labor_minutes),
|
||||
len(actual_labor_amounts),
|
||||
)
|
||||
for index in range(actual_labor_max_length):
|
||||
row = {
|
||||
"grade": actual_labor_grades[index] if index < len(actual_labor_grades) else "",
|
||||
"minutes": actual_labor_minutes[index] if index < len(actual_labor_minutes) else "",
|
||||
"amount": actual_labor_amounts[index] if index < len(actual_labor_amounts) else "",
|
||||
}
|
||||
normalized_row = {key: clean_row_text(value) for key, value in row.items()}
|
||||
amount = normalize_amount(normalized_row.get("amount"))
|
||||
has_other_value = any(
|
||||
value for key, value in normalized_row.items()
|
||||
if key != "amount"
|
||||
)
|
||||
if amount or has_other_value:
|
||||
normalized_row["amount"] = amount
|
||||
actual_labor_rows.append(normalized_row)
|
||||
for row in actual_labor_rows:
|
||||
row["group"] = "labor"
|
||||
actual_labor_adjustment_total = normalize_amount(payload.get("actual_labor_adjustment_total"))
|
||||
actual_labor_adjustment_rows = []
|
||||
if actual_labor_adjustment_total:
|
||||
actual_labor_adjustment_rows.append(
|
||||
{
|
||||
"group": "labor_adjustment",
|
||||
"label": "인건비 조정",
|
||||
"amount": actual_labor_adjustment_total,
|
||||
}
|
||||
)
|
||||
|
||||
actual_as_rows = build_named_amount_rows(
|
||||
payload.get("actual_as_label[]", []),
|
||||
payload.get("actual_as_amount[]", []),
|
||||
label_key="label",
|
||||
amount_key="amount",
|
||||
)
|
||||
for row in actual_as_rows:
|
||||
row["group"] = "as"
|
||||
|
||||
actual_labor_joint_rows = build_named_amount_rows(
|
||||
payload.get("actual_labor_joint_label[]", []),
|
||||
payload.get("actual_labor_joint_amount[]", []),
|
||||
label_key="label",
|
||||
amount_key="amount",
|
||||
)
|
||||
for row in actual_labor_joint_rows:
|
||||
row["group"] = "labor_joint"
|
||||
|
||||
actual_sga_rows = build_named_amount_rows(
|
||||
payload.get("actual_sga_label[]", []),
|
||||
payload.get("actual_sga_amount[]", []),
|
||||
label_key="label",
|
||||
amount_key="amount",
|
||||
)
|
||||
for row in actual_sga_rows:
|
||||
row["group"] = "sga"
|
||||
|
||||
actual_input_rows = actual_labor_rows + actual_labor_adjustment_rows + actual_labor_joint_rows + actual_as_rows + actual_sga_rows
|
||||
|
||||
if not actual_input_rows:
|
||||
legacy_refs = payload.get("actual_input_ref[]", [])
|
||||
legacy_amounts = payload.get("actual_input_amount[]", [])
|
||||
legacy_notes = payload.get("actual_input_note[]", [])
|
||||
for index, ref in enumerate(legacy_refs):
|
||||
actual_input_rows.append(
|
||||
{
|
||||
"reference": ref,
|
||||
"amount": actual_amounts[index] if index < len(actual_amounts) else "",
|
||||
"note": actual_notes[index] if index < len(actual_notes) else "",
|
||||
"amount": legacy_amounts[index] if index < len(legacy_amounts) else "",
|
||||
"note": legacy_notes[index] if index < len(legacy_notes) else "",
|
||||
}
|
||||
)
|
||||
actual_input_rows = filter_amount_rows(actual_input_rows, amount_key="amount")
|
||||
@@ -1587,6 +1964,7 @@ def build_project_status_payload(payload: dict[str, Any]) -> dict[str, Any]:
|
||||
expected_sga_rate = normalize_amount(payload.get("expected_sga_rate"))
|
||||
expected_as_cost = contract_amount * expected_as_rate / 100 if contract_amount else 0.0
|
||||
expected_sga_budget = contract_amount * expected_sga_rate / 100 if contract_amount else 0.0
|
||||
exec_labor_rates = normalize_text(payload.get("exec_labor_rates_json")) or "{}"
|
||||
|
||||
return {
|
||||
"support_dept_code": normalize_text(payload.get("support_dept_code")),
|
||||
@@ -1600,12 +1978,13 @@ def build_project_status_payload(payload: dict[str, Any]) -> dict[str, Any]:
|
||||
"task_plan_department_budget": sum_row_amounts(task_plan_department_rows),
|
||||
"task_plan_outsource_budget": sum_row_amounts(task_plan_outsource_rows),
|
||||
"task_plan_outsource_detail": "\n".join(
|
||||
f"{row.get('label', '')}: {format_amount_for_text(row.get('amount'))}{' / ' + row.get('note', '') if row.get('note') else ''}"
|
||||
f"{normalize_text(row.get('dept_name'))} / {normalize_text(row.get('work_name'))}: {format_amount_for_text(row.get('amount'))}".strip(" /:")
|
||||
for row in task_plan_outsource_rows
|
||||
),
|
||||
"task_plan_joint_operating_cost": sum_row_amounts(task_plan_joint_rows),
|
||||
"task_plan_entries_json": encode_json_rows(task_plan_rows),
|
||||
"exec_budget_labor_by_grade": sum_row_amounts(exec_labor_rows),
|
||||
"exec_labor_rates_json": exec_labor_rates,
|
||||
"exec_budget_outsource": sum_row_amounts(exec_outsource_rows),
|
||||
"exec_budget_cost_plan": sum_row_amounts(exec_cost_plan_rows),
|
||||
"exec_budget_entries_json": encode_json_rows(exec_budget_rows),
|
||||
@@ -1714,6 +2093,7 @@ def save_project_status(payload: dict[str, Any]) -> None:
|
||||
task_plan_joint_operating_cost,
|
||||
task_plan_entries_json,
|
||||
exec_budget_labor_by_grade,
|
||||
exec_labor_rates_json,
|
||||
exec_budget_outsource,
|
||||
exec_budget_cost_plan,
|
||||
exec_budget_entries_json,
|
||||
@@ -1745,6 +2125,7 @@ def save_project_status(payload: dict[str, Any]) -> None:
|
||||
:task_plan_joint_operating_cost,
|
||||
:task_plan_entries_json,
|
||||
:exec_budget_labor_by_grade,
|
||||
:exec_labor_rates_json,
|
||||
:exec_budget_outsource,
|
||||
:exec_budget_cost_plan,
|
||||
:exec_budget_entries_json,
|
||||
@@ -1776,6 +2157,7 @@ def save_project_status(payload: dict[str, Any]) -> None:
|
||||
task_plan_joint_operating_cost = excluded.task_plan_joint_operating_cost,
|
||||
task_plan_entries_json = excluded.task_plan_entries_json,
|
||||
exec_budget_labor_by_grade = excluded.exec_budget_labor_by_grade,
|
||||
exec_labor_rates_json = excluded.exec_labor_rates_json,
|
||||
exec_budget_outsource = excluded.exec_budget_outsource,
|
||||
exec_budget_cost_plan = excluded.exec_budget_cost_plan,
|
||||
exec_budget_entries_json = excluded.exec_budget_entries_json,
|
||||
@@ -1820,6 +2202,9 @@ def render_home(
|
||||
**base_context(request, message),
|
||||
"overview": get_overview_stats(overview_year),
|
||||
"overview_selected_year": overview_year,
|
||||
"project_dashboard": get_project_dashboard_summary(overview_year),
|
||||
"project_revenue_mix_yearly": get_project_revenue_mix(),
|
||||
"project_revenue_mix_monthly": get_project_revenue_mix_monthly(),
|
||||
"yearly_summary": get_yearly_summary(),
|
||||
"monthly_summary": get_monthly_summary(),
|
||||
"available_years": available_years,
|
||||
@@ -1845,7 +2230,10 @@ def render_projects_page(
|
||||
"project_account_breakdowns": get_project_account_breakdowns(selected_year),
|
||||
"project_status_rows": get_project_status_rows(),
|
||||
"project_edit": get_project_status_for_edit(edit_code),
|
||||
"project_page_state": get_project_page_state(),
|
||||
"support_department_options": get_support_department_options(),
|
||||
"cost_department_options": get_cost_department_options(),
|
||||
"cost_account_options": get_cost_account_options(),
|
||||
}
|
||||
return templates.TemplateResponse(request, "projects.html", context)
|
||||
|
||||
@@ -1884,6 +2272,28 @@ async def projects(request: Request, edit_code: str | None = None, year: str | N
|
||||
return HTMLResponse("<h1>서버 오류</h1><p>로그를 확인해주세요.</p>", status_code=500)
|
||||
|
||||
|
||||
@app.get("/projects/edit-data")
|
||||
async def project_edit_data(code: str | None = None):
|
||||
try:
|
||||
return JSONResponse(content=jsonable_encoder(get_project_status_for_edit(code)))
|
||||
except Exception as exc:
|
||||
logger.exception("사업현황 편집 데이터 조회 에러: %s", exc)
|
||||
return JSONResponse(content={"error": str(exc)}, status_code=500)
|
||||
|
||||
|
||||
@app.post("/projects/page-state")
|
||||
async def project_page_state_save(request: Request):
|
||||
try:
|
||||
payload = await request.json()
|
||||
if not isinstance(payload, dict):
|
||||
raise ValueError("잘못된 페이지 상태 형식입니다.")
|
||||
save_project_page_state(payload)
|
||||
return JSONResponse(content={"status": "ok"})
|
||||
except Exception as exc:
|
||||
logger.exception("사업현황 페이지 상태 저장 에러: %s", exc)
|
||||
return JSONResponse(content={"error": str(exc)}, status_code=500)
|
||||
|
||||
|
||||
@app.get("/annual-summary")
|
||||
async def annual_summary(request: Request):
|
||||
try:
|
||||
@@ -1953,4 +2363,5 @@ async def save_project(request: Request):
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
uvicorn.run(app, host="0.0.0.0", port=8010, reload=False)
|
||||
auto_reload = os.getenv("INTRANET_AUTO_RELOAD", "1").lower() not in {"0", "false", "no"}
|
||||
uvicorn.run("main:app", host="0.0.0.0", port=8010, reload=auto_reload, reload_dirs=[str(BASE_DIR)])
|
||||
|
||||
@@ -11,4 +11,6 @@ if [ ! -x ".venv/bin/python" ]; then
|
||||
exit 1
|
||||
fi
|
||||
|
||||
export INTRANET_AUTO_RELOAD="${INTRANET_AUTO_RELOAD:-1}"
|
||||
|
||||
exec ./.venv/bin/python main.py
|
||||
|
||||
@@ -0,0 +1,466 @@
|
||||
{% extends "base.html" %}
|
||||
|
||||
{% block title %}연도별 수익 비용 정리{% endblock %}
|
||||
|
||||
{% block head_extra %}
|
||||
<style>
|
||||
.filter-grid {
|
||||
display: grid;
|
||||
grid-template-columns: 220px 220px 1fr;
|
||||
gap: 12px;
|
||||
align-items: end;
|
||||
}
|
||||
|
||||
.legend {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 10px;
|
||||
margin-top: 10px;
|
||||
}
|
||||
|
||||
.legend-item {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
color: #363b44;
|
||||
font-size: 13px;
|
||||
font-weight: 700;
|
||||
background: rgba(255,255,255,0.92);
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 10px;
|
||||
padding: 6px 10px;
|
||||
}
|
||||
|
||||
.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: 16px;
|
||||
box-shadow: inset 0 1px 0 rgba(255,255,255,0.92);
|
||||
}
|
||||
|
||||
.chart-legend {
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
.chart-title {
|
||||
font-size: 18px;
|
||||
font-weight: 700;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.chart-svg {
|
||||
width: 100%;
|
||||
height: auto;
|
||||
aspect-ratio: 1200 / 420;
|
||||
min-height: 320px;
|
||||
display: block;
|
||||
}
|
||||
|
||||
.expense-chart-svg {
|
||||
aspect-ratio: 1600 / 420;
|
||||
}
|
||||
|
||||
.chart-note {
|
||||
margin-top: 10px;
|
||||
color: var(--muted);
|
||||
font-size: 13px;
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
.metric-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
@media (max-width: 1000px) {
|
||||
.filter-grid,
|
||||
.metric-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<section class="panel">
|
||||
<div class="section-title">
|
||||
<h2>연도별 수익 비용 정리</h2>
|
||||
</div>
|
||||
<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>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="panel">
|
||||
<div class="section-title">
|
||||
<h2>선택 구간 요약</h2>
|
||||
</div>
|
||||
<div class="metric-grid" id="metricGrid"></div>
|
||||
</section>
|
||||
|
||||
<section class="panel">
|
||||
<div class="section-title">
|
||||
<h2>비용 구조</h2>
|
||||
</div>
|
||||
<div class="chart-box">
|
||||
<div class="legend chart-legend" id="expenseLegend"></div>
|
||||
<svg id="expenseChart" class="chart-svg expense-chart-svg" viewBox="0 0 1600 420" preserveAspectRatio="xMidYMid meet"></svg>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="panel">
|
||||
<div class="section-title">
|
||||
<h2>수금/비용/영업수지 그래프</h2>
|
||||
</div>
|
||||
<div class="chart-box">
|
||||
<div class="legend chart-legend" id="balanceLegend"></div>
|
||||
<svg id="balanceChart" class="chart-svg" viewBox="0 0 1200 420" preserveAspectRatio="xMidYMid meet"></svg>
|
||||
</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 palette = {
|
||||
revenue_sum: "#0f766e",
|
||||
project_cost_sum: "#0ea5a4",
|
||||
support_cost_sum: "#67b7dc",
|
||||
support_sga_sum: "#f59e0b",
|
||||
field_sga_sum: "#f97316",
|
||||
labor_sum: "#8b5cf6",
|
||||
outsourcing_sum: "#ec4899",
|
||||
total_expense: "#1d4ed8",
|
||||
operating_balance: "#dc2626",
|
||||
};
|
||||
|
||||
const labels = {
|
||||
revenue_sum: "수금",
|
||||
project_cost_sum: "원가(프로젝트)",
|
||||
support_cost_sum: "원가(지원부서)",
|
||||
support_sga_sum: "판관비(지원부서)",
|
||||
field_sga_sum: "판관비(현업부서)",
|
||||
labor_sum: "원가인건비",
|
||||
outsourcing_sum: "원가외주비",
|
||||
total_expense: "비용합계",
|
||||
operating_balance: "영업수지",
|
||||
};
|
||||
|
||||
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 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 = [
|
||||
"revenue_sum",
|
||||
"project_cost_sum",
|
||||
"support_cost_sum",
|
||||
"support_sga_sum",
|
||||
"field_sga_sum",
|
||||
"labor_sum",
|
||||
"outsourcing_sum",
|
||||
"total_expense",
|
||||
"operating_balance",
|
||||
];
|
||||
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 buildSignedAxis(maxPositiveValue, minNegativeValue, width, height, margin) {
|
||||
const rangeMax = Math.max(Math.abs(maxPositiveValue || 0), Math.abs(minNegativeValue || 0), 1);
|
||||
const { tickStep, tickMax } = buildPositiveAxisScale(rangeMax, 4);
|
||||
const plotHeight = height - margin.top - margin.bottom;
|
||||
const zeroY = margin.top + (plotHeight * tickMax) / (tickMax * 2);
|
||||
let axis = "";
|
||||
for (let value = -tickMax; value <= tickMax; value += tickStep) {
|
||||
const y = zeroY - (plotHeight * value) / (tickMax * 2);
|
||||
axis += `<line x1="${margin.left}" y1="${y}" x2="${width - margin.right}" y2="${y}" stroke="#d8dee5" stroke-dasharray="3 7" />`;
|
||||
axis += `<text x="${margin.left - 12}" y="${y + 4}" text-anchor="end" fill="#5a6672" font-size="11" font-weight="700">${formatAxisLabel(value)}</text>`;
|
||||
}
|
||||
axis += `<line x1="${margin.left}" y1="${zeroY}" x2="${width - margin.right}" y2="${zeroY}" stroke="#8ba0ae" stroke-width="1.2" />`;
|
||||
return { axis, tickMax, zeroY };
|
||||
}
|
||||
|
||||
function renderEmptyChart(svgId, message) {
|
||||
const svg = document.getElementById(svgId);
|
||||
if (!svg) return;
|
||||
svg.innerHTML = `
|
||||
<rect x="0" y="0" width="1200" height="420" rx="8" fill="#f7fafb" stroke="#d6e2e8"></rect>
|
||||
<text x="600" y="210" text-anchor="middle" fill="#667887" font-size="18" font-weight="700">${message}</text>
|
||||
`;
|
||||
}
|
||||
|
||||
function renderExpenseChart(series) {
|
||||
const svg = document.getElementById("expenseChart");
|
||||
const keys = ["labor_sum", "outsourcing_sum", "project_cost_sum", "support_cost_sum", "support_sga_sum", "field_sga_sum"];
|
||||
const isMonthlyView = series.some((item) => String(item.label || "").includes("월"));
|
||||
renderLegend("expenseLegend", keys);
|
||||
if (!series.length) {
|
||||
renderEmptyChart("expenseChart", "표시할 비용 구조 데이터가 없습니다.");
|
||||
return;
|
||||
}
|
||||
|
||||
const width = 1600;
|
||||
const height = 420;
|
||||
const margin = { top: 30, right: isMonthlyView ? 72 : 36, bottom: 74, left: 98 };
|
||||
const barWidth = (width - margin.left - margin.right) / series.length * (isMonthlyView ? 0.18 : 0.29);
|
||||
const step = (width - margin.left - margin.right) / series.length;
|
||||
const maxValue = Math.max(...series.map((item) => keys.reduce((sum, key) => sum + (item[key] || 0), 0)), 1);
|
||||
const { axis, tickMax } = buildAxis(maxValue, width, height, margin);
|
||||
let markup = `
|
||||
<defs>
|
||||
<filter id="expenseShadow" x="-20%" y="-20%" width="140%" height="160%">
|
||||
<feDropShadow dx="0" dy="8" stdDeviation="8" flood-color="rgba(44, 68, 89, 0.12)" />
|
||||
</filter>
|
||||
</defs>
|
||||
<rect x="${margin.left}" y="${margin.top}" width="${width - margin.left - margin.right}" height="${height - margin.top - margin.bottom}" rx="6" fill="rgba(255,255,255,0.7)" stroke="#dde7ec"></rect>
|
||||
${axis}
|
||||
`;
|
||||
series.forEach((item, index) => {
|
||||
let cumulative = 0;
|
||||
const total = keys.reduce((sum, key) => sum + (item[key] || 0), 0);
|
||||
const x = margin.left + index * step + (step - barWidth) / 2;
|
||||
const detailX = x + barWidth + 8;
|
||||
const labelEntries = [];
|
||||
keys.forEach((key) => {
|
||||
const value = item[key] || 0;
|
||||
const barHeight = ((height - margin.top - margin.bottom) * value) / tickMax;
|
||||
const y = height - margin.bottom - barHeight - ((height - margin.top - margin.bottom) * cumulative) / tickMax;
|
||||
cumulative += value;
|
||||
markup += `<rect x="${x}" y="${y}" width="${barWidth}" height="${barHeight}" rx="2" fill="${palette[key]}" filter="url(#expenseShadow)" />`;
|
||||
if (value > 0) {
|
||||
const percent = total ? ((value / total) * 100).toFixed(1) : "0.0";
|
||||
labelEntries.push({
|
||||
key,
|
||||
value,
|
||||
percent,
|
||||
desiredY: y + (barHeight / 2),
|
||||
});
|
||||
}
|
||||
});
|
||||
labelEntries.sort((a, b) => a.desiredY - b.desiredY);
|
||||
const minY = margin.top + 12;
|
||||
const maxY = height - margin.bottom - 12;
|
||||
const gap = isMonthlyView ? 16 : 18;
|
||||
let lastY = minY - gap;
|
||||
labelEntries.forEach((entry) => {
|
||||
const lineY = Math.max(entry.desiredY, lastY + gap, minY);
|
||||
const finalY = Math.min(lineY, maxY);
|
||||
lastY = finalY;
|
||||
markup += `<rect x="${detailX}" y="${finalY - 10}" width="8" height="8" rx="1.5" fill="${palette[entry.key]}"></rect>`;
|
||||
markup += `<text x="${detailX + 14}" y="${finalY + 4}" text-anchor="start" fill="#6b7b88" font-size="9" font-weight="700">${entry.percent}%</text>`;
|
||||
});
|
||||
if (total > 0) {
|
||||
const topY = height - margin.bottom - ((height - margin.top - margin.bottom) * total) / tickMax;
|
||||
markup += `<text x="${x + barWidth / 2}" y="${Math.max(topY - 10, margin.top + 10)}" text-anchor="middle" fill="#314555" font-size="10.5" font-weight="800">${formatAxisLabel(total)}</text>`;
|
||||
}
|
||||
markup += `<text x="${x + barWidth / 2}" y="${height - margin.bottom + 20}" text-anchor="middle" fill="#5a6672" font-size="11.5" font-weight="700">${item.label}</text>`;
|
||||
});
|
||||
svg.innerHTML = markup;
|
||||
}
|
||||
|
||||
function renderBalanceChart(series) {
|
||||
const svg = document.getElementById("balanceChart");
|
||||
const metrics = ["revenue_sum", "total_expense", "operating_balance"];
|
||||
renderLegend("balanceLegend", metrics);
|
||||
if (!series.length) {
|
||||
renderEmptyChart("balanceChart", "표시할 수익/비용 데이터가 없습니다.");
|
||||
return;
|
||||
}
|
||||
|
||||
const width = 1200;
|
||||
const height = 420;
|
||||
const margin = { top: 30, right: 24, bottom: 74, left: 98 };
|
||||
const 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, tickMax, zeroY } = buildSignedAxis(maxPositiveValue, minNegativeValue, width, height, margin);
|
||||
const groupWidth = plotWidth / Math.max(series.length, 1);
|
||||
const groupGap = groupWidth * 0.22;
|
||||
const innerGap = 0;
|
||||
const barWidth = Math.min((groupWidth - groupGap * 2) / metrics.length, 44);
|
||||
const 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;
|
||||
metrics.forEach((key, metricIndex) => {
|
||||
const rawValue = Number(item[key] || 0);
|
||||
const value = key === "operating_balance" ? rawValue : Math.max(rawValue, 0);
|
||||
const barHeight = (plotHeight * Math.abs(value)) / (tickMax * 2);
|
||||
const x = baseX + groupStartOffset + metricIndex * (barWidth + innerGap);
|
||||
const y = value < 0 ? zeroY : zeroY - barHeight;
|
||||
markup += `<rect x="${x}" y="${y}" width="${barWidth}" height="${barHeight}" rx="2" fill="${palette[key]}" filter="url(#balanceShadow)" />`;
|
||||
if (value !== 0) {
|
||||
const labelY = value < 0
|
||||
? Math.min(y + barHeight + 14, height - margin.bottom + 6)
|
||||
: Math.max(y - 8, margin.top + 12);
|
||||
markup += `<text x="${x + barWidth / 2}" y="${labelY}" text-anchor="middle" fill="#314555" font-size="9.5" font-weight="800">${formatAxisLabel(value)}</text>`;
|
||||
}
|
||||
});
|
||||
markup += `<text x="${baseX + groupWidth / 2}" y="${height - margin.bottom + 20}" text-anchor="middle" fill="#5a6672" font-size="11.5" font-weight="700">${item.label}</text>`;
|
||||
});
|
||||
|
||||
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,665 @@
|
||||
<!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;
|
||||
}
|
||||
|
||||
* {
|
||||
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: 14px;
|
||||
}
|
||||
|
||||
.page {
|
||||
max-width: 1520px;
|
||||
margin: 0 auto;
|
||||
display: grid;
|
||||
gap: 14px;
|
||||
}
|
||||
|
||||
.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: 18px;
|
||||
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) {
|
||||
body {
|
||||
padding: 14px;
|
||||
}
|
||||
|
||||
.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 %}"
|
||||
>
|
||||
<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>
|
||||
</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;
|
||||
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();
|
||||
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 hasActiveEditor() {
|
||||
const active = document.activeElement;
|
||||
return Boolean(active && active.closest && active.closest("form[data-collab-form]"));
|
||||
}
|
||||
|
||||
function shouldDelayRefresh() {
|
||||
return isFormDirty || hasActiveEditor();
|
||||
}
|
||||
|
||||
async function refreshPageWhenSafe(nextVersion) {
|
||||
if (refreshInFlight) return;
|
||||
if (shouldDelayRefresh()) {
|
||||
pendingVersion = nextVersion || pendingVersion || pageVersion;
|
||||
setStatus("online", "새 데이터 대기 중");
|
||||
return;
|
||||
}
|
||||
|
||||
refreshInFlight = true;
|
||||
pendingVersion = nextVersion || pendingVersion || "";
|
||||
setStatus("online", "새 데이터 반영 중");
|
||||
|
||||
try {
|
||||
const response = await fetch(refreshUrl, {
|
||||
cache: "no-store",
|
||||
credentials: "same-origin",
|
||||
headers: { "X-Requested-With": "XMLHttpRequest" },
|
||||
});
|
||||
if (!response.ok) throw new Error(`HTTP ${response.status}`);
|
||||
const html = await response.text();
|
||||
document.open();
|
||||
document.write(html);
|
||||
document.close();
|
||||
} 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,595 @@
|
||||
{% 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>
|
||||
<option value="design_revenue">설계</option>
|
||||
<option value="design_other_revenue">설계 외</option>
|
||||
<option value="supervision_revenue">감리</option>
|
||||
<option value="inspection_revenue">점검</option>
|
||||
</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>
|
||||
<option value="cost_sum">원가</option>
|
||||
<option value="sga_sum">판관비</option>
|
||||
<option value="labor_sum">원가인건비</option>
|
||||
<option value="outsourcing_sum">원가외주비</option>
|
||||
</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 revenuePalette = {
|
||||
design_revenue: { label: "설계", color: "#4f7cff" },
|
||||
design_other_revenue: { label: "설계 외", color: "#67c7c9" },
|
||||
supervision_revenue: { label: "감리", color: "#233a5a" },
|
||||
inspection_revenue: { label: "점검", color: "#ffb54a" },
|
||||
};
|
||||
|
||||
const expensePalette = {
|
||||
cost_sum: { label: "원가", color: "#4f7cff" },
|
||||
sga_sum: { label: "판관비", color: "#67c7c9" },
|
||||
labor_sum: { label: "원가인건비", color: "#233a5a" },
|
||||
outsourcing_sum: { label: "원가외주비", color: "#ffb54a" },
|
||||
};
|
||||
|
||||
const revenueMetricMap = {
|
||||
all: ["design_revenue", "design_other_revenue", "supervision_revenue", "inspection_revenue"],
|
||||
design_revenue: ["design_revenue"],
|
||||
design_other_revenue: ["design_other_revenue"],
|
||||
supervision_revenue: ["supervision_revenue"],
|
||||
inspection_revenue: ["inspection_revenue"],
|
||||
};
|
||||
|
||||
const expenseMetricMap = {
|
||||
all: ["cost_sum", "sga_sum", "labor_sum", "outsourcing_sum"],
|
||||
cost_sum: ["cost_sum"],
|
||||
sga_sum: ["sga_sum"],
|
||||
labor_sum: ["labor_sum"],
|
||||
outsourcing_sum: ["outsourcing_sum"],
|
||||
};
|
||||
|
||||
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,591 @@
|
||||
<!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;
|
||||
}
|
||||
|
||||
* {
|
||||
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: 24px;
|
||||
}
|
||||
|
||||
.page {
|
||||
max-width: 1480px;
|
||||
margin: 0 auto;
|
||||
display: grid;
|
||||
gap: 20px;
|
||||
}
|
||||
|
||||
.panel {
|
||||
background: var(--panel);
|
||||
border: 1px solid rgba(255, 255, 255, 0.6);
|
||||
border-radius: 24px;
|
||||
padding: 24px;
|
||||
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="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
@@ -134,7 +134,7 @@
|
||||
|
||||
<section class="panel">
|
||||
<div class="section-title">
|
||||
<h2>수익/비용/영업수지 그래프</h2>
|
||||
<h2>수금/비용/영업수지 그래프</h2>
|
||||
</div>
|
||||
<div class="chart-box">
|
||||
<div class="legend chart-legend" id="balanceLegend"></div>
|
||||
@@ -162,7 +162,7 @@
|
||||
};
|
||||
|
||||
const labels = {
|
||||
revenue_sum: "수익",
|
||||
revenue_sum: "수금",
|
||||
project_cost_sum: "원가(프로젝트)",
|
||||
support_cost_sum: "원가(지원부서)",
|
||||
support_sga_sum: "판관비(지원부서)",
|
||||
@@ -180,16 +180,18 @@
|
||||
function formatAxisLabel(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)}천`;
|
||||
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 / 5 / baseUnit, 1);
|
||||
const raw = Math.max(maxValue / baseUnit, 1);
|
||||
let power = 1;
|
||||
while (power * 10 <= raw) power *= 10;
|
||||
for (const unit of units) {
|
||||
@@ -199,6 +201,14 @@
|
||||
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 renderLegend(targetId, keys) {
|
||||
const target = document.getElementById(targetId);
|
||||
if (!target) return;
|
||||
@@ -271,8 +281,7 @@
|
||||
}
|
||||
|
||||
function buildAxis(maxValue, width, height, margin) {
|
||||
const tickStep = pickTickStep(maxValue);
|
||||
const tickMax = Math.ceil(maxValue / tickStep) * tickStep;
|
||||
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;
|
||||
@@ -283,6 +292,21 @@
|
||||
return { axis, tickMax };
|
||||
}
|
||||
|
||||
function buildSignedAxis(maxPositiveValue, minNegativeValue, width, height, margin) {
|
||||
const rangeMax = Math.max(Math.abs(maxPositiveValue || 0), Math.abs(minNegativeValue || 0), 1);
|
||||
const { tickStep, tickMax } = buildPositiveAxisScale(rangeMax, 4);
|
||||
const plotHeight = height - margin.top - margin.bottom;
|
||||
const zeroY = margin.top + (plotHeight * tickMax) / (tickMax * 2);
|
||||
let axis = "";
|
||||
for (let value = -tickMax; value <= tickMax; value += tickStep) {
|
||||
const y = zeroY - (plotHeight * value) / (tickMax * 2);
|
||||
axis += `<line x1="${margin.left}" y1="${y}" x2="${width - margin.right}" y2="${y}" stroke="#d8dee5" stroke-dasharray="3 7" />`;
|
||||
axis += `<text x="${margin.left - 12}" y="${y + 4}" text-anchor="end" fill="#5a6672" font-size="11" font-weight="700">${formatAxisLabel(value)}</text>`;
|
||||
}
|
||||
axis += `<line x1="${margin.left}" y1="${zeroY}" x2="${width - margin.right}" y2="${zeroY}" stroke="#8ba0ae" stroke-width="1.2" />`;
|
||||
return { axis, tickMax, zeroY };
|
||||
}
|
||||
|
||||
function renderEmptyChart(svgId, message) {
|
||||
const svg = document.getElementById(svgId);
|
||||
if (!svg) return;
|
||||
@@ -375,12 +399,13 @@
|
||||
const margin = { top: 30, right: 24, bottom: 74, left: 98 };
|
||||
const plotWidth = width - margin.left - margin.right;
|
||||
const plotHeight = height - margin.top - margin.bottom;
|
||||
const maxValue = Math.max(...series.flatMap((item) => [
|
||||
const maxPositiveValue = Math.max(...series.flatMap((item) => [
|
||||
item.revenue_sum || 0,
|
||||
item.total_expense || 0,
|
||||
Math.abs(item.operating_balance || 0),
|
||||
Math.max(item.operating_balance || 0, 0),
|
||||
]), 1);
|
||||
const { axis, tickMax } = buildAxis(maxValue, width, height, margin);
|
||||
const minNegativeValue = Math.min(...series.map((item) => Math.min(item.operating_balance || 0, 0)), 0);
|
||||
const { axis, tickMax, zeroY } = buildSignedAxis(maxPositiveValue, minNegativeValue, width, height, margin);
|
||||
const groupWidth = plotWidth / Math.max(series.length, 1);
|
||||
const groupGap = groupWidth * 0.22;
|
||||
const innerGap = 0;
|
||||
@@ -399,13 +424,17 @@
|
||||
series.forEach((item, index) => {
|
||||
const baseX = margin.left + index * groupWidth;
|
||||
metrics.forEach((key, metricIndex) => {
|
||||
const value = key === "operating_balance" ? Math.abs(item[key] || 0) : (item[key] || 0);
|
||||
const barHeight = (plotHeight * value) / tickMax;
|
||||
const rawValue = Number(item[key] || 0);
|
||||
const value = key === "operating_balance" ? rawValue : Math.max(rawValue, 0);
|
||||
const barHeight = (plotHeight * Math.abs(value)) / (tickMax * 2);
|
||||
const x = baseX + groupStartOffset + metricIndex * (barWidth + innerGap);
|
||||
const y = height - margin.bottom - barHeight;
|
||||
const y = value < 0 ? zeroY : zeroY - barHeight;
|
||||
markup += `<rect x="${x}" y="${y}" width="${barWidth}" height="${barHeight}" rx="2" fill="${palette[key]}" filter="url(#balanceShadow)" />`;
|
||||
if (value > 0) {
|
||||
markup += `<text x="${x + barWidth / 2}" y="${Math.max(y - 8, margin.top + 12)}" text-anchor="middle" fill="#314555" font-size="9.5" font-weight="800">${formatAxisLabel(value)}</text>`;
|
||||
if (value !== 0) {
|
||||
const labelY = value < 0
|
||||
? Math.min(y + barHeight + 14, height - margin.bottom + 6)
|
||||
: Math.max(y - 8, margin.top + 12);
|
||||
markup += `<text x="${x + barWidth / 2}" y="${labelY}" text-anchor="middle" fill="#314555" font-size="9.5" font-weight="800">${formatAxisLabel(value)}</text>`;
|
||||
}
|
||||
});
|
||||
markup += `<text x="${baseX + groupWidth / 2}" y="${height - margin.bottom + 20}" text-anchor="middle" fill="#5a6672" font-size="11.5" font-weight="700">${item.label}</text>`;
|
||||
|
||||
+1
-1
@@ -476,7 +476,7 @@
|
||||
<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="/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
|
||||
|
||||
+253
-276
@@ -4,23 +4,24 @@
|
||||
|
||||
{% block head_extra %}
|
||||
<style>
|
||||
.hero {
|
||||
.dashboard-topbar {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: flex-start;
|
||||
gap: 18px;
|
||||
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;
|
||||
}
|
||||
|
||||
.hero-copy {
|
||||
display: grid;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.hero-copy p {
|
||||
color: var(--muted);
|
||||
max-width: 920px;
|
||||
line-height: 1.7;
|
||||
.dashboard-year-select {
|
||||
width: 180px;
|
||||
}
|
||||
|
||||
.upload-actions {
|
||||
@@ -69,22 +70,62 @@
|
||||
display: none;
|
||||
}
|
||||
|
||||
.metric-grid {
|
||||
.dashboard-layout {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(280px, 340px) minmax(0, 1fr);
|
||||
gap: 16px;
|
||||
align-items: stretch;
|
||||
}
|
||||
|
||||
.dashboard-status-panel {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(4, minmax(150px, 1fr));
|
||||
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: 16px;
|
||||
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, 220px)) auto;
|
||||
gap: 12px;
|
||||
align-items: end;
|
||||
grid-template-columns: repeat(3, minmax(0, 180px));
|
||||
gap: 10px;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.chart-shell {
|
||||
@@ -93,23 +134,10 @@
|
||||
radial-gradient(circle at top left, rgba(24, 24, 27, 0.045), transparent 38%);
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 16px;
|
||||
padding: 16px;
|
||||
padding: 14px 16px 16px;
|
||||
box-shadow: inset 0 1px 0 rgba(255,255,255,0.92);
|
||||
}
|
||||
|
||||
.chart-head {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: flex-start;
|
||||
gap: 14px;
|
||||
margin-bottom: 10px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.chart-head p {
|
||||
color: var(--muted);
|
||||
font-size: 14px;
|
||||
line-height: 1.7;
|
||||
display: grid;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.legend-box {
|
||||
@@ -118,6 +146,10 @@
|
||||
gap: 10px 12px;
|
||||
}
|
||||
|
||||
.legend-box.center {
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.legend-item {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
@@ -131,14 +163,6 @@
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.metric-grid .stat-card {
|
||||
min-height: 118px;
|
||||
}
|
||||
|
||||
.metric-grid .stat-card .value {
|
||||
margin-top: 2px;
|
||||
}
|
||||
|
||||
.legend-swatch {
|
||||
width: 10px;
|
||||
height: 10px;
|
||||
@@ -150,19 +174,21 @@
|
||||
width: 100%;
|
||||
height: auto;
|
||||
aspect-ratio: 1120 / 390;
|
||||
min-height: 320px;
|
||||
min-height: 300px;
|
||||
display: block;
|
||||
}
|
||||
|
||||
@media (max-width: 1200px) {
|
||||
.metric-grid,
|
||||
.filter-row {
|
||||
grid-template-columns: 1fr 1fr;
|
||||
.dashboard-layout {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.dashboard-chart-stack {
|
||||
grid-template-rows: auto;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 780px) {
|
||||
.metric-grid,
|
||||
@media (max-width: 860px) {
|
||||
.filter-row {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
@@ -172,12 +198,19 @@
|
||||
|
||||
{% block content %}
|
||||
<section class="panel">
|
||||
<div class="hero">
|
||||
<div class="hero-copy">
|
||||
<div class="dashboard-topbar">
|
||||
<div class="section-title" style="margin-bottom: 0;">
|
||||
<h2>대시보드</h2>
|
||||
</div>
|
||||
<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>
|
||||
@@ -193,63 +226,83 @@
|
||||
<div class="upload-tooltip">엑셀 파일을 선택하면 회계 데이터를 DB에 바로 저장합니다. 프로젝트 폴더에 둔 파일 외에 추가 파일을 수동 반영할 때 사용하세요.</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="panel">
|
||||
<div class="section-title">
|
||||
<h2>현황 요약</h2>
|
||||
</div>
|
||||
<form method="get" action="/" class="filter-row" style="margin-bottom: 16px;">
|
||||
<div class="field">
|
||||
<select id="overviewYear" name="overview_year" aria-label="연도 선택">
|
||||
<option value="" {% if not overview_selected_year %}selected{% endif %}>최근 10개년</option>
|
||||
|
||||
<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>
|
||||
</div>
|
||||
</form>
|
||||
<div class="metric-grid">
|
||||
<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_revenue 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>
|
||||
|
||||
<section class="panel chart-panel">
|
||||
<div class="section-title">
|
||||
<h2>통합 비용 집계</h2>
|
||||
</div>
|
||||
<div class="filter-row">
|
||||
<div class="field">
|
||||
<select id="summaryGranularity" aria-label="집계 단위">
|
||||
<option value="yearly">연도별</option>
|
||||
<option value="monthly">월별</option>
|
||||
<select id="revenueMetric" aria-label="수금 구성 항목 선택">
|
||||
<option value="all">전체 항목</option>
|
||||
<option value="design_revenue">설계</option>
|
||||
<option value="design_other_revenue">설계 외</option>
|
||||
<option value="supervision_revenue">감리</option>
|
||||
<option value="inspection_revenue">점검</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="field">
|
||||
<select id="summaryYear" aria-label="연도 선택">
|
||||
</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 }}">{{ year }}</option>
|
||||
<option value="{{ year }}" {% if overview_selected_year == year %}selected{% endif %}>{{ year }}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
</div>
|
||||
<div class="field">
|
||||
<select id="summaryMetric" aria-label="강조 항목">
|
||||
<select id="expenseMetric" aria-label="지출 구성 항목 선택">
|
||||
<option value="all">전체 항목</option>
|
||||
<option value="cost_sum">원가</option>
|
||||
<option value="sga_sum">판관비</option>
|
||||
@@ -257,56 +310,49 @@
|
||||
<option value="outsourcing_sum">원가외주비</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="legend-box" id="summaryLegend"></div>
|
||||
</div>
|
||||
<div class="chart-shell">
|
||||
<svg id="summaryChart" class="chart-svg" viewBox="0 0 1120 390" preserveAspectRatio="xMidYMid meet"></svg>
|
||||
<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>
|
||||
|
||||
<section class="panel chart-panel">
|
||||
<div class="section-title">
|
||||
<h2>원가·판관비 추세</h2>
|
||||
</section>
|
||||
</div>
|
||||
<div class="filter-row">
|
||||
<div class="field">
|
||||
<select id="trendYear" aria-label="연도 선택">
|
||||
<option value="recent10">최근 10개년</option>
|
||||
{% for year in available_years %}
|
||||
<option value="{{ year }}">{{ year }}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
</div>
|
||||
<div class="field">
|
||||
<select id="trendMetric" aria-label="표시 방식">
|
||||
<option value="cost_sga">원가 + 판관비</option>
|
||||
<option value="all">전체 항목</option>
|
||||
<option value="labor_outsourcing">인건비 + 외주비</option>
|
||||
</select>
|
||||
</div>
|
||||
<div></div>
|
||||
<div class="legend-box" id="trendLegend"></div>
|
||||
</div>
|
||||
<div class="chart-shell">
|
||||
<svg id="trendChart" class="chart-svg" viewBox="0 0 1120 390" preserveAspectRatio="xMidYMid meet"></svg>
|
||||
</div>
|
||||
</section>
|
||||
{% endblock %}
|
||||
|
||||
{% block script %}
|
||||
<script>
|
||||
const availableYears = {{ available_years | tojson }};
|
||||
const yearlySummary = {{ yearly_summary | tojson }};
|
||||
const monthlySummary = {{ monthly_summary | tojson }};
|
||||
const availableYears = {{ available_years | tojson }};
|
||||
const revenueYearly = {{ project_revenue_mix_yearly | tojson }};
|
||||
const revenueMonthly = {{ project_revenue_mix_monthly | tojson }};
|
||||
const pageSelectedYear = {{ overview_selected_year | tojson }};
|
||||
|
||||
const palette = {
|
||||
const revenuePalette = {
|
||||
design_revenue: { label: "설계", color: "#4f7cff" },
|
||||
design_other_revenue: { label: "설계 외", color: "#67c7c9" },
|
||||
supervision_revenue: { label: "감리", color: "#233a5a" },
|
||||
inspection_revenue: { label: "점검", color: "#ffb54a" },
|
||||
};
|
||||
|
||||
const expensePalette = {
|
||||
cost_sum: { label: "원가", color: "#4f7cff" },
|
||||
sga_sum: { label: "판관비", color: "#67c7c9" },
|
||||
labor_sum: { label: "원가인건비", color: "#233a5a" },
|
||||
outsourcing_sum: { label: "원가외주비", color: "#ffb54a" },
|
||||
};
|
||||
|
||||
const summaryMetricsMap = {
|
||||
const revenueMetricMap = {
|
||||
all: ["design_revenue", "design_other_revenue", "supervision_revenue", "inspection_revenue"],
|
||||
design_revenue: ["design_revenue"],
|
||||
design_other_revenue: ["design_other_revenue"],
|
||||
supervision_revenue: ["supervision_revenue"],
|
||||
inspection_revenue: ["inspection_revenue"],
|
||||
};
|
||||
|
||||
const expenseMetricMap = {
|
||||
all: ["cost_sum", "sga_sum", "labor_sum", "outsourcing_sum"],
|
||||
cost_sum: ["cost_sum"],
|
||||
sga_sum: ["sga_sum"],
|
||||
@@ -314,12 +360,6 @@
|
||||
outsourcing_sum: ["outsourcing_sum"],
|
||||
};
|
||||
|
||||
const trendMetricsMap = {
|
||||
cost_sga: ["cost_sum", "sga_sum"],
|
||||
all: ["cost_sum", "sga_sum", "labor_sum", "outsourcing_sum"],
|
||||
labor_outsourcing: ["labor_sum", "outsourcing_sum"],
|
||||
};
|
||||
|
||||
function formatNumber(value) {
|
||||
return new Intl.NumberFormat("ko-KR", { maximumFractionDigits: 0 }).format(value || 0);
|
||||
}
|
||||
@@ -345,7 +385,7 @@
|
||||
function pickTickStep(maxValue) {
|
||||
const baseUnit = maxValue < 1000000 ? 1000 : 1000000;
|
||||
const units = [1, 2, 5];
|
||||
const raw = Math.max(maxValue / 5 / baseUnit, 1);
|
||||
const raw = Math.max(maxValue / baseUnit, 1);
|
||||
let power = 1;
|
||||
while (power * 10 <= raw) power *= 10;
|
||||
for (const unit of units) {
|
||||
@@ -355,13 +395,21 @@
|
||||
return power * 10 * baseUnit;
|
||||
}
|
||||
|
||||
function setLegend(containerId, metricKeys) {
|
||||
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:${palette[key].color}"></span>
|
||||
${palette[key].label}
|
||||
<span class="legend-swatch" style="background:${paletteMap[key].color}"></span>
|
||||
${paletteMap[key].label}
|
||||
</span>
|
||||
`).join("");
|
||||
}
|
||||
@@ -375,7 +423,7 @@
|
||||
`;
|
||||
}
|
||||
|
||||
function renderGroupedBarChart(svgId, rows, metricKeys, options = {}) {
|
||||
function renderGroupedBarChart(svgId, rows, metricKeys, paletteMap, options = {}) {
|
||||
const svg = document.getElementById(svgId);
|
||||
if (!svg) return;
|
||||
if (!rows.length || !metricKeys.length) {
|
||||
@@ -386,15 +434,11 @@
|
||||
const granularity = options.granularity || "yearly";
|
||||
const width = 1120;
|
||||
const height = 390;
|
||||
const margin = { top: 42, right: 36, bottom: 78, left: 94 };
|
||||
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 = pickTickStep(maxValue);
|
||||
const tickMax = Math.ceil(maxValue / tickStep) * tickStep;
|
||||
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;
|
||||
@@ -418,7 +462,6 @@
|
||||
</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>
|
||||
<text x="${margin.left}" y="24" fill="#334654" font-size="14" font-weight="700">${granularity === "monthly" ? "월간 비교" : "연간 비교"}</text>
|
||||
`;
|
||||
|
||||
for (let value = 0; value <= tickMax; value += tickStep) {
|
||||
@@ -439,7 +482,7 @@
|
||||
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="${palette[key].color}" filter="url(#${svgId}Shadow)"></rect>`;
|
||||
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>`;
|
||||
}
|
||||
@@ -452,69 +495,44 @@
|
||||
svg.innerHTML = markup;
|
||||
}
|
||||
|
||||
function renderLineChart(svgId, rows, metricKeys) {
|
||||
const svg = document.getElementById(svgId);
|
||||
if (!svg) return;
|
||||
const width = 1120;
|
||||
const height = 390;
|
||||
const margin = { top: 24, right: 24, bottom: 64, left: 86 };
|
||||
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 = pickTickStep(maxValue);
|
||||
const tickMax = Math.ceil(maxValue / tickStep) * tickStep;
|
||||
const xStep = rows.length > 1 ? plotWidth / (rows.length - 1) : 0;
|
||||
const axisBaseY = height - margin.bottom;
|
||||
|
||||
let markup = `
|
||||
<defs>
|
||||
<linearGradient id="${svgId}Bg" x1="0" x2="0" y1="0" y2="1">
|
||||
<stop offset="0%" stop-color="#ffffff" stop-opacity="0.9" />
|
||||
<stop offset="100%" stop-color="#f3f8fa" stop-opacity="0.98" />
|
||||
</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.16)" />
|
||||
</filter>
|
||||
</defs>
|
||||
<rect x="0" y="0" width="${width}" height="${height}" rx="24" fill="url(#${svgId}Bg)"></rect>
|
||||
<rect x="${margin.left}" y="${margin.top}" width="${plotWidth}" height="${plotHeight}" rx="18" fill="rgba(255,255,255,0.68)"></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="#d6e2e8" stroke-dasharray="4 7"></line>`;
|
||||
markup += `<text x="${margin.left - 14}" y="${y + 5}" text-anchor="end" fill="#62717f" font-size="12">${formatAxisLabel(value)}</text>`;
|
||||
}
|
||||
markup += `<line x1="${margin.left}" y1="${axisBaseY}" x2="${width - margin.right}" y2="${axisBaseY}" stroke="#7f95a5" stroke-width="1.2"></line>`;
|
||||
|
||||
metricKeys.forEach((key) => {
|
||||
const points = rows.map((row, index) => {
|
||||
const x = margin.left + index * xStep;
|
||||
const y = margin.top + plotHeight - ((Number(row[key] || 0) / tickMax) * plotHeight);
|
||||
return { x, y, value: Number(row[key] || 0), label: row.label };
|
||||
});
|
||||
markup += `<path d="M ${points.map((p) => `${p.x} ${p.y}`).join(" L ")}" fill="none" stroke="${palette[key].color}" stroke-width="4" stroke-linecap="round" stroke-linejoin="round" filter="url(#${svgId}Shadow)"></path>`;
|
||||
points.forEach((point) => {
|
||||
markup += `<circle cx="${point.x}" cy="${point.y}" r="5.5" fill="${palette[key].color}" stroke="#ffffff" stroke-width="2"></circle>`;
|
||||
});
|
||||
});
|
||||
|
||||
rows.forEach((row, index) => {
|
||||
const x = margin.left + index * xStep;
|
||||
if (rows.length <= 18 || index % Math.ceil(rows.length / 12) === 0) {
|
||||
markup += `<text x="${x}" y="${height - 26}" text-anchor="middle" fill="#425464" font-size="13" font-weight="700">${row.label}</text>`;
|
||||
}
|
||||
});
|
||||
|
||||
svg.innerHTML = markup;
|
||||
function getLatestAvailableYear() {
|
||||
return String(availableYears[availableYears.length - 1] || "all");
|
||||
}
|
||||
|
||||
function getSummaryRows() {
|
||||
const granularity = document.getElementById("summaryGranularity").value;
|
||||
const yearFilter = document.getElementById("summaryYear").value;
|
||||
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) => {
|
||||
@@ -528,79 +546,36 @@
|
||||
.slice(granularity === "yearly" ? -10 : 0);
|
||||
}
|
||||
|
||||
function getLatestAvailableYear() {
|
||||
return String(availableYears[availableYears.length - 1] || "all");
|
||||
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 syncSummaryYearSelection(granularity) {
|
||||
const summaryYearSelect = document.getElementById("summaryYear");
|
||||
if (!summaryYearSelect) return;
|
||||
|
||||
if (granularity === "yearly") {
|
||||
summaryYearSelect.value = "all";
|
||||
summaryYearSelect.disabled = true;
|
||||
return;
|
||||
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 });
|
||||
}
|
||||
|
||||
const hasSelectedYear = availableYears.some((year) => String(year) === String(summaryYearSelect.value));
|
||||
if (summaryYearSelect.value === "all" || !hasSelectedYear) {
|
||||
summaryYearSelect.value = getLatestAvailableYear();
|
||||
}
|
||||
summaryYearSelect.disabled = false;
|
||||
}
|
||||
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);
|
||||
|
||||
function updateSummaryChart() {
|
||||
const granularity = document.getElementById("summaryGranularity").value;
|
||||
const metricMode = document.getElementById("summaryMetric").value;
|
||||
const metricKeys = summaryMetricsMap[metricMode];
|
||||
syncSummaryYearSelection(granularity);
|
||||
const rows = getSummaryRows();
|
||||
setLegend("summaryLegend", metricKeys);
|
||||
renderGroupedBarChart("summaryChart", rows, metricKeys, { granularity });
|
||||
}
|
||||
|
||||
function updateTrendChart() {
|
||||
const selectedYear = document.getElementById("trendYear").value;
|
||||
const metricMode = document.getElementById("trendMetric").value;
|
||||
const metricKeys = trendMetricsMap[metricMode];
|
||||
const rows = selectedYear === "recent10"
|
||||
? yearlySummary
|
||||
.slice(-10)
|
||||
.map((row) => ({
|
||||
...row,
|
||||
label: String(row.year),
|
||||
}))
|
||||
: monthlySummary
|
||||
.filter((row) => String(row.year) === String(selectedYear))
|
||||
.map((row) => ({ ...row, label: `${row.month}월` }));
|
||||
setLegend("trendLegend", metricKeys);
|
||||
renderLineChart("trendChart", rows, metricKeys);
|
||||
}
|
||||
|
||||
document.getElementById("summaryGranularity")?.addEventListener("change", updateSummaryChart);
|
||||
document.getElementById("summaryYear")?.addEventListener("change", updateSummaryChart);
|
||||
document.getElementById("summaryMetric")?.addEventListener("change", updateSummaryChart);
|
||||
document.getElementById("trendYear")?.addEventListener("change", updateTrendChart);
|
||||
document.getElementById("trendMetric")?.addEventListener("change", updateTrendChart);
|
||||
|
||||
const defaultYear = getLatestAvailableYear();
|
||||
const trendYear = document.getElementById("trendYear");
|
||||
if (trendYear) {
|
||||
trendYear.value = "recent10";
|
||||
}
|
||||
const summaryYear = document.getElementById("summaryYear");
|
||||
if (summaryYear) {
|
||||
syncSummaryYearSelection(document.getElementById("summaryGranularity")?.value || "yearly");
|
||||
}
|
||||
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");
|
||||
const overviewYear = document.getElementById("overviewYear");
|
||||
|
||||
overviewYear?.addEventListener("change", (event) => {
|
||||
event.target.form?.submit();
|
||||
});
|
||||
|
||||
uploadButton?.addEventListener("click", () => {
|
||||
excelInput?.click();
|
||||
@@ -612,7 +587,9 @@
|
||||
}
|
||||
});
|
||||
|
||||
updateSummaryChart();
|
||||
updateTrendChart();
|
||||
syncYearSelection("revenueYear", document.getElementById("revenueGranularity")?.value || "yearly");
|
||||
syncYearSelection("expenseYear", document.getElementById("expenseGranularity")?.value || "yearly");
|
||||
updateRevenueChart();
|
||||
updateExpenseChart();
|
||||
</script>
|
||||
{% endblock %}
|
||||
|
||||
+2036
-371
File diff suppressed because it is too large
Load Diff
Binary file not shown.
Binary file not shown.
Reference in New Issue
Block a user