diff --git a/data.db b/data.db index 2205d0e..858432b 100644 Binary files a/data.db and b/data.db differ diff --git a/main.py b/main.py index 9fadeda..1ca20c1 100644 --- a/main.py +++ b/main.py @@ -2,6 +2,7 @@ import os import logging import json import re +import time import zipfile from datetime import date, datetime from functools import lru_cache @@ -21,6 +22,11 @@ from sqlalchemy import create_engine, event, text logging.basicConfig(level=logging.INFO) logger = logging.getLogger(__name__) +_HEALTH_PAYLOAD_CACHE: dict[str, Any] = { + "expires_at": 0.0, + "payload": None, +} + app = FastAPI() BASE_DIR = Path(__file__).resolve().parent @@ -636,6 +642,27 @@ def init_db() -> None: """ ) ) + conn.execute( + text( + """ + CREATE TABLE IF NOT EXISTS project_analysis_settings ( + support_dept_code TEXT NOT NULL PRIMARY KEY, + detail_note TEXT DEFAULT '', + inactive_related_codes_json TEXT DEFAULT '[]', + labor_joint_exempt INTEGER DEFAULT 0, + updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP + ) + """ + ) + ) + conn.execute( + text( + """ + CREATE INDEX IF NOT EXISTS idx_project_analysis_settings_code + ON project_analysis_settings (support_dept_code) + """ + ) + ) page_state_columns_before = { row[1] for row in conn.execute(text("PRAGMA table_info(project_page_state)")).fetchall() @@ -3339,6 +3366,16 @@ def normalize_account_display(account_code: Any, account_name: Any) -> tuple[str return normalized_code, normalized_name, label +def build_transaction_posting_display(voucher_number: Any, posting_date: Any) -> str: + year, month, day = extract_period(normalize_text(voucher_number), normalize_text(posting_date)) + if year and month and day: + return f"{year:04d}-{month:02d}-{day:02d}" + parsed_date = normalize_date_text(posting_date) + if re.match(r"^\d{4}-\d{2}-\d{2}$", parsed_date): + return parsed_date + return "-" + + def get_data_version() -> str: with engine.begin() as conn: transaction_updated = conn.execute(text("SELECT MAX(updated_at) FROM transactions")).scalar() @@ -3358,12 +3395,20 @@ def get_data_version() -> str: return max((version for version in versions if version), default="") -def build_health_payload() -> dict[str, str]: - return { +def build_health_payload(force: bool = False) -> dict[str, str]: + now = time.monotonic() + cached_payload = _HEALTH_PAYLOAD_CACHE.get("payload") + if not force and cached_payload and now < float(_HEALTH_PAYLOAD_CACHE.get("expires_at") or 0.0): + return dict(cached_payload) + + payload = { "status": "ok", "server_time": datetime.now().isoformat(timespec="seconds"), "data_version": get_data_version(), } + _HEALTH_PAYLOAD_CACHE["payload"] = payload + _HEALTH_PAYLOAD_CACHE["expires_at"] = now + 2.0 + return dict(payload) def check_record_revision(conn: Any, table_name: str, key_column: str, key_value: Any, edit_revision: str) -> None: @@ -3953,6 +3998,83 @@ def save_project_comparison_note(support_dept_code: str | None, item_key: str | ) +def get_project_analysis_settings_map() -> dict[str, dict[str, object]]: + with engine.begin() as conn: + rows = conn.execute( + text( + """ + SELECT + support_dept_code, + COALESCE(detail_note, '') AS detail_note, + COALESCE(inactive_related_codes_json, '[]') AS inactive_related_codes_json, + COALESCE(labor_joint_exempt, 0) AS labor_joint_exempt + FROM project_analysis_settings + WHERE COALESCE(support_dept_code, '') <> '' + """ + ) + ).mappings().all() + result: dict[str, dict[str, object]] = {} + for row in rows: + code = normalize_text(row.get("support_dept_code")) + if not code: + continue + try: + inactive_codes = json.loads(row.get("inactive_related_codes_json") or "[]") + except Exception: + inactive_codes = [] + result[code] = { + "detail_note": normalize_text(row.get("detail_note")), + "inactive_related_codes": [ + normalize_text(value) for value in (inactive_codes or []) if normalize_text(value) + ], + "labor_joint_exempt": bool(row.get("labor_joint_exempt")), + } + return result + + +def save_project_analysis_settings( + support_dept_code: str | None, + detail_note: str | None = None, + inactive_related_codes: list[str] | None = None, + labor_joint_exempt: bool | None = None, +) -> None: + code = normalize_text(support_dept_code) + if not code: + return + current = get_project_analysis_settings_map().get(code, {}) + next_detail_note = normalize_text(detail_note) if detail_note is not None else normalize_text(current.get("detail_note")) + current_inactive = current.get("inactive_related_codes", []) + next_inactive_related_codes = [ + normalize_text(value) + for value in (inactive_related_codes if inactive_related_codes is not None else current_inactive) + if normalize_text(value) + ] + next_labor_joint_exempt = bool(labor_joint_exempt) if labor_joint_exempt is not None else bool(current.get("labor_joint_exempt")) + with engine.begin() as conn: + conn.execute( + text( + """ + INSERT INTO project_analysis_settings ( + support_dept_code, detail_note, inactive_related_codes_json, labor_joint_exempt, updated_at + ) VALUES ( + :support_dept_code, :detail_note, :inactive_related_codes_json, :labor_joint_exempt, CURRENT_TIMESTAMP + ) + ON CONFLICT(support_dept_code) DO UPDATE SET + detail_note = excluded.detail_note, + inactive_related_codes_json = excluded.inactive_related_codes_json, + labor_joint_exempt = excluded.labor_joint_exempt, + updated_at = CURRENT_TIMESTAMP + """ + ), + { + "support_dept_code": code, + "detail_note": next_detail_note, + "inactive_related_codes_json": json.dumps(next_inactive_related_codes, ensure_ascii=False), + "labor_joint_exempt": 1 if next_labor_joint_exempt else 0, + }, + ) + + def get_project_status_for_edit(support_dept_code: str | None) -> dict[str, Any]: contract_info_map = get_project_contract_info_map() billing_summary_map = get_project_billing_summary_map() @@ -4931,12 +5053,128 @@ def get_project_account_breakdowns(selected_year: int | None) -> dict[str, dict[ normalized_result[code] = {} for kind, entries in buckets.items(): normalized_result[code][kind] = [ - {"label": label, "amount": amount} + { + "label": label, + "amount": amount, + "account_code": label.split(" · ", 1)[0] if " · " in label else "", + "account_name": label.split(" · ", 1)[1] if " · " in label else label, + } for label, amount in sorted(entries.items(), key=lambda item: item[1], reverse=True) ] return normalized_result +def parse_support_dept_codes_param(raw_codes: Any, fallback_code: Any = "") -> list[str]: + values: list[str] = [] + for chunk in re.split(r"[\s,]+", normalize_text(raw_codes)): + normalized = normalize_text(chunk) + if normalized and normalized not in values: + values.append(normalized) + normalized_fallback = normalize_text(fallback_code) + if normalized_fallback and normalized_fallback not in values: + values.insert(0, normalized_fallback) + return values + + +def build_in_clause(prefix: str, values: list[str]) -> tuple[str, dict[str, Any]]: + params: dict[str, Any] = {} + placeholders: list[str] = [] + for index, value in enumerate(values): + key = f"{prefix}_{index}" + placeholders.append(f":{key}") + params[key] = value + return ", ".join(placeholders), params + + +def fetch_project_expense_transaction_rows( + codes: list[str], + expense_group: str = "", + account_label: str = "", +) -> list[dict[str, Any]]: + normalized_codes = [normalize_text(code) for code in codes if normalize_text(code)] + if not normalized_codes: + return [] + in_clause, code_params = build_in_clause("project_code", normalized_codes) + query = text( + f""" + SELECT + COALESCE(voucher_number, '') AS voucher_number, + COALESCE(posting_date, '') AS posting_date, + COALESCE(partner_name, '') AS partner_name, + COALESCE(partner_code, '') AS partner_code, + COALESCE(cost_dept_name, '') AS cost_dept_name, + COALESCE(support_dept_code, '') AS support_dept_code, + COALESCE(support_dept_name, '') AS support_dept_name, + COALESCE(account_code, '') AS account_code, + COALESCE(account_name, '') AS account_name, + amount + FROM transactions + WHERE support_dept_code IN ({in_clause}) + AND accounting_category = '원가' + ORDER BY posting_date DESC, voucher_number DESC, partner_name, cost_dept_name, account_code + """ + ) + with engine.begin() as conn: + rows = conn.execute(query, code_params).mappings().all() + normalized_group = normalize_text(expense_group).lower() + normalized_account_label = normalize_text(account_label) + result: list[dict[str, Any]] = [] + for row in rows: + normalized_code, normalized_name, normalized_label = normalize_account_display( + row["account_code"], + row["account_name"], + ) + is_design_outsource = "기술협력비" in normalized_label or "기술협력비" in normalized_name + if normalized_group == "outsource" and not is_design_outsource: + continue + if normalized_group == "overhead" and is_design_outsource: + continue + if normalized_account_label and normalized_label != normalized_account_label: + continue + result.append( + { + "posting_date": build_transaction_posting_display(row["voucher_number"], row["posting_date"]), + "voucher_number": normalize_text(row["voucher_number"]), + "partner_name": normalize_text(row["partner_name"]), + "partner_code": normalize_text(row["partner_code"]), + "cost_dept_name": normalize_text(row["cost_dept_name"]), + "support_dept_code": normalize_text(row["support_dept_code"]), + "support_dept_name": normalize_text(row["support_dept_name"]), + "account_code": normalized_code, + "account_name": normalized_name, + "amount": int(round(float(row["amount"] or 0))), + } + ) + return result + + +def get_project_expense_date_range(codes: list[str]) -> tuple[str, str]: + normalized_codes = [normalize_text(code) for code in (codes or []) if normalize_text(code)] + if not normalized_codes: + return "", "" + in_clause, code_params = build_in_clause("project_code", normalized_codes) + query = text( + f""" + SELECT + COALESCE(voucher_number, '') AS voucher_number, + COALESCE(posting_date, '') AS posting_date + FROM transactions + WHERE support_dept_code IN ({in_clause}) + AND accounting_category = '원가' + """ + ) + dates: list[str] = [] + with engine.begin() as conn: + rows = conn.execute(query, code_params).mappings().all() + for row in rows: + display = build_transaction_posting_display(row["voucher_number"], row["posting_date"]) + if re.match(r"^\d{4}-\d{2}-\d{2}$", display): + dates.append(display) + if not dates: + return "", "" + return min(dates), max(dates) + + def get_recent_transactions(limit: int = 50) -> list[dict[str, Any]]: with engine.begin() as conn: rows = conn.execute( @@ -5862,6 +6100,7 @@ def render_projects_page( "project_account_breakdowns": get_project_account_breakdowns(selected_year), "project_status_rows": get_project_status_rows(), "project_comparison_notes": get_project_comparison_notes_map(), + "project_analysis_settings": get_project_analysis_settings_map(), "project_edit": get_project_status_for_edit(edit_code), "project_focus_code": normalize_text(focus_code), "project_page_state": get_project_page_state(), @@ -6049,6 +6288,329 @@ async def project_comparison_note_save(request: Request): return JSONResponse(content={"error": str(exc)}, status_code=500) +@app.post("/projects/analysis-settings") +async def project_analysis_settings_save(request: Request): + try: + payload = await request.json() + if not isinstance(payload, dict): + raise ValueError("잘못된 프로젝트 상세 설정 형식입니다.") + inactive_related_codes = payload.get("inactive_related_codes") + if inactive_related_codes is not None and not isinstance(inactive_related_codes, list): + raise ValueError("제외 연관 프로젝트 형식이 올바르지 않습니다.") + save_project_analysis_settings( + payload.get("support_dept_code"), + detail_note=payload.get("detail_note") if "detail_note" in payload else None, + inactive_related_codes=inactive_related_codes if isinstance(inactive_related_codes, list) else None, + labor_joint_exempt=payload.get("labor_joint_exempt") if "labor_joint_exempt" in payload else None, + ) + return JSONResponse(content={"status": "ok"}) + except Exception as exc: + logger.exception("프로젝트 상세 설정 저장 에러: %s", exc) + return JSONResponse(content={"error": str(exc)}, status_code=500) + + +@app.get("/projects/uncontracted-detail-transactions") +async def project_uncontracted_detail_transactions( + support_dept_code: str = "", + kind: str = "expense", + detail_type: str = "", + year: int | None = None, + month: int | None = None, + category: str | None = None, + start_year: int | None = None, + end_year: int | None = None, +): + try: + normalized_code = normalize_text(support_dept_code) + if not normalized_code: + raise ValueError("프로젝트 코드가 필요합니다.") + normalized_kind = normalize_text(kind).lower() + if normalized_kind not in {"expense", "revenue"}: + raise ValueError("조회 종류가 올바르지 않습니다.") + normalized_detail_type = normalize_text(detail_type).lower() + if normalized_detail_type not in {"year", "month", "category"}: + raise ValueError("세부 조회 형식이 올바르지 않습니다.") + + filters = ["support_dept_code = :support_dept_code"] + params: dict[str, Any] = {"support_dept_code": normalized_code} + + if normalized_kind == "expense": + filters.append("accounting_category IN ('원가', '판관비')") + else: + filters.append(REVENUE_SQL) + + if normalized_detail_type == "month": + if not year or not month: + raise ValueError("월별 세부 조회에는 연도와 월이 필요합니다.") + filters.append("year = :year") + filters.append("month = :month") + params["year"] = int(year) + params["month"] = int(month) + elif normalized_detail_type == "year": + if not year: + raise ValueError("연도별 세부 조회에는 연도가 필요합니다.") + filters.append("year = :year") + params["year"] = int(year) + else: + # category 상세는 프로젝트 생성 시기와 무관하게 선택된 연도 구간 안의 발생 전표를 모두 보여준다. + if start_year: + filters.append("year >= :start_year") + params["start_year"] = int(start_year) + if end_year: + filters.append("year <= :end_year") + params["end_year"] = int(end_year) + if category: + params["category"] = normalize_text(category) + + query = text( + f""" + SELECT + COALESCE(voucher_number, '') AS voucher_number, + COALESCE(posting_date, '') AS posting_date, + COALESCE(partner_name, '') AS partner_name, + COALESCE(partner_code, '') AS partner_code, + COALESCE(cost_dept_name, '') AS cost_dept_name, + COALESCE(support_dept_code, '') AS support_dept_code, + COALESCE(support_dept_name, '') AS support_dept_name, + COALESCE(account_code, '') AS account_code, + COALESCE(account_name, '') AS account_name, + amount + FROM transactions + WHERE {' AND '.join(filters)} + ORDER BY posting_date DESC, voucher_number DESC, partner_name, cost_dept_name, account_code + """ + ) + + with engine.begin() as conn: + rows = [ + { + "posting_date": build_transaction_posting_display(row["voucher_number"], row["posting_date"]), + "voucher_number": normalize_text(row["voucher_number"]), + "partner_name": normalize_text(row["partner_name"]), + "partner_code": normalize_text(row["partner_code"]), + "cost_dept_name": normalize_text(row["cost_dept_name"]), + "support_dept_code": normalize_text(row["support_dept_code"]), + "support_dept_name": normalize_text(row["support_dept_name"]), + "account_code": normalize_text(row["account_code"]), + "account_name": normalize_text(row["account_name"]), + "amount": int(round(float(row["amount"] or 0))), + } + for row in conn.execute(query, params).mappings() + ] + + return JSONResponse( + content={ + "rows": rows, + "total_amount": sum(int(row["amount"] or 0) for row in rows), + "support_dept_code": normalized_code, + "kind": normalized_kind, + "detail_type": normalized_detail_type, + "category": normalize_text(category), + } + ) + except Exception as exc: + logger.exception("미계약 세부 거래내역 조회 에러: %s", exc) + return JSONResponse(content={"error": str(exc)}, status_code=500) + + +@app.get("/projects/uncontracted-vendor-transactions") +async def project_uncontracted_vendor_transactions( + partner_code: str = "", + partner_name: str = "", + start_year: int | None = None, + end_year: int | None = None, +): + try: + normalized_partner_code = normalize_text(partner_code) + normalized_partner_name = normalize_text(partner_name) + if not normalized_partner_code and not normalized_partner_name: + raise ValueError("거래처 정보가 필요합니다.") + + filters = ["accounting_category IN ('원가', '판관비')"] + params: dict[str, Any] = {} + + if start_year: + filters.append("year >= :start_year") + params["start_year"] = int(start_year) + if end_year: + filters.append("year <= :end_year") + params["end_year"] = int(end_year) + if normalized_partner_code: + filters.append("COALESCE(partner_code, '') = :partner_code") + params["partner_code"] = normalized_partner_code + else: + filters.append("COALESCE(partner_name, '') = :partner_name") + params["partner_name"] = normalized_partner_name + + query = text( + f""" + SELECT + COALESCE(voucher_number, '') AS voucher_number, + COALESCE(posting_date, '') AS posting_date, + COALESCE(partner_name, '') AS partner_name, + COALESCE(partner_code, '') AS partner_code, + COALESCE(cost_dept_name, '') AS cost_dept_name, + COALESCE(support_dept_code, '') AS support_dept_code, + COALESCE(support_dept_name, '') AS support_dept_name, + COALESCE(account_code, '') AS account_code, + COALESCE(account_name, '') AS account_name, + amount + FROM transactions + WHERE {' AND '.join(filters)} + ORDER BY posting_date DESC, voucher_number DESC, cost_dept_name, account_code + """ + ) + + with engine.begin() as conn: + rows = [ + { + "posting_date": build_transaction_posting_display(row["voucher_number"], row["posting_date"]), + "voucher_number": normalize_text(row["voucher_number"]), + "partner_name": normalize_text(row["partner_name"]), + "partner_code": normalize_text(row["partner_code"]), + "cost_dept_name": normalize_text(row["cost_dept_name"]), + "support_dept_code": normalize_text(row["support_dept_code"]), + "support_dept_name": normalize_text(row["support_dept_name"]), + "account_code": normalize_text(row["account_code"]), + "account_name": normalize_text(row["account_name"]), + "amount": int(round(float(row["amount"] or 0))), + } + for row in conn.execute(query, params).mappings() + ] + + return JSONResponse( + content={ + "rows": rows, + "total_amount": sum(int(row["amount"] or 0) for row in rows), + "partner_code": normalized_partner_code, + "partner_name": normalized_partner_name, + } + ) + except Exception as exc: + logger.exception("거래처별 미계약 비용 상세 조회 에러: %s", exc) + return JSONResponse(content={"error": str(exc)}, status_code=500) + + +@app.get("/projects/comparison-actual-transactions") +async def project_comparison_actual_transactions( + support_dept_code: str = "", + codes: str = "", + group: str = "", + account_label: str = "", +): + try: + normalized_codes = parse_support_dept_codes_param(codes, support_dept_code) + if not normalized_codes: + raise ValueError("프로젝트 코드가 필요합니다.") + rows = fetch_project_expense_transaction_rows( + normalized_codes, + expense_group=group, + account_label=account_label, + ) + return JSONResponse( + content={ + "rows": rows, + "total_amount": sum(int(row["amount"] or 0) for row in rows), + "support_dept_code": normalize_text(support_dept_code), + "codes": normalized_codes, + "group": normalize_text(group).lower(), + "account_label": normalize_text(account_label), + } + ) + except Exception as exc: + logger.exception("비교 실제 집행 세부 거래내역 조회 에러: %s", exc) + return JSONResponse(content={"error": str(exc)}, status_code=500) + + +@app.get("/projects/comparison-vendor-transactions") +async def project_comparison_vendor_transactions( + support_dept_code: str = "", + codes: str = "", + partner_code: str = "", + partner_name: str = "", + start_date: str = "", + end_date: str = "", +): + try: + normalized_codes = parse_support_dept_codes_param(codes, support_dept_code) + if not normalized_codes: + raise ValueError("프로젝트 코드가 필요합니다.") + normalized_partner_code = normalize_text(partner_code) + normalized_partner_name = normalize_text(partner_name) + if not normalized_partner_code and not normalized_partner_name: + raise ValueError("거래처 정보가 필요합니다.") + project_start_date, project_end_date = get_project_expense_date_range(normalized_codes) + effective_start_date = normalize_text(start_date) or project_start_date + effective_end_date = normalize_text(end_date) or project_end_date + + filters = ["accounting_category IN ('원가', '판관비')"] + params: dict[str, Any] = {} + if effective_start_date: + filters.append("COALESCE(posting_date, '') >= :start_date") + params["start_date"] = effective_start_date + if effective_end_date: + filters.append("COALESCE(posting_date, '') <= :end_date") + params["end_date"] = effective_end_date + if normalized_partner_code: + filters.append("COALESCE(partner_code, '') = :partner_code") + params["partner_code"] = normalized_partner_code + else: + filters.append("COALESCE(partner_name, '') = :partner_name") + params["partner_name"] = normalized_partner_name + + query = text( + f""" + SELECT + COALESCE(voucher_number, '') AS voucher_number, + COALESCE(posting_date, '') AS posting_date, + COALESCE(partner_name, '') AS partner_name, + COALESCE(partner_code, '') AS partner_code, + COALESCE(cost_dept_name, '') AS cost_dept_name, + COALESCE(support_dept_code, '') AS support_dept_code, + COALESCE(support_dept_name, '') AS support_dept_name, + COALESCE(account_code, '') AS account_code, + COALESCE(account_name, '') AS account_name, + amount + FROM transactions + WHERE {' AND '.join(filters)} + ORDER BY posting_date DESC, voucher_number DESC, cost_dept_name, account_code + """ + ) + with engine.begin() as conn: + filtered_rows = [ + { + "posting_date": build_transaction_posting_display(row["voucher_number"], row["posting_date"]), + "voucher_number": normalize_text(row["voucher_number"]), + "partner_name": normalize_text(row["partner_name"]), + "partner_code": normalize_text(row["partner_code"]), + "cost_dept_name": normalize_text(row["cost_dept_name"]), + "support_dept_code": normalize_text(row["support_dept_code"]), + "support_dept_name": normalize_text(row["support_dept_name"]), + "account_code": normalize_text(row["account_code"]), + "account_name": normalize_text(row["account_name"]), + "amount": int(round(float(row["amount"] or 0))), + } + for row in conn.execute(query, params).mappings() + ] + return JSONResponse( + content={ + "rows": filtered_rows, + "total_amount": sum(int(row["amount"] or 0) for row in filtered_rows), + "support_dept_code": normalize_text(support_dept_code), + "codes": normalized_codes, + "partner_code": normalized_partner_code, + "partner_name": normalized_partner_name, + "start_date": effective_start_date, + "end_date": effective_end_date, + "project_start_date": project_start_date, + "project_end_date": project_end_date, + } + ) + 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: @@ -6125,7 +6687,7 @@ async def save_project_json(request: Request): form_data = parse_project_form(await request.body()) save_project_status(form_data) code = normalize_text(form_data.get("support_dept_code")) - health_payload = build_health_payload() + health_payload = build_health_payload(force=True) return JSONResponse( content=jsonable_encoder( { diff --git a/templates/annual_summary.html b/templates/annual_summary.html index 90ed07b..c549ce9 100644 --- a/templates/annual_summary.html +++ b/templates/annual_summary.html @@ -61,6 +61,8 @@ border-radius: 16px; padding: 18px 20px 20px; box-shadow: inset 0 1px 0 rgba(255,255,255,0.92); + content-visibility: auto; + contain-intrinsic-size: 620px; } .chart-legend { @@ -92,6 +94,8 @@ grid-template-rows: auto auto 1fr; gap: 14px; min-height: 100%; + content-visibility: auto; + contain-intrinsic-size: 760px; } .metric-grid { diff --git a/templates/base.html b/templates/base.html index 6451db4..aaab3c0 100644 --- a/templates/base.html +++ b/templates/base.html @@ -28,6 +28,16 @@ padding: 0; } + ::selection { + background: rgba(59, 130, 246, 0.18); + color: var(--ink); + } + + ::-moz-selection { + background: rgba(59, 130, 246, 0.18); + color: var(--ink); + } + body { font-family: "SUIT", "Noto Sans KR", "Malgun Gothic", sans-serif; color: var(--ink); @@ -241,6 +251,19 @@ box-shadow: inset 0 1px 2px rgba(16, 24, 40, 0.03); } + select option { + color: var(--ink); + background: #ffffff; + } + + select option:checked, + select option:hover, + select option:focus { + color: var(--ink); + background: #eef2f6; + box-shadow: inset 0 0 0 999px #eef2f6; + } + textarea { min-height: 96px; resize: vertical; @@ -293,6 +316,15 @@ box-shadow: none; } + .button-secondary:hover, + .button-secondary:focus-visible { + background: #eef2f6; + color: #111827; + border-color: #d6dde5; + transform: none; + box-shadow: none; + } + .button-icon { width: 38px; height: 38px; @@ -646,6 +678,9 @@ } async function pollHealth() { + if (document.hidden) { + return; + } try { const response = await fetch(`/health?ts=${Date.now()}`, { cache: "no-store" }); if (!response.ok) throw new Error(`HTTP ${response.status}`); @@ -675,7 +710,11 @@ pollHealth(); } }); - window.setInterval(pollHealth, 15000); + window.setInterval(() => { + if (!document.hidden) { + pollHealth(); + } + }, 15000); })(); diff --git a/templates/dashboard.html b/templates/dashboard.html index 0e91590..425809c 100644 --- a/templates/dashboard.html +++ b/templates/dashboard.html @@ -106,6 +106,8 @@ .chart-panel { display: grid; gap: 12px; + content-visibility: auto; + contain-intrinsic-size: 480px; } .chart-panel-header { @@ -138,6 +140,8 @@ box-shadow: inset 0 1px 0 rgba(255,255,255,0.92); display: grid; gap: 12px; + content-visibility: auto; + contain-intrinsic-size: 420px; } .legend-box { diff --git a/templates/index.html b/templates/index.html index e0c976c..997e0d4 100644 --- a/templates/index.html +++ b/templates/index.html @@ -29,6 +29,16 @@ padding: 0; } + ::selection { + background: rgba(11, 107, 99, 0.16); + color: var(--ink); + } + + ::-moz-selection { + background: rgba(11, 107, 99, 0.16); + color: var(--ink); + } + body { font-family: "Noto Sans KR", "Malgun Gothic", sans-serif; color: var(--ink); @@ -138,6 +148,8 @@ border: 1px dashed #a7d1c8; border-radius: 18px; padding: 18px; + content-visibility: auto; + contain-intrinsic-size: 240px; } .upload-box p { @@ -182,6 +194,7 @@ input[type="number"], input[type="date"], input[type="file"], + select, textarea { width: 100%; border: 1px solid var(--line); @@ -197,12 +210,26 @@ } input:focus, + select:focus, textarea:focus { outline: none; border-color: var(--accent); box-shadow: 0 0 0 4px rgba(11, 107, 99, 0.12); } + select option { + color: var(--ink); + background: var(--white); + } + + select option:checked, + select option:hover, + select option:focus { + color: var(--ink); + background: #e7f0f5; + box-shadow: inset 0 0 0 999px #e7f0f5; + } + .actions { display: flex; flex-wrap: wrap; @@ -236,11 +263,19 @@ color: #204257; } + .button-secondary:hover, + .button-secondary:focus-visible { + background: #dbeaf2; + color: #163345; + } + .table-wrap { overflow: auto; border: 1px solid var(--line); border-radius: 18px; background: var(--white); + content-visibility: auto; + contain-intrinsic-size: 360px; } table { diff --git a/templates/projects.html b/templates/projects.html index 783feeb..cfb7df6 100644 --- a/templates/projects.html +++ b/templates/projects.html @@ -493,6 +493,12 @@ align-items: center; } + .related-tag-stack { + display: grid; + gap: 10px; + width: 100%; + } + .related-tag-label { color: var(--muted); font-size: 12px; @@ -513,6 +519,10 @@ line-height: 1.2; } + .related-project-tag.is-muted { + opacity: 0.72; + } + .related-project-tag button { width: 18px; height: 18px; @@ -530,16 +540,33 @@ background: rgba(41, 72, 95, 0.14); } + .related-project-toggle { + font-weight: 900; + } + + .analysis-inline-note { + width: 100%; + min-height: 40px; + padding: 8px 10px; + border: 1px solid #d7dfe7; + border-radius: 12px; + background: #fff; + font: inherit; + color: var(--ink); + resize: vertical; + } + .related-modal-card { width: min(680px, 100%); max-height: min(70vh, 760px); - overflow: auto; + overflow: hidden; 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; + display: flex; + flex-direction: column; gap: 12px; } @@ -548,6 +575,12 @@ justify-content: space-between; align-items: center; gap: 12px; + flex-wrap: wrap; + } + + .related-modal-head--singleline { + flex-wrap: nowrap; + align-items: center; } .related-modal-title { @@ -558,6 +591,17 @@ .related-modal-body { display: grid; gap: 10px; + overflow: auto; + min-height: 0; + overscroll-behavior: contain; + } + + .related-modal-body .compact-table thead th { + position: sticky; + top: 0; + z-index: 2; + background: #f5f8fb; + box-shadow: inset 0 -1px 0 #e5ebf0; } .related-search-results { @@ -664,6 +708,21 @@ padding: 8px 10px; } + .comparison-detail-head-inline { + display: flex; + align-items: center; + justify-content: space-between; + gap: 10px; + background: #f5f8fb; + padding: 8px 10px; + } + + .comparison-detail-head-inline h5 { + background: transparent; + padding: 0; + margin: 0; + } + .comparison-detail-list { display: grid; gap: 0; @@ -679,6 +738,15 @@ min-width: 0; } + .comparison-detail-checkbox { + display: inline-flex; + align-items: center; + gap: 6px; + font-size: 12px; + font-weight: 700; + color: #547085; + } + .comparison-detail-item { display: grid; grid-template-columns: minmax(0, 1fr) auto; @@ -761,6 +829,33 @@ text-align: center; } + .comparison-project-block { + display: grid; + gap: 0; + } + + .comparison-project-detail-area { + display: grid; + gap: 0; + position: relative; + align-content: start; + } + + .comparison-project-detail-area.is-empty { + position: relative; + } + + .comparison-project-warning-anchor { + position: absolute; + inset: 0; + display: flex; + align-items: center; + justify-content: center; + pointer-events: none; + padding: 8px 10px; + box-sizing: border-box; + } + .analysis-kpis { display: grid; grid-template-columns: repeat(6, minmax(0, 1fr)); @@ -827,6 +922,15 @@ color: #9aa8b5; } + .comparison-note-auto { + margin-bottom: 6px; + color: #b91c1c; + font-size: 11px; + font-weight: 800; + line-height: 1.35; + text-align: left; + } + .delta-positive { color: #15803d; font-weight: 800; @@ -897,11 +1001,15 @@ position: relative; z-index: 40; overflow: visible; + content-visibility: auto; + contain-intrinsic-size: 1200px; } .project-uncontracted-panel { position: relative; z-index: 1; + content-visibility: auto; + contain-intrinsic-size: 960px; } .uncontracted-toolbar { @@ -1034,6 +1142,136 @@ width: 180px; } + .compact-table th.uncontracted-col-project, + .compact-table td.uncontracted-col-project { + width: auto; + } + + .compact-table th.uncontracted-col-amount, + .compact-table td.uncontracted-col-amount { + width: 180px; + white-space: nowrap; + } + + .compact-table th.uncontracted-col-amount { + text-align: center; + } + + .compact-table td.uncontracted-col-amount { + text-align: right; + user-select: none; + -webkit-user-select: none; + -moz-user-select: none; + } + + .compact-table td.uncontracted-col-amount.is-clickable { + cursor: pointer; + } + + .compact-table td.uncontracted-col-amount::selection, + .compact-table td.uncontracted-col-amount *::selection { + background: transparent !important; + color: inherit !important; + } + + .compact-table td.uncontracted-col-amount::-moz-selection, + .compact-table td.uncontracted-col-amount *::-moz-selection { + background: transparent !important; + color: inherit !important; + } + + .detail-amount-trigger { + background: transparent; + color: inherit; + font: inherit; + font-weight: 700; + display: flex; + justify-content: flex-end; + align-items: center; + width: 100%; + user-select: none !important; + -webkit-user-select: none !important; + -moz-user-select: none !important; + -webkit-touch-callout: none; + caret-color: transparent; + -webkit-tap-highlight-color: transparent; + padding: 4px 8px; + border-radius: 8px; + transition: background 0.16s ease, color 0.16s ease; + text-align: right; + } + + .detail-amount-trigger * { + user-select: none !important; + -webkit-user-select: none !important; + -moz-user-select: none !important; + } + + .detail-amount-trigger::selection { + background: transparent; + color: inherit; + } + + .detail-amount-trigger::-moz-selection { + background: transparent; + color: inherit; + } + + .detail-amount-trigger:hover { + background: #eef2f6; + color: #1d4ed8; + } + + .detail-amount-trigger.is-empty { + justify-content: center; + color: #8aa0b5; + font-weight: 600; + } + + .uncontracted-col-amount.is-clickable:hover .detail-amount-trigger, + .uncontracted-col-amount.is-clickable:focus-within .detail-amount-trigger, + .uncontracted-col-amount.is-clickable.is-active .detail-amount-trigger { + background: #eef2f6; + color: #1d4ed8; + } + + .sortable-header { + display: inline-flex; + align-items: center; + gap: 6px; + } + + .sort-toggle { + display: inline-flex; + align-items: center; + justify-content: center; + width: 18px; + height: 18px; + padding: 0; + border: 0; + background: transparent; + color: #7b92a8; + cursor: pointer; + } + + .sort-toggle:hover { + color: #1d4ed8; + } + + .sort-toggle.is-active { + color: #1d4ed8; + } + + .sort-toggle svg { + width: 14px; + height: 14px; + stroke: currentColor; + stroke-width: 2; + fill: none; + stroke-linecap: round; + stroke-linejoin: round; + } + .detail-classification-select { width: 100%; min-width: 0; @@ -1045,6 +1283,93 @@ cursor: pointer; } + .modal-title-with-total { + display: flex; + align-items: baseline; + justify-content: space-between; + gap: 16px; + width: 100%; + } + + .modal-title-with-total--compact { + flex: 1 1 auto; + min-width: 0; + width: auto; + } + + .modal-title-with-total--compact .related-modal-title { + min-width: 0; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + } + + .modal-inline-total { + font-size: 16px; + font-weight: 800; + color: #132238; + white-space: nowrap; + flex: 0 0 auto; + } + + .vendor-period-toolbar { + display: inline-flex; + align-items: center; + gap: 8px; + margin-left: auto; + flex-wrap: nowrap; + flex: 0 0 auto; + } + + .vendor-period-toolbar.is-hidden { + display: none; + } + + .vendor-period-check { + display: inline-flex; + align-items: center; + gap: 6px; + font-size: 13px; + color: #4b5563; + white-space: nowrap; + } + + .vendor-period-toolbar input[type="date"] { + width: 126px; + min-width: 126px; + height: 36px; + padding: 0 8px; + border: 1px solid #d7dfe7; + border-radius: 10px; + background: #fff; + font: inherit; + color: var(--ink); + } + + .related-modal-head--singleline > .button-icon { + flex: 0 0 auto; + } + + .vendor-period-toolbar input[type="date"]:disabled { + background: #f5f7fa; + color: #94a3b8; + cursor: not-allowed; + } + + .transaction-partner-link { + display: inline-flex; + align-items: center; + gap: 6px; + color: #0f172a; + text-decoration: underline; + text-underline-offset: 2px; + cursor: pointer; + } + + .transaction-partner-link:hover { + color: #1d4ed8; + } + .compact-table .interactive-row:hover { background: #f8fbfe; } @@ -1419,6 +1744,21 @@ border-bottom: none; } + .lookup-option:hover, + .lookup-option:focus-visible { + background: #eef2f6; + color: var(--ink); + outline: none; + } + + .lookup-option, + .sort-toggle, + .detail-amount-button, + .button-secondary, + .button-icon { + -webkit-tap-highlight-color: transparent; + } + .lookup-option strong, .lookup-option span { display: block; @@ -2696,6 +3036,13 @@

+ + + + + + + @@ -2712,6 +3059,213 @@ + +
프로젝트
+ + + + + + + + + + + + + + + + + + + +
+
+ 전표작성일 + +
+
+
+ 거래처 + +
+
+
+ 원가부서 + +
+
+
+ 계정명칭 + +
+
+
+ 금액 + +
+
표시할 거래내역이 없습니다.
+
+ + + + + {% endblock %} {% block script %} @@ -2735,6 +3289,7 @@ const projectMonthlyCostRows = {{ project_monthly_cost_rows | tojson }}; const projectStatusRows = {{ project_status_rows | tojson }}; const projectComparisonNotes = {{ project_comparison_notes | tojson }}; + const projectAnalysisSettings = {{ project_analysis_settings | tojson }}; const persistedProjectPageState = {{ project_page_state | tojson }}; const persistedProjectRelatedLinks = {{ project_related_links | tojson }}; const persistedUncontractedCategoryOverrides = {{ project_uncontracted_classifications | tojson }}; @@ -2797,8 +3352,56 @@ const closeExcludedDetailItemsModalButton = document.getElementById("closeExcludedDetailItemsModal"); const excludedDetailItemsSummary = document.getElementById("excludedDetailItemsSummary"); const excludedDetailItemsRows = document.getElementById("excludedDetailItemsRows"); + const uncontractedTransactionModal = document.getElementById("uncontractedTransactionModal"); + const closeUncontractedTransactionModalButton = document.getElementById("closeUncontractedTransactionModal"); + const uncontractedTransactionModalTitle = document.getElementById("uncontractedTransactionModalTitle"); + const uncontractedTransactionModalTotal = document.getElementById("uncontractedTransactionModalTotal"); + const uncontractedTransactionRows = document.getElementById("uncontractedTransactionRows"); + const vendorTransactionModal = document.getElementById("vendorTransactionModal"); + const closeVendorTransactionModalButton = document.getElementById("closeVendorTransactionModal"); + const vendorTransactionModalTitle = document.getElementById("vendorTransactionModalTitle"); + const vendorTransactionModalTotal = document.getElementById("vendorTransactionModalTotal"); + const vendorTransactionRows = document.getElementById("vendorTransactionRows"); + const vendorPeriodToolbar = document.getElementById("vendorPeriodToolbar"); + const vendorUseProjectPeriodCheckbox = document.getElementById("vendorUseProjectPeriod"); + const vendorPeriodStartInput = document.getElementById("vendorPeriodStart"); + const vendorPeriodEndInput = document.getElementById("vendorPeriodEnd"); + const vendorPeriodSearchButton = document.getElementById("vendorPeriodSearch"); let currentLaborRates = {}; let latestExcludedDetailRows = []; + let uncontractedDetailSort = { key: "code", direction: "asc" }; + let uncontractedTransactionSort = { key: "posting_date", direction: "desc" }; + let vendorTransactionSort = { key: "posting_date", direction: "desc" }; + let latestUncontractedTransactionRows = []; + let latestVendorTransactionRows = []; + let latestTransactionModalContext = { type: "uncontracted" }; + let latestVendorModalContext = { type: "uncontracted" }; + let uncontractedDashboardDataRevision = 0; + const uncontractedDashboardDataCache = new Map(); + + function bumpUncontractedDashboardRevision() { + uncontractedDashboardDataRevision += 1; + uncontractedDashboardDataCache.clear(); + } + + function buildUncontractedDashboardCacheKey(startYear, endYear) { + return JSON.stringify({ + revision: uncontractedDashboardDataRevision, + startYear: Number(startYear || 0), + endYear: Number(endYear || 0), + selection: { + type: String(uncontractedDetailSelection?.type || ""), + year: Number(uncontractedDetailSelection?.year || 0), + month: Number(uncontractedDetailSelection?.month || 0), + category: String(uncontractedDetailSelection?.category || ""), + }, + visible: Boolean(uncontractedDetailVisible), + sort: { + key: String(uncontractedDetailSort?.key || "code"), + direction: String(uncontractedDetailSort?.direction || "asc"), + }, + }); + } yearFilter?.addEventListener("change", (event) => { const nextYear = event.target.value; @@ -2834,6 +3437,213 @@ return formatNumber(numeric); } + function sortUncontractedDetailRows(rows = []) { + const items = [...rows]; + const key = uncontractedDetailSort?.key || "code"; + const direction = uncontractedDetailSort?.direction === "desc" ? -1 : 1; + if (key === "expense") { + return items.sort((a, b) => ( + (Number(a.expense_amount || 0) - Number(b.expense_amount || 0)) * direction + ) || String(a.support_dept_code || "").localeCompare(String(b.support_dept_code || ""), "ko", { numeric: true })); + } + if (key === "revenue") { + return items.sort((a, b) => ( + (Number(a.revenue_amount || 0) - Number(b.revenue_amount || 0)) * direction + ) || String(a.support_dept_code || "").localeCompare(String(b.support_dept_code || ""), "ko", { numeric: true })); + } + return items.sort((a, b) => String(a.support_dept_code || "").localeCompare(String(b.support_dept_code || ""), "ko", { numeric: true }) * direction); + } + + function renderSortToggle(key) { + const isActive = uncontractedDetailSort?.key === key; + const isDesc = isActive && uncontractedDetailSort?.direction === "desc"; + return ` + + `; + } + + function renderModalSortToggle(state, attrName, key) { + const isActive = state?.key === key; + return ` + + `; + } + + function compareTransactionRows(a, b, key, direction) { + const multiplier = direction === "asc" ? 1 : -1; + const getText = (value) => String(value || "").toLocaleLowerCase(); + if (key === "amount") { + return ((Number(a.amount || 0) - Number(b.amount || 0)) * multiplier) + || getText(a.partner_name).localeCompare(getText(b.partner_name), "ko", { numeric: true }); + } + const fieldMap = { + posting_date: "posting_date", + partner: "partner_name", + cost_dept: "cost_dept_name", + account: "account_name", + support_dept: "support_dept_name", + }; + const field = fieldMap[key] || "posting_date"; + return getText(a[field]).localeCompare(getText(b[field]), "ko", { numeric: true }) * multiplier; + } + + function sortTransactionRows(rows, sortState) { + return [...(rows || [])].sort((a, b) => + compareTransactionRows(a, b, sortState?.key || "posting_date", sortState?.direction || "desc") + ); + } + + function formatPartnerDisplay(name, code) { + const normalizedName = String(name || "").trim(); + const normalizedCode = String(code || "").trim(); + if (normalizedName && normalizedCode) return `${normalizedName} (${normalizedCode})`; + return normalizedName || normalizedCode || "-"; + } + + function formatAccountDisplay(name, code) { + const normalizedName = String(name || "").trim(); + const normalizedCode = String(code || "").trim(); + if (normalizedName && normalizedCode) return `${normalizedName} (${normalizedCode})`; + return normalizedName || normalizedCode || "-"; + } + + function formatSupportDeptDisplay(name, code) { + const normalizedName = String(name || "").trim(); + const normalizedCode = String(code || "").trim(); + if (normalizedName && normalizedCode) return `${normalizedName} (${normalizedCode})`; + return normalizedName || normalizedCode || "-"; + } + + function renderUncontractedTransactionRows() { + const rows = sortTransactionRows(latestUncontractedTransactionRows, uncontractedTransactionSort); + uncontractedTransactionRows.innerHTML = rows.length + ? rows.map((row) => ` + + ${escapeHtml(row.posting_date || "-")} + + ${escapeHtml(formatPartnerDisplay(row.partner_name, row.partner_code))} + + ${escapeHtml(row.cost_dept_name || "-")} + ${escapeHtml(formatAccountDisplay(row.account_name, row.account_code))} + ${formatNumber(row.amount || 0)} + + `).join("") + : `표시할 거래내역이 없습니다.`; + } + + function renderVendorTransactionRows() { + const rows = sortTransactionRows(latestVendorTransactionRows, vendorTransactionSort); + vendorTransactionRows.innerHTML = rows.length + ? rows.map((row) => ` + + ${escapeHtml(row.posting_date || "-")} + ${escapeHtml(formatPartnerDisplay(row.partner_name, row.partner_code))} + ${escapeHtml(row.cost_dept_name || "-")} + ${escapeHtml(formatAccountDisplay(row.account_name, row.account_code))} + ${escapeHtml(formatSupportDeptDisplay(row.support_dept_name, row.support_dept_code))} + ${formatNumber(row.amount || 0)} + + `).join("") + : `표시할 거래내역이 없습니다.`; + } + + function syncVendorPeriodToolbar() { + const isComparison = latestVendorModalContext?.type === "comparison"; + vendorPeriodToolbar?.classList.toggle("is-hidden", !isComparison); + const useProjectPeriod = vendorUseProjectPeriodCheckbox?.checked !== false; + if (vendorPeriodStartInput) vendorPeriodStartInput.disabled = !isComparison || useProjectPeriod; + if (vendorPeriodEndInput) vendorPeriodEndInput.disabled = !isComparison || useProjectPeriod; + if (vendorPeriodSearchButton) vendorPeriodSearchButton.disabled = !isComparison; + if (!isComparison) { + if (vendorUseProjectPeriodCheckbox) vendorUseProjectPeriodCheckbox.checked = true; + if (vendorPeriodStartInput) vendorPeriodStartInput.value = ""; + if (vendorPeriodEndInput) vendorPeriodEndInput.value = ""; + } + } + + async function loadComparisonVendorTransactionRows({ useProjectPeriod = true } = {}) { + const normalizedPartnerCode = String(latestVendorModalContext.partnerCode || "").trim(); + const normalizedPartnerName = String(latestVendorModalContext.partnerName || "").trim(); + const normalizedProjectCode = String(latestVendorModalContext.supportDeptCode || "").trim(); + const normalizedCodes = Array.isArray(latestVendorModalContext.codes) ? latestVendorModalContext.codes : []; + if ((!normalizedPartnerCode && !normalizedPartnerName) || !normalizedCodes.length) return; + const params = new URLSearchParams({ + support_dept_code: normalizedProjectCode, + codes: normalizedCodes.join(","), + partner_code: normalizedPartnerCode, + partner_name: normalizedPartnerName, + }); + if (!useProjectPeriod) { + const startDate = String(vendorPeriodStartInput?.value || "").trim(); + const endDate = String(vendorPeriodEndInput?.value || "").trim(); + if (startDate) params.set("start_date", startDate); + if (endDate) params.set("end_date", endDate); + } + vendorTransactionRows.innerHTML = `거래내역을 불러오는 중입니다.`; + vendorTransactionModalTotal.textContent = "0원"; + try { + const response = await fetch(`/projects/comparison-vendor-transactions?${params.toString()}`, { + credentials: "same-origin", + headers: { Accept: "application/json" }, + }); + const payload = await response.json(); + if (!response.ok || payload?.error) { + throw new Error(payload?.error || `HTTP ${response.status}`); + } + latestVendorTransactionRows = Array.isArray(payload?.rows) ? payload.rows : []; + latestVendorModalContext.defaultStartDate = String(payload?.project_start_date || payload?.start_date || "").trim(); + latestVendorModalContext.defaultEndDate = String(payload?.project_end_date || payload?.end_date || "").trim(); + if (vendorPeriodStartInput && useProjectPeriod) { + vendorPeriodStartInput.value = latestVendorModalContext.defaultStartDate || ""; + } + if (vendorPeriodEndInput && useProjectPeriod) { + vendorPeriodEndInput.value = latestVendorModalContext.defaultEndDate || ""; + } + vendorTransactionModalTotal.textContent = `${formatNumber(payload?.total_amount || 0)}원`; + renderVendorTransactionRows(); + } catch (error) { + console.error(error); + latestVendorTransactionRows = []; + vendorTransactionRows.innerHTML = `거래내역을 불러오지 못했습니다.`; + vendorTransactionModalTotal.textContent = "0원"; + } + } + + function formatUncontractedCellAmount(value) { + const numeric = Number(value || 0); + if (!numeric) { + return { text: "-", empty: true }; + } + return { text: formatNumber(numeric), empty: false }; + } + function formatCompactCurrency(value) { const numeric = Number(value || 0); if (!numeric) return "0원"; @@ -4086,6 +4896,12 @@ } const updatedEdit = cloneProjectEditData(payload?.project_edit || {}); const updatedRow = payload?.project_row || null; + const savedCode = String( + updatedRow?.support_dept_code + || updatedEdit?.support_dept_code + || formData.get("support_dept_code") + || "" + ).trim(); if (payload?.data_version && typeof window.__setPageDataVersion === "function") { window.__setPageDataVersion(payload.data_version); } @@ -4096,6 +4912,7 @@ applyProjectEditData(updatedEdit); } if (updatedRow?.support_dept_code) { + bumpUncontractedDashboardRevision(); const projectPageHelpers = window.__projectPageHelpers || null; if (projectPageHelpers?.upsertProjectStatusRow) { projectPageHelpers.upsertProjectStatusRow(updatedRow); @@ -4104,6 +4921,38 @@ projectPageHelpers.refreshAnalysisForCode(updatedRow.support_dept_code); } } + if (savedCode) { + selectedCode = savedCode; + currentSelectedProjectCode = savedCode; + analysisOpen = true; + const refreshedItem = getAnalysisItem(savedCode) || updatedRow || projectStatusMap[savedCode] || null; + if (refreshedItem) { + if (input) { + input.value = refreshedItem.support_dept_name || ""; + } + setAnalysisVisibility(true); + renderAnalysis(refreshedItem); + } + try { + const nextUrl = new URL(window.location.href); + nextUrl.searchParams.delete("edit_code"); + nextUrl.searchParams.set("focus_code", savedCode); + const currentYearValue = String(selectedYear || yearSelect?.value || "").trim(); + if (currentYearValue) { + nextUrl.searchParams.set("year", currentYearValue); + } else { + nextUrl.searchParams.delete("year"); + } + window.history.replaceState({}, "", nextUrl.toString()); + } catch (urlError) { + console.warn("저장 후 URL 상태를 갱신하지 못했습니다.", urlError); + } + try { + await saveProjectPageStateToServer(false); + } catch (stateError) { + console.warn("저장 후 페이지 상태를 저장하지 못했습니다.", stateError); + } + } } catch (error) { console.error(error); window.alert(error?.message || "사업현황 저장 중 오류가 발생했습니다."); @@ -4427,6 +5276,7 @@ type: "item", label: `${groupCode} · ${entry.label || "-"}`, amount: Number(entry.amount || 0), + clickMeta: entry.clickMeta || null, }); }); } else { @@ -4505,7 +5355,25 @@ return `
${escapeHtml(row.label || "-")} - ${row.amount === null || row.amount === undefined ? "" : formatNumber(row.amount)} + ${row.amount === null || row.amount === undefined + ? "" + : row.clickMeta + ? ` + ${formatNumber(row.amount)} + ` + : `${formatNumber(row.amount)}` + }
`; }).join(""); @@ -4589,46 +5457,133 @@ `; } - function renderLaborComparisonDetails(plannedEntries, erpEntries, jointEntries) { + function renderLaborComparisonDetails(baseCode, plannedEntries, erpEntries, jointEntries) { const plannedGrouped = buildGroupedComparisonEntries(plannedEntries); const erpGrouped = buildGroupedComparisonEntries(erpEntries); const jointGrouped = buildGroupedComparisonEntries(jointEntries); + const analysisSetting = getProjectAnalysisSetting(baseCode); + const laborJointExempt = Boolean(analysisSetting.labor_joint_exempt); const plannedMap = new Map(plannedGrouped.groups.map((group) => [group.code, group])); const erpMap = new Map(erpGrouped.groups.map((group) => [group.code, group])); const jointMap = new Map(jointGrouped.groups.map((group) => [group.code, group])); const order = getProjectPairOrder([...plannedEntries, ...erpEntries], jointEntries); - const plannedRows = []; - const erpRows = []; - const jointRows = []; + const detailRowHeight = 38; + + function renderProjectBlock(group, rowCount, mode = "default") { + const items = group.items || []; + const detailRows = []; + if (mode === "joint-warning" && !items.length) { + for (let index = 0; index < rowCount; index += 1) { + detailRows.push('
'); + } + const detailHeight = Math.max(rowCount, 1) * detailRowHeight; + return ` +
+
+ ${detailRows.join("")} +
+
+ 합사인건비 반영 필요 + (사업관리팀 자료 회신 대기 중) +
+
+
+
+ ${group.code} 소계 + ${formatNumber(group.subtotal || 0)} +
+
+ `; + } + + for (let index = 0; index < rowCount; index += 1) { + const item = items[index]; + if (item) { + detailRows.push(` +
+ ${item.label} + ${formatNumber(item.amount)} +
+ `); + } else { + detailRows.push('
'); + } + } + + return ` +
+
+ ${detailRows.join("")} +
+
+ ${group.code} 소계 + ${formatNumber(group.subtotal || 0)} +
+
+ `; + } + + const plannedBlocks = []; + const erpBlocks = []; + const jointBlocks = []; + order.forEach((projectCode) => { const plannedGroup = plannedMap.get(projectCode) || { code: projectCode, items: [], subtotal: 0 }; const erpGroup = erpMap.get(projectCode) || { code: projectCode, items: [], subtotal: 0 }; const jointGroup = jointMap.get(projectCode) || { code: projectCode, items: [], subtotal: 0 }; - const plannedCount = plannedGroup.items?.length ? plannedGroup.items.length : 1; - const erpCount = erpGroup.items?.length ? erpGroup.items.length : 1; - const jointCount = jointGroup.items?.length ? jointGroup.items.length : 1; - const maxCount = Math.max(plannedCount, erpCount, jointCount); - plannedRows.push(...buildComparisonSideRows(plannedGroup, "", maxCount - plannedCount)); - erpRows.push(...buildComparisonSideRows(erpGroup, "", maxCount - erpCount)); - jointRows.push(...buildComparisonSideRows( + const maxCount = Math.max( + (plannedGroup.items || []).length || 1, + (erpGroup.items || []).length || 1, + (jointGroup.items || []).length || 1, + ); + plannedBlocks.push(renderProjectBlock(plannedGroup, maxCount)); + erpBlocks.push(renderProjectBlock(erpGroup, maxCount)); + jointBlocks.push(renderProjectBlock( jointGroup, - "", - maxCount - jointCount, - !(jointGroup.items || []).length ? { - title: "합사인건비 반영 필요", - subtitle: "(사업관리팀 자료 회신 대기 중)", - rowSpan: maxCount, - } : null, + maxCount, + !(jointGroup.items || []).length && !laborJointExempt ? "joint-warning" : "default", )); }); + return `
- ${renderComparisonSideBox(plannedRows, plannedGrouped.total, "총계", "계획/입력 세부")}
-
실제 집행 세부
+
계획/입력 세부
+
+ ${plannedBlocks.join("")} +
+ 총계 + ${formatNumber(plannedGrouped.total)} +
+
+
+
+
+
실제 집행 세부
+ +
- ${renderComparisonSideBox(erpRows, erpGrouped.total, "인건비(ERP) 총계")} - ${renderComparisonSideBox(jointRows, jointGrouped.total, "인건비(합사) 총계")} +
+
+ ${erpBlocks.join("")} +
+ 인건비(ERP) 총계 + ${formatNumber(erpGrouped.total)} +
+
+
+
+
+ ${jointBlocks.join("")} +
+ 인건비(합사) 총계 + ${formatNumber(jointGrouped.total)} +
+
+
@@ -4891,9 +5846,37 @@ const costEntries = aggregateCodes.flatMap((code) => getCostBreakdownEntries(code).map((entry) => ({ label: `${code} · ${entry.label || "-"}`, amount: Number(entry.amount || 0), + account_label: String(entry.label || "").trim(), + account_code: String(entry.account_code || "").trim(), + account_name: String(entry.account_name || "").trim(), + support_dept_code: code, }))); - const outsourceActual = costEntries.filter(isDesignOutsourceEntry); - const overheadActual = costEntries.filter((entry) => !isDesignOutsourceEntry(entry)); + const outsourceActual = costEntries + .filter(isDesignOutsourceEntry) + .map((entry) => ({ + ...entry, + clickMeta: { + support_dept_code: item.support_dept_code, + codes: aggregateCodes, + group: "outsource", + account_label: entry.account_label || entry.account_name || "", + project_code: item.support_dept_code, + project_name: item.support_dept_name, + }, + })); + const overheadActual = costEntries + .filter((entry) => !isDesignOutsourceEntry(entry)) + .map((entry) => ({ + ...entry, + clickMeta: { + support_dept_code: item.support_dept_code, + codes: aggregateCodes, + group: "overhead", + account_label: entry.account_label || entry.account_name || "", + project_code: item.support_dept_code, + project_name: item.support_dept_name, + }, + })); return { collection: { planned: revenuePlanned, @@ -4998,12 +5981,62 @@ return /(\d+\s*차|[nN]\s*차|변경|보완|지연보상금|가칭|연차|년분)/.test(String(name || "")); } + function getProjectAnalysisSetting(code) { + const normalizedCode = String(code || "").trim(); + const source = (normalizedCode && projectAnalysisSettings?.[normalizedCode]) || {}; + const inactiveRelatedCodes = Array.isArray(source?.inactive_related_codes) + ? source.inactive_related_codes.map((value) => String(value || "").trim()).filter(Boolean) + : []; + return { + detail_note: String(source?.detail_note || ""), + inactive_related_codes: inactiveRelatedCodes, + labor_joint_exempt: Boolean(source?.labor_joint_exempt), + }; + } + + async function saveProjectAnalysisSettingsToServer(code, patch = {}) { + const normalizedCode = String(code || "").trim(); + if (!normalizedCode) return; + const current = getProjectAnalysisSetting(normalizedCode); + const payload = { + support_dept_code: normalizedCode, + detail_note: Object.prototype.hasOwnProperty.call(patch, "detail_note") ? patch.detail_note : current.detail_note, + inactive_related_codes: Object.prototype.hasOwnProperty.call(patch, "inactive_related_codes") ? patch.inactive_related_codes : current.inactive_related_codes, + labor_joint_exempt: Object.prototype.hasOwnProperty.call(patch, "labor_joint_exempt") ? patch.labor_joint_exempt : current.labor_joint_exempt, + }; + const response = await fetch("/projects/analysis-settings", { + method: "POST", + credentials: "same-origin", + headers: { + "Content-Type": "application/json", + "Accept": "application/json", + }, + body: JSON.stringify(payload), + }); + const result = await response.json().catch(() => ({})); + if (!response.ok || result?.error) { + throw new Error(result?.error || "프로젝트 상세 설정을 저장하지 못했습니다."); + } + projectAnalysisSettings[normalizedCode] = { + detail_note: String(payload.detail_note || ""), + inactive_related_codes: Array.isArray(payload.inactive_related_codes) ? payload.inactive_related_codes : [], + labor_joint_exempt: Boolean(payload.labor_joint_exempt), + }; + bumpUncontractedDashboardRevision(); + } + function getCurrentRelatedCodes(baseCode) { const selectionMap = window.__projectRelatedSelections; if (!(selectionMap instanceof Map)) return []; return [...(selectionMap.get(baseCode) || new Set())]; } + function getActiveRelatedCodes(baseCode) { + const allCodes = getCurrentRelatedCodes(baseCode); + const inactiveCodes = new Set(getProjectAnalysisSetting(baseCode).inactive_related_codes || []); + return allCodes.filter((code) => !inactiveCodes.has(code)); + } + let uncontractedDetailSelection = { type: "", year: null, month: null }; let uncontractedDetailVisible = false; @@ -5034,6 +6067,12 @@ function buildUncontractedDashboardData() { const { startYear, endYear } = getUncontractedYearRange(); + const cacheKey = buildUncontractedDashboardCacheKey(startYear, endYear); + const cached = uncontractedDashboardDataCache.get(cacheKey); + if (cached) { + latestExcludedDetailRows = cached.excludedDetailRows || []; + return cached; + } const filteredRows = projectCostRows.filter((row) => { const year = Number(row.year || 0); return year >= startYear && year <= endYear; @@ -5144,7 +6183,7 @@ current.expense_amount += Number(row.cost_sum || 0) + Number(row.sga_sum || 0); map.set(row.support_dept_code, current); }); - detailRows = [...map.values()].sort((a, b) => Number(b.expense_amount || 0) - Number(a.expense_amount || 0)); + detailRows = sortUncontractedDetailRows([...map.values()]); } else if (uncontractedDetailSelection.type === "year" && uncontractedDetailSelection.year) { const map = new Map(); const excludedMap = new Map(); @@ -5176,7 +6215,7 @@ current.revenue_amount += Number(row.revenue_amount || 0); map.set(row.support_dept_code, current); }); - detailRows = [...map.values()].sort((a, b) => Number(b.expense_amount || 0) - Number(a.expense_amount || 0)); + detailRows = sortUncontractedDetailRows([...map.values()]); excludedDetailRows = [...excludedMap.values()].sort((a, b) => Number(b.expense_amount || 0) - Number(a.expense_amount || 0)); } else if (uncontractedDetailSelection.type === "category" && uncontractedDetailSelection.category) { const map = new Map(); @@ -5190,7 +6229,7 @@ category: classifyUncontractedSpecialType(item), }); }); - detailRows = [...map.values()].sort((a, b) => Number(b.expense_amount || 0) - Number(a.expense_amount || 0)); + detailRows = sortUncontractedDetailRows([...map.values()]); } const selectedYearlyRow = uncontractedDetailSelection.type === "year" @@ -5199,7 +6238,7 @@ const detailExpenseTotal = detailRows.reduce((sum, row) => sum + Number(row.expense_amount || 0), 0); const detailExpenseGap = Math.max(0, Number(selectedYearlyRow?.expense_amount || 0) - detailExpenseTotal); - return { + const result = { startYear, endYear, summary, @@ -5211,6 +6250,8 @@ detailExpenseGap, detailTitle, }; + uncontractedDashboardDataCache.set(cacheKey, result); + return result; } function renderUncontractedDashboard() { @@ -5299,9 +6340,9 @@

세부 내역

- @@ -5327,18 +6368,43 @@ - - - ${uncontractedDetailSelection.type === "month" ? "" : ""} + + + ${uncontractedDetailSelection.type === "month" ? "" : ``} ${(data.detailRows || []).length ? data.detailRows.map((row) => ` - - - ${uncontractedDetailSelection.type === "month" ? "" : ``} + + + ${uncontractedDetailSelection.type === "month" ? "" : ` + + `} @@ -5475,6 +6553,7 @@ ${key === "labor" ? ` ${renderLaborComparisonDetails( + item.support_dept_code, plannedEntries, detail?.actual || [], detail?.actual_joint || [], @@ -5927,6 +7006,7 @@ if (result?.error) { throw new Error(result.error); } + bumpUncontractedDashboardRevision(); } async function saveProjectPageStateToServer(showFeedback = false) { @@ -6080,8 +7160,9 @@ return getCurrentRelatedCodes(baseCode); } - function getRelatedItems(baseCode) { - return getRelatedCodes(baseCode) + function getRelatedItems(baseCode, { includeInactive = false } = {}) { + const codes = includeInactive ? getRelatedCodes(baseCode) : getActiveRelatedCodes(baseCode); + return codes .map((code) => getAnalysisItem(code)) .filter(Boolean); } @@ -6134,6 +7215,187 @@ excludedDetailItemsModal?.classList.remove("open"); } + async function openUncontractedTransactionModal(code, kind) { + if (window.getSelection) { + const selection = window.getSelection(); + if (selection && typeof selection.removeAllRanges === "function") { + selection.removeAllRanges(); + } + } + const normalizedCode = String(code || "").trim(); + const normalizedKind = String(kind || "").trim().toLowerCase(); + if (!normalizedCode || !normalizedKind || !uncontractedDetailSelection.type) return; + const params = new URLSearchParams({ + support_dept_code: normalizedCode, + kind: normalizedKind, + detail_type: uncontractedDetailSelection.type, + start_year: String(getUncontractedYearRange().startYear || ""), + end_year: String(getUncontractedYearRange().endYear || ""), + }); + if (uncontractedDetailSelection.year) { + params.set("year", String(uncontractedDetailSelection.year)); + } + if (uncontractedDetailSelection.month) { + params.set("month", String(uncontractedDetailSelection.month)); + } + if (uncontractedDetailSelection.category) { + params.set("category", String(uncontractedDetailSelection.category)); + } + uncontractedTransactionModalTitle.textContent = `${normalizedCode} · ${normalizedKind === "expense" ? "발생 비용" : "관련 수금"} 세부내역`; + uncontractedTransactionModalTotal.textContent = "0원"; + uncontractedTransactionRows.innerHTML = ``; + latestTransactionModalContext = { + type: "uncontracted", + supportDeptCode: normalizedCode, + codes: [normalizedCode], + projectName: "", + }; + uncontractedTransactionModal?.classList.add("open"); + try { + const response = await fetch(`/projects/uncontracted-detail-transactions?${params.toString()}`, { + credentials: "same-origin", + headers: { Accept: "application/json" }, + }); + const payload = await response.json(); + if (!response.ok || payload?.error) { + throw new Error(payload?.error || `HTTP ${response.status}`); + } + latestUncontractedTransactionRows = Array.isArray(payload?.rows) ? payload.rows : []; + uncontractedTransactionModalTotal.textContent = `${formatNumber(payload?.total_amount || 0)}원`; + renderUncontractedTransactionRows(); + } catch (error) { + console.error(error); + uncontractedTransactionRows.innerHTML = ``; + uncontractedTransactionModalTotal.textContent = "0원"; + } + } + + function closeUncontractedTransactionModal() { + uncontractedTransactionModal?.classList.remove("open"); + } + + async function openComparisonTransactionModal({ supportDeptCode = "", codes = [], group = "", accountLabel = "", projectCode = "", projectName = "" } = {}) { + if (window.getSelection) { + const selection = window.getSelection(); + if (selection && typeof selection.removeAllRanges === "function") { + selection.removeAllRanges(); + } + } + const normalizedSupportDeptCode = String(supportDeptCode || "").trim(); + const normalizedCodes = (Array.isArray(codes) ? codes : String(codes || "").split(",")) + .map((code) => String(code || "").trim()) + .filter(Boolean); + const normalizedGroup = String(group || "").trim(); + const normalizedAccountLabel = String(accountLabel || "").trim(); + if (!normalizedSupportDeptCode || !normalizedCodes.length || !normalizedGroup) return; + const params = new URLSearchParams({ + support_dept_code: normalizedSupportDeptCode, + codes: normalizedCodes.join(","), + group: normalizedGroup, + account_label: normalizedAccountLabel, + }); + latestTransactionModalContext = { + type: "comparison", + supportDeptCode: normalizedSupportDeptCode, + codes: normalizedCodes, + projectName: String(projectName || "").trim(), + }; + uncontractedTransactionModalTitle.textContent = `${String(projectCode || normalizedSupportDeptCode).trim()} · 실제 집행 세부내역`; + uncontractedTransactionModalTotal.textContent = "0원"; + uncontractedTransactionRows.innerHTML = ``; + uncontractedTransactionModal?.classList.add("open"); + try { + const response = await fetch(`/projects/comparison-actual-transactions?${params.toString()}`, { + credentials: "same-origin", + headers: { Accept: "application/json" }, + }); + const payload = await response.json(); + if (!response.ok || payload?.error) { + throw new Error(payload?.error || `HTTP ${response.status}`); + } + latestUncontractedTransactionRows = Array.isArray(payload?.rows) ? payload.rows : []; + uncontractedTransactionModalTotal.textContent = `${formatNumber(payload?.total_amount || 0)}원`; + renderUncontractedTransactionRows(); + } catch (error) { + console.error(error); + latestUncontractedTransactionRows = []; + uncontractedTransactionRows.innerHTML = ``; + uncontractedTransactionModalTotal.textContent = "0원"; + } + } + + async function openVendorTransactionModal(partnerCode, partnerName, projectCode, projectName) { + const normalizedPartnerCode = String(partnerCode || "").trim(); + const normalizedPartnerName = String(partnerName || "").trim(); + const normalizedProjectCode = String(projectCode || "").trim(); + const normalizedProjectName = String(projectName || "").trim(); + if (!normalizedPartnerCode && !normalizedPartnerName) return; + latestVendorModalContext = { type: "uncontracted" }; + syncVendorPeriodToolbar(); + const range = getUncontractedYearRange(); + const params = new URLSearchParams({ + partner_code: normalizedPartnerCode, + partner_name: normalizedPartnerName, + start_year: String(range.startYear || ""), + end_year: String(range.endYear || ""), + }); + vendorTransactionModalTitle.textContent = "거래처 세부내역"; + vendorTransactionModalTotal.textContent = "0원"; + vendorTransactionRows.innerHTML = ``; + vendorTransactionModal?.classList.add("open"); + try { + const response = await fetch(`/projects/uncontracted-vendor-transactions?${params.toString()}`, { + credentials: "same-origin", + headers: { Accept: "application/json" }, + }); + const payload = await response.json(); + if (!response.ok || payload?.error) { + throw new Error(payload?.error || `HTTP ${response.status}`); + } + latestVendorTransactionRows = Array.isArray(payload?.rows) ? payload.rows : []; + vendorTransactionModalTotal.textContent = `${formatNumber(payload?.total_amount || 0)}원`; + renderVendorTransactionRows(); + } catch (error) { + console.error(error); + vendorTransactionRows.innerHTML = ``; + vendorTransactionModalTotal.textContent = "0원"; + } + } + + function closeVendorTransactionModal() { + vendorTransactionModal?.classList.remove("open"); + } + + async function openComparisonVendorTransactionModal(partnerCode, partnerName, projectCode, projectName, codes = []) { + const normalizedPartnerCode = String(partnerCode || "").trim(); + const normalizedPartnerName = String(partnerName || "").trim(); + const normalizedProjectCode = String(projectCode || "").trim(); + const normalizedCodes = (Array.isArray(codes) ? codes : String(codes || "").split(",")) + .map((code) => String(code || "").trim()) + .filter(Boolean); + if ((!normalizedPartnerCode && !normalizedPartnerName) || !normalizedCodes.length) return; + latestVendorModalContext = { + type: "comparison", + supportDeptCode: normalizedProjectCode, + codes: normalizedCodes, + partnerCode: normalizedPartnerCode, + partnerName: normalizedPartnerName, + projectName: String(projectName || "").trim(), + projectCode: normalizedProjectCode, + defaultStartDate: "", + defaultEndDate: "", + }; + vendorTransactionModalTitle.textContent = "거래처 세부내역"; + vendorTransactionModalTotal.textContent = "0원"; + vendorTransactionRows.innerHTML = ``; + if (vendorUseProjectPeriodCheckbox) vendorUseProjectPeriodCheckbox.checked = true; + if (vendorPeriodStartInput) vendorPeriodStartInput.value = ""; + if (vendorPeriodEndInput) vendorPeriodEndInput.value = ""; + syncVendorPeriodToolbar(); + vendorTransactionModal?.classList.add("open"); + await loadComparisonVendorTransactionRows({ useProjectPeriod: true }); + } + async function saveDetailYearSetting() { const numericYear = Number(detailVisibleMinYearInput?.value || 2023); if (!numericYear || numericYear < 2000 || numericYear > 2099) { @@ -6158,6 +7420,7 @@ throw new Error(payload?.error || `HTTP ${response.status}`); } Object.assign(projectRuntimeSettings, payload?.settings || {}, { detail_visible_min_year: String(numericYear) }); + bumpUncontractedDashboardRevision(); closeDetailYearSettingModal(); scheduleStandaloneUncontractedDashboardRender(); } catch (error) { @@ -6179,6 +7442,7 @@ console.error(error); window.alert("연관 프로젝트를 DB에 저장하지 못했습니다."); } + bumpUncontractedDashboardRevision(); closeRelatedProjectPicker(); renderAnalysis(getAnalysisItem(selectedCode)); } @@ -6200,6 +7464,7 @@ console.error(error); window.alert("연관 프로젝트 변경을 DB에 저장하지 못했습니다."); } + bumpUncontractedDashboardRevision(); renderAnalysis(getAnalysisItem(selectedCode)); } @@ -6379,9 +7644,14 @@ scheduleStandaloneUncontractedDashboardRender(); return; } + const allRelatedItems = getRelatedItems(item.support_dept_code, { includeInactive: true }); const relatedItems = getRelatedItems(item.support_dept_code); const aggregateItem = aggregateAnalysisItem(item, relatedItems); - relatedBarBox.innerHTML = renderRelatedProjectBar(item, relatedItems); + relatedBarBox.innerHTML = renderRelatedProjectBar(item, allRelatedItems); + const detailNoteInput = relatedBarBox.querySelector("[data-analysis-detail-note]"); + if (detailNoteInput) { + detailNoteInput.value = getProjectAnalysisSetting(item.support_dept_code).detail_note || ""; + } heroBox.innerHTML = renderAnalysisHero(aggregateItem); metricsBox.innerHTML = renderAnalysisMetrics(aggregateItem); comparisonBox.innerHTML = renderAnalysisComparison(aggregateItem); @@ -6392,9 +7662,51 @@ }); }); + relatedBarBox.querySelectorAll("[data-toggle-related]").forEach((button) => { + button.addEventListener("click", async (event) => { + event.preventDefault(); + event.stopPropagation(); + const toggleCode = String(button.dataset.toggleRelated || "").trim(); + if (!toggleCode || !item.support_dept_code) return; + const setting = getProjectAnalysisSetting(item.support_dept_code); + const nextInactive = new Set(setting.inactive_related_codes || []); + if (nextInactive.has(toggleCode)) { + nextInactive.delete(toggleCode); + } else { + nextInactive.add(toggleCode); + } + try { + await saveProjectAnalysisSettingsToServer(item.support_dept_code, { + inactive_related_codes: [...nextInactive], + }); + renderAnalysis(getAnalysisItem(item.support_dept_code)); + } catch (error) { + console.error(error); + window.alert(error.message || "연관 프로젝트 반영 설정을 저장하지 못했습니다."); + } + }); + }); relatedBarBox.querySelectorAll("[data-remove-related]").forEach((button) => { button.addEventListener("click", () => removeRelatedProject(button.dataset.removeRelated || "")); }); + relatedBarBox.querySelectorAll("[data-analysis-detail-note]").forEach((inputEl) => { + inputEl.addEventListener("focusout", async () => { + const baseCode = String(inputEl.dataset.analysisDetailNote || "").trim(); + if (!baseCode) return; + const note = inputEl.value || ""; + const previous = getProjectAnalysisSetting(baseCode).detail_note || ""; + if (note === previous) return; + try { + await saveProjectAnalysisSettingsToServer(baseCode, { + detail_note: note, + }); + } catch (error) { + console.error(error); + window.alert(error.message || "프로젝트 설명을 저장하지 못했습니다."); + inputEl.value = previous; + } + }); + }); comparisonBox.querySelectorAll(".comparison-main-row[data-detail-target]").forEach((row) => { row.addEventListener("click", () => { const key = row.dataset.detailTarget; @@ -6404,6 +7716,46 @@ detailRow?.classList.toggle("is-open", !isOpen); }); }); + comparisonBox.querySelectorAll("[data-labor-joint-exempt]").forEach((checkbox) => { + checkbox.addEventListener("change", async () => { + const code = String(checkbox.dataset.laborJointExempt || "").trim(); + if (!code) return; + const checked = Boolean(checkbox.checked); + try { + await saveProjectAnalysisSettingsToServer(code, { + labor_joint_exempt: checked, + }); + renderAnalysis(getAnalysisItem(code)); + } catch (error) { + console.error(error); + checkbox.checked = !checked; + window.alert(error.message || "합사없음 설정을 저장하지 못했습니다."); + } + }); + }); + comparisonBox.querySelectorAll("[data-comparison-open-transaction]").forEach((trigger) => { + const openModal = () => { + openComparisonTransactionModal({ + supportDeptCode: trigger.dataset.supportDeptCode || "", + codes: trigger.dataset.codes || "", + group: trigger.dataset.comparisonGroup || "", + accountLabel: trigger.dataset.accountLabel || "", + projectCode: trigger.dataset.projectCode || "", + projectName: trigger.dataset.projectName || "", + }); + }; + trigger.addEventListener("click", (event) => { + event.preventDefault(); + event.stopPropagation(); + openModal(); + }); + trigger.addEventListener("keydown", (event) => { + if (event.key !== "Enter" && event.key !== " ") return; + event.preventDefault(); + event.stopPropagation(); + openModal(); + }); + }); } window.__projectPageHelpers = { @@ -6515,6 +7867,7 @@ throw new Error(payload?.error || `HTTP ${response.status}`); } persistedUncontractedCategoryOverrides[supportDeptCode] = category; + bumpUncontractedDashboardRevision(); scheduleStandaloneUncontractedDashboardRender({ urgent: true }); } catch (error) { console.error(error); @@ -6542,6 +7895,8 @@ const toggleButton = event.target.closest("#toggleUncontractedDetail"); const saveFilterButton = event.target.closest("#saveUncontractedFilterState"); const excludedItemsButton = event.target.closest("#openExcludedDetailItems"); + const detailAmountButton = event.target.closest("[data-uncontracted-open-transaction]"); + const sortButton = event.target.closest("[data-uncontracted-sort]"); const openProjectRow = event.target.closest("[data-open-project-code]"); if (toggleButton) { uncontractedDetailVisible = !uncontractedDetailVisible; @@ -6556,6 +7911,27 @@ openExcludedDetailItemsModal(); return; } + if (sortButton) { + const nextKey = sortButton.dataset.uncontractedSort || "code"; + if (uncontractedDetailSort?.key === nextKey) { + uncontractedDetailSort = { + key: nextKey, + direction: uncontractedDetailSort.direction === "asc" ? "desc" : "asc", + }; + } else { + uncontractedDetailSort = { key: nextKey, direction: nextKey === "code" ? "asc" : "desc" }; + } + scheduleStandaloneUncontractedDashboardRender({ urgent: true }); + return; + } + if (detailAmountButton) { + event.stopPropagation(); + openUncontractedTransactionModal( + detailAmountButton.dataset.uncontractedCode || "", + detailAmountButton.dataset.uncontractedOpenTransaction || "expense", + ); + return; + } if (categoryButton) { uncontractedDetailSelection = { type: "category", @@ -6595,15 +7971,32 @@ renderAnalysis(getAnalysisItem(selectedCode)); } }); + uncontractedBox?.addEventListener("mousedown", (event) => { + const detailAmountButton = event.target.closest("[data-uncontracted-open-transaction]"); + if (!detailAmountButton) return; + event.preventDefault(); + }); + uncontractedBox?.addEventListener("pointerdown", (event) => { + const detailAmountButton = event.target.closest("[data-uncontracted-open-transaction]"); + if (!detailAmountButton) return; + event.preventDefault(); + }); uncontractedBox?.addEventListener("keydown", (event) => { const categoryButton = event.target.closest("[data-uncontracted-category]"); const excludedItemsButton = event.target.closest("#openExcludedDetailItems"); + const detailAmountButton = event.target.closest("[data-uncontracted-open-transaction]"); if (categoryButton) { if (event.key !== "Enter" && event.key !== " ") return; event.preventDefault(); categoryButton.click(); return; } + if (detailAmountButton) { + if (event.key !== "Enter" && event.key !== " ") return; + event.preventDefault(); + detailAmountButton.click(); + return; + } if (!excludedItemsButton) return; if (event.key !== "Enter" && event.key !== " ") return; event.preventDefault(); @@ -6662,6 +8055,82 @@ closeExcludedDetailItemsModal(); } }); + closeUncontractedTransactionModalButton?.addEventListener("click", closeUncontractedTransactionModal); + closeVendorTransactionModalButton?.addEventListener("click", closeVendorTransactionModal); + uncontractedTransactionModal?.addEventListener("click", (event) => { + const sortButton = event.target.closest("[data-uncontracted-transaction-sort]"); + const vendorLink = event.target.closest("[data-uncontracted-open-vendor]"); + const comparisonVendorLink = event.target.closest("[data-comparison-open-vendor]"); + if (sortButton) { + const nextKey = sortButton.dataset.uncontractedTransactionSort || "posting_date"; + if (uncontractedTransactionSort?.key === nextKey) { + uncontractedTransactionSort = { + key: nextKey, + direction: uncontractedTransactionSort.direction === "asc" ? "desc" : "asc", + }; + } else { + uncontractedTransactionSort = { key: nextKey, direction: nextKey === "amount" ? "desc" : "asc" }; + } + renderUncontractedTransactionRows(); + return; + } + if (vendorLink) { + openVendorTransactionModal( + vendorLink.dataset.uncontractedOpenVendor || "", + vendorLink.dataset.uncontractedVendorName || "", + vendorLink.dataset.uncontractedProjectCode || "", + vendorLink.dataset.uncontractedProjectName || "", + ); + return; + } + if (comparisonVendorLink) { + openComparisonVendorTransactionModal( + comparisonVendorLink.dataset.comparisonOpenVendor || "", + comparisonVendorLink.dataset.comparisonVendorName || "", + comparisonVendorLink.dataset.comparisonProjectCode || "", + comparisonVendorLink.dataset.comparisonProjectName || "", + comparisonVendorLink.dataset.comparisonCodes || "", + ); + return; + } + if (event.target === uncontractedTransactionModal) { + closeUncontractedTransactionModal(); + } + }); + vendorTransactionModal?.addEventListener("click", (event) => { + const sortButton = event.target.closest("[data-vendor-transaction-sort]"); + if (sortButton) { + const nextKey = sortButton.dataset.vendorTransactionSort || "posting_date"; + if (vendorTransactionSort?.key === nextKey) { + vendorTransactionSort = { + key: nextKey, + direction: vendorTransactionSort.direction === "asc" ? "desc" : "asc", + }; + } else { + vendorTransactionSort = { key: nextKey, direction: nextKey === "amount" ? "desc" : "asc" }; + } + renderVendorTransactionRows(); + return; + } + if (event.target === vendorTransactionModal) { + closeVendorTransactionModal(); + } + }); + vendorUseProjectPeriodCheckbox?.addEventListener("change", async () => { + syncVendorPeriodToolbar(); + if (latestVendorModalContext?.type !== "comparison") return; + if (vendorUseProjectPeriodCheckbox.checked) { + await loadComparisonVendorTransactionRows({ useProjectPeriod: true }); + } + }); + vendorPeriodSearchButton?.addEventListener("click", async () => { + if (latestVendorModalContext?.type !== "comparison") return; + if (vendorUseProjectPeriodCheckbox?.checked) { + await loadComparisonVendorTransactionRows({ useProjectPeriod: true }); + return; + } + await loadComparisonVendorTransactionRows({ useProjectPeriod: false }); + }); analysisToggle?.addEventListener("click", () => { const nextOpen = !analysisOpen; setAnalysisVisibility(nextOpen);
프로젝트발생 비용관련 수금 +
+ 구분 + ${renderSortToggle("code")} +
+
+
+ 발생 비용 + ${renderSortToggle("expense")} +
+
+
+ 관련 수금 + ${renderSortToggle("revenue")} +
+
섹터 수정
${escapeHtml(row.support_dept_code)} · ${escapeHtml(row.support_dept_name)}${formatNumber(row.expense_amount || 0)}${formatNumber(row.revenue_amount || 0)}${escapeHtml(row.support_dept_code)} · ${escapeHtml(row.support_dept_name)} + + ${formatUncontractedCellAmount(row.expense_amount || 0).text} + + + + ${formatUncontractedCellAmount(row.revenue_amount || 0).text} + + `; } @@ -5445,6 +6520,8 @@ const hasDetail = Boolean(detail); const plannedEntries = detail?.planned || []; const actualEntries = detail?.actual || []; + const analysisSetting = getProjectAnalysisSetting(item.support_dept_code); + const showLaborJointPending = key === "labor" && !analysisSetting.labor_joint_exempt; return `
@@ -5465,6 +6542,7 @@ class="comparison-note-input" data-comparison-note-code="${escapeHtml(item.support_dept_code)}" data-comparison-note-key="${escapeHtml(key)}" + placeholder="${showLaborJointPending ? "합사인건비 미반영" : ""}" >${escapeHtml(comparisonNotes[key] || "")}
거래내역을 불러오는 중입니다.
거래내역을 불러오지 못했습니다.
거래내역을 불러오는 중입니다.
거래내역을 불러오지 못했습니다.
거래내역을 불러오는 중입니다.
거래내역을 불러오지 못했습니다.
거래내역을 불러오는 중입니다.