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 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(
{
+4
View File
@@ -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 {
+40 -1
View File
@@ -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);
})();
</script>
</body>
+4
View File
@@ -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 {
+35
View File
@@ -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 {
+1517 -48
View File
File diff suppressed because it is too large Load Diff