diff --git a/data.db b/data.db index 4196149..d131c95 100644 Binary files a/data.db and b/data.db differ diff --git a/main.py b/main.py index d6eb047..9f8e175 100644 --- a/main.py +++ b/main.py @@ -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 '[]', @@ -227,6 +230,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 '[]'", @@ -368,6 +372,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 +815,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 +874,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 +902,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 +937,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 +981,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 +1002,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 +1020,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"] = [ @@ -1175,7 +1299,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 +1309,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 +1584,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,57 +1665,59 @@ 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 = filter_amount_rows(exec_outsource_rows, amount_key="amount") - exec_cost_plan_rows = build_named_amount_rows( - payload.get("exec_cost_plan_label[]", []), + 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", + ) + 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" @@ -1587,6 +1741,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 +1755,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 +1870,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 +1902,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 +1934,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, @@ -1846,6 +2005,8 @@ def render_projects_page( "project_status_rows": get_project_status_rows(), "project_edit": get_project_status_for_edit(edit_code), "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 +2045,15 @@ async def projects(request: Request, edit_code: str | None = None, year: str | N return HTMLResponse("

서버 오류

로그를 확인해주세요.

", 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.get("/annual-summary") async def annual_summary(request: Request): try: @@ -1953,4 +2123,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)]) diff --git a/scripts/run_server.sh b/scripts/run_server.sh index 81a893f..4ed8cce 100755 --- a/scripts/run_server.sh +++ b/scripts/run_server.sh @@ -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 diff --git a/templates/annual_summary.html b/templates/annual_summary.html index 83ca800..35b7915 100644 --- a/templates/annual_summary.html +++ b/templates/annual_summary.html @@ -134,7 +134,7 @@
-

수익/비용/영업수지 그래프

+

수금/비용/영업수지 그래프

@@ -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 += ``; + axis += `${formatAxisLabel(value)}`; + } + axis += ``; + 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 += ``; - if (value > 0) { - markup += `${formatAxisLabel(value)}`; + if (value !== 0) { + const labelY = value < 0 + ? Math.min(y + barHeight + 14, height - margin.bottom + 6) + : Math.max(y - 8, margin.top + 12); + markup += `${formatAxisLabel(value)}`; } }); markup += `${item.label}`; diff --git a/templates/dashboard.html b/templates/dashboard.html index d0dfd26..13de0d7 100644 --- a/templates/dashboard.html +++ b/templates/dashboard.html @@ -345,7 +345,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,6 +355,14 @@ 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) { const target = document.getElementById(containerId); if (!target) return; @@ -393,8 +401,7 @@ 1, ...rows.flatMap((row) => metricKeys.map((key) => Number(row[key] || 0))), ); - const tickStep = pickTickStep(maxValue); - const tickMax = Math.ceil(maxValue / tickStep) * tickStep; + 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; @@ -464,8 +471,7 @@ 1, ...rows.flatMap((row) => metricKeys.map((key) => Number(row[key] || 0))), ); - const tickStep = pickTickStep(maxValue); - const tickMax = Math.ceil(maxValue / tickStep) * tickStep; + const { tickStep, tickMax } = buildPositiveAxis(maxValue, 4); const xStep = rows.length > 1 ? plotWidth / (rows.length - 1) : 0; const axisBaseY = height - margin.bottom; diff --git a/templates/projects.html b/templates/projects.html index bcb03b9..4e321a8 100644 --- a/templates/projects.html +++ b/templates/projects.html @@ -184,9 +184,31 @@ padding: 14px; } + .toolbar-head { + display: flex; + justify-content: space-between; + align-items: center; + gap: 12px; + margin-bottom: 10px; + } + .toolbar-field h3 { font-size: 16px; - margin-bottom: 10px; + margin-bottom: 0; + } + + .toolbar-head-actions { + display: inline-flex; + align-items: center; + gap: 8px; + } + + .toggle-analysis-button svg { + transition: transform 0.16s ease; + } + + .toggle-analysis-button.is-open svg { + transform: rotate(180deg); } .project-search-controls { @@ -314,9 +336,18 @@ gap: 14px; } + .analysis-title-block { + display: flex; + align-items: flex-start; + gap: 10px; + min-width: 0; + flex: 1 1 auto; + } + .analysis-title { display: grid; gap: 6px; + min-width: 0; } .analysis-title h3 { @@ -369,6 +400,133 @@ font-size: 16px; } + .related-trigger-button { + margin-top: 2px; + } + + .analysis-related-bar { + display: grid; + gap: 10px; + } + + .analysis-related-bar:empty { + display: none; + } + + .related-tag-row { + display: flex; + flex-wrap: wrap; + gap: 8px; + align-items: center; + } + + .related-tag-label { + color: var(--muted); + font-size: 12px; + font-weight: 700; + } + + .related-project-tag { + display: inline-flex; + align-items: center; + gap: 8px; + border: 1px solid #d7e2eb; + background: #f7fbfe; + color: #29485f; + border-radius: 999px; + padding: 5px 10px; + font-size: 12px; + font-weight: 700; + line-height: 1.2; + } + + .related-project-tag button { + width: 18px; + height: 18px; + min-width: 18px; + padding: 0; + border-radius: 999px; + border: 0; + background: rgba(41, 72, 95, 0.08); + color: #29485f; + box-shadow: none; + } + + .related-project-tag button:hover { + transform: none; + background: rgba(41, 72, 95, 0.14); + } + + .related-modal-card { + width: min(680px, 100%); + max-height: min(70vh, 760px); + overflow: auto; + background: #ffffff; + border-radius: 16px; + border: 1px solid var(--line); + box-shadow: 0 24px 64px rgba(21, 24, 29, 0.18); + padding: 14px; + display: grid; + gap: 12px; + } + + .related-modal-head { + display: flex; + justify-content: space-between; + align-items: center; + gap: 12px; + } + + .related-modal-title { + font-size: 16px; + font-weight: 800; + } + + .related-modal-body { + display: grid; + gap: 10px; + } + + .related-search-results { + display: grid; + gap: 0; + border: 1px solid #e5ebf0; + border-radius: 14px; + overflow: hidden; + background: #fff; + } + + .related-search-item { + display: flex; + justify-content: space-between; + align-items: center; + gap: 12px; + padding: 11px 14px; + border-top: 1px solid #eef2f5; + } + + .related-search-item:first-child { + border-top: 0; + } + + .related-search-copy { + display: grid; + gap: 4px; + text-align: left; + min-width: 0; + } + + .related-search-copy strong { + font-size: 13px; + line-height: 1.45; + } + + .related-search-copy span { + font-size: 12px; + color: var(--muted); + line-height: 1.45; + } + .comparison-main-row { cursor: pointer; } @@ -741,7 +899,7 @@ border-radius: 14px; border: 1px solid var(--line); box-shadow: 0 24px 64px rgba(21, 24, 29, 0.18); - padding: 16px; + padding: 12px; } .modal-form { @@ -838,23 +996,32 @@ border-radius: 10px; background: #ffffff; box-shadow: 0 14px 30px rgba(21, 24, 29, 0.08); - max-height: 180px; + max-height: 240px; overflow: auto; display: none; } .lookup-results.open { display: grid; + justify-items: stretch; + align-items: start; } .lookup-option { padding: 8px 10px; border: none; background: transparent; + display: grid; + justify-items: start; + align-items: start; text-align: left; color: var(--ink); cursor: pointer; border-bottom: 1px solid #eef1f4; + white-space: normal; + overflow-wrap: anywhere; + line-height: 1.35; + width: 100%; } .lookup-option:last-child { @@ -864,19 +1031,54 @@ .lookup-option strong, .lookup-option span { display: block; + width: 100%; + white-space: normal; + overflow-wrap: anywhere; + text-align: left; + } + + .lookup-option strong { + font-size: 11px; + font-weight: 600; + line-height: 1.35; } .lookup-option span { color: var(--muted); - font-size: 11px; + font-size: 10px; margin-top: 2px; + line-height: 1.35; + } + + #supportDeptCodeResults, + #supportDeptNameResults { + text-align: left; + } + + #supportDeptCodeResults .lookup-option, + #supportDeptNameResults .lookup-option { + display: block; + width: 100%; + justify-self: stretch; + text-align: left !important; + } + + #supportDeptCodeResults .lookup-option strong, + #supportDeptCodeResults .lookup-option span, + #supportDeptNameResults .lookup-option strong, + #supportDeptNameResults .lookup-option span { + width: 100%; + margin: 0; + text-align: left !important; } .row-table-wrap { - overflow: hidden; + overflow: visible; border: 1px solid var(--line); border-radius: 10px; background: #ffffff; + position: relative; + z-index: 1; } .row-table { @@ -891,7 +1093,7 @@ padding: 6px 7px; border-bottom: 1px solid #eef1f4; font-size: 12px; - vertical-align: middle; + vertical-align: top; } .row-table th { @@ -905,10 +1107,42 @@ width: 100%; } + .row-table td .lookup-wrap { + width: 100%; + } + + .row-table .lookup-results { + max-height: 150px; + } + + .modal-col-dept, + .modal-col-work, + .modal-col-amount, + .modal-col-remove { + width: auto; + } + + .modal-col-dept { + width: 34%; + } + + .modal-col-work { + width: 28%; + } + + .modal-col-amount { + width: 28%; + } + + .modal-col-remove { + width: 10%; + } + .section-columns { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 8px; + align-items: start; } .mini-sector { @@ -918,6 +1152,8 @@ padding: 10px; display: grid; gap: 8px; + align-content: start; + overflow: visible; } .mini-sector h4 { @@ -925,6 +1161,25 @@ line-height: 1.35; } + .row-remove-button { + min-width: 16px; + width: 16px; + height: 16px; + padding: 0; + border-radius: 5px; + box-shadow: none; + } + + .row-remove-button svg { + width: 10px; + height: 10px; + stroke: currentColor; + fill: none; + stroke-width: 2; + stroke-linecap: round; + stroke-linejoin: round; + } + .row-table.compact { min-width: 100%; } @@ -1102,7 +1357,26 @@ justify-content: space-between; align-items: center; gap: 12px; - margin-bottom: 10px; + position: sticky; + top: -12px; + z-index: 4; + margin: -12px -12px 8px; + padding: 9px 12px; + background: rgba(255, 255, 255, 0.98); + border-bottom: 1px solid #e4e9ef; + backdrop-filter: blur(10px); + } + + .modal-title { + font-size: 16px; + line-height: 1.2; + letter-spacing: -0.02em; + } + + .modal-header-actions { + display: inline-flex; + align-items: center; + gap: 8px; } .close-button { @@ -1199,7 +1473,32 @@
-

프로젝트 검색

+
+

프로젝트 검색

+
+ + + +
+
@@ -1209,32 +1508,18 @@
- - {% for year in project_year_options %} {% endfor %}
- -
- @@ -1900,26 +2320,28 @@ function createTaskPlanRow(group, row = {}) { const fieldMap = { - department: ["task_plan_department_label[]", "task_plan_department_amount[]"], - outsource: ["task_plan_outsource_vendor[]", "task_plan_outsource_amount[]"], - joint: ["task_plan_joint_label[]", "task_plan_joint_amount[]"], + department: ["task_plan_department_dept[]", "task_plan_department_work[]", "task_plan_department_amount[]"], + outsource: ["task_plan_outsource_dept[]", "task_plan_outsource_work[]", "task_plan_outsource_amount[]"], + joint: ["task_plan_joint_dept[]", "task_plan_joint_work[]", "task_plan_joint_amount[]"], }; - const [labelName, amountName] = fieldMap[group]; - const noteField = group === "outsource" - ? `` - : ""; + const [deptName, workName, amountName] = fieldMap[group]; + const deptValue = row.dept_name || row.label || ""; + const workValue = row.work_name || row.note || ""; + const deptField = ` +
+ +
+
+ `; + const workField = ``; return ` - - - ${group === "outsource" ? `${noteField}` : ""} - @@ -1928,27 +2350,63 @@ } function createExecBudgetRow(group, row = {}) { + if (group === "labor") { + const selectedGrade = row.grade || row.dept_name || ""; + const inputHours = row.hours || row.work_name || ""; + const amountValue = row.amount || calculateLaborAmount(selectedGrade, inputHours); + return ` + + + + + + + + + `; + } const fieldMap = { - labor: ["exec_labor_role[]", "exec_labor_amount[]"], - outsource: ["exec_outsource_vendor[]", "exec_outsource_amount[]"], - cost_plan: ["exec_cost_plan_label[]", "exec_cost_plan_amount[]"], + outsource: ["exec_outsource_dept[]", "exec_outsource_work[]", "exec_outsource_amount[]"], + cost_plan: ["exec_cost_plan_code[]", "exec_cost_plan_name[]", "exec_cost_plan_amount[]"], }; - const [labelName, amountName] = fieldMap[group]; - const noteField = group === "outsource" - ? `` - : ""; + const [firstName, secondName, amountName] = fieldMap[group]; + const firstValue = row.dept_name || row.account_code || row.label || ""; + const secondValue = row.work_name || row.account_name || row.note || ""; return ` - - - ${group === "outsource" ? `${noteField}` : ""} - @@ -1963,9 +2421,11 @@ ...execCostPlanRows.querySelectorAll("tr"), ]; return rows.map((row, index) => { - const label = row.querySelector("td:first-child input")?.value?.trim(); + const dept = row.querySelector("td:nth-child(1) input, td:nth-child(1) select")?.value?.trim(); + const work = row.querySelector("td:nth-child(2) input, td:nth-child(2) select")?.value?.trim(); const containerTitle = row.closest(".mini-sector")?.querySelector("h4")?.textContent?.trim() || ""; - if (!label) return null; + if (!dept && !work) return null; + const label = [dept, work].filter(Boolean).join(" / "); return { value: `${containerTitle}::${label}`, label: `${containerTitle} / ${label}`, @@ -1986,15 +2446,11 @@ ${options} - + - @@ -2080,9 +2536,7 @@ } function attachTableEvents(container) { - if (container === collectionRows) { - bindCollectionFieldBehaviors(container); - } + bindCollectionFieldBehaviors(container); container.querySelectorAll("[data-remove-row]").forEach((button) => { button.onclick = () => { button.closest("[data-collection-row], tr")?.remove(); @@ -2094,6 +2548,9 @@ }); container.querySelectorAll("input, select").forEach((input) => { input.oninput = () => { + if (container === execLaborRows) { + recalculateExecLaborAmounts(); + } if ([execLaborRows, execOutsourceRows, execCostPlanRows].includes(container)) { refreshActualInputOptions(); } @@ -2176,6 +2633,119 @@ }); } + function renderCostDeptResults(keyword, target, input) { + const normalized = keyword.trim().toLowerCase(); + if (!normalized || !target) { + target?.classList.remove("open"); + if (target) target.innerHTML = ""; + return; + } + const matches = costDepartmentOptions + .filter((item) => String(item.cost_dept_name || "").toLowerCase().includes(normalized)) + .slice(0, 10); + if (!matches.length) { + target.classList.remove("open"); + target.innerHTML = ""; + return; + } + target.innerHTML = matches.map((item) => ` + + `).join(""); + target.classList.add("open"); + target.querySelectorAll("[data-cost-dept-name]").forEach((button) => { + button.addEventListener("click", () => { + input.value = button.dataset.costDeptName || ""; + target.classList.remove("open"); + target.innerHTML = ""; + input.focus(); + }); + }); + } + + function renderCostAccountResults(keyword, target, row, mode) { + const normalized = keyword.trim().toLowerCase(); + if (!normalized || !target) { + target?.classList.remove("open"); + if (target) target.innerHTML = ""; + return; + } + const matches = costAccountOptions + .filter((item) => `${item.account_code} ${item.account_name}`.toLowerCase().includes(normalized)) + .slice(0, 10); + if (!matches.length) { + target.classList.remove("open"); + target.innerHTML = ""; + return; + } + target.innerHTML = matches.map((item) => ` + + `).join(""); + target.classList.add("open"); + target.querySelectorAll("[data-account-code]").forEach((button) => { + button.addEventListener("click", () => { + applyCostAccountSelection(row, { + account_code: button.dataset.accountCode || "", + account_name: button.dataset.accountName || "", + }); + target.classList.remove("open"); + target.innerHTML = ""; + const nextField = mode === "code" + ? row.querySelector(".cost-account-name-lookup") + : row.querySelector(".cost-account-code-lookup"); + nextField?.focus(); + }); + }); + } + + function applyCostAccountSelection(row, item) { + row.querySelector(".cost-account-code-lookup").value = item.account_code || ""; + row.querySelector(".cost-account-name-lookup").value = item.account_name || ""; + } + + function renderLaborRateRows() { + if (!laborRateRows) return; + laborRateRows.innerHTML = laborGradeOptions.map((grade) => ` + + ${escapeHtml(grade)} + + + `).join(""); + bindCollectionFieldBehaviors(laborRateRows); + laborRateRows.querySelectorAll(".labor-rate-input").forEach((input) => { + input.addEventListener("input", () => { + const grade = input.dataset.grade || ""; + currentLaborRates[grade] = parseAmount(input.value); + writeLaborRates(currentLaborRates); + recalculateExecLaborAmounts(); + }); + }); + } + + function openLaborRateModal() { + renderLaborRateRows(); + laborRateModal?.classList.add("open"); + } + + function closeLaborRateModal() { + laborRateModal?.classList.remove("open"); + } + + function recalculateExecLaborAmounts() { + execLaborRows.querySelectorAll("tr").forEach((row) => { + const grade = row.querySelector(".exec-labor-grade")?.value || ""; + const hours = row.querySelector(".exec-labor-hours")?.value || ""; + const amountInput = row.querySelector(".exec-labor-amount"); + if (amountInput) { + amountInput.value = formatAmountInputValue(calculateLaborAmount(grade, hours)); + } + }); + updateComputedFields(); + } + function bindSupportLookup(input, target) { input?.addEventListener("input", (event) => { renderSupportDeptResults(event.target.value, target); @@ -2200,10 +2770,223 @@ } } + function createEmptyProjectEdit(code = "", name = "") { + return { + support_dept_code: code, + support_dept_name: name, + progress_rate: "", + contract_amount: "", + collection_amount: "", + collection_entries: [], + change_round: "", + item_investment: "", + task_plan_department_budget: "", + task_plan_outsource_budget: "", + task_plan_outsource_detail: "", + 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: [], + actual_input_entries: [], + project_type: "", + expected_as_rate: "0", + expected_sga_rate: "13", + expected_as_cost: "", + expected_sga_budget: "", + project_start_date: "", + project_end_date: "", + completion_status: "", + notes: "", + updated_at: "", + }; + } + + function cloneProjectEditData(data) { + return JSON.parse(JSON.stringify(data || createEmptyProjectEdit())); + } + + function clearDynamicRows() { + collectionRows.innerHTML = ""; + taskPlanDepartmentRows.innerHTML = ""; + taskPlanOutsourceRows.innerHTML = ""; + taskPlanJointRows.innerHTML = ""; + execLaborRows.innerHTML = ""; + execOutsourceRows.innerHTML = ""; + execCostPlanRows.innerHTML = ""; + actualInputRows.innerHTML = ""; + } + + function applyProjectEditData(editData) { + const normalized = { + ...createEmptyProjectEdit(), + ...(editData || {}), + collection_entries: Array.isArray(editData?.collection_entries) ? editData.collection_entries : [], + task_plan_entries: Array.isArray(editData?.task_plan_entries) ? editData.task_plan_entries : [], + exec_budget_entries: Array.isArray(editData?.exec_budget_entries) ? editData.exec_budget_entries : [], + actual_input_entries: Array.isArray(editData?.actual_input_entries) ? editData.actual_input_entries : [], + }; + projectEdit = normalized; + currentLaborRates = { ...(normalized.exec_labor_rates || {}) }; + writeLaborRates(currentLaborRates); + if (normalized.support_dept_code) { + projectEditCache.set(normalized.support_dept_code, normalized); + } + currentSelectedProjectCode = normalized.support_dept_code || currentSelectedProjectCode || ""; + supportDeptCodeInput.value = normalized.support_dept_code || ""; + supportDeptCodeLookup.value = normalized.support_dept_code || ""; + supportDeptNameInput.value = normalized.support_dept_name || ""; + projectTypeInput.value = normalized.project_type || ""; + contractAmountInput.value = normalized.contract_amount || ""; + completionStatusInput.value = normalized.completion_status || ""; + changeRoundInput.value = normalized.change_round || ""; + projectStartDateInput.value = normalized.project_start_date || ""; + projectEndDateInput.value = normalized.project_end_date || ""; + notesInput.value = normalized.notes || ""; + expectedAsRateInput.value = String(normalized.expected_as_rate || "0"); + expectedSgaRateInput.value = String(normalized.expected_sga_rate || "13"); + if (editRevisionInput) { + editRevisionInput.value = normalized.updated_at || ""; + } + + clearDynamicRows(); + + (normalized.collection_entries || []).forEach((row) => addRow("collection", row)); + if (!normalized.collection_entries?.length) { + addRow("collection"); + } + + (normalized.task_plan_entries || []).forEach((row) => { + if (row.group === "outsource") addRow("task-plan-outsource", row); + else if (row.group === "joint") addRow("task-plan-joint", row); + else addRow("task-plan-department", row); + }); + if (!normalized.task_plan_entries?.length) { + addRow("task-plan-department"); + } + + const execEntries = [...(normalized.exec_budget_entries || [])]; + if (!execEntries.some((row) => row.group === "outsource")) { + execEntries.push( + ...(normalized.task_plan_entries || []) + .filter((row) => row.group === "outsource") + .map((row) => ({ ...row, group: "outsource" })) + ); + } + + execEntries.forEach((row) => { + if (row.group === "outsource") addRow("exec-outsource", row); + else if (row.group === "cost_plan") addRow("exec-cost-plan", row); + else addRow("exec-labor", row); + }); + if (!execEntries.length) { + addRow("exec-labor"); + } + + (normalized.actual_input_entries || []).forEach((row) => addRow("actual-input", row)); + if (!normalized.actual_input_entries?.length) { + addRow("actual-input"); + } + + bindDateTextInputs(document); + recalculateExecLaborAmounts(); + updateComputedFields(); + } + + async function openProjectModalForCode(code = "") { + const normalizedCode = String(code || "").trim(); + if (!normalizedCode) { + openProjectModalForFreshEntry(currentSelectedProjectCode || ""); + return; + } + if (projectEditCache.has(normalizedCode)) { + const cachedData = cloneProjectEditData(projectEditCache.get(normalizedCode)); + modalOriginalEdit = cloneProjectEditData(cachedData); + applyProjectEditData(cachedData); + modal?.classList.add("open"); + return; + } + try { + const response = await fetch(`/projects/edit-data?code=${encodeURIComponent(normalizedCode)}`, { + credentials: "same-origin", + cache: "no-store", + headers: { "Accept": "application/json" }, + }); + if (!response.ok) { + throw new Error(`HTTP ${response.status}`); + } + const payload = await response.json(); + if (payload?.error) { + throw new Error(payload.error); + } + modalOriginalEdit = cloneProjectEditData(payload); + applyProjectEditData(payload); + modal?.classList.add("open"); + } catch (error) { + console.error(error); + window.alert("사업현황 데이터를 불러오지 못했습니다."); + } + } + + function openProjectModalForFreshEntry(code = "") { + const normalizedCode = String(code || "").trim(); + const selectedItem = supportDepartmentOptions.find((item) => item.support_dept_code === normalizedCode); + const emptyData = createEmptyProjectEdit(normalizedCode, selectedItem?.support_dept_name || ""); + modalOriginalEdit = cloneProjectEditData(emptyData); + applyProjectEditData(emptyData); + modal?.classList.add("open"); + } + bindSupportLookup(supportDeptCodeLookup, supportDeptCodeResults); bindSupportLookup(supportDeptNameInput, supportDeptNameResults); bindDateTextInputs(document); + projectStatusForm?.addEventListener("input", (event) => { + const lookupInput = event.target.closest(".cost-dept-lookup"); + if (lookupInput) { + const resultBox = lookupInput.parentElement?.querySelector(".cost-dept-results"); + renderCostDeptResults(lookupInput.value || "", resultBox, lookupInput); + return; + } + const accountCodeInput = event.target.closest(".cost-account-code-lookup"); + if (accountCodeInput) { + const row = accountCodeInput.closest("tr"); + const resultBox = accountCodeInput.parentElement?.querySelector(".cost-account-results"); + renderCostAccountResults(accountCodeInput.value || "", resultBox, row, "code"); + return; + } + const accountNameInput = event.target.closest(".cost-account-name-lookup"); + if (accountNameInput) { + const row = accountNameInput.closest("tr"); + const resultBox = accountNameInput.parentElement?.querySelector(".cost-account-results"); + renderCostAccountResults(accountNameInput.value || "", resultBox, row, "name"); + } + }); + + projectStatusForm?.addEventListener("focusout", (event) => { + const lookupInput = event.target.closest(".cost-dept-lookup"); + if (lookupInput) { + const resultBox = lookupInput.parentElement?.querySelector(".cost-dept-results"); + setTimeout(() => resultBox?.classList.remove("open"), 120); + return; + } + const accountLookup = event.target.closest(".cost-account-code-lookup, .cost-account-name-lookup"); + if (accountLookup) { + const resultBox = accountLookup.parentElement?.querySelector(".cost-account-results"); + setTimeout(() => resultBox?.classList.remove("open"), 120); + } + }); + + openLaborRateModalButton?.addEventListener("click", openLaborRateModal); + closeLaborRateModalButton?.addEventListener("click", closeLaborRateModal); + laborRateModal?.addEventListener("click", (event) => { + if (event.target === laborRateModal) { + closeLaborRateModal(); + } + }); + contractAmountInput?.addEventListener("input", updateComputedFields); expectedAsRateInput?.addEventListener("change", updateComputedFields); expectedSgaRateInput?.addEventListener("change", updateComputedFields); @@ -2214,40 +2997,15 @@ }); }); - (projectEdit.collection_entries || []).forEach((row) => addRow("collection", row)); - if (!projectEdit.collection_entries?.length) { - addRow("collection"); - } - - (projectEdit.task_plan_entries || []).forEach((row) => { - if (row.group === "outsource") addRow("task-plan-outsource", row); - else if (row.group === "joint") addRow("task-plan-joint", row); - else addRow("task-plan-department", row); - }); - if (!projectEdit.task_plan_entries?.length) { - addRow("task-plan-department"); - } - - (projectEdit.exec_budget_entries || []).forEach((row) => { - if (row.group === "outsource") addRow("exec-outsource", row); - else if (row.group === "cost_plan") addRow("exec-cost-plan", row); - else addRow("exec-labor", row); - }); - if (!projectEdit.exec_budget_entries?.length) { - addRow("exec-labor"); - } - - (projectEdit.actual_input_entries || []).forEach((row) => addRow("actual-input", row)); - if (!projectEdit.actual_input_entries?.length) { - addRow("actual-input"); + if (projectEdit?.support_dept_code) { + projectEditCache.set(projectEdit.support_dept_code, projectEdit); } + applyProjectEditData(projectEdit); document.getElementById("projectStatusForm")?.addEventListener("submit", () => { resolveSupportDepartmentFromLookup(); }); - updateComputedFields(); - function renderRevenueLegend(items) { const legend = document.getElementById("revenueLegend"); if (!legend) return; @@ -2279,8 +3037,7 @@ const rows = revenueMix || []; const values = rows.flatMap((row) => items.map((item) => Number(row[item.key] || 0))); const maxValue = Math.max(...values, 1); - const tickStep = pickTickStep(maxValue); - const tickMax = Math.ceil(maxValue / tickStep) * tickStep; + const { tickStep, tickMax } = buildPositiveAxis(maxValue, 4); const plotWidth = width - margin.left - margin.right; const plotHeight = height - margin.top - margin.bottom; const groupWidth = plotWidth / Math.max(rows.length, 1); @@ -2397,7 +3154,15 @@ function normalizeDetailEntries(entries, fallbackLabel) { if (!Array.isArray(entries) || !entries.length) return []; return entries.map((entry, index) => ({ - label: entry.label || entry.reference || entry.note || `${fallbackLabel} ${index + 1}`, + ...entry, + label: + entry.label + || [entry.grade, entry.hours ? `${entry.hours}h` : ""].filter(Boolean).join(" / ") + || [entry.account_code, entry.account_name].filter(Boolean).join(" / ") + || [entry.dept_name, entry.work_name].filter(Boolean).join(" / ") + || entry.reference + || entry.note + || `${fallbackLabel} ${index + 1}`, amount: Number(entry.amount || 0), })).filter((entry) => entry.label || entry.amount); } @@ -2414,19 +3179,200 @@ `).join(""); } + function sumEntryAmounts(entries) { + return (entries || []).reduce((sum, entry) => sum + Number(entry?.amount || 0), 0); + } + + function getExecBudgetGroupEntries(item, group, fallbackLabel) { + const entries = Array.isArray(item?.exec_budget_entries) + ? item.exec_budget_entries.filter((entry) => (entry?.group || "labor") === group) + : []; + const normalized = normalizeDetailEntries(entries, fallbackLabel); + if (normalized.length) return normalized; + const fallbackAmountMap = { + labor: Number(item?.exec_budget_labor_by_grade || 0), + outsource: Number(item?.exec_budget_outsource || 0), + cost_plan: Number(item?.exec_budget_cost_plan || 0), + }; + const amount = fallbackAmountMap[group] || 0; + return amount ? [{ label: fallbackLabel, amount }] : []; + } + + function getCostBreakdownEntries(code) { + return Array.isArray(projectAccountBreakdowns?.[code]?.cost) ? projectAccountBreakdowns[code].cost : []; + } + + function sumNumericFields(target, source, keys) { + keys.forEach((key) => { + target[key] = Number(target[key] || 0) + Number(source?.[key] || 0); + }); + } + + function mergeLabeledAmountEntries(entries) { + const merged = new Map(); + (entries || []).forEach((entry) => { + const label = String(entry?.label || entry?.reference || entry?.note || "").trim() || "미분류"; + const current = merged.get(label) || { label, amount: 0 }; + current.amount += Number(entry?.amount || 0); + merged.set(label, current); + }); + return [...merged.values()].sort((a, b) => Number(b.amount || 0) - Number(a.amount || 0)); + } + + function prefixProjectEntries(item, entries, fallbackLabel) { + return normalizeDetailEntries(entries, fallbackLabel).map((entry) => ({ + ...entry, + label: `${item.support_dept_code} · ${entry.label}`, + amount: Number(entry.amount || 0), + })); + } + + function getCombinedBreakdownList(codes, kind) { + const merged = []; + (codes || []).forEach((code) => { + const entries = getBreakdownList(code, kind); + entries.forEach((entry) => { + merged.push({ + label: `${code} · ${entry.label || "-"}`, + amount: Number(entry.amount || 0), + }); + }); + }); + return mergeLabeledAmountEntries(merged); + } + + function aggregateAnalysisItem(baseItem, relatedItems = []) { + if (!baseItem) return null; + const items = [baseItem, ...relatedItems].filter(Boolean); + const aggregated = { + ...baseItem, + _aggregateCodes: items.map((item) => item.support_dept_code), + _relatedItems: relatedItems, + min_year: Math.min(...items.map((item) => Number(item.min_year || item.year || 0)).filter(Boolean)), + max_year: Math.max(...items.map((item) => Number(item.max_year || item.year || 0)).filter(Boolean)), + latest_year: baseItem.latest_year, + latest_month: baseItem.latest_month, + revenue_amount: 0, + expense_amount: 0, + total_revenue: 0, + total_expense: 0, + total_cost: 0, + total_sga: 0, + contract_amount: 0, + collection_amount: 0, + planned_task_total: 0, + exec_budget_total: 0, + operating_balance: 0, + expected_as_cost: 0, + expected_sga_budget: 0, + task_plan_department_budget: 0, + task_plan_outsource_budget: 0, + task_plan_joint_operating_cost: 0, + exec_budget_labor_by_grade: 0, + exec_budget_outsource: 0, + exec_budget_cost_plan: 0, + actual_labor: 0, + actual_outsource: 0, + item_investment: 0, + collection_entries: [], + task_plan_entries: [], + exec_budget_entries: [], + actual_input_entries: [], + }; + const sumKeys = [ + "revenue_amount", + "expense_amount", + "total_revenue", + "total_cost", + "total_sga", + "contract_amount", + "collection_amount", + "planned_task_total", + "exec_budget_total", + "expected_as_cost", + "expected_sga_budget", + "task_plan_department_budget", + "task_plan_outsource_budget", + "task_plan_joint_operating_cost", + "exec_budget_labor_by_grade", + "exec_budget_outsource", + "exec_budget_cost_plan", + "actual_labor", + "actual_outsource", + "item_investment", + ]; + items.forEach((item) => { + sumNumericFields(aggregated, item, sumKeys); + aggregated.collection_entries.push(...prefixProjectEntries(item, item.collection_entries, "수금 입력")); + aggregated.task_plan_entries.push(...prefixProjectEntries(item, item.task_plan_entries, "과업수행계획")); + aggregated.exec_budget_entries.push(...prefixProjectEntries(item, item.exec_budget_entries, "실행예산")); + aggregated.actual_input_entries.push(...prefixProjectEntries(item, item.actual_input_entries, "실투입")); + const itemLatest = Number(item.latest_year || 0) * 100 + Number(item.latest_month || 0); + const aggregatedLatest = Number(aggregated.latest_year || 0) * 100 + Number(aggregated.latest_month || 0); + if (itemLatest > aggregatedLatest) { + aggregated.latest_year = item.latest_year; + aggregated.latest_month = item.latest_month; + } + }); + aggregated.total_expense = Number(aggregated.total_cost || 0) + Number(aggregated.total_sga || 0); + aggregated.operating_balance = Number(aggregated.total_revenue || 0) - Number(aggregated.total_expense || 0); + return aggregated; + } + + function isDesignOutsourceEntry(entry) { + const label = String(entry?.label || ""); + return label.includes("기술협력비"); + } + + function formatComparisonCell(value) { + return value === null || value === undefined ? "" : formatNumber(value); + } + + function formatComparisonDiff(planned, actual) { + const diff = Number(planned || 0) - Number(actual || 0); + return `${formatSignedAmount(diff)}`; + } + function buildComparisonDetails(item) { + const aggregateCodes = Array.isArray(item?._aggregateCodes) && item._aggregateCodes.length + ? item._aggregateCodes + : [item.support_dept_code]; + const revenuePlanned = normalizeDetailEntries(item.collection_entries, "수금 입력"); + const revenueActual = getCombinedBreakdownList(aggregateCodes, "revenue"); + const laborPlanned = getExecBudgetGroupEntries(item, "labor", "직급별 인건비"); + const outsourcePlanned = getExecBudgetGroupEntries(item, "outsource", "외주비"); + const overheadPlanned = getExecBudgetGroupEntries(item, "cost_plan", "계정별비용계획"); + const costEntries = aggregateCodes.flatMap((code) => getCostBreakdownEntries(code).map((entry) => ({ + label: `${code} · ${entry.label || "-"}`, + amount: Number(entry.amount || 0), + }))); + const outsourceActual = costEntries.filter(isDesignOutsourceEntry); + const overheadActual = costEntries.filter((entry) => !isDesignOutsourceEntry(entry)); + const sgaActual = getCombinedBreakdownList(aggregateCodes, "sga"); return { - revenue: { - planned: normalizeDetailEntries(item.collection_entries, "수금 입력"), - actual: getBreakdownList(item.support_dept_code, "revenue"), + collection: { + planned: revenuePlanned, + actual: revenueActual, }, - cost: { - planned: normalizeDetailEntries(item.task_plan_entries, "과업수행계획"), - actual: getBreakdownList(item.support_dept_code, "cost"), + labor: { + planned: laborPlanned, + actual: [], + }, + outsource: { + planned: outsourcePlanned, + actual: outsourceActual, + }, + overhead: { + planned: overheadPlanned, + actual: overheadActual, + }, + as: { + planned: item.expected_as_cost ? [{ label: "예상 A/S비", amount: Number(item.expected_as_cost || 0) }] : [], + actual: [], }, sga: { planned: item.expected_sga_budget ? [{ label: "예상 판관비", amount: Number(item.expected_sga_budget || 0) }] : [], - actual: getBreakdownList(item.support_dept_code, "sga"), + actual: sgaActual, }, }; } @@ -2437,12 +3383,14 @@
-
-

${item.support_dept_name}

-

${item.support_dept_code} · ${item.year_range} · 최신 반영 ${detailValue(item.latest_year, "-")}년 ${detailValue(item.latest_month, "-")}월

+
+
+

${item.support_dept_name}

+

${item.support_dept_code} · ${item.year_range} · 최신 반영 ${detailValue(item.latest_year, "-")}년 ${detailValue(item.latest_month, "-")}월

+
- 실집행 수익 - ${formatDisplayAmount(item.total_revenue)} -
-
- 실집행 비용 - ${formatDisplayAmount(item.total_expense)} + 계약금액 + ${formatDisplayAmount(item.contract_amount)}
수금액 ${formatDisplayAmount(item.collection_amount)}
+
+ 실집행 비용 + ${formatDisplayAmount(item.total_expense)} +
영업수지 ${formatDisplayAmount(item.operating_balance)} @@ -2494,20 +3442,37 @@ `; } + function renderRelatedProjectBar(baseItem, relatedItems) { + if (!baseItem) return ""; + const tags = (relatedItems || []).map((item) => ` + + ${escapeHtml(item.support_dept_code)} · ${escapeHtml(item.support_dept_name)} + + + `).join(""); + if (!tags) return ""; + return ` + + `; + } + function renderAnalysisMetrics(item) { return `

핵심 비교 지표

- 연도별 집계 수익 - ${formatDisplayAmount(item.revenue_amount)} + 수금집계 + ${formatDisplayAmount(item.collection_amount)}
- 연도별 집계 지출 - ${formatDisplayAmount(item.expense_amount)} + 비용집계 + ${formatDisplayAmount(item.total_expense)}
- 과업수행계획 합계 + 과업수행계획비 합계 ${formatDisplayAmount(item.planned_task_total)}
- + + `).join(""); + relatedSearchEmpty.textContent = ""; + relatedSearchResults.querySelectorAll("[data-add-related]").forEach((button) => { + button.addEventListener("click", () => addRelatedProject(button.dataset.addRelated || "")); + }); + } + function renderAnalysis(item) { if (!item) { + relatedBarBox.innerHTML = ""; heroBox.innerHTML = ""; metricsBox.innerHTML = ""; comparisonBox.innerHTML = ""; notesBox.innerHTML = ""; return; } - heroBox.innerHTML = renderAnalysisHero(item); - metricsBox.innerHTML = renderAnalysisMetrics(item); - comparisonBox.innerHTML = renderAnalysisComparison(item); - notesBox.innerHTML = renderAnalysisNotes(item); + const relatedItems = getRelatedItems(item.support_dept_code); + const aggregateItem = aggregateAnalysisItem(item, relatedItems); + relatedBarBox.innerHTML = renderRelatedProjectBar(item, relatedItems); + heroBox.innerHTML = renderAnalysisHero(aggregateItem); + metricsBox.innerHTML = renderAnalysisMetrics(aggregateItem); + comparisonBox.innerHTML = renderAnalysisComparison(aggregateItem); + notesBox.innerHTML = renderAnalysisNotes(aggregateItem); + + relatedBarBox.querySelectorAll("[data-remove-related]").forEach((button) => { + button.addEventListener("click", () => removeRelatedProject(button.dataset.removeRelated || "")); + }); comparisonBox.querySelectorAll(".comparison-main-row[data-detail-target]").forEach((row) => { row.addEventListener("click", () => { const key = row.dataset.detailTarget; @@ -3016,9 +4122,20 @@ input?.addEventListener("input", refresh); yearSelect?.addEventListener("change", refresh); input?.addEventListener("focus", refresh); + relatedSearchInput?.addEventListener("input", refreshRelatedProjectResults); + relatedModalCloseButton?.addEventListener("click", closeRelatedProjectPicker); + relatedModal?.addEventListener("click", (event) => { + if (event.target === relatedModal) { + closeRelatedProjectPicker(); + } + }); analysisToggle?.addEventListener("click", () => { setAnalysisVisibility(!analysisOpen); }); + + relatedToolbarButton?.addEventListener("click", () => { + openRelatedProjectPicker(); + }); document.addEventListener("click", (event) => { if (!dropdownBox?.contains(event.target) && event.target !== input) { dropdownBox?.classList.remove("open"); @@ -3033,24 +4150,42 @@ const closeModalButton = document.getElementById("closeProjectModal"); if (openModalButton) { - openModalButton.addEventListener("click", () => { - if (currentSelectedProjectCode) { - window.location.href = getProjectEditUrl(currentSelectedProjectCode); - return; - } - modal.classList.add("open"); + openModalButton.addEventListener("click", async () => { + openProjectModalForFreshEntry(""); }); } + document.addEventListener("click", async (event) => { + const editButton = event.target.closest("[data-edit-code]"); + if (!editButton) return; + event.preventDefault(); + await openProjectModalForCode(editButton.dataset.editCode || ""); + }); + + if (modal?.classList.contains("open") && projectEdit.support_dept_code) { + modalOriginalEdit = cloneProjectEditData(projectEdit); + applyProjectEditData(projectEdit); + } + + function closeProjectModal() { + if (modalOriginalEdit) { + applyProjectEditData(cloneProjectEditData(modalOriginalEdit)); + } + modal?.classList.remove("open"); + supportDeptCodeResults.classList.remove("open"); + supportDeptNameResults.classList.remove("open"); + document.activeElement?.blur?.(); + } + if (closeModalButton) { closeModalButton.addEventListener("click", () => { - window.location.href = "/projects{% if selected_year %}?year={{ selected_year }}{% endif %}"; + closeProjectModal(); }); } modal?.addEventListener("click", (event) => { if (event.target === modal) { - window.location.href = "/projects{% if selected_year %}?year={{ selected_year }}{% endif %}"; + closeProjectModal(); } });