Polish project detail modals and data flows

This commit is contained in:
b17301
2026-04-10 19:11:15 +09:00
parent 9aaae5b820
commit 60168eb1d2
7 changed files with 2166 additions and 53 deletions
BIN
View File
Binary file not shown.
+566 -4
View File
@@ -2,6 +2,7 @@ import os
import logging import logging
import json import json
import re import re
import time
import zipfile import zipfile
from datetime import date, datetime from datetime import date, datetime
from functools import lru_cache from functools import lru_cache
@@ -21,6 +22,11 @@ from sqlalchemy import create_engine, event, text
logging.basicConfig(level=logging.INFO) logging.basicConfig(level=logging.INFO)
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
_HEALTH_PAYLOAD_CACHE: dict[str, Any] = {
"expires_at": 0.0,
"payload": None,
}
app = FastAPI() app = FastAPI()
BASE_DIR = Path(__file__).resolve().parent 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 = { page_state_columns_before = {
row[1] row[1]
for row in conn.execute(text("PRAGMA table_info(project_page_state)")).fetchall() 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 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: def get_data_version() -> str:
with engine.begin() as conn: with engine.begin() as conn:
transaction_updated = conn.execute(text("SELECT MAX(updated_at) FROM transactions")).scalar() 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="") return max((version for version in versions if version), default="")
def build_health_payload() -> dict[str, str]: def build_health_payload(force: bool = False) -> dict[str, str]:
return { 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", "status": "ok",
"server_time": datetime.now().isoformat(timespec="seconds"), "server_time": datetime.now().isoformat(timespec="seconds"),
"data_version": get_data_version(), "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: 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]: def get_project_status_for_edit(support_dept_code: str | None) -> dict[str, Any]:
contract_info_map = get_project_contract_info_map() contract_info_map = get_project_contract_info_map()
billing_summary_map = get_project_billing_summary_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] = {} normalized_result[code] = {}
for kind, entries in buckets.items(): for kind, entries in buckets.items():
normalized_result[code][kind] = [ 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) for label, amount in sorted(entries.items(), key=lambda item: item[1], reverse=True)
] ]
return normalized_result 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]]: def get_recent_transactions(limit: int = 50) -> list[dict[str, Any]]:
with engine.begin() as conn: with engine.begin() as conn:
rows = conn.execute( rows = conn.execute(
@@ -5862,6 +6100,7 @@ def render_projects_page(
"project_account_breakdowns": get_project_account_breakdowns(selected_year), "project_account_breakdowns": get_project_account_breakdowns(selected_year),
"project_status_rows": get_project_status_rows(), "project_status_rows": get_project_status_rows(),
"project_comparison_notes": get_project_comparison_notes_map(), "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_edit": get_project_status_for_edit(edit_code),
"project_focus_code": normalize_text(focus_code), "project_focus_code": normalize_text(focus_code),
"project_page_state": get_project_page_state(), "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) 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") @app.get("/annual-summary")
async def annual_summary(request: Request): async def annual_summary(request: Request):
try: try:
@@ -6125,7 +6687,7 @@ async def save_project_json(request: Request):
form_data = parse_project_form(await request.body()) form_data = parse_project_form(await request.body())
save_project_status(form_data) save_project_status(form_data)
code = normalize_text(form_data.get("support_dept_code")) code = normalize_text(form_data.get("support_dept_code"))
health_payload = build_health_payload() health_payload = build_health_payload(force=True)
return JSONResponse( return JSONResponse(
content=jsonable_encoder( content=jsonable_encoder(
{ {
+4
View File
@@ -61,6 +61,8 @@
border-radius: 16px; border-radius: 16px;
padding: 18px 20px 20px; padding: 18px 20px 20px;
box-shadow: inset 0 1px 0 rgba(255,255,255,0.92); box-shadow: inset 0 1px 0 rgba(255,255,255,0.92);
content-visibility: auto;
contain-intrinsic-size: 620px;
} }
.chart-legend { .chart-legend {
@@ -92,6 +94,8 @@
grid-template-rows: auto auto 1fr; grid-template-rows: auto auto 1fr;
gap: 14px; gap: 14px;
min-height: 100%; min-height: 100%;
content-visibility: auto;
contain-intrinsic-size: 760px;
} }
.metric-grid { .metric-grid {
+40 -1
View File
@@ -28,6 +28,16 @@
padding: 0; 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 { body {
font-family: "SUIT", "Noto Sans KR", "Malgun Gothic", sans-serif; font-family: "SUIT", "Noto Sans KR", "Malgun Gothic", sans-serif;
color: var(--ink); color: var(--ink);
@@ -241,6 +251,19 @@
box-shadow: inset 0 1px 2px rgba(16, 24, 40, 0.03); 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 { textarea {
min-height: 96px; min-height: 96px;
resize: vertical; resize: vertical;
@@ -293,6 +316,15 @@
box-shadow: none; box-shadow: none;
} }
.button-secondary:hover,
.button-secondary:focus-visible {
background: #eef2f6;
color: #111827;
border-color: #d6dde5;
transform: none;
box-shadow: none;
}
.button-icon { .button-icon {
width: 38px; width: 38px;
height: 38px; height: 38px;
@@ -646,6 +678,9 @@
} }
async function pollHealth() { async function pollHealth() {
if (document.hidden) {
return;
}
try { try {
const response = await fetch(`/health?ts=${Date.now()}`, { cache: "no-store" }); const response = await fetch(`/health?ts=${Date.now()}`, { cache: "no-store" });
if (!response.ok) throw new Error(`HTTP ${response.status}`); if (!response.ok) throw new Error(`HTTP ${response.status}`);
@@ -675,7 +710,11 @@
pollHealth(); pollHealth();
} }
}); });
window.setInterval(pollHealth, 15000); window.setInterval(() => {
if (!document.hidden) {
pollHealth();
}
}, 15000);
})(); })();
</script> </script>
</body> </body>
+4
View File
@@ -106,6 +106,8 @@
.chart-panel { .chart-panel {
display: grid; display: grid;
gap: 12px; gap: 12px;
content-visibility: auto;
contain-intrinsic-size: 480px;
} }
.chart-panel-header { .chart-panel-header {
@@ -138,6 +140,8 @@
box-shadow: inset 0 1px 0 rgba(255,255,255,0.92); box-shadow: inset 0 1px 0 rgba(255,255,255,0.92);
display: grid; display: grid;
gap: 12px; gap: 12px;
content-visibility: auto;
contain-intrinsic-size: 420px;
} }
.legend-box { .legend-box {
+35
View File
@@ -29,6 +29,16 @@
padding: 0; 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 { body {
font-family: "Noto Sans KR", "Malgun Gothic", sans-serif; font-family: "Noto Sans KR", "Malgun Gothic", sans-serif;
color: var(--ink); color: var(--ink);
@@ -138,6 +148,8 @@
border: 1px dashed #a7d1c8; border: 1px dashed #a7d1c8;
border-radius: 18px; border-radius: 18px;
padding: 18px; padding: 18px;
content-visibility: auto;
contain-intrinsic-size: 240px;
} }
.upload-box p { .upload-box p {
@@ -182,6 +194,7 @@
input[type="number"], input[type="number"],
input[type="date"], input[type="date"],
input[type="file"], input[type="file"],
select,
textarea { textarea {
width: 100%; width: 100%;
border: 1px solid var(--line); border: 1px solid var(--line);
@@ -197,12 +210,26 @@
} }
input:focus, input:focus,
select:focus,
textarea:focus { textarea:focus {
outline: none; outline: none;
border-color: var(--accent); border-color: var(--accent);
box-shadow: 0 0 0 4px rgba(11, 107, 99, 0.12); 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 { .actions {
display: flex; display: flex;
flex-wrap: wrap; flex-wrap: wrap;
@@ -236,11 +263,19 @@
color: #204257; color: #204257;
} }
.button-secondary:hover,
.button-secondary:focus-visible {
background: #dbeaf2;
color: #163345;
}
.table-wrap { .table-wrap {
overflow: auto; overflow: auto;
border: 1px solid var(--line); border: 1px solid var(--line);
border-radius: 18px; border-radius: 18px;
background: var(--white); background: var(--white);
content-visibility: auto;
contain-intrinsic-size: 360px;
} }
table { table {
+1517 -48
View File
File diff suppressed because it is too large Load Diff