diff --git a/WORK_SUMMARY_20260408.md b/WORK_SUMMARY_20260408.md
new file mode 100644
index 0000000..de2a715
--- /dev/null
+++ b/WORK_SUMMARY_20260408.md
@@ -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/`에 생성했습니다.
diff --git a/data.db b/data.db
index d131c95..8475417 100644
Binary files a/data.db and b/data.db differ
diff --git a/main.py b/main.py
index 9f8e175..82f50db 100644
--- a/main.py
+++ b/main.py
@@ -215,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()
@@ -247,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:
@@ -1088,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()
@@ -1240,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] = {}
@@ -1723,19 +1880,85 @@ def build_project_status_payload(payload: dict[str, Any]) -> dict[str, Any]:
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_input_rows.append(
+ 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(
{
- "reference": ref,
- "amount": actual_amounts[index] if index < len(actual_amounts) else "",
- "note": actual_notes[index] if index < len(actual_notes) else "",
+ "group": "labor_adjustment",
+ "label": "인건비 조정",
+ "amount": actual_labor_adjustment_total,
}
)
- actual_input_rows = filter_amount_rows(actual_input_rows, amount_key="amount")
+
+ 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": 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")
expected_as_rate = normalize_amount(payload.get("expected_as_rate"))
expected_sga_rate = normalize_amount(payload.get("expected_sga_rate"))
@@ -1979,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,
@@ -2004,6 +2230,7 @@ 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(),
@@ -2054,6 +2281,19 @@ async def project_edit_data(code: str | None = None):
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:
diff --git a/template_backups/20260408_ko/annual_summary.html b/template_backups/20260408_ko/annual_summary.html
new file mode 100644
index 0000000..35b7915
--- /dev/null
+++ b/template_backups/20260408_ko/annual_summary.html
@@ -0,0 +1,466 @@
+{% extends "base.html" %}
+
+{% block title %}연도별 수익 비용 정리{% endblock %}
+
+{% block head_extra %}
+
+{% endblock %}
+
+{% block content %}
+연도별 수익 비용 정리
+ 선택 구간 요약
+ 비용 구조
+ 수금/비용/영업수지 그래프
+
DB에 저장된 전체 자료 기준
+| 연도 | +원가 합계 | +판관비 합계 | +원가인건비 | +원가외주비 | +
|---|---|---|---|---|
| {{ item.year }} | +{{ "{:,.0f}".format(item.cost_sum or 0) }} | +{{ "{:,.0f}".format(item.sga_sum or 0) }} | +{{ "{:,.0f}".format(item.labor_sum or 0) }} | +{{ "{:,.0f}".format(item.outsourcing_sum or 0) }} | +
| 연도 | +월 | +원가 합계 | +판관비 합계 | +원가인건비 | +원가외주비 | +
|---|---|---|---|---|---|
| {{ item.year }} | +{{ item.month }} | +{{ "{:,.0f}".format(item.cost_sum or 0) }} | +{{ "{:,.0f}".format(item.sga_sum or 0) }} | +{{ "{:,.0f}".format(item.labor_sum or 0) }} | +{{ "{:,.0f}".format(item.outsourcing_sum or 0) }} | +
업로드 즉시 DB 저장
++ 업로드 파일은 이미지에 보인 열 형식 기준으로 읽습니다. + 예: 결재상태, 가전표번호, 계정코드, 계정명칭, 차변공급가, 대변공급가, 지원부서코드, + 지원부서명, 원가부서코드, 원가부서명, 적요1, 관리항목 등 +
++ 현재 프로젝트 폴더에 있는 엑셀 파일은 서버 시작 시 DB가 비어 있으면 자동으로 적재됩니다. +
+ +엑셀 없이도 직접 등록/수정 가능
+| 지원부서코드 | +사업명 | +행 수 | +
|---|---|---|
| {{ item.support_dept_code }} | +{{ item.support_dept_name }} | +{{ item.row_count }} | +
| 연도 | +월 | +지원부서코드 | +사업명 | +원가 | +판관비 | +
|---|---|---|---|---|---|
| {{ item.year }} | +{{ item.month }} | +{{ item.support_dept_code }} | +{{ item.support_dept_name }} | +{{ "{:,.0f}".format(item.cost_sum or 0) }} | +{{ "{:,.0f}".format(item.sga_sum or 0) }} | +