diff --git a/dashboard/__init__.py b/dashboard/__init__.py
new file mode 100644
index 0000000..f543386
--- /dev/null
+++ b/dashboard/__init__.py
@@ -0,0 +1 @@
+"""Jarca Ledger Streamlit dashboard package."""
diff --git a/dashboard/app.py b/dashboard/app.py
index 5adff15..46389de 100644
--- a/dashboard/app.py
+++ b/dashboard/app.py
@@ -1,2074 +1,32 @@
from __future__ import annotations
-import sqlite3
-from datetime import datetime
-from html import escape
+import sys
from pathlib import Path
-from typing import Any
-from urllib.parse import quote
-import importlib
-import inspect
-from zoneinfo import ZoneInfo
-import pandas as pd
-import plotly.express as px
-import plotly.io as pio
import streamlit as st
-from jarca_ledger_mcp.adapters.streamlit import accounts_gateway as accounts_gateway
-from jarca_ledger_mcp.adapters.streamlit import assets_gateway as assets_gateway
-from jarca_ledger_mcp.adapters.streamlit import categorization_gateway as categorization_gateway
-from jarca_ledger_mcp.adapters.streamlit import imports_gateway as imports_gateway
-from jarca_ledger_mcp.adapters.streamlit import overview_gateway as overview_gateway
-from jarca_ledger_mcp.adapters.streamlit import reconciliation_gateway as reconciliation_gateway
-from jarca_ledger_mcp.adapters.streamlit import transactions_gateway as transactions_gateway
-from jarca_ledger_mcp.adapters.streamlit import transfers_gateway as transfers_gateway
-from jarca_ledger_mcp.db import resolve_db_path
+# Streamlit executes this file as a script, so ensure absolute dashboard imports
+# resolve identically to package imports used by tests.
+if __package__ in (None, ""):
+ sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
st.set_page_config(page_title="Jarca Ledger", page_icon="๐ฐ", layout="wide")
-JARCA_COLORS = {
- "navy": "#0f172a",
- "slate": "#475569",
- "muted": "#64748b",
- "border": "#e2e8f0",
- "surface": "#ffffff",
- "surface_soft": "#f8fafc",
- "blue": "#2563eb",
- "cyan": "#06b6d4",
- "emerald": "#10b981",
- "amber": "#f59e0b",
- "rose": "#f43f5e",
-}
-
-JARCA_PLOTLY_TEMPLATE = "jarca_clean"
-pio.templates[JARCA_PLOTLY_TEMPLATE] = pio.templates["plotly_white"].update(
- {
- "layout": {
- "font": {"family": "Inter, Pretendard, Noto Sans KR, Arial, sans-serif", "color": JARCA_COLORS["navy"]},
- "paper_bgcolor": "rgba(0,0,0,0)",
- "plot_bgcolor": "rgba(255,255,255,0)",
- "colorway": [JARCA_COLORS["blue"], JARCA_COLORS["rose"], JARCA_COLORS["emerald"], JARCA_COLORS["amber"], JARCA_COLORS["cyan"]],
- "title": {"font": {"size": 16, "color": JARCA_COLORS["navy"]}, "x": 0.02, "xanchor": "left"},
- "legend": {"orientation": "h", "yanchor": "bottom", "y": 1.02, "xanchor": "right", "x": 1},
- "margin": {"l": 12, "r": 12, "t": 56, "b": 28},
- "xaxis": {"gridcolor": "rgba(148, 163, 184, 0.20)", "zerolinecolor": "rgba(148, 163, 184, 0.35)"},
- "yaxis": {"gridcolor": "rgba(148, 163, 184, 0.20)", "zerolinecolor": "rgba(148, 163, 184, 0.35)"},
- }
- }
-)
-px.defaults.template = JARCA_PLOTLY_TEMPLATE
-
-DEFAULT_CATEGORIES = [
- "", "์์
", "์ด์ฒด", "์๋น", "์ฃผ๊ฑฐ", "ํต์ ", "๊ตํต", "๊ต์ก", "์๋ฃ", "์ํ", "๋ฌธํ/์ฌ๊ฐ", "๊ธ์ต", "๊ฐ์กฑ/์ฉ๋", "๋ฏธ๋ถ๋ฅ"
-]
-DEFAULT_SUBCATEGORIES: dict[str, list[str]] = {
- "์์
": ["", "๊ธ์ฌ", "์ด์", "ํ๊ธ", "๋ฐฐ๋น", "๊ธฐํ์์
"],
- "์ด์ฒด": ["", "๋ณธ์ธ๊ณ์ข์ด๋", "ํ๊ธ์
์ถ๊ธ", "๊ฐ์กฑ์ก๊ธ", "ํฌ์์ด๋", "ํ์ธํ์"],
- "์๋น": ["", "๋งํธ", "์ธ์", "์นดํ", "๋ฐฐ๋ฌ"],
- "์ฃผ๊ฑฐ": ["", "๊ด๋ฆฌ๋น", "์ ๊ธฐ", "๊ฐ์ค", "์๋", "์ธํฐ๋ท"],
- "ํต์ ": ["", "ํด๋ํฐ", "์ธํฐ๋ท", "ํต์ ์๊ธ"],
- "๊ตํต": ["", "๋์ค๊ตํต", "ํ์", "์ฃผ์ ", "์ฃผ์ฐจ", "์ฐจ๋์ ๋น"],
- "๊ต์ก": ["", "ํ์", "๊ต์ฌ", "ํ๊ต", "์์ฒด๋ฅ"],
- "์๋ฃ": ["", "๋ณ์", "์ฝ๊ตญ", "์น๊ณผ", "๋ณดํ"],
- "์ํ": ["", "์ํํ", "์๋ฅ", "๋ฏธ์ฉ", "์ธํ"],
- "๋ฌธํ/์ฌ๊ฐ": ["", "์ฌํ", "์๋ฐ", "๊ณต์ฐ", "์ทจ๋ฏธ", "์คํฌ์ธ "],
- "๊ธ์ต": ["", "์นด๋๋๊ธ", "๋์ถ์ํ", "์์๋ฃ", "๋ณดํ๋ฃ"],
- "๊ฐ์กฑ/์ฉ๋": ["", "๋ถ๋ชจ๋", "์๋
", "๊ฐ์กฑ์ก๊ธ"],
- "๋ฏธ๋ถ๋ฅ": ["", "ํ์ธํ์"],
-}
-CASHFLOW_KIND_OPTIONS = ["income", "expense", "transfer", "investment", "debt_payment", "exclude", "unknown"]
-CASHFLOW_KIND_LABELS = {
- "income": "์ค์ ์์
",
- "expense": "์ค์ ์ง์ถ",
- "transfer": "์ด์ฒด/์ ์ธ",
- "investment": "ํฌ์์ด๋/์ ์ธ",
- "debt_payment": "๋ถ์ฑ์ํ/๋ณ๋",
- "exclude": "๋ถ์ ์ ์ธ",
- "unknown": "ํ์ธํ์",
-}
-STATEMENT_STATUS_OPTIONS = ["unknown", "import_supported", "manual_only", "partial_import"]
-STATEMENT_STATUS_LABELS = {
- "unknown": "๋ฏธ์ ",
- "import_supported": "๋ช
์ธ์ import ์ง์",
- "manual_only": "์๋ ์ฆ๋น ์ ์ฉ",
- "partial_import": "๋ถ๋ถ import + ์๋ ๋ณด์",
-}
-LIQUIDITY_BUCKET_OPTIONS = ["cash", "investment", "deposit", "real_estate", "loan", "other"]
-LIQUIDITY_BUCKET_LABELS = {
- "cash": "ํ๊ธ์ฑ",
- "investment": "ํฌ์์์ฐ",
- "deposit": "์์ ๊ธ",
- "real_estate": "๋ถ๋์ฐ",
- "loan": "๋ถ์ฑ",
- "other": "๊ธฐํ",
-}
-
-
-def format_krw(value: Any) -> str:
- try:
- if value is None:
- return "-"
- return f"{int(value):,}์"
- except Exception:
- return str(value)
-
-
-def current_kst_month() -> str:
- return datetime.now(ZoneInfo("Asia/Seoul")).strftime("%Y-%m")
-
-
-def format_percent(value: Any) -> str:
- if value is None:
- return "-"
- try:
- return f"{float(value):.1%}"
- except Exception:
- return str(value)
-
-
-def get_transfer_review_items(payload: dict[str, Any] | None) -> list[dict[str, Any]]:
- if not payload or not payload.get("success"):
- return []
- items = payload.get("review_items")
- if isinstance(items, list):
- return items
- items = payload.get("transactions")
- if isinstance(items, list):
- return items
- return []
-
-
-@st.cache_data(ttl=5)
-def read_sql(db_path: str, sql: str, params: tuple[Any, ...] = ()) -> pd.DataFrame:
- with sqlite3.connect(db_path) as conn:
- return pd.read_sql_query(sql, conn, params=params)
-
-
-def clear_cache() -> None:
- read_sql.clear()
-
-
-def get_db_path() -> str:
- return str(resolve_db_path())
-
-
-def get_service_callable(module, name: str, required_params: list[str] | None = None):
- """Return a service function, reloading once for Streamlit hot-reload edge cases.
-
- Streamlit can keep an already-imported service module in memory while the
- dashboard script itself reloads. When a UI change starts passing a new
- keyword argument, a stale service function may still exist but have the old
- signature. In that case reload the service module before returning it.
- """
- required_params = required_params or []
- fn = getattr(module, name, None)
- if callable(fn):
- try:
- params = inspect.signature(fn).parameters
- if all(param in params for param in required_params):
- return fn
- except (TypeError, ValueError):
- if not required_params:
- return fn
- importlib.reload(module)
- return getattr(module, name, None)
-
-
-def inject_app_css() -> None:
- st.markdown(
- """
-
- """,
- unsafe_allow_html=True,
- )
-
-
-def render_page_header(title: str, subtitle: str, kicker: str = "Jarca Ledger", pills: list[str] | None = None) -> None:
- pill_html = "".join(f'{escape(pill)}' for pill in (pills or []))
- st.markdown(
- f"""
-
-
{escape(kicker)}
-
{escape(title)}
-
{escape(subtitle)}
- {f'
{pill_html}
' if pill_html else ''}
-
- """,
- unsafe_allow_html=True,
- )
-
-
-def render_section_label(icon: str, label: str) -> None:
- st.markdown(f'{escape(icon)}{escape(label)}
', unsafe_allow_html=True)
-
-
-def render_sidebar_nav(pages: list[str], current_page: str) -> None:
- items = []
- for name in pages:
- active = " active" if name == current_page else ""
- href = f"?page={quote(name)}"
- items.append(f'{escape(name)}')
- st.markdown(f'{"".join(items)}
', unsafe_allow_html=True)
-
-
-def parse_optional_int(value: str, field_name: str) -> int | None:
- value = (value or "").strip()
- if not value:
- return None
- try:
- return int(value)
- except ValueError as exc:
- raise ValueError(f"{field_name}๋ ์ ์์ฌ์ผ ํฉ๋๋ค.") from exc
-
-
-def get_category_options(db_path: str) -> tuple[list[str], dict[str, list[str]]]:
- result = categorization_gateway.list_ledger_categories(active_only=True, include_usage=False, db_path=db_path)
- if result.get("success") and result.get("count", 0) == 0:
- categorization_gateway.seed_default_ledger_categories(db_path=db_path)
- result = categorization_gateway.list_ledger_categories(active_only=True, include_usage=False, db_path=db_path)
- grouped = result.get("grouped", {}) if result.get("success") else {}
- categories = [""] + sorted(grouped.keys())
- subcategories = {cat: [""] + sorted([s for s in subs if s]) for cat, subs in grouped.items()}
- # Keep hardcoded defaults as fallback for fresh/broken DBs and for existing transaction values.
- for cat in DEFAULT_CATEGORIES:
- if cat and cat not in categories:
- categories.append(cat)
- for cat, subs in DEFAULT_SUBCATEGORIES.items():
- subcategories.setdefault(cat, [""])
- for sub in subs:
- if sub not in subcategories[cat]:
- subcategories[cat].append(sub)
- return categories, subcategories
-
-
-def transaction_df(db_path: str, where: str = "", params: tuple[Any, ...] = (), limit: int = 1000) -> pd.DataFrame:
- sql = f"""
- SELECT t.id, t.transaction_datetime, t.transaction_date, t.type, t.amount, t.category, t.subcategory,
- COALESCE(lc.cashflow_kind, 'unknown') AS cashflow_kind,
- t.merchant, t.raw_summary, t.counterparty, t.person, t.is_fixed, t.balance_after, t.branch,
- t.source, t.import_batch_id, t.memo, t.status
- FROM transactions t
- LEFT JOIN ledger_categories lc
- ON lc.active=1 AND t.category=lc.category AND t.subcategory=lc.subcategory
- {where}
- ORDER BY COALESCE(NULLIF(t.transaction_datetime,''), t.transaction_date) DESC, t.created_at DESC
- LIMIT ?
- """
- df = read_sql(db_path, sql, params + (limit,))
- if not df.empty:
- df["๊ธ์ก"] = df["amount"].map(format_krw)
- df["๊ฑฐ๋ํ์์ก"] = df["balance_after"].map(format_krw)
- df["๊ณ ์ ๋น"] = df["is_fixed"].fillna(0).astype(bool)
- return df
-
-
-def update_transaction_form(tx: dict[str, Any], key_prefix: str, db_path: str | None = None) -> None:
- db_path = db_path or get_db_path()
- category_options, subcategory_options = get_category_options(db_path)
-
- category_key = f"{key_prefix}_category"
- subcategory_key = f"{key_prefix}_subcategory"
- person_key = f"{key_prefix}_person"
- fixed_key = f"{key_prefix}_fixed"
- memo_key = f"{key_prefix}_memo"
- save_rule_key = f"{key_prefix}_save_rule"
- tx_marker_key = f"{key_prefix}_tx_id"
-
- current_category = tx.get("category", "") or ""
- if current_category and current_category not in category_options:
- category_options = category_options + [current_category]
-
- if st.session_state.get(tx_marker_key) != tx["id"]:
- st.session_state[tx_marker_key] = tx["id"]
- st.session_state[category_key] = current_category
- st.session_state[subcategory_key] = tx.get("subcategory", "") or ""
- st.session_state[person_key] = tx.get("person", "") or ""
- st.session_state[fixed_key] = bool(tx.get("is_fixed") or False)
- st.session_state[memo_key] = tx.get("memo", "") or ""
- st.session_state[save_rule_key] = False
-
- st.caption(f"๊ฑฐ๋ ID: `{tx['id']}`")
- st.write(f"**{tx.get('transaction_datetime') or tx.get('transaction_date')}** ยท {tx.get('merchant')} ยท {format_krw(tx.get('amount'))}")
-
- category = st.selectbox(
- "๋๋ถ๋ฅ",
- category_options,
- index=category_options.index(st.session_state.get(category_key, current_category)) if st.session_state.get(category_key, current_category) in category_options else 0,
- key=category_key,
- )
-
- sub_opts = subcategory_options.get(category, [""])
- current_sub = st.session_state.get(subcategory_key, tx.get("subcategory", "") or "") or ""
- if current_sub and current_sub not in sub_opts:
- current_sub = ""
- st.session_state[subcategory_key] = ""
- subcategory = st.selectbox(
- "์๋ถ๋ฅ",
- sub_opts,
- index=sub_opts.index(current_sub) if current_sub in sub_opts else 0,
- key=subcategory_key,
- )
- person = st.text_input("๊ฐ์กฑ ๊ตฌ์ฑ์/person", key=person_key)
- is_fixed = st.checkbox("๊ณ ์ ๋น", key=fixed_key)
- memo = st.text_input("๋ฉ๋ชจ", key=memo_key)
- save_rule = st.checkbox("์ด ์ฌ์ฉ์ฒ ํจํด์ ๊ท์น์ผ๋ก ์ ์ฅ", key=save_rule_key)
- submitted = st.button("์ ์ฅ", type="primary", key=f"{key_prefix}_submit")
-
- if submitted:
- result = transactions_gateway.update_ledger_transaction(
- tx["id"], category=category, subcategory=subcategory, person=person,
- is_fixed=is_fixed, memo=memo, db_path=db_path,
- )
- if not result.get("success"):
- st.error(result.get("error", result))
- return
- if save_rule and tx.get("merchant") and category:
- rule = categorization_gateway.create_category_rule(
- match_field="merchant",
- pattern=tx["merchant"],
- category=category,
- subcategory=subcategory,
- transaction_type=tx.get("type", ""),
- person=person,
- is_fixed=is_fixed,
- priority=70,
- memo="dashboard: user-confirmed rule",
- db_path=db_path,
- )
- if not rule.get("success"):
- st.warning(f"๊ฑฐ๋๋ ์ ์ฅ๋์ง๋ง ๊ท์น ์ ์ฅ ์คํจ: {rule.get('error')}")
- clear_cache()
- st.success("์ ์ฅํ์ต๋๋ค.")
- st.rerun()
-
-
-@st.dialog("๊ฑฐ๋ ๋ถ๋ฅ", width="large")
-def transaction_edit_dialog(tx: dict[str, Any], key_prefix: str, db_path: str) -> None:
- update_transaction_form(tx, key_prefix, db_path)
-
-
-@st.dialog("์ฐ๊ฒฐ ๊ฑฐ๋ ํ๋ณด", width="large")
-def linked_transfer_candidates_dialog(tx: dict[str, Any], db_path: str) -> None:
- st.caption(f"๊ธฐ์ค ๊ฑฐ๋: `{tx['id']}`")
- st.write(f"**{tx.get('transaction_datetime') or tx.get('transaction_date')}** ยท {tx.get('merchant')} ยท {format_krw(tx.get('amount'))}")
- result = transfers_gateway.find_self_transfer_candidates(tx["id"], limit=20, db_path=db_path)
- if not result.get("success"):
- st.error(result.get("error", result))
- return
- candidates = result.get("candidates", [])
- if not candidates:
- st.info("๊ฐ์ ๊ธ์ก/๊ฐ๊น์ด ๋ ์ง ์กฐ๊ฑด์์ ์ฐ๊ฒฐ ํ๋ณด๋ฅผ ์ฐพ์ง ๋ชปํ์ต๋๋ค.")
- return
-
- st.success(f"์ฐ๊ฒฐ ํ๋ณด {result.get('count', len(candidates))}๊ฑด")
- for idx, candidate in enumerate(candidates):
- cols = st.columns([1.45, 0.95, 0.7, 1.2, 1.15, 0.95])
- cols[0].write(candidate.get("transaction_datetime") or candidate.get("transaction_date") or "")
- cols[1].write(candidate.get("source") or "")
- cols[2].write(candidate.get("type") or "")
- cols[3].write(format_krw(candidate.get("amount")))
- cols[4].write(candidate.get("merchant") or candidate.get("counterparty") or "")
- if cols[5].button("์ด ๊ฑฐ๋์ ์ฐ๊ฒฐ", key=f"link_pair_{tx['id']}_{candidate['id']}_{idx}", use_container_width=True, type="primary"):
- link_res = transfers_gateway.link_self_transfer_pair(tx["id"], candidate["id"], db_path=db_path)
- if not link_res.get("success"):
- st.error(link_res.get("error", link_res))
- else:
- clear_cache()
- st.success("์์ชฝ ๊ฑฐ๋๋ฅผ ๋ณธ์ธ๊ณ์ข์ด๋์ผ๋ก ์ฐ๊ฒฐํ์ต๋๋ค.")
- st.rerun()
- meta = []
- if candidate.get("account_name"):
- meta.append(f"๊ณ์ข: {candidate['account_name']}")
- if candidate.get("category") or candidate.get("subcategory"):
- meta.append(f"ํ์ฌ ๋ถ๋ฅ: {candidate.get('category', '')} / {candidate.get('subcategory', '')}")
- if candidate.get("score") is not None:
- meta.append(f"score={candidate['score']}")
- if candidate.get("reason"):
- meta.append(candidate["reason"])
- st.caption(" ยท ".join(meta))
- if idx < len(candidates) - 1:
- st.divider()
-
-
-@st.dialog("์๊ฑฐ๋ ํ๋ณด", width="large")
-def cancel_original_candidates_dialog(tx: dict[str, Any], db_path: str) -> None:
- st.caption(f"์ทจ์/ํ๊ธ ๊ฑฐ๋: `{tx['id']}`")
- st.write(f"**{tx.get('transaction_datetime') or tx.get('transaction_date')}** ยท {tx.get('merchant')} ยท {format_krw(tx.get('amount'))}")
- result = transfers_gateway.find_cancel_original_candidates(tx["id"], limit=20, db_path=db_path)
- if not result.get("success"):
- st.error(result.get("error", result))
- return
- candidates = result.get("candidates", [])
- if not candidates:
- st.info("๊ฐ์ ๊ธ์ก ๊ธฐ์ค์ผ๋ก ์ฐ๊ฒฐํ ์๊ฑฐ๋ ํ๋ณด๋ฅผ ์ฐพ์ง ๋ชปํ์ต๋๋ค.")
- return
-
- st.success(f"์๊ฑฐ๋ ํ๋ณด {result.get('count', len(candidates))}๊ฑด")
- for idx, candidate in enumerate(candidates):
- cols = st.columns([1.4, 0.95, 0.7, 1.25, 1.15, 0.95])
- cols[0].write(candidate.get("transaction_datetime") or candidate.get("transaction_date") or "")
- cols[1].write(candidate.get("source") or "")
- cols[2].write(candidate.get("type") or "")
- cols[3].write(format_krw(candidate.get("amount")))
- cols[4].write(candidate.get("merchant") or candidate.get("counterparty") or "")
- if cols[5].button("์ด ์๊ฑฐ๋์ ์ฐ๊ฒฐ", key=f"cancel_pair_{tx['id']}_{candidate['id']}_{idx}", use_container_width=True, type="primary"):
- link_res = transfers_gateway.link_cancel_to_original(tx["id"], candidate["id"], db_path=db_path)
- if not link_res.get("success"):
- st.error(link_res.get("error", link_res))
- else:
- clear_cache()
- st.success("์ทจ์/ํ๊ธ ๊ฑฐ๋๋ฅผ ์๊ฑฐ๋์ ์ฐ๊ฒฐํ์ต๋๋ค.")
- st.rerun()
- meta = []
- if candidate.get("account_name"):
- meta.append(f"๊ณ์ข: {candidate['account_name']}")
- if candidate.get("category") or candidate.get("subcategory"):
- meta.append(f"ํ์ฌ ๋ถ๋ฅ: {candidate.get('category', '')} / {candidate.get('subcategory', '')}")
- if candidate.get("score") is not None:
- meta.append(f"score={candidate['score']}")
- if candidate.get("reason"):
- meta.append(candidate["reason"])
- st.caption(" ยท ".join(meta))
- if idx < len(candidates) - 1:
- st.divider()
-
-
-def render_review_decision_actions(gateway: Any, target_type: str, target_id: str, key_prefix: str, db_path: str, target_payload: dict[str, Any] | None = None) -> None:
- decision_type = st.selectbox(
- "ํ์ ",
- ["unknown", "account_transfer", "real_expense", "real_income", "investment_transfer", "excluded_from_analysis", "duplicate_or_cancel"],
- key=f"{key_prefix}_decision_type",
- )
- reason_code = st.text_input("์ฌ์ ์ฝ๋", value="operator_review", key=f"{key_prefix}_reason")
- memo = st.text_input("๊ฒฐ์ ๋ฉ๋ชจ", value="", key=f"{key_prefix}_memo")
- cols = st.columns(4)
- if cols[0].button("๊ฒฐ์ ์ ์ฅ", key=f"{key_prefix}_decided", use_container_width=True, type="primary"):
- res = gateway.upsert_review_decision(target_type, target_id, status="decided", decision_type=decision_type, reason_code=reason_code, memo=memo, target_payload=target_payload or {}, db_path=db_path)
- clear_cache(); st.success("์ ์ฅํ์ต๋๋ค." if res.get("success") else res.get("error", res)); st.rerun()
- if cols[1].button("๋ณด๋ฅ", key=f"{key_prefix}_held", use_container_width=True):
- res = gateway.hold_review_decision(target_type, target_id, reason_code=reason_code or "held", memo=memo, db_path=db_path)
- clear_cache(); st.success("๋ณด๋ฅํ์ต๋๋ค." if res.get("success") else res.get("error", res)); st.rerun()
- if cols[2].button("์ฌ๊ฒํ ", key=f"{key_prefix}_recheck", use_container_width=True):
- res = gateway.mark_review_recheck(target_type, target_id, reason_code=reason_code or "needs_recheck", memo=memo, db_path=db_path)
- clear_cache(); st.success("์ฌ๊ฒํ ๋ก ํ์ํ์ต๋๋ค." if res.get("success") else res.get("error", res)); st.rerun()
- if cols[3].button("ํด์ ", key=f"{key_prefix}_release", use_container_width=True):
- res = gateway.release_review_decision(target_type, target_id, memo=memo, db_path=db_path)
- clear_cache(); st.success("pending์ผ๋ก ํด์ ํ์ต๋๋ค." if res.get("success") else res.get("error", res)); st.rerun()
-
-
-RULE_MATCH_FIELDS = ["merchant", "raw_summary", "counterparty", "payment_method", "branch", "description", "memo"]
-RULE_MATCH_TYPES = ["contains", "equals", "starts_with", "ends_with", "regex"]
-RULE_TX_TYPES = ["", "income", "expense", "transfer", "refund", "adjustment"]
-RULE_FIXED_LABELS = ["๋ณ๊ฒฝ์ํจ", "์", "์๋์ค"]
-
-
-@st.dialog("๋ถ๋ฅ ๊ท์น ์ถ๊ฐ", width="large")
-def category_rule_create_dialog(db_path: str) -> None:
- category_options, _ = get_category_options(db_path)
- editable_categories = [c for c in category_options if c]
-
- with st.form("rule_create_dialog"):
- cols = st.columns(4)
- match_field = cols[0].selectbox("๋์ ํ๋", RULE_MATCH_FIELDS)
- match_type = cols[1].selectbox("๋งค์นญ ๋ฐฉ์", RULE_MATCH_TYPES)
- pattern = cols[2].text_input("ํจํด")
- tx_type = cols[3].selectbox("๊ฑฐ๋ ์ ํ", RULE_TX_TYPES)
-
- cols2 = st.columns(5)
- category = cols2[0].selectbox("๋๋ถ๋ฅ", editable_categories)
- subcategory = cols2[1].text_input("์๋ถ๋ฅ")
- person = cols2[2].text_input("person")
- priority = cols2[3].number_input("์ฐ์ ์์", value=100, step=10)
- is_fixed = cols2[4].selectbox("๊ณ ์ ๋น", RULE_FIXED_LABELS)
-
- cols3 = st.columns(3)
- amount_min_text = cols3[0].text_input("์ต์ ๊ธ์ก(์ ํ)", placeholder="์: 1200000")
- amount_max_text = cols3[1].text_input("์ต๋ ๊ธ์ก(์ ํ)", placeholder="์: 1200000")
- day_of_month_text = cols3[2].text_input("๋งค์ ์ผ์(์ ํ)", placeholder="1~31")
- memo = st.text_input("๋ฉ๋ชจ", value="dashboard: user rule")
- submitted = st.form_submit_button("๊ท์น ์ ์ฅ", type="primary")
-
- if submitted:
- try:
- amount_min = parse_optional_int(amount_min_text, "์ต์ ๊ธ์ก")
- amount_max = parse_optional_int(amount_max_text, "์ต๋ ๊ธ์ก")
- day_of_month = parse_optional_int(day_of_month_text, "๋งค์ ์ผ์")
- except ValueError as exc:
- st.error(str(exc))
- else:
- fixed_value = None if is_fixed == "๋ณ๊ฒฝ์ํจ" else is_fixed == "์"
- res = categorization_gateway.create_category_rule(match_field, pattern, category, subcategory, tx_type, match_type, int(priority), person, fixed_value, 1.0, memo, amount_min, amount_max, day_of_month, db_path=db_path)
- clear_cache()
- if res.get("success"):
- st.success("๊ท์น์ ์ ์ฅํ์ต๋๋ค.")
- st.rerun()
- else:
- st.error(res.get("error", res))
-
-
-@st.dialog("๋ถ๋ฅ ๊ท์น ์์ ", width="large")
-def category_rule_edit_dialog(rule: dict[str, Any], db_path: str) -> None:
- category_options, _ = get_category_options(db_path)
- current_category = rule.get("category", "") or ""
- editable_categories = [c for c in category_options if c]
- if current_category and current_category not in editable_categories:
- editable_categories.append(current_category)
- fixed_value = "๋ณ๊ฒฝ์ํจ" if rule.get("is_fixed") is None else ("์" if rule.get("is_fixed") else "์๋์ค")
-
- st.caption(f"๊ท์น ID: `{rule['id']}`")
- st.write(f"**{rule.get('pattern') or '-'}**")
-
- with st.form(f"rule_edit_dialog_{rule['id']}"):
- cols = st.columns(4)
- edit_match_field = cols[0].selectbox("๋์ ํ๋", RULE_MATCH_FIELDS, index=RULE_MATCH_FIELDS.index(rule.get("match_field", "merchant")) if rule.get("match_field", "merchant") in RULE_MATCH_FIELDS else 0)
- edit_match_type = cols[1].selectbox("๋งค์นญ ๋ฐฉ์", RULE_MATCH_TYPES, index=RULE_MATCH_TYPES.index(rule.get("match_type", "contains")) if rule.get("match_type", "contains") in RULE_MATCH_TYPES else 0)
- edit_pattern = cols[2].text_input("ํจํด", value=rule.get("pattern", "") or "")
- edit_tx_type = cols[3].selectbox("๊ฑฐ๋ ์ ํ", RULE_TX_TYPES, index=RULE_TX_TYPES.index(rule.get("transaction_type", "") or "") if (rule.get("transaction_type", "") or "") in RULE_TX_TYPES else 0)
-
- cols2 = st.columns(5)
- edit_category = cols2[0].selectbox("๋๋ถ๋ฅ", editable_categories, index=editable_categories.index(current_category) if current_category in editable_categories else 0)
- edit_subcategory = cols2[1].text_input("์๋ถ๋ฅ", value=rule.get("subcategory", "") or "")
- edit_person = cols2[2].text_input("person", value=rule.get("person", "") or "")
- edit_priority = cols2[3].number_input("์ฐ์ ์์", value=int(rule.get("priority", 100) or 100), step=10)
- edit_is_fixed = cols2[4].selectbox("๊ณ ์ ๋น", RULE_FIXED_LABELS, index=RULE_FIXED_LABELS.index(fixed_value))
-
- cols3 = st.columns(4)
- edit_amount_min_text = cols3[0].text_input("์ต์ ๊ธ์ก(์ ํ)", value="" if rule.get("amount_min") is None else str(rule.get("amount_min")))
- edit_amount_max_text = cols3[1].text_input("์ต๋ ๊ธ์ก(์ ํ)", value="" if rule.get("amount_max") is None else str(rule.get("amount_max")))
- edit_day_of_month_text = cols3[2].text_input("๋งค์ ์ผ์(์ ํ)", value="" if rule.get("day_of_month") is None else str(rule.get("day_of_month")))
- edit_active = cols3[3].checkbox("ํ์ฑ", value=bool(rule.get("active", True)))
- edit_memo = st.text_input("๋ฉ๋ชจ", value=rule.get("memo", "") or "")
- edit_submitted = st.form_submit_button("๊ท์น ์์ ", type="primary")
-
- if edit_submitted:
- try:
- edit_amount_min = parse_optional_int(edit_amount_min_text, "์ต์ ๊ธ์ก")
- edit_amount_max = parse_optional_int(edit_amount_max_text, "์ต๋ ๊ธ์ก")
- edit_day_of_month = parse_optional_int(edit_day_of_month_text, "๋งค์ ์ผ์")
- except ValueError as exc:
- st.error(str(exc))
- else:
- edit_fixed_value = None if edit_is_fixed == "๋ณ๊ฒฝ์ํจ" else edit_is_fixed == "์"
- update_rule = getattr(categorization_gateway, "update_category_rule", None)
- if update_rule is None:
- st.error("ํ์ฌ ์คํ ์ค์ธ ์๋น์ค์ update_category_rule ํจ์๊ฐ ์์ต๋๋ค. ์ฑ์ ์ฌ์์ํด ์ต์ ์ฝ๋๋ฅผ ๋ค์ ๋ถ๋ฌ์ค์ธ์.")
- else:
- res = update_rule(
- rule["id"], edit_match_field, edit_pattern, edit_category, edit_subcategory,
- edit_tx_type, edit_match_type, int(edit_priority), edit_person, edit_fixed_value,
- 1.0, edit_memo, edit_amount_min, edit_amount_max, edit_day_of_month, edit_active,
- db_path=db_path,
- )
- clear_cache()
- if res.get("success"):
- st.success("๊ท์น์ ์์ ํ์ต๋๋ค.")
- st.rerun()
- else:
- st.error(res.get("error", res))
-
-
-def render_uncategorized_action_table(rows: list[dict[str, Any]], db_path: str) -> None:
- header_cols = st.columns([1.45, 0.6, 0.8, 1.35, 1.3, 0.7, 1.0, 1.0])
- headers = ["์ผ์", "๊ตฌ๋ถ", "๊ธ์ก", "์ฌ์ฉ์ฒ", "์์ฝ", "", "", ""]
- for col, label in zip(header_cols, headers):
- col.markdown(f"**{label}**")
-
- for idx, row in enumerate(rows):
- cols = st.columns([1.45, 0.6, 0.8, 1.35, 1.3, 0.7, 1.0, 1.0])
- cols[0].write(row.get("transaction_datetime") or row.get("transaction_date") or "")
- cols[1].write(row.get("type") or "")
- cols[2].write(format_krw(row.get("amount")))
- cols[3].write(row.get("merchant") or "")
- cols[4].write(row.get("raw_summary") or "")
- if cols[5].button("๋ถ๋ฅ", key=f"uncat_dialog_{row['id']}_{idx}", use_container_width=True, type="tertiary"):
- transaction_edit_dialog(row, f"uncat_dialog_{row['id']}", db_path)
- if cols[6].button("์ฐ๊ฒฐ๊ฑฐ๋์ฐพ๊ธฐ", key=f"uncat_link_{row['id']}_{idx}", use_container_width=True, type="tertiary"):
- linked_transfer_candidates_dialog(row, db_path)
- if cols[7].button("์๊ฑฐ๋์ฐพ๊ธฐ", key=f"uncat_cancel_{row['id']}_{idx}", use_container_width=True, type="tertiary"):
- cancel_original_candidates_dialog(row, db_path)
-
-
-def recommendation_text(rec: dict[str, Any] | None) -> str:
- rec = rec or {}
- if not rec.get("category"):
- return "์ถ์ฒ ์์"
- pct = int(round(float(rec.get("confidence") or 0) * 100))
- return f"{rec.get('category')} / {rec.get('subcategory') or '-'} ยท {pct}%"
-
-
-def save_merchant_rule_from_recommendation(merchant: str, tx_type: str, rec: dict[str, Any], db_path: str) -> dict[str, Any]:
- if not merchant:
- return {"success": False, "error": "merchant๊ฐ ๋น์ด ์์ด ๊ท์น์ ๋ง๋ค ์ ์์ต๋๋ค."}
- if not rec.get("category"):
- return {"success": False, "error": "์ถ์ฒ ๋ถ๋ฅ๊ฐ ์์ด ๊ท์น์ ๋ง๋ค ์ ์์ต๋๋ค."}
- return categorization_gateway.create_category_rule(
- match_field="merchant",
- match_type="contains",
- pattern=merchant,
- transaction_type=tx_type or "",
- category=rec.get("category", ""),
- subcategory=rec.get("subcategory", ""),
- priority=70,
- confidence=float(rec.get("confidence") or 0.8),
- memo="dashboard: uncategorized workbench rule",
- db_path=db_path,
- )
-
-
-def render_uncategorized_groups(groups: list[dict[str, Any]], db_path: str, repeated_only: bool = False) -> None:
- visible = [g for g in groups if not repeated_only or int(g.get("count") or 0) >= 2]
- visible = visible[:10]
- if not visible:
- st.info("๋ฐ๋ณต ํ๋ณด๊ฐ ์์ต๋๋ค.")
- return
- for idx, group in enumerate(visible):
- rec = group.get("recommendation") or {}
- cols = st.columns([2.0, 0.7, 1.0, 1.55, 1.0, 1.0], vertical_alignment="center")
- merchant = group.get("merchant") or group.get("raw_summary") or "-"
- cols[0].markdown(f"**{merchant}** \n{group.get('raw_summary') or '-'} ยท {group.get('type') or '-'}", unsafe_allow_html=True)
- cols[1].write(f"{int(group.get('count') or 0):,}๊ฑด")
- cols[2].write(format_krw(group.get("total_amount")))
- cols[3].write(recommendation_text(rec))
- apply_disabled = not bool(rec.get("category"))
- if cols[4].button("๊ทธ๋ฃน ์ ์ฉ", key=f"uncat_group_apply_{idx}_{merchant}", disabled=apply_disabled, use_container_width=True):
- res = categorization_gateway.apply_uncategorized_group_recommendation(
- group.get("merchant", ""), group.get("raw_summary", ""), group.get("type", ""), db_path=db_path
- )
- clear_cache()
- if res.get("success"):
- st.success(f"๊ทธ๋ฃน {res.get('applied_count', 0):,}๊ฑด์ {recommendation_text(res.get('recommendation'))}๋ก ๋ถ๋ฅํ์ต๋๋ค.")
- st.rerun()
- else:
- st.error(res.get("error", res))
- if cols[5].button("๊ท์น ๋ง๋ค๊ธฐ", key=f"uncat_group_rule_{idx}_{merchant}", disabled=apply_disabled, use_container_width=True):
- res = save_merchant_rule_from_recommendation(group.get("merchant", ""), group.get("type", ""), rec, db_path)
- clear_cache()
- if res.get("success"):
- st.success("์ฌ์ฉ์ฒ ๊ท์น์ ์ ์ฅํ์ต๋๋ค.")
- st.rerun()
- else:
- st.warning(res.get("error", res))
- st.caption(f"์ต๊ทผ: {group.get('latest_date') or '-'} ยท ์ต๋๊ธ์ก: {format_krw(group.get('max_amount'))} ยท ๊ทผ๊ฑฐ: {rec.get('reason') or '์ถ์ฒ ์์'}")
- if idx < len(visible) - 1:
- st.divider()
-
-
-def render_uncategorized_work_items(rows: list[dict[str, Any]], db_path: str, recommended_only: bool = False) -> None:
- visible = [r for r in rows if not recommended_only or r.get("recommended_category")]
- if not visible:
- st.info("์กฐ๊ฑด์ ๋ง๋ ๋ฏธ๋ถ๋ฅ ๊ฑฐ๋๊ฐ ์์ต๋๋ค.")
- return
- for idx, row in enumerate(visible):
- rec = row.get("recommendation") or {}
- date_text = row.get("transaction_datetime") or row.get("transaction_date") or ""
- title = f"{date_text} ยท {format_krw(row.get('amount'))} ยท {row.get('merchant') or row.get('counterparty') or '-'} ยท ์ถ์ฒ: {recommendation_text(rec)}"
- with st.expander(title, expanded=False):
- c1, c2, c3, c4 = st.columns(4)
- c1.metric("๊ฑฐ๋์ ํ", row.get("type") or "-")
- c2.metric("๊ธ์ก", format_krw(row.get("amount")))
- c3.metric("์ถ์ฒ", recommendation_text(rec))
- c4.metric("์ถ์ฒ ๊ทผ๊ฑฐ", rec.get("source") or "none")
- st.caption(f"์ฌ์ฉ์ฒ: {row.get('merchant') or '-'} ยท ์์ฝ: {row.get('raw_summary') or '-'} ยท ์๋: {row.get('counterparty') or '-'} ยท source: {row.get('source') or '-'} ยท ID: `{row.get('id')}`")
- if rec.get("reason"):
- st.info(rec.get("reason"))
- action_cols = st.columns([1.0, 1.0, 1.0, 1.1, 1.1, 2.5], vertical_alignment="center")
- apply_disabled = not bool(rec.get("category"))
- if action_cols[0].button("์ถ์ฒ ์ ์ฉ", key=f"uncat_apply_rec_{row['id']}_{idx}", disabled=apply_disabled, use_container_width=True, type="primary"):
- res = categorization_gateway.apply_uncategorized_recommendation(row["id"], db_path=db_path)
- clear_cache()
- if res.get("success"):
- st.success(f"{recommendation_text(res.get('recommendation'))}๋ก ๋ถ๋ฅํ์ต๋๋ค.")
- st.rerun()
- else:
- st.error(res.get("error", res))
- if action_cols[1].button("์ง์ ๋ถ๋ฅ", key=f"uncat_direct_{row['id']}_{idx}", use_container_width=True):
- transaction_edit_dialog(row, f"uncat_direct_{row['id']}", db_path)
- if action_cols[2].button("๊ท์น ๋ง๋ค๊ธฐ", key=f"uncat_rule_{row['id']}_{idx}", disabled=apply_disabled, use_container_width=True):
- res = save_merchant_rule_from_recommendation(row.get("merchant", ""), row.get("type", ""), rec, db_path)
- clear_cache()
- if res.get("success"):
- st.success("์ฌ์ฉ์ฒ ๊ท์น์ ์ ์ฅํ์ต๋๋ค.")
- st.rerun()
- else:
- st.warning(res.get("error", res))
- if action_cols[3].button("์ฐ๊ฒฐ๊ฑฐ๋์ฐพ๊ธฐ", key=f"uncat_link_{row['id']}_{idx}", use_container_width=True):
- linked_transfer_candidates_dialog(row, db_path)
- if action_cols[4].button("์๊ฑฐ๋์ฐพ๊ธฐ", key=f"uncat_cancel_{row['id']}_{idx}", use_container_width=True):
- cancel_original_candidates_dialog(row, db_path)
- with st.expander(f"Review decision ยท ํ์ฌ {row.get('review_status', 'pending')}", expanded=False):
- render_review_decision_actions(categorization_gateway, "uncategorized_transaction", row["id"], f"uncat_review_decision_{row['id']}_{idx}", db_path, target_payload=row)
-
-
-
-@st.dialog("๊ท์น ์ ์ฉ ๋ฏธ๋ฆฌ๋ณด๊ธฐ", width="large")
-def category_rule_preview_dialog(rule: dict[str, Any], db_path: str) -> None:
- rule_id = rule.get("id", "")
- pattern = rule.get("pattern") or "-"
- result_text = f"{rule.get('category') or '-'} / {rule.get('subcategory') or '-'}"
- st.caption(f"๊ท์น ID: `{rule_id}`")
- st.write(f"**{pattern}** โ **{result_text}**")
-
- preview_fn = get_service_callable(categorization_gateway, "preview_category_rule_matches")
- if not callable(preview_fn):
- st.error("ํ์ฌ ์คํ ์ค์ธ ์๋น์ค์์ ๋จ์ผ ๊ท์น ๋ฏธ๋ฆฌ๋ณด๊ธฐ ํจ์๋ฅผ ์ฐพ์ง ๋ชปํ์ต๋๋ค. Streamlit ์๋ฒ๋ฅผ ์์ ํ ์ข
๋ฃํ ๋ค ๋ค์ ์์ํด ์ฃผ์ธ์.")
- return
- preview = preview_fn(rule_id, limit=10000, sample_limit=3, db_path=db_path)
- if not preview.get("success"):
- st.error(preview.get("error", preview))
- return
-
- m1, m2, m3 = st.columns(3)
- m1.metric("์ค์บํ ํ์ฑ ๊ฑฐ๋", f"{preview.get('scanned', 0):,}๊ฑด")
- m2.metric("์ด ๊ท์น๊ณผ ๋งค์นญ", f"{preview.get('matched_count', 0):,}๊ฑด")
- m3.metric("๋ฏธ๋ถ๋ฅ ์ค ์ ์ฉ ๋์", f"{preview.get('uncategorized_matched_count', 0):,}๊ฑด")
-
- sample = preview.get("sample", [])
- if sample:
- df = pd.DataFrame(sample)
- if "amount" in df:
- df["๊ธ์ก"] = df["amount"].map(format_krw)
- display_cols = [
- c for c in [
- "transaction_date", "transaction_datetime", "type", "๊ธ์ก", "merchant", "raw_summary",
- "counterparty", "current_category", "current_subcategory", "new_category", "new_subcategory",
- ] if c in df.columns
- ]
- st.dataframe(df[display_cols], width="stretch", height=160)
- else:
- st.info("์ด ๊ท์น์ ๋ถํฉํ๋ ๊ฑฐ๋๊ฐ ์์ต๋๋ค.")
-
-def page_monthly_settlement(db_path: str) -> None:
- render_page_header(
- "์์ ์ฐ",
- "์ ํํ ์์ ๊ธฐ์ค์ผ๋ก ์๋ง ๊ฒฐ์ฐ ์ํ์ ๋ฏธํด๊ฒฐ ๋ธ๋ก์ปค๋ฅผ ๋น ๋ฅด๊ฒ ์ ๊ฒํฉ๋๋ค.",
- kicker="๐งพ ์๋ง ์ ๊ฒ",
- pills=["read-model ๊ธฐ๋ฐ", "๋ง๊ฐ ์ ์ ๊ฒ", "fixture-safe"],
- )
-
- query_month = st.query_params.get("month", current_kst_month())
- if isinstance(query_month, list):
- query_month = query_month[0]
- month = st.text_input("๋์ ์", value=str(query_month), key="monthly_settlement_month", help="YYYY-MM ํ์")
- settlement = overview_gateway.get_monthly_settlement_summary(month, db_path=db_path)
- if not settlement.get("success"):
- st.error(settlement.get("error", settlement))
- st.caption(f"์
๋ ฅํ ์: {month}")
- return
-
- selected_month = settlement.get("month") or settlement.get("selected_month") or month
- basis_date = settlement.get("basis_date") or f"{selected_month}-01"
- source_coverage = settlement.get("source_coverage", {}) if isinstance(settlement.get("source_coverage"), dict) else {}
- unresolved_blockers = settlement.get("unresolved_blockers", {}) if isinstance(settlement.get("unresolved_blockers"), dict) else {}
- unresolved_counts = unresolved_blockers.get("counts", {}) if isinstance(unresolved_blockers.get("counts"), dict) else {}
- review_queue_res = overview_gateway.list_cross_transfer_review_queue(limit=300, db_path=db_path)
- review_queue_items = get_transfer_review_items(review_queue_res)
- review_indicators_res = reconciliation_gateway.get_review_operating_indicators(db_path=db_path)
- review_indicators = review_indicators_res.get("indicators", {}) if review_indicators_res.get("success") else {}
- reconciliation_res = reconciliation_gateway.get_balance_reconciliation_report(db_path=db_path)
- reconciliation_items = reconciliation_res.get("items", []) if reconciliation_res.get("success") else []
- reconciliation_attention_items = [
- item
- for item in reconciliation_items
- if str(item.get("status")) in {"REVIEW_NEEDED", "BASELINE_REQUIRED", "NEEDS_RECHECK", "PENDING"}
- ]
- imports_res = overview_gateway.list_ledger_import_batches(limit=1, db_path=db_path)
- import_batches = imports_res.get("import_batches", []) if imports_res.get("success") else []
- asset_snapshot = overview_gateway.get_asset_snapshot_report(basis_date=basis_date, db_path=db_path)
-
- read_only_stamp = datetime.now(ZoneInfo("Asia/Seoul")).strftime("%Y-%m-%d %H:%M:%S KST")
- render_section_label("๐", "์์ ์ฐ ์
ธ")
- st.caption(f"์ ํ ์: {selected_month} / ๊ธฐ์ค์ผ: {basis_date} / ๊ธฐ์ค ์๊ฐ: {read_only_stamp} / ๋ชจ๋: ์ฝ๊ธฐ ์ ์ฉ")
- st.markdown(
- ""
- "๐ ์ฝ๊ธฐ ์ ์ฉ",
- unsafe_allow_html=True,
- )
- st.info("๊ฑฐ๋/์์ฐ ์์ ์ ๊ฐ ์ด์ ํ๋ฉด(๊ฑฐ๋๋ด์ญ, ์์ฝ)์์ ์ฒ๋ฆฌํ์ธ์.")
- st.markdown("- [๊ฑฐ๋๋ด์ญ์ผ๋ก ์ด๋](?page=๊ฑฐ๋๋ด์ญ)")
- st.markdown("- [์์ฐ ํ์ธ์ผ๋ก ์ด๋](?page=์์ฝ)")
-
- st.markdown("์๊ฐ ํต์ฌ KPI")
- st.caption("์๊ฐ ํต์ฌ ์งํ๋ฅผ ํตํด ์์
ยท์ง์ถยท์๊ณ ํ๋ฆ์ ์ ๊ฒํ์ธ์.")
- k1, k2, k3, k4 = st.columns(4)
- k1.metric("์ค์ ์์
", format_krw(settlement.get("actual_income", 0)))
- k2.metric("์ค์ ์ง์ถ", format_krw(settlement.get("actual_expense", 0)))
- k3.metric("์ํ๊ธํ๋ฆ", format_krw(settlement.get("net_cashflow", 0)))
- k4.metric("์ ์ถ๋ฅ ", format_percent(settlement.get("savings_rate")))
-
- st.markdown("ํ๊ธํ๋ฆ / ์ง์ถ ๊ตฌ์กฐ")
- render_section_label("๐ธ", "ํ๊ธํ๋ฆ / ์ง์ถ ๊ตฌ์กฐ")
- cashflow = settlement.get("reports", {}).get("cashflow", {}) if isinstance(settlement.get("reports"), dict) else {}
- if cashflow:
- c1, c2 = st.columns(2)
- by_type = cashflow.get("by_type", {}) or {}
- by_type_df = pd.DataFrame(
- [{"๊ตฌ๋ถ": str(key), "๊ธ์ก": int(value)} for key, value in by_type.items()]
- )
- if not by_type_df.empty:
- by_type_df["๊ธ์ก"] = by_type_df["๊ธ์ก"].map(format_krw)
- by_type_df = by_type_df.sort_values("๊ธ์ก", ascending=False)
- c1.dataframe(by_type_df, width="stretch", height=170)
-
- by_kind = cashflow.get("by_cashflow_kind", {}) or {}
- by_kind_df = pd.DataFrame(
- [{"ํ๊ธํ๋ฆ ๊ตฌ๋ถ": str(key), "๊ธ์ก": int(value)} for key, value in by_kind.items()]
- )
- if not by_kind_df.empty:
- by_kind_df["๊ธ์ก"] = by_kind_df["๊ธ์ก"].map(format_krw)
- c2.dataframe(by_kind_df, width="stretch", height=170)
- else:
- st.info("ํด๋น ์ ํ๊ธํ๋ฆ ์ ๋ณด๊ฐ ์์ง ์์ต๋๋ค.")
-
- st.markdown("์์ฐ ๋๋น ์ ๊ฒ")
- render_section_label("๐งฎ", "์์ฐ ๋๋น ์ ๊ฒ")
- budget_report = settlement.get("reports", {}).get("budget", {}) if isinstance(settlement.get("reports"), dict) else {}
- b1, b2, b3 = st.columns(3)
- b1.metric("์์ฐ ํญ๋ชฉ ์", f"{int(budget_report.get('count', 0))}๊ฑด")
- b2.metric("์ ์์ฐ", format_krw(budget_report.get("total_budget", 0)))
- b3.metric("์์ฐ ์ด๊ณผ ํญ๋ชฉ", f"{int(settlement.get('budget_overrun_count', 0))}๊ฑด")
-
- budget_items = budget_report.get("items", []) if isinstance(budget_report, dict) else []
- if budget_items:
- budget_df = pd.DataFrame(budget_items)
- budget_df["์์ฐ"] = budget_df["budget"].map(format_krw)
- budget_df["์ค์ "] = budget_df["actual"].map(format_krw)
- budget_df["์์ฌ"] = budget_df["remaining"].map(format_krw)
- budget_df["์ฌ์ฉ๋ฅ "] = budget_df["usage_rate"].map(format_percent)
- st.dataframe(
- budget_df[["category", "subcategory", "person", "์์ฐ", "์ค์ ", "์์ฌ", "์ฌ์ฉ๋ฅ ", "status"]],
- width="stretch",
- height=220,
- )
- else:
- st.info(f"{selected_month} ์์ฐ ํญ๋ชฉ์ด ์์ต๋๋ค.")
-
- render_section_label("โ ๏ธ", "์ ์ฐ ์ ์ด์ ๋ธ๋ก์ปค ์ ๊ฒ")
- unresolved_blocker_count = int(settlement.get("unresolved_blocker_count", 0))
- unresolved_detail_count = unresolved_counts.get("unresolved_count", unresolved_blocker_count)
- st.caption(f"๋ฏธํด๊ฒฐ ๋ธ๋ก์ปค: {int(unresolved_detail_count)}๊ฑด")
-
- render_section_label("๐งฏ", "์ ์ฐ ์ ๊ฒํ ")
- review_count = int(unresolved_counts.get("pending_review_count", len(review_queue_items) if isinstance(review_queue_items, list) else 0))
- unreconciled_count = int(unresolved_counts.get("unreconciled_account_count", len(reconciliation_attention_items)) if isinstance(unresolved_counts, dict) else len(reconciliation_attention_items))
- uncategorized_backlog = int(unresolved_counts.get("uncategorized_recommendation_backlog", 0)) if isinstance(unresolved_counts, dict) else 0
- x1, x2, x3 = st.columns(3)
- x1.metric("๋ฏธ์ฒ๋ฆฌ review", f"{review_count}๊ฑด")
- x2.metric("๋ฏธ๋์กฐ ๊ณ์ข", f"{unreconciled_count}๊ฑด")
- x3.metric("๋ฏธ๋ถ๋ฅ ์ถ์ฒ ๋๊ธฐ", f"{uncategorized_backlog}๊ฑด")
- if review_queue_items:
- st.markdown("ํ์ธํ์ ํ ์ต๊ทผ ํญ๋ชฉ")
- for item in review_queue_items[:5]:
- tx_date = item.get("transaction_date") or item.get("reviewed_at") or "-"
- merchant = item.get("merchant") or item.get("counterparty") or "-"
- amount = format_krw(item.get("amount") if item.get("amount") is not None else item.get("difference"))
- status = item.get("review_status") or item.get("status") or "pending"
- st.markdown(f"- {tx_date} ยท {merchant} ยท {amount} ยท {status}")
- else:
- st.caption("ํ์ธํ์ ํ๋ ๋น์ด ์์ต๋๋ค.")
- st.markdown("- [ํ์ธํ์ ํ๋ก ์ด๋](?page=ํ์ธํ์ ํ)")
-
- render_section_label("๐ฅ", "๊ฑฐ๋ ๋ฐ์ / ๊ณ์ข ์ํ")
- source_imported_at = source_coverage.get("imports", {}).get("latest_imported_at")
- t1, t2, t3 = st.columns(3)
- t1.metric("์ต๊ทผ Import ๊ฑด์", f"{int(source_coverage.get('imports', {}).get('recent_batch_count', 0))}๊ฑด")
- t2.metric("์ต๊ทผ Import ์๊ฐ", source_imported_at or "์์")
- t3.metric("๋ณด๋ฅ ํ ์ฒ๋ฆฌ", str(review_indicators.get("held_recheck_count", "-") or "0") + "๊ฑด")
-
- if import_batches:
- latest_import = import_batches[0]
- st.caption(f"์ต๊ทผ Import: ์ถ์ฒ {latest_import.get('source', '์ ์ ์์')} / ์์ง์ผ {latest_import.get('imported_at', '๋ฏธ๊ธฐ๋ก')}")
- else:
- st.info("์ต๊ทผ Import ๋ฐฐ์น๊ฐ ์์ต๋๋ค.")
-
- st.markdown("- [๊ณ์ข ํ๋ฉด์์ ์์ก๋์กฐ ํ์ธ](?page=๊ณ์ข)")
- st.markdown("- [Import ํ๋ฉด์์ ์ด๋ ฅ ํ์ธ](?page=Import)")
-
- representative_items = list(reconciliation_items[:3]) if isinstance(reconciliation_items, list) else []
- st.markdown("๋ํ ๊ณ์ข ๋ฐ์ ์ํ")
- if representative_items:
- reconciliation_status_label = {
- "OK": "์ ์",
- "REVIEW_NEEDED": "๋ฏธ๋์กฐ",
- "BASELINE_REQUIRED": "๊ธฐ์ค ์์ก ๋ฏธ์
๋ ฅ",
- "NEEDS_RECHECK": "์ฌํ์ธ ํ์",
- "PENDING": "๋๊ธฐ",
- }
- for idx, item in enumerate(representative_items, start=1):
- raw_status = str(item.get("status", "-") or "-")
- synced_at = item.get("last_synced_at") or item.get("baseline_at") or item.get("calculation_basis_at") or "-"
- baseline_balance = item.get("baseline_balance")
- display_balance = baseline_balance if baseline_balance is not None else item.get("calculated_balance")
- item_line = (
- f"- [{idx}] ๊ณ์ข๋ช
: {item.get('account_name', '-')}, "
- f"๊ธฐ๊ด: {item.get('institution', item.get('bank', '๋ฏธ๊ธฐ๋ก'))}, "
- f"์ ํ: {item.get('account_type', item.get('type', '๋ฏธ๊ธฐ๋ก'))}, "
- f"์ต๊ทผ ๋๊ธฐํ: {synced_at}, "
- f"์์ก: {format_krw(display_balance)}, "
- f"์ํ: {reconciliation_status_label.get(raw_status, raw_status)}"
- )
- st.markdown(
- item_line,
- )
- else:
- st.caption("์์ก๋์กฐ ๋์ ๊ณ์ข๊ฐ ํ์ฌ ์์ต๋๋ค.")
-
- st.markdown("- [๋ฏธ๋ถ๋ฅ ํ์ด์ง](?page=๋ฏธ๋ถ๋ฅ)")
-
- render_section_label("๐ฆ", "์์ฐ / ์์์ฐ ๋ง๊ฐ ์ํ")
- latest_asset_status = "๋ฏธํ์ธ"
- if asset_snapshot.get("success"):
- latest_asset_status = "๋ง๊ฐ ์๋ฃ" if bool(asset_snapshot.get("is_closed_snapshot")) else "๋ฏธ๋ง๊ฐ"
- warnings = asset_snapshot.get("warnings") if isinstance(asset_snapshot, dict) else []
- missing_valuation_items = asset_snapshot.get("missing_valuation_items") if isinstance(asset_snapshot, dict) else []
- warning_text = "๊ฒฝ๊ณ ์์"
- if warnings or missing_valuation_items:
- warning_text = f"๊ฒฝ๊ณ {len(warnings or [])}๊ฑด / ์ ์ธ {len(missing_valuation_items or [])}๊ฑด"
-
- m1, m2, m3, m4 = st.columns(4)
- m1.metric("์์ฐ ํฉ๊ณ", format_krw(asset_snapshot.get("total_assets") if isinstance(asset_snapshot, dict) else source_coverage.get("assets", {}).get("total_assets")))
- m2.metric("์ด๋ถ์ฑ", format_krw(asset_snapshot.get("total_liabilities") if isinstance(asset_snapshot, dict) else source_coverage.get("assets", {}).get("total_liabilities")))
- m3.metric("์์์ฐ", format_krw(
- asset_snapshot.get("net_worth")
- if isinstance(asset_snapshot, dict)
- else source_coverage.get("assets", {}).get("net_worth")
- ))
- m4.metric("์์ฐ ๋ง๊ฐ ์ํ", latest_asset_status)
- st.caption(f"์์ฐ ์์ธ: {warning_text}")
- st.caption(f"๋ง๊ฐ ๊ธฐ์ค์ผ: {asset_snapshot.get('basis_date', basis_date)}")
- st.markdown("- [์์ฐ/๋ง๊ฐ ํ๋ฉด์์ ์ค๋
์ท ํ์ธ](?page=์์ฐ/๋ง๊ฐ)")
-
- render_section_label("๐", "์์ ์ฐ ๊ฒฐ๊ณผ / Evidence")
- st.markdown("### ์ฝ๊ธฐ ์ ์ฉ ๊ทผ๊ฑฐ ์์ฝ")
- evidence_close_status = "๋ง๊ฐ ์๋ฃ" if bool(asset_snapshot.get("is_closed_snapshot")) else "๋ฏธ๋ง๊ฐ"
- import_summary = source_coverage.get("imports", {}) if isinstance(source_coverage, dict) else {}
- evidence_rows = [
- f"- close ์ค๋น ์ํ: {evidence_close_status}",
- f"- source coverage: ์ต๊ทผ Import ๋ฐฐ์น {int(import_summary.get('recent_batch_count', 0))}๊ฑด",
- f"- ์ต๊ทผ Import: {import_summary.get('latest_imported_at') or '์์'}",
- f"- ์์ฐ/๋ง๊ฐ: {evidence_close_status} ({asset_snapshot.get('basis_date', basis_date)})",
- f"- ๋ฏธํด๊ฒฐ ๋ธ๋ก์ปค: {int(unresolved_detail_count)}๊ฑด",
- ]
- for row in evidence_rows:
- st.markdown(row)
-
- st.markdown("### ๋ค์ ์ก์
")
- st.caption("์ด๋ฒ ๋ฒ์์๋ ์์ ์ฐ close ์ ์ฅ/์คํ์ด ํฌํจ๋์ง ์์ต๋๋ค")
- action_candidates = [
- "์์ฐ ์กฐ์ ํ๋ณด: ์๋ง ์์ฐ ์ด๊ณผ/๋ถ์กฑ ํญ๋ชฉ์ด ์๋์ง ์ ๊ฒํ๊ณ ์์ฝ ํ์ด์ง์์ ์์ฐ์ ๊ฐฑ์ ํ์ธ์.",
- "๋ถ๋ฅ/๊ฒํ ๊ท์น ํ๋ณด: ๋ฏธ๋ถ๋ฅ ์ถ์ฒ ๋ฐ ํ์ธํ์ ํ์์ ๊ท์น ํ๋ณด๋ฅผ ๋จผ์ ์ ๊ฒํ์ธ์.",
- "๋์กฐ/Import/๋ฐฑ์
ํ์ ์์
: ๊ณ์ข ์์ก๋์กฐ, ์ต๊ทผ Import ์ด๋ ฅ, ๋ฐฑ์
์ต์ ํ ์ํ๋ฅผ ํจ๊ป ํ์ธํ์ธ์.",
- "์๊ธฐ ์์ฐ/๋ง๊ฐ ํ๋ฉด์์ ๊ธฐ์ค์ผยทํ๊ฐ ์ํ๋ฅผ ์ ํฉ์ฑ ํ์ธ ํ ์ด์ ์น์ธ ์ฌ๋ถ๋ฅผ ๊ฒฐ์ ํ์ธ์.",
- ]
- for action in action_candidates:
- st.markdown(f"- {action}")
-
- st.divider()
- st.markdown("- [์์ฝ ํ์ด์ง๋ก ์ด๋](?page=์์ฝ)")
-
-
-def page_overview(db_path: str) -> None:
- health = overview_gateway.health_check(db_path)
- render_page_header(
- "๊ฐ๊ณ๋ถ ์ด์ ๋์๋ณด๋",
- "์ด๋ฒ ๋ฌ ์ค์ ํ๊ธํ๋ฆ, ์์ฐ ์ ๋์ฑ, ์ด์ ๊ฒฝ๊ณ ๋ฅผ ์ฐ์ ์์๋๋ก ํ์ธํฉ๋๋ค.",
- kicker="๐ ์์ฝ",
- pills=[f"schema v{health.get('schema_version')}", "KST ์ด๋ฒ ๋ฌ", "last_balance ๊ธฐ์ค ์์ฐ"],
- )
- st.caption(f"DB: `{health.get('db_path')}`")
- month = current_kst_month()
- cashflow = overview_gateway.get_monthly_cashflow_report(month, db_path=db_path)
- asset_report = overview_gateway.get_asset_liquidity_report(db_path=db_path)
- budget_report = overview_gateway.get_budget_vs_actual_report(month, db_path=db_path)
- uncategorized_res = overview_gateway.list_uncategorized_transactions(limit=1000, db_path=db_path)
- transfer_queue_res = overview_gateway.list_cross_transfer_review_queue(limit=300, db_path=db_path)
- reconciliation_res = reconciliation_gateway.get_balance_reconciliation_report(db_path=db_path)
- review_indicators_res = reconciliation_gateway.get_review_operating_indicators(db_path=db_path)
- review_indicators = review_indicators_res.get("indicators", {}) if review_indicators_res.get("success") else {}
- backups_res = overview_gateway.list_ledger_backups(limit=1)
- imports_res = overview_gateway.list_ledger_import_batches(limit=1, db_path=db_path)
-
- render_section_label("๐", "์ด๋ฒ ๋ฌ ์์
/์ง์ถ ํํฉ")
- st.caption(f"๊ธฐ์ค: KST ํ์ฌ ์ {month}, transactions.transaction_date, ledger_categories.cashflow_kind ์ค์ ํ๊ธํ๋ฆ")
- c1, c2, c3, c4 = st.columns(4)
- c1.metric("์ค์ ์์
", format_krw(cashflow.get("income", 0)))
- c2.metric("์ค์ ์ง์ถ", format_krw(cashflow.get("expenses", 0)))
- c3.metric("์ํ๊ธํ๋ฆ", format_krw(cashflow.get("net_cashflow", 0)))
- c4.metric("์ ์ถ๋ฅ ", format_percent(cashflow.get("savings_rate")))
-
- render_section_label("๐ฆ", "์์ฐ ์ ๋์ฑ ํํฉ")
- st.caption("๊ธฐ์ค: service ๊ณตํต ํ๊ฐ ํฌํผ(accounts.last_balance + manual_assets ๋ณด๊ฐ). ๊ฑฐ๋ ํฉ๊ณ๋ก ๋น๊ฑฐ๋ ์์ฐ์ ์ถ์ ํ์ง ์์ต๋๋ค.")
- a1, a2, a3 = st.columns(3)
- a1.metric("์์ฐ ํฉ๊ณ(๋ถ์ฑ ์ ์ธ)", format_krw(asset_report.get("total_assets", 0)))
- a2.metric("์ด๋ถ์ฑ", format_krw(asset_report.get("total_liabilities", 0)))
- a3.metric("์์์ฐ", format_krw(asset_report.get("net_worth", 0)))
- buckets_df = pd.DataFrame(asset_report.get("buckets", []))
- if not buckets_df.empty:
- buckets_df["label"] = buckets_df["liquidity_bucket"].map(LIQUIDITY_BUCKET_LABELS).fillna(buckets_df["liquidity_bucket"])
- buckets_df["๊ธ์ก"] = buckets_df["amount"].map(format_krw)
- buckets_df["๊ตฌ์ฑ๋น"] = buckets_df["asset_share"].map(format_percent)
- st.dataframe(buckets_df[["label", "liquidity_bucket", "๊ธ์ก", "account_count", "๊ตฌ์ฑ๋น"]], width="stretch", height=250)
- chart_df = buckets_df[(buckets_df["liquidity_bucket"] != "loan") & (buckets_df["amount"] != 0)]
- if not chart_df.empty:
- st.plotly_chart(px.pie(chart_df, names="label", values="amount", title="์์ฐ ๊ตฌ์ฑ(loan ์ ์ธ)"), width="stretch")
-
- render_section_label("๐งญ", "์์ฐ ๊ตฌ์ฑ")
- source_accounts = pd.DataFrame(asset_report.get("accounts", []))
- manual_assets = pd.DataFrame(asset_report.get("manual_assets", []))
- if not source_accounts.empty:
- source_accounts["bucket_label"] = source_accounts["liquidity_bucket"].map(LIQUIDITY_BUCKET_LABELS).fillna(source_accounts["liquidity_bucket"])
- source_accounts["last_balance_display"] = source_accounts["last_balance"].map(format_krw)
- show_cols = ["name", "type", "bucket_label", "last_balance_display", "last_synced_at", "memo"]
- st.dataframe(source_accounts[show_cols], width="stretch", height=220)
- else:
- st.info("last_balance๊ฐ ์
๋ ฅ๋ ํ์ฑ ๊ณ์ข/์์ฐ ํญ๋ชฉ์ด ์์ต๋๋ค.")
- if not manual_assets.empty:
- manual_assets["bucket_label"] = manual_assets["liquidity_bucket"].map(LIQUIDITY_BUCKET_LABELS).fillna(manual_assets["liquidity_bucket"])
- manual_assets["valuation_display"] = manual_assets["amount"].map(format_krw)
- st.caption("์๊ธฐ ์์ฐ/๋ถ์ฑ(manual_assets) โ ๋น๊ฑฐ๋ ์์ฐ ๊ถ์ ์์ค")
- st.dataframe(manual_assets[["name", "bucket_label", "valuation_display", "valuation_date", "source_note", "memo"]], width="stretch", height=180)
-
- render_section_label("๐", "์ง์ถ ๊ตฌ์กฐ/์์ฐ")
- budgets = pd.DataFrame(budget_report.get("items", []))
- if not budgets.empty:
- over_count = int((budgets["status"] == "over").sum()) if "status" in budgets else 0
- b1, b2, b3 = st.columns(3)
- b1.metric("์ ์์ฐ", format_krw(budget_report.get("total_budget", 0)))
- b2.metric("์์ฐ ๋์ ์ง์ถ", format_krw(budget_report.get("total_actual", 0)))
- b3.metric("์ด๊ณผ ํญ๋ชฉ", f"{over_count}๊ฑด")
- budgets["budget_display"] = budgets["budget"].map(format_krw)
- budgets["actual_display"] = budgets["actual"].map(format_krw)
- budgets["remaining_display"] = budgets["remaining"].map(format_krw)
- budgets["usage_rate_display"] = budgets["usage_rate"].map(format_percent)
- st.dataframe(budgets[["category", "subcategory", "person", "budget_display", "actual_display", "remaining_display", "usage_rate_display", "status"]], width="stretch", height=240)
- else:
- st.info(f"{month} ์์ฐ์ด ์์ต๋๋ค. ์์ฐ ๋ฉ๋ด์์ ์๋ณ ์์ฐ์ ๋ฑ๋กํ๋ฉด ์ด ์์ญ์ ํ์๋ฉ๋๋ค.")
-
- render_section_label("โ ๏ธ", "์ด์ ๊ฒฝ๊ณ ")
- review_items = get_transfer_review_items(transfer_queue_res)
- reconciliation_items = reconciliation_res.get("items", []) if reconciliation_res.get("success") else []
- reconciliation_review = [item for item in reconciliation_items if item.get("status") == "REVIEW_NEEDED"]
- missing_balance = asset_report.get("missing_balance_accounts", [])
- w1, w2, w3, w4, w5 = st.columns(5)
- w1.metric("๋ฏธ์ฒ๋ฆฌ review", f"{review_indicators.get('pending_review_count', len(review_items)):,}๊ฑด")
- w2.metric("๋ณด๋ฅ/์ฌ๊ฒํ ", f"{review_indicators.get('held_recheck_count', 0):,}๊ฑด")
- w3.metric("๋ฏธ๋์กฐ ๊ณ์ข", f"{review_indicators.get('unreconciled_account_count', len(reconciliation_review)):,}๊ฐ")
- w4.metric("์ต๊ทผ ๋์กฐ ๋ง๊ฐ", review_indicators.get("last_reconciliation_closed_at") or "-")
- w5.metric("๋ฏธ๋ถ๋ฅ ์ถ์ฒ ๋๊ธฐ", f"{review_indicators.get('uncategorized_recommendation_backlog', uncategorized_res.get('count', 0)):,}๊ฑด")
- latest_backup = (backups_res.get("backups") or [{}])[0] if backups_res.get("success") else {}
- latest_import = (imports_res.get("import_batches") or [{}])[0] if imports_res.get("success") else {}
- st.caption(
- "์์ธ ์ด๋: ๋ฏธ๋ถ๋ฅ โ ๋ฏธ๋ถ๋ฅ, ํ์ธํ์ ํ โ ํ์ธํ์ ํ, ์์ก๋์กฐ ํ์/์์ก ๋ฏธ์
๋ ฅ โ ๊ณ์ข, "
- f"์ต๊ทผ ๋ฐฑ์
: {latest_backup.get('name', '์์')}, ์ต๊ทผ import: {latest_import.get('source', '์์')} {latest_import.get('imported_at', '')}"
- )
- if missing_balance:
- st.warning("last_balance๊ฐ ์๋ ๊ณ์ข/์์ฐ์ ์์ฐ ์ ๋์ฑ ๊ณ์ฐ์์ ์ ์ธํ์ต๋๋ค. ๊ณ์ข ํ๋ฉด์์ ๊ธฐ์ค ์์ก๊ณผ ๊ธฐ์ค ์๊ฐ์ ์
๋ ฅํ์ธ์.")
-
-
-def page_assets(db_path: str) -> None:
- render_page_header(
- "์์ฐ/๋ง๊ฐ",
- "์๊ธฐ ์์ฐยท๋ถ์ฑ๋ฅผ ๊ด๋ฆฌํ๊ณ ์๋ง ์์์ฐ ์ค๋
์ท์ preview ํ ๋ง๊ฐํฉ๋๋ค.",
- kicker="๐ฆ Asset Close",
- pills=["์๊ธฐ ์์ฐ CRUD", "Preview ์ฐ์ ", "Append-only ์ ์ "],
- )
- st.caption("ํ์ ๊ธฐ์ค: preview๋ live_authority ์ถ์ ๊ฐ, close ์ดํ ์กฐํ๋ closed snapshot ๊ฐ์
๋๋ค. ์ค์ด์ ์ synthetic/validation DB์์ ๋จผ์ ๊ฒ์ฆํ์ธ์.")
-
- render_section_label("โ๏ธ", "์๊ธฐ ์์ฐ/๋ถ์ฑ ๋ฑ๋ก")
- with st.form("manual_asset_create"):
- c1, c2, c3 = st.columns([1.3, 1, 1])
- name = c1.text_input("์์ฐ/๋ถ์ฑ๋ช
", placeholder="์: ์ ์ธ๋ณด์ฆ๊ธ, ์ฃผํ๋ด๋ณด๋์ถ")
- amount = c2.number_input("ํ๊ฐ๊ธ์ก(์)", min_value=0, value=0, step=10000)
- valuation_date = c3.date_input("ํ๊ฐ ๊ธฐ์ค์ผ")
- c4, c5 = st.columns([1, 2])
- bucket = c4.selectbox("์ ๋์ฑ ๋ฒํท", LIQUIDITY_BUCKET_OPTIONS, format_func=lambda x: LIQUIDITY_BUCKET_LABELS.get(x, x))
- source_note = c5.text_input("์ถ์ฒ ๋ฉ๋ชจ", placeholder="์: ์๋ง ์ฑ ์์ก, ๊ณ์ฝ์, synthetic")
- memo = st.text_input("์ด์ ๋ฉ๋ชจ", value="")
- submitted = st.form_submit_button("์๊ธฐ ์์ฐ ์ถ๊ฐ", type="primary")
- if submitted:
- res = assets_gateway.create_manual_asset(name, int(amount), valuation_date.isoformat(), bucket, source_note=source_note, memo=memo, db_path=db_path)
- if res.get("success"):
- clear_cache()
- st.success("์๊ธฐ ์์ฐ์ ์ถ๊ฐํ์ต๋๋ค.")
- st.rerun()
- else:
- st.error(res.get("error", res))
-
- assets_res = assets_gateway.list_manual_assets(active_only=False, db_path=db_path)
- assets = assets_res.get("manual_assets", []) if assets_res.get("success") else []
- render_section_label("๐", "์๊ธฐ ์์ฐ/๋ถ์ฑ ๋ชฉ๋ก")
- if assets:
- df = pd.DataFrame(assets)
- df["bucket_label"] = df["liquidity_bucket"].map(LIQUIDITY_BUCKET_LABELS).fillna(df["liquidity_bucket"])
- df["valuation_display"] = df["valuation_amount"].map(format_krw)
- st.dataframe(df[["name", "active", "bucket_label", "valuation_display", "valuation_date", "source_note", "memo", "id"]], width="stretch", height=220)
- with st.form("manual_asset_update"):
- asset_options = {f"{a['name']} ({a['id']})": a for a in assets}
- selected_label = st.selectbox("์์ /๋นํ์ฑ ๋์", list(asset_options.keys()))
- selected = asset_options[selected_label]
- u1, u2, u3 = st.columns([1.3, 1, 1])
- new_name = u1.text_input("์ด๋ฆ", value=selected.get("name", ""))
- new_amount = u2.number_input("ํ๊ฐ๊ธ์ก(์)", min_value=0, value=int(selected.get("valuation_amount") or 0), step=10000, key="asset_update_amount")
- new_date = u3.text_input("ํ๊ฐ ๊ธฐ์ค์ผ", value=selected.get("valuation_date", ""))
- u4, u5 = st.columns([1, 2])
- current_bucket = selected.get("liquidity_bucket", "other") if selected.get("liquidity_bucket", "other") in LIQUIDITY_BUCKET_OPTIONS else "other"
- new_bucket = u4.selectbox("๋ฒํท", LIQUIDITY_BUCKET_OPTIONS, index=LIQUIDITY_BUCKET_OPTIONS.index(current_bucket), format_func=lambda x: LIQUIDITY_BUCKET_LABELS.get(x, x), key="asset_update_bucket")
- new_source = u5.text_input("์ถ์ฒ ๋ฉ๋ชจ", value=selected.get("source_note", ""))
- new_memo = st.text_input("๋ฉ๋ชจ", value=selected.get("memo", ""), key="asset_update_memo")
- make_active = st.checkbox("ํ์ฑ", value=bool(selected.get("active", True)))
- col_update, col_deactivate = st.columns(2)
- do_update = col_update.form_submit_button("์ ํ ํญ๋ชฉ ์์ ", type="primary")
- do_deactivate = col_deactivate.form_submit_button("์ ํ ํญ๋ชฉ ๋นํ์ฑ", type="secondary")
- if do_update:
- res = assets_gateway.update_manual_asset(selected["id"], name=new_name, valuation_amount=int(new_amount), valuation_date=new_date, liquidity_bucket=new_bucket, source_note=new_source, memo=new_memo, active=make_active, db_path=db_path)
- if res.get("success"):
- clear_cache(); st.success("์์ ํ์ต๋๋ค."); st.rerun()
- else:
- st.error(res.get("error", res))
- if do_deactivate:
- res = assets_gateway.deactivate_manual_asset(selected["id"], db_path=db_path)
- if res.get("success"):
- clear_cache(); st.warning("๋นํ์ฑ ์ฒ๋ฆฌํ์ต๋๋ค."); st.rerun()
- else:
- st.error(res.get("error", res))
- else:
- st.info("๋ฑ๋ก๋ ์๊ธฐ ์์ฐ/๋ถ์ฑ๊ฐ ์์ต๋๋ค.")
-
- st.divider()
- render_section_label("๐", "์๋ง ์ค๋
์ท Preview / Close")
- st.caption("Preview๋ DB๋ฅผ ๋ณ๊ฒฝํ์ง ์์ต๋๋ค. ๊ฐ์ ๊ธฐ์ค์ผ์ ๋ค์ closeํ๋ฉด ์ ์ ์ฌ์ ๊ฐ ํ์ํ๋ฉฐ ๊ธฐ์กด ํ์ ๋ฎ์ด์ฐ์ง ์๊ณ ์ correction run์ appendํฉ๋๋ค.")
- c1, c2, c3 = st.columns([1, 1.5, 1.5])
- basis_date = c1.text_input("๋ง๊ฐ ๊ธฐ์ค์ผ", value=f"{current_kst_month()}-01", help="YYYY-MM-DD")
- close_note = c2.text_input("Close ์ถ์ฒ ๋ฉ๋ชจ", value="month-end close")
- correction_reason = c3.text_input("์ ์ ์ฌ์ (์ฌ๋ง๊ฐ ์ ํ์)", value="")
- p1, p2 = st.columns(2)
- if p1.button("์ค๋
์ท Preview", type="secondary"):
- st.session_state["asset_snapshot_preview"] = assets_gateway.preview_asset_snapshot_close(basis_date, db_path=db_path)
- if p2.button("์ค๋
์ท Close ์คํ", type="primary"):
- st.session_state["asset_snapshot_close"] = assets_gateway.close_asset_snapshot(basis_date, source_note=close_note, correction_reason=correction_reason, db_path=db_path)
- clear_cache()
- preview = st.session_state.get("asset_snapshot_preview")
- close_res = st.session_state.get("asset_snapshot_close")
- if preview:
- if preview.get("success"):
- st.success(f"Preview: source={preview.get('source')}, closed={preview.get('is_closed_snapshot')}, warnings={len(preview.get('warnings', []))}")
- m1, m2, m3 = st.columns(3)
- m1.metric("์์ฐ ํฉ๊ณ", format_krw(preview.get("total_assets")))
- m2.metric("์ด๋ถ์ฑ", format_krw(preview.get("total_liabilities")))
- m3.metric("์์์ฐ", format_krw(preview.get("net_worth")))
- st.dataframe(pd.DataFrame(preview.get("included_items", [])), width="stretch", height=220)
- if preview.get("warnings"):
- st.warning(preview.get("warnings"))
- else:
- st.error(preview.get("error", preview))
- if close_res:
- if close_res.get("success"):
- st.success(f"Close ์๋ฃ: run={close_res.get('snapshot_run_id')}, correction={close_res.get('is_correction')}, inserted={close_res.get('inserted_count')}")
- st.json({k: close_res.get(k) for k in ["basis_date", "source", "is_closed_snapshot", "snapshot_run_id", "correction_of_run_id", "correction_reason", "closed_at", "net_worth"]})
- else:
- st.error(close_res.get("error", close_res))
-
-
-def page_transactions(db_path: str) -> None:
- render_page_header(
- "๊ฑฐ๋๋ด์ญ",
- "๋ ์ง, ์นดํ
๊ณ ๋ฆฌ, ๊ฐ์กฑ ๊ตฌ์ฑ์, ์ฌ์ฉ์ฒ๋ฅผ ๊ธฐ์ค์ผ๋ก ๊ฑฐ๋๋ฅผ ๋น ๋ฅด๊ฒ ์ฐพ์ ์์ ํฉ๋๋ค.",
- kicker="๐ Ledger Explorer",
- pills=["ํํฐ๋ง", "๊ฑฐ๋ ์์ ", "์์ฅ ๊ฒํ "],
- )
- category_options, _ = get_category_options(db_path)
- people_df = read_sql(db_path, "SELECT DISTINCT person FROM transactions WHERE status='active' AND person<>'' ORDER BY person")
- people = people_df["person"].tolist() if not people_df.empty else []
- col1, col2, col3, col4, col5 = st.columns(5)
- start = col1.date_input("์์์ผ", value=None)
- end = col2.date_input("์ข
๋ฃ์ผ", value=None)
- category = col3.selectbox("์นดํ
๊ณ ๋ฆฌ", ["์ ์ฒด"] + [c for c in category_options if c])
- person = col4.selectbox("์ธ์", ["์ ์ฒด"] + people)
- text = col5.text_input("์ฌ์ฉ์ฒ ๊ฒ์")
- clauses = ["t.status='active'"]
- params: list[Any] = []
- if start:
- clauses.append("t.transaction_date>=?"); params.append(str(start))
- if end:
- clauses.append("t.transaction_date<=?"); params.append(str(end))
- if category != "์ ์ฒด":
- clauses.append("t.category=?"); params.append(category)
- if person != "์ ์ฒด":
- clauses.append("t.person=?"); params.append(person)
- if text:
- clauses.append("t.merchant LIKE ?"); params.append(f"%{text}%")
- df = transaction_df(db_path, "WHERE " + " AND ".join(clauses), tuple(params), limit=2000)
- st.dataframe(df.drop(columns=["id", "amount", "balance_after", "is_fixed"], errors="ignore"), width="stretch", height=500)
-
- if not df.empty:
- render_section_label("โ๏ธ", "์ ํ ๊ฑฐ๋ ์์ ")
- options = {f"{r.transaction_datetime or r.transaction_date} ยท {r.merchant} ยท {format_krw(r.amount)} ยท {r.category or '๋ฏธ๋ถ๋ฅ'}": r.id for r in df.itertuples()}
- label = st.selectbox("์์ ํ ๊ฑฐ๋", list(options.keys()))
- tx_id = options[label]
- tx = df[df["id"] == tx_id].iloc[0].to_dict()
- update_transaction_form(tx, "tx", db_path)
-
-
-def page_cross_transfer_review(db_path: str) -> None:
- render_page_header(
- "KBโํ ์ค๋ฑ
ํฌ ํ์ธํ์ ํ",
- "๋ ์งยท๊ธ์กยท์๋ ๊ธฐ์ค์ผ๋ก ๊ณ์ข ๊ฐ ์ด๋ ํ๋ณด๋ฅผ ๋์กฐํ๊ณ , ๋๋ผ์ด๋ฐ ํ ์์ ํ๊ฒ ๋งค์นญํฉ๋๋ค.",
- kicker="๐ Transfer Review",
- pills=["๋๋ผ์ด๋ฐ ์ฐ์ ", "๋ณธ์ธ๊ณ์ข์ด๋", "์๋ ๊ฒํ "],
- )
- c1, c2 = st.columns(2)
- with c1:
- if st.button("๋ฏธ๋ฆฌ๋ณด๊ธฐ(๋๋ผ์ด๋ฐ)"):
- st.session_state["cross_transfer_preview"] = transfers_gateway.preview_cross_account_transfers(db_path=db_path)
- with c2:
- if st.button("์๋ ๋งค์นญ ์คํ"):
- res = transfers_gateway.run_cross_account_transfer_match(db_path=db_path)
- clear_cache()
- st.session_state["cross_transfer_last_run"] = res
- st.success(f"๋งค์นญ {res.get('matched_count', 0)}๊ฑด ์ ์ฉ, ํ์ธํ์ {res.get('review_count', 0)}๊ฑด")
- st.rerun()
-
- if "cross_transfer_preview" in st.session_state:
- prev = st.session_state["cross_transfer_preview"]
- render_section_label("๐งช", "๋ฏธ๋ฆฌ๋ณด๊ธฐ ๊ฒฐ๊ณผ")
- st.info(f"์์ ๋งค์นญ {prev.get('matched_count', 0)}๊ฑด / ํ์ธํ์ {prev.get('review_count', 0)}๊ฑด")
- if prev.get("matched_pairs"):
- st.dataframe(pd.DataFrame(prev.get("matched_pairs")), width="stretch", height=250)
- if prev.get("review_items"):
- st.dataframe(pd.DataFrame(prev.get("review_items")), width="stretch", height=250)
-
- st.divider()
- render_section_label("๐", "ํ์ธํ์ ํ")
- list_queue_fn = getattr(transfers_gateway, "list_cross_transfer_review_queue", None)
- if callable(list_queue_fn):
- queue = list_queue_fn(db_path=db_path)
- rows = queue.get("transactions", [])
- else:
- # ํธํ์ฑ ๋ณดํธ: ๊ตฌ๋ฒ์ ์๋น์ค์์ ๋ฉ์๋๊ฐ ์์ ๋ ์ง์ ์กฐํ
- st.info("ํ์ธํ์ ํ ์กฐํ ํจ์๊ฐ ํ์ฌ ์๋น์ค ๋ชจ๋์ ์์ด ์ง์ DB ์กฐํ๋ก ํ์ํฉ๋๋ค.")
- with sqlite3.connect(db_path) as _conn:
- _conn.row_factory = lambda cursor, row: {description[0]: row[idx] for idx, description in enumerate(cursor.description)}
- _rows = _conn.execute(
- """
- SELECT * FROM transactions
- WHERE status='active'
- AND source IN ('kb_bank_pdf','toss_bank_pdf')
- AND memo LIKE '%cross_transfer_review:%'
- ORDER BY transaction_date DESC, created_at DESC
- LIMIT 300
- """
- ).fetchall()
- rows = [dict(r) for r in _rows]
-
- if not rows:
- st.success("ํ์ฌ ํ์ธํ์ ํ๊ฐ ๋น์ด ์์ต๋๋ค.")
- return
- df = pd.DataFrame(rows)
- if "cashflow_kind" in df.columns:
- df["cashflow_kind"] = df["cashflow_kind"].fillna("")
- st.dataframe(df[["transaction_date", "source", "type", "amount", "counterparty", "category", "subcategory", "memo", "id"]].rename(columns={"amount": "๊ธ์ก"}), width="stretch", height=320)
-
- options = {
- f"{r.get('transaction_date')} ยท {r.get('source')} ยท {r.get('type')} ยท {int(r.get('amount', 0)):,}์ ยท {r.get('counterparty', '')}": r
- for r in rows
- }
- selected = st.selectbox("์๋ ์์ ํ ๊ฑฐ๋", list(options.keys()))
- selected_row = options[selected]
- st.caption(f"ํ์ฌ review_status: {selected_row.get('review_status', 'pending')}")
- with st.expander("Review decision ์ ์ฅ/๋ณด๋ฅ/์ฌ๊ฒํ /ํด์ ", expanded=False):
- render_review_decision_actions(transfers_gateway, "cross_transfer", selected_row["id"], f"transfer_review_decision_{selected_row['id']}", db_path, target_payload=selected_row)
- update_transaction_form(selected_row, "transfer_review", db_path)
-
-
-def page_uncategorized(db_path: str) -> None:
- render_page_header(
- "๋ฏธ๋ถ๋ฅ ๊ฑฐ๋",
- "์ถ์ฒ ๋ถ๋ฅ๋ฅผ ํ์ธํ๊ณ , ๋ฐ๋ณต ์ฌ์ฉ์ฒ๋ ๊ท์น์ผ๋ก ์ ํํด ๋ค์ import๋ฅผ ์๋ํํฉ๋๋ค.",
- kicker="๐งฉ Categorization Inbox",
- pills=["์ถ์ฒ", "๋ฐ๋ณต ํ๋ณด", "๊ท์นํ"],
- )
- filter_cols = st.columns([1.0, 1.2, 1.15, 1.15, 1.25])
- min_amount = filter_cols[0].number_input("์ต์ ๊ธ์ก", min_value=0, value=0, step=10000)
- limit = filter_cols[1].slider("ํ์ ๊ฑด์", 10, 500, 100)
- recommended_only = filter_cols[2].checkbox("์ถ์ฒ ์๋ ํญ๋ชฉ๋ง", value=False)
- repeated_only = filter_cols[3].checkbox("๋ฐ๋ณต ํ๋ณด๋ง", value=False)
- sort_label = filter_cols[4].selectbox("์์
๋ชฉ๋ก ์ ๋ ฌ", ["๊ธ์ก์", "๋ ์ง์(์ต์ ์)"], index=0)
- sort_by = "date_desc" if sort_label == "๋ ์ง์(์ต์ ์)" else "amount_desc"
-
- workbench_fn = get_service_callable(categorization_gateway, "get_uncategorized_workbench", required_params=["sort_by"])
- if not callable(workbench_fn):
- st.error("ํ์ฌ ์คํ ์ค์ธ ์๋น์ค์์ ๋ฏธ๋ถ๋ฅ ์์
๋ ํจ์๋ฅผ ์ฐพ์ง ๋ชปํ์ต๋๋ค. Streamlit ์๋ฒ๋ฅผ ์์ ํ ์ฌ์์ํด ์ฃผ์ธ์.")
- return
- result = workbench_fn(limit=limit, min_amount=int(min_amount), sort_by=sort_by, db_path=db_path)
- if not result.get("success"):
- st.error(result.get("error", result))
- return
-
- summary = result.get("summary", {})
- metric_cols = st.columns(5)
- metric_cols[0].metric("๋ฏธ๋ถ๋ฅ", f"{summary.get('uncategorized_count', 0):,}๊ฑด")
- metric_cols[1].metric("๋ฏธ๋ถ๋ฅ ์ด์ก", format_krw(summary.get("uncategorized_total_amount")))
- metric_cols[2].metric("์ฌ์ฉ์ฒ ๊ทธ๋ฃน", f"{summary.get('merchant_group_count', 0):,}๊ฐ")
- metric_cols[3].metric("์ถ์ฒ ๊ฐ๋ฅ", f"{summary.get('recommendable_count', 0):,}๊ฑด")
- metric_cols[4].metric("๋ฐ๋ณต ํ๋ณด", f"{summary.get('repeated_group_count', 0):,}๊ฐ")
-
- rows = result.get("transactions", [])
- groups = result.get("groups", [])
- if not rows:
- st.success("๋ฏธ๋ถ๋ฅ ๊ฑฐ๋๊ฐ ์์ต๋๋ค.")
- return
-
- render_section_label("๐", "๋ฐ๋ณต ์ฌ์ฉ์ฒ ํ๋ณด")
- st.caption("๊ฐ์ ์ฌ์ฉ์ฒ/์์ฝ/๊ฑฐ๋์ ํ์ผ๋ก ๋ฌถ์ธ ๋ฏธ๋ถ๋ฅ ๊ฑฐ๋์
๋๋ค. ์ถ์ฒ์ด ์๋ ๊ทธ๋ฃน์ ํ ๋ฒ์ ์ ์ฉํ๊ฑฐ๋ ๊ท์น์ผ๋ก ์ ์ฅํ ์ ์์ต๋๋ค.")
- render_uncategorized_groups(groups, db_path, repeated_only=repeated_only)
-
- render_section_label("๐ ๏ธ", "๋ฏธ๋ถ๋ฅ ์์
๋ชฉ๋ก")
- st.caption("๊ฑฐ๋๋ฅผ ํผ์น๋ฉด ์ถ์ฒ ๊ทผ๊ฑฐ์ ์์
๋ฒํผ์ด ๋ณด์
๋๋ค. ์ถ์ฒ ์ ์ฉ์ ์นดํ
๊ณ ๋ฆฌ๊ฐ ๋น์ด ์๋ ํ์ฌ ๊ฑฐ๋์๋ง ์ ์ฉ๋ฉ๋๋ค.")
- render_uncategorized_work_items(rows, db_path, recommended_only=recommended_only)
-
-
-def render_rule_list_items(rules: list[dict[str, Any]], db_path: str) -> None:
- if not rules:
- st.info("๋ฑ๋ก๋ ๊ท์น์ด ์์ต๋๋ค.")
- return
-
- st.markdown(
- """
-
- """,
- unsafe_allow_html=True,
- )
-
- for idx, rule in enumerate(rules):
- rule_id = rule.get("id", "")
- active = bool(rule.get("active", True))
- status_label = "ํ์ฑ" if active else "๋นํ์ฑ"
- tx_type = rule.get("transaction_type") or "์ ์ฒด"
- person = rule.get("person") or "-"
- fixed = rule.get("is_fixed")
- fixed_label = "์" if fixed is True else "์๋์ค" if fixed is False else "๋ณ๊ฒฝ์ํจ"
- amount_bits = []
- if rule.get("amount_min") is not None:
- amount_bits.append(f"์ต์ {format_krw(rule.get('amount_min'))}")
- if rule.get("amount_max") is not None:
- amount_bits.append(f"์ต๋ {format_krw(rule.get('amount_max'))}")
- if rule.get("day_of_month") is not None:
- amount_bits.append(f"๋งค์ {rule.get('day_of_month')}์ผ")
- condition_text = " ยท ".join(amount_bits) if amount_bits else "์ถ๊ฐ ์กฐ๊ฑด ์์"
- match_field = rule.get("match_field") or "-"
- match_type = rule.get("match_type") or "-"
- pattern_text = rule.get("pattern") or "-"
- result_text = f"{rule.get('category') or '-'} / {rule.get('subcategory') or '-'}"
- memo = rule.get("memo") or ""
- priority = rule.get("priority", "-")
- summary = f"{'๐ข' if active else 'โช'} {pattern_text} ยท {match_field}/{match_type} โ {result_text} ยท p{priority} ยท {tx_type}"
-
- with st.expander(summary, expanded=False):
- st.markdown(
- f"""
-
-
-
IF
-
{escape(match_field)} {escape(match_type)} โ{escape(pattern_text)}โ
-
-
โ
-
-
THEN
-
{escape(result_text)}
-
-
-
- ์ํ: {escape(status_label)}
- ์ฐ์ ์์: p{escape(str(priority))}
- ๊ฑฐ๋์ ํ: {escape(tx_type)}
- ์กฐ๊ฑด: {escape(condition_text)}
- person: {escape(person)}
- ๊ณ ์ ๋น: {escape(fixed_label)}
- ID: {escape(rule_id)}
-
- {f'๋ฉ๋ชจ: {escape(memo)}
' if memo else ''}
- """,
- unsafe_allow_html=True,
- )
- action_cols = st.columns([1.35, 0.9, 0.8, 1.4, 3.2], vertical_alignment="center")
- if action_cols[0].button("๐ ์ ์ฉ(๋ฏธ๋ฆฌ๋ณด๊ธฐ)", key=f"rule_expander_preview_{rule_id}_{idx}", use_container_width=True):
- category_rule_preview_dialog(rule, db_path)
- if action_cols[1].button("โ
์ ์ฉ", key=f"rule_expander_apply_{rule_id}_{idx}", use_container_width=True):
- apply_fn = get_service_callable(categorization_gateway, "apply_category_rule")
- if not callable(apply_fn):
- st.error("ํ์ฌ ์คํ ์ค์ธ ์๋น์ค์์ ๋จ์ผ ๊ท์น ์ ์ฉ ํจ์๋ฅผ ์ฐพ์ง ๋ชปํ์ต๋๋ค. Streamlit ์๋ฒ๋ฅผ ์์ ํ ์ข
๋ฃํ ๋ค ๋ค์ ์์ํด ์ฃผ์ธ์.")
- else:
- res = apply_fn(rule_id, limit=10000, db_path=db_path)
- clear_cache()
- if res.get("success"):
- st.success(f"๋ฏธ๋ถ๋ฅ ๋งค์นญ {res.get('applied_count', 0):,}๊ฑด์ ๊ท์น์ ์ ์ฉํ์ต๋๋ค.")
- st.rerun()
- else:
- st.error(res.get("error", res))
- if action_cols[2].button("โ๏ธ ์์ ", key=f"rule_expander_edit_{rule_id}_{idx}", use_container_width=True):
- category_rule_edit_dialog(rule, db_path)
- with action_cols[3]:
- st.markdown('์ญ์ ๋ณด๋ค ๋นํ์ฑํ๋ฅผ ๊ถ์ฅํฉ๋๋ค.
', unsafe_allow_html=True)
- if action_cols[4].button("๐๏ธ ์ญ์ ", key=f"rule_expander_delete_{rule_id}_{idx}"):
- delete_rule = getattr(categorization_gateway, "delete_category_rule", None)
- if delete_rule is None:
- st.error("ํ์ฌ ์คํ ์ค์ธ ์๋น์ค์ delete_category_rule ํจ์๊ฐ ์์ต๋๋ค. ์ฑ์ ์ฌ์์ํด ์ต์ ์ฝ๋๋ฅผ ๋ค์ ๋ถ๋ฌ์ค์ธ์.")
- else:
- res = delete_rule(rule_id, db_path=db_path)
- clear_cache()
- if res.get("success"):
- if st.session_state.get("rules_edit_rule_id") == rule_id:
- st.session_state.pop("rules_edit_rule_id", None)
- st.success("๊ท์น์ ์ญ์ ํ์ต๋๋ค.")
- st.rerun()
- else:
- st.error(res.get("error", res))
-
-
-def page_rules(db_path: str) -> None:
- render_page_header(
- "๋ถ๋ฅ ๊ท์น",
- "๋ฐ๋ณต๋๋ ์ฌ์ฉ์ฒ์ ๊ฑฐ๋ ํจํด์ ๊ท์น์ผ๋ก ๊ด๋ฆฌํด ๋ถ๋ฅ ์๋ํ ํ์ง์ ๋์
๋๋ค.",
- kicker="โ๏ธ Rule Engine",
- pills=["์๋ ๋ถ๋ฅ", "Preview", "Seed/Update"],
- )
- action_cols = st.columns([5, 1], vertical_alignment="center")
- with action_cols[1]:
- if st.button("์ถ๊ฐ", key="rule_add_dialog_open", use_container_width=True, type="primary"):
- category_rule_create_dialog(db_path)
- rules = categorization_gateway.list_category_rules(active_only=False, db_path=db_path).get("rules", [])
- render_section_label("๐", "ํ์ฌ ๋ถ๋ฅ ๊ท์น")
- render_rule_list_items(rules, db_path)
-
- st.caption("๊ท์น ์ถ๊ฐ๋ ์ ๋ชฉ ์ค๋ฅธ์ชฝ `์ถ๊ฐ` ๋ฒํผ, ๊ธฐ์กด ๊ท์น ์์ ์ ๊ฐ ํญ๋ชฉ์ `โฎ โ ๋ณ๊ฒฝ` ํ์
์ ์ฌ์ฉํ์ธ์.")
-
- c1, c2, c3 = st.columns(3)
- if c1.button("๊ธฐ๋ณธ ๊ท์น seed/update"):
- res = categorization_gateway.seed_default_category_rules(db_path=db_path)
- clear_cache(); st.success(res)
- if c2.button("๊ท์น preview"):
- st.session_state["rule_preview"] = categorization_gateway.preview_categorization(limit=5000, db_path=db_path)
- if c3.button("๊ท์น ์ ์ฉ"):
- res = categorization_gateway.apply_categorization_rules(limit=5000, db_path=db_path)
- clear_cache(); st.success(f"{res.get('applied_count', 0)}๊ฑด ์ ์ฉ")
- if "rule_preview" in st.session_state:
- prev = st.session_state["rule_preview"]
- st.info(f"์ค์บ {prev.get('scanned')}๊ฑด ยท ๋งค์นญ {prev.get('matched_count')}๊ฑด ยท ๋ฏธ๋ถ๋ฅ {prev.get('unmatched_count')}๊ฑด")
- st.write("์นดํ
๊ณ ๋ฆฌ๋ณ ์์")
- st.dataframe(pd.DataFrame([{"category": k, **v} for k, v in prev.get("by_category", {}).items()]), width="stretch")
- with st.expander("๋งค์นญ ์ํ"):
- st.dataframe(pd.DataFrame(prev.get("matches", [])), width="stretch")
- with st.expander("๋ฏธ๋งค์นญ ์ํ"):
- st.dataframe(pd.DataFrame(prev.get("unmatched", [])), width="stretch")
-
-
-def page_categories(db_path: str) -> None:
- render_page_header(
- "์นดํ
๊ณ ๋ฆฌ ๊ด๋ฆฌ",
- "ํ๊ธํ๋ฆ ์ฑ๊ฒฉ๊น์ง ํจ๊ป ๊ด๋ฆฌํด ์์ฝ ๋ฆฌํฌํธ์ ํฌ์ ๊ฐ๋ฅ ํ๊ธํ๋ฆ ๊ณ์ฐ์ ๊ธฐ์ค์ ์ ๋ํฉ๋๋ค.",
- kicker="๐ท๏ธ Category Taxonomy",
- pills=["์์
/์ง์ถ/์ด์ฒด", "๋นํ์ฑํ ์์ ์ฅ์น", "์ฌ์ฉ๋ ํ์ธ"],
- )
- st.caption("์นดํ
๊ณ ๋ฆฌ ์ญ์ ๋ ๊ธฐ๋ณธ์ ์ผ๋ก ๋นํ์ฑํ ์ฒ๋ฆฌํฉ๋๋ค. ๊ธฐ์กด ๊ฑฐ๋์ ๊ณผ๊ฑฐ category ๊ฐ์ ๋ณด์กด๋ฉ๋๋ค.")
-
- c1, c2 = st.columns(2)
- if c1.button("๊ธฐ๋ณธ ์นดํ
๊ณ ๋ฆฌ seed/update"):
- res = categorization_gateway.seed_default_ledger_categories(db_path=db_path)
- clear_cache()
- st.success(f"{res.get('created_or_updated', 0)}๊ฐ ์นดํ
๊ณ ๋ฆฌ๋ฅผ ์์ฑ/๊ฐฑ์ ํ์ต๋๋ค.")
- st.rerun()
- show_inactive = c2.checkbox("๋นํ์ฑ ์นดํ
๊ณ ๋ฆฌ๋ ๋ณด๊ธฐ", value=False)
-
- render_section_label("โ", "์นดํ
๊ณ ๋ฆฌ ์ถ๊ฐ")
- with st.form("create_ledger_category"):
- cols = st.columns([2, 2, 2, 1, 3])
- category = cols[0].text_input("๋๋ถ๋ฅ", placeholder="์: ๋ฐ๋ ค๋๋ฌผ")
- subcategory = cols[1].text_input("์๋ถ๋ฅ", placeholder="์: ์ฌ๋ฃ")
- cashflow_kind_label = cols[2].selectbox("ํ๊ธํ๋ฆ ์ฑ๊ฒฉ", [CASHFLOW_KIND_LABELS[k] for k in CASHFLOW_KIND_OPTIONS], index=1)
- sort_order = cols[3].number_input("์ ๋ ฌ", value=100, step=10)
- memo = cols[4].text_input("๋ฉ๋ชจ", value="dashboard: user category")
- submitted = st.form_submit_button("์ถ๊ฐ/์ฌํ์ฑํ", type="primary")
- if submitted:
- cashflow_kind = next(k for k, v in CASHFLOW_KIND_LABELS.items() if v == cashflow_kind_label)
- res = categorization_gateway.create_ledger_category(category, subcategory, int(sort_order), memo, cashflow_kind, db_path=db_path)
- clear_cache()
- if res.get("success"):
- st.success("์นดํ
๊ณ ๋ฆฌ๋ฅผ ์ ์ฅํ์ต๋๋ค.")
- st.rerun()
- else:
- st.error(res.get("error", res))
-
- result = categorization_gateway.list_ledger_categories(active_only=not show_inactive, include_usage=True, db_path=db_path)
- rows = result.get("categories", [])
- render_section_label("๐๏ธ", "์นดํ
๊ณ ๋ฆฌ ๋ชฉ๋ก")
- if rows:
- df = pd.DataFrame(rows)
- if "cashflow_kind" in df:
- df["cashflow_kind_label"] = df["cashflow_kind"].map(CASHFLOW_KIND_LABELS).fillna(df["cashflow_kind"])
- if "total_amount" in df:
- df["total_amount_display"] = df["total_amount"].map(format_krw)
- st.dataframe(df, width="stretch", height=360)
- else:
- st.info("๋ฑ๋ก๋ ์นดํ
๊ณ ๋ฆฌ๊ฐ ์์ต๋๋ค. ๊ธฐ๋ณธ ์นดํ
๊ณ ๋ฆฌ๋ฅผ seedํ๊ฑฐ๋ ์ ์นดํ
๊ณ ๋ฆฌ๋ฅผ ์ถ๊ฐํ์ธ์.")
- return
-
- render_section_label("๐ก๏ธ", "์นดํ
๊ณ ๋ฆฌ ์ญ์ /๋นํ์ฑํ")
- options = {
- f"{r['category']} / {r['subcategory'] or '-'} ยท ์ฌ์ฉ {r.get('transaction_count', 0)}๊ฑด ยท {'ํ์ฑ' if r.get('active') else '๋นํ์ฑ'}": r
- for r in rows
- }
- selected = st.selectbox("๋์", list(options.keys()))
- target = options[selected]
- hard_delete = st.checkbox("DB์์ ์์ ์ญ์ ", value=False, help="๋ณดํต์ ๋๊ณ ๋นํ์ฑํ๋ง ํ์ธ์. ๊ฑฐ๋ ๊ธฐ๋ก์ ๋ณ๋ ๋ณด์กด๋ฉ๋๋ค.")
- force = st.checkbox("์ฌ์ฉ ์ค์ด์ด๋ ๊ฐ์ ", value=False, help="๊ฑฐ๋/์์ฐ/๊ท์น์์ ์ฌ์ฉ ์ค์ธ ์นดํ
๊ณ ๋ฆฌ๋ ์ญ์ /๋นํ์ฑํํฉ๋๋ค. ๊ฑฐ๋ category ๊ฐ์ ๋ณ๊ฒฝํ์ง ์์ต๋๋ค.")
- if st.button("์ญ์ /๋นํ์ฑํ ์คํ", type="secondary"):
- res = categorization_gateway.delete_ledger_category(target["category"], target.get("subcategory", ""), hard_delete=hard_delete, force=force, db_path=db_path)
- clear_cache()
- if res.get("success"):
- st.warning(f"์ฒ๋ฆฌํ์ต๋๋ค. ์ฌ์ฉ ๊ฑฐ๋ {res.get('transaction_count', 0)}๊ฑด")
- st.rerun()
- else:
- st.error(res.get("error", res))
-
-
-def page_accounts(db_path: str) -> None:
- render_page_header(
- "๊ณ์ข",
- "์์งยทimport์ ์ฐ๊ฒฐ๋ ๊ณ์ข์ ์นด๋, ๋ง์ง๋ง ์์ก ์ ๋ณด๋ฅผ ํ์ธํฉ๋๋ค.",
- kicker="๐ฆ Accounts",
- pills=["์์ก", "๊ธฐ๊ด", "statement_status", "์๋ ์์ก๋์กฐ"],
- )
- accounts_res = accounts_gateway.list_ledger_accounts(active_only=False, db_path=db_path)
- accounts_data = accounts_res.get("accounts", []) if accounts_res.get("success") else []
- accounts = pd.DataFrame(accounts_data)
- if not accounts.empty:
- accounts["statement_status_label"] = accounts["statement_status"].map(STATEMENT_STATUS_LABELS).fillna(accounts["statement_status"])
- accounts["liquidity_bucket_label"] = accounts["liquidity_bucket"].map(LIQUIDITY_BUCKET_LABELS).fillna(accounts["liquidity_bucket"])
- accounts["last_balance_display"] = accounts["last_balance"].map(format_krw)
- display_cols = [c for c in ["id", "name", "type", "institution", "liquidity_bucket", "liquidity_bucket_label", "statement_status", "statement_status_label", "last_balance_display", "last_synced_at", "active", "memo"] if c in accounts.columns]
- st.dataframe(accounts[display_cols], width="stretch")
- else:
- st.info("๋ฑ๋ก๋ ๊ณ์ข๊ฐ ์์ต๋๋ค.")
-
- st.divider()
- render_section_label("๐ ๏ธ", "๊ณ์ข ์ด์ ์์ฑ ์์ ")
- if accounts_data:
- account_options = {f"{a['name']} ({a['id']})": a for a in accounts_data}
- with st.form("account_statement_status_form"):
- selected_label = st.selectbox("๊ณ์ข", list(account_options.keys()))
- selected = account_options[selected_label]
- current_status = selected.get("statement_status", "unknown")
- status = st.selectbox(
- "statement_status",
- STATEMENT_STATUS_OPTIONS,
- index=STATEMENT_STATUS_OPTIONS.index(current_status) if current_status in STATEMENT_STATUS_OPTIONS else 0,
- format_func=lambda v: f"{v} โ {STATEMENT_STATUS_LABELS.get(v, v)}",
- )
- current_bucket = selected.get("liquidity_bucket", "other") or "other"
- liquidity_bucket = st.selectbox(
- "liquidity_bucket",
- LIQUIDITY_BUCKET_OPTIONS,
- index=LIQUIDITY_BUCKET_OPTIONS.index(current_bucket) if current_bucket in LIQUIDITY_BUCKET_OPTIONS else LIQUIDITY_BUCKET_OPTIONS.index("other"),
- format_func=lambda v: f"{v} โ {LIQUIDITY_BUCKET_LABELS.get(v, v)}",
- )
- last_balance_raw = st.text_input("๊ธฐ์ค ์์ก(last_balance, ์ ํ)", value="" if selected.get("last_balance") is None else str(selected.get("last_balance")))
- last_synced_at = st.text_input("๊ธฐ์ค ์๊ฐ(last_synced_at, ์ ํ)", value=selected.get("last_synced_at") or "", help="์: 2026-06 ๋๋ 2026-06-30T23:59:00+09:00")
- memo = st.text_input("๋ฉ๋ชจ", value=selected.get("memo") or "")
- submitted = st.form_submit_button("๊ณ์ข ์ํ ์ ์ฅ", type="primary")
- if submitted:
- update_fields: dict[str, Any] = {"statement_status": status, "liquidity_bucket": liquidity_bucket, "last_synced_at": last_synced_at, "memo": memo}
- if last_balance_raw.strip():
- try:
- update_fields["last_balance"] = int(last_balance_raw.replace(",", ""))
- except ValueError:
- st.error("๊ธฐ์ค ์์ก์ ์ ์๋ก ์
๋ ฅํ์ธ์.")
- return
- res = accounts_gateway.update_ledger_account(selected["id"], db_path=db_path, **update_fields)
- clear_cache()
- if res.get("success"):
- st.success("๊ณ์ข ์ํ๋ฅผ ์ ์ฅํ์ต๋๋ค.")
- st.rerun()
- else:
- st.error(res.get("error", res))
-
- st.divider()
- render_section_label("โ๏ธ", "์ต์ ์์ก๋์กฐ ๊ฒฐ๊ณผ")
- st.caption("๋์: statement_status๊ฐ manual_only ๋๋ partial_import์ธ ๊ณ์ข. ๊ฒฐ๊ณผ๋ preview/report์ด๋ฉฐ ์๋ ํ์ ยท์๋ฆผยทrestore๋ฅผ ์ํํ์ง ์์ต๋๋ค.")
- c1, c2, c3 = st.columns([1, 1, 1])
- baseline_at = c1.text_input("์ค๋
์ท ๊ธฐ์ค ์๊ฐ", value="", placeholder="2026-06 ๋๋ 2026-06-30T23:59:00+09:00")
- tolerance = c2.number_input("ํ์ฉ ์ค์ฐจ(์)", min_value=0, value=1000, step=100)
- source_note = c3.text_input("์ถ์ฒ ๋ฉ๋ชจ", value="", placeholder="์: ์๋ง ์ฑ ์์ก")
- if st.button("์์ก๋์กฐ ์คํ", type="secondary"):
- st.session_state["balance_reconciliation_report"] = reconciliation_gateway.get_balance_reconciliation_report(
- baseline_at=baseline_at,
- source_note=source_note,
- tolerance=int(tolerance),
- db_path=db_path,
- )
- report = st.session_state.get("balance_reconciliation_report")
- if report:
- if report.get("success"):
- items = pd.DataFrame(report.get("items", []))
- if not items.empty:
- for col in ["baseline_balance", "calculated_balance", "difference"]:
- if col in items.columns:
- items[f"{col}_display"] = items[col].map(format_krw)
- show_cols = [c for c in ["account_name", "statement_status", "status", "baseline_at", "baseline_balance_display", "calculated_balance_display", "difference_display", "difference_ratio", "calculation_basis", "transaction_count"] if c in items.columns]
- st.dataframe(items[show_cols], width="stretch")
- review_count = int((items["status"] == "REVIEW_NEEDED").sum()) if "status" in items else 0
- st.metric("ํ์ธํ์", f"{review_count}๊ฑด")
- selectable = report.get("items", [])
- if selectable:
- labels = {f"{item.get('account_name')} ยท {item.get('status')} ยท {format_krw(item.get('difference'))}": item for item in selectable}
- chosen_label = st.selectbox("๋์กฐ ์ธ์
/ํ์ ๋์", list(labels.keys()))
- chosen = labels[chosen_label]
- with st.expander("Reconciliation session ๊ธฐ๋ก/๋ง๊ฐ", expanded=False):
- status = st.selectbox("Session status", ["open", "closed", "needs_recheck", "ignored"], key="recon_session_status")
- memo = st.text_input("Session memo", value="", key="recon_session_memo")
- if st.button("Session ์ ์ฅ/๊ฐฑ์ ", key="recon_session_save", type="primary"):
- res = reconciliation_gateway.upsert_reconciliation_session(
- chosen["account_id"],
- str(chosen.get("baseline_at", ""))[:10],
- expected_balance=chosen.get("baseline_balance"),
- actual_balance=chosen.get("calculated_balance"),
- difference=chosen.get("difference"),
- status=status,
- source_note=source_note,
- memo=memo,
- db_path=db_path,
- )
- clear_cache(); st.success("์ ์ฅํ์ต๋๋ค." if res.get("success") else res.get("error", res)); st.rerun()
- existing_session = chosen.get("reconciliation_session") or {}
- if existing_session.get("id") and st.button("๊ธฐ์กด Session ๋ง๊ฐ", key="recon_session_close"):
- res = reconciliation_gateway.close_reconciliation_session(session_id=existing_session["id"], memo=memo, db_path=db_path)
- clear_cache(); st.success("๋ง๊ฐํ์ต๋๋ค." if res.get("success") else res.get("error", res)); st.rerun()
- with st.expander(f"Review decision ยท ํ์ฌ {chosen.get('review_status', 'pending')}", expanded=False):
- render_review_decision_actions(reconciliation_gateway, "reconciliation_account", chosen["account_id"], f"recon_review_{chosen['account_id']}", db_path, target_payload=chosen)
- else:
- st.info("์์ก๋์กฐ ๋์ ๊ณ์ข๊ฐ ์์ต๋๋ค.")
- else:
- st.error(report.get("error", report))
-
-
-def page_imports(db_path: str) -> None:
- render_page_header(
- "Import",
- "PDF/XLSX ๊ฑฐ๋๋ด์ญ์ ๋จผ์ Preview๋ก ๊ฒ์ฆํ ๋ค ์์ฅ์ ๋ฐ์ํฉ๋๋ค.",
- kicker="๐ฅ Statement Import",
- pills=["Preview ์ฐ์ ", "์ค๋ณต skip", "Import ์ด๋ ฅ"],
- )
-
- render_section_label("๐", "ํ ์ค์ฆ๊ถ ๊ฑฐ๋๋ด์ญ PDF ๊ฐ์ ธ์ค๊ธฐ")
- st.caption("ํ ์ค์ฆ๊ถ ๊ฑฐ๋๋ด์ญ์ PDF๋ฅผ ์
๋ก๋ํ๋ฉด ํฌ์๊ณ์ข ๊ฑฐ๋๋ก ๊ฐ์ ธ์ต๋๋ค. ๋งค์/๋งค๋/ํ์ /์
์ถ๊ธ์ `์ด์ฒด/ํฌ์์ด๋`, ๋ฐฐ๋น/์ด์๋ ์ค์ ์์
์ผ๋ก ๋ถ๋ฅํฉ๋๋ค.")
- uploaded = st.file_uploader("ํ ์ค์ฆ๊ถ PDF", type=["pdf"], key="toss_pdf")
- if uploaded is not None:
- upload_dir = Path.home() / ".local" / "share" / "jarca" / "uploads"
- upload_dir.mkdir(parents=True, exist_ok=True)
- upload_path = upload_dir / uploaded.name
- upload_path.write_bytes(uploaded.getbuffer())
- c1, c2 = st.columns(2)
- if c1.button("ํ ์ค์ฆ๊ถ Preview"):
- res = imports_gateway.preview_toss_securities_pdf_import(str(upload_path), db_path=db_path)
- st.session_state["toss_preview"] = res
- if c2.button("ํ ์ค์ฆ๊ถ Import", type="primary"):
- res = imports_gateway.import_toss_securities_pdf(str(upload_path), db_path=db_path)
- clear_cache()
- st.session_state["toss_import_result"] = res
- st.rerun()
- if "toss_preview" in st.session_state:
- res = st.session_state["toss_preview"]
- if res.get("success"):
- st.success(f"Preview: {res.get('row_count', 0)}๊ฑด, ์ค๋ณต {res.get('duplicate_count', 0)}๊ฑด")
- st.json({"metadata": res.get("metadata"), "by_action": res.get("by_action"), "validation": res.get("validation")})
- st.dataframe(pd.DataFrame(res.get("preview", [])), width="stretch", height=300)
- else:
- st.error(res)
- if "toss_import_result" in st.session_state:
- res = st.session_state["toss_import_result"]
- if res.get("success"):
- st.success(f"Import ์๋ฃ: {res.get('inserted_count', 0)}๊ฑด ์ถ๊ฐ, ์ค๋ณต skip {res.get('skipped_duplicate_count', 0)}๊ฑด")
- st.json(res)
- else:
- st.error(res)
-
- st.divider()
- render_section_label("๐ฆ", "ํ ์ค๋ฑ
ํฌ ๊ฑฐ๋๋ด์ญ PDF ๊ฐ์ ธ์ค๊ธฐ")
- st.caption("ํ ์ค๋ฑ
ํฌ ๊ณ์ข ๊ฑฐ๋๋ด์ญ PDF๋ฅผ ์
๋ก๋ํ๋ฉด ์
์ถ๊ธ/์ด์ฒด/์นด๋๊ฒฐ์ ๋ฅผ ๊ฐ์ ธ์ต๋๋ค. ํ์ธ ์ด์ฒด/์ถ๊ธ์ `์ด์ฒด/ํ์ธํ์`๋ก ๋ถ๋ฅ๋ฉ๋๋ค.")
- toss_bank_uploaded = st.file_uploader("ํ ์ค๋ฑ
ํฌ PDF", type=["pdf"], key="tossbank_pdf")
- if toss_bank_uploaded is not None:
- upload_dir = Path.home() / ".local" / "share" / "jarca" / "uploads"
- upload_dir.mkdir(parents=True, exist_ok=True)
- upload_path = upload_dir / toss_bank_uploaded.name
- upload_path.write_bytes(toss_bank_uploaded.getbuffer())
- c1, c2 = st.columns(2)
- if c1.button("ํ ์ค๋ฑ
ํฌ Preview"):
- res = imports_gateway.preview_toss_bank_pdf_import(str(upload_path), db_path=db_path)
- st.session_state["toss_bank_preview"] = res
- if c2.button("ํ ์ค๋ฑ
ํฌ Import", type="primary"):
- res = imports_gateway.import_toss_bank_pdf(str(upload_path), db_path=db_path)
- clear_cache()
- st.session_state["toss_bank_import_result"] = res
- st.rerun()
- if "toss_bank_preview" in st.session_state:
- res = st.session_state["toss_bank_preview"]
- if res.get("success"):
- st.success(f"Preview: {res.get('row_count', 0)}๊ฑด, ์ค๋ณต {res.get('duplicate_count', 0)}๊ฑด")
- st.json({"metadata": res.get("metadata"), "by_action": res.get("by_action"), "validation": res.get("validation")})
- st.dataframe(pd.DataFrame(res.get("preview", [])), width="stretch", height=300)
- else:
- st.error(res)
- if "toss_bank_import_result" in st.session_state:
- res = st.session_state["toss_bank_import_result"]
- if res.get("success"):
- st.success(f"Import ์๋ฃ: {res.get('inserted_count', 0)}๊ฑด ์ถ๊ฐ, ์ค๋ณต skip {res.get('skipped_duplicate_count', 0)}๊ฑด")
- st.json(res)
- else:
- st.error(res)
-
- st.divider()
- render_section_label("๐งพ", "Import ์ด๋ ฅ")
- batches = overview_gateway.list_ledger_import_batches(limit=100, db_path=db_path).get("import_batches", [])
- st.dataframe(pd.DataFrame(batches), width="stretch", height=350)
- if batches:
- with st.form("rollback"):
- batch_id = st.selectbox("Rollback batch", [b["id"] for b in batches])
- hard = st.checkbox("Hard delete", value=False)
- submitted = st.form_submit_button("Rollback ์คํ", type="secondary")
- if submitted:
- res = imports_gateway.rollback_ledger_import_batch(batch_id, hard_delete=hard, db_path=db_path)
- clear_cache()
- st.warning(res)
- st.rerun()
-
+from dashboard.common import clear_cache, get_db_path, get_transfer_review_items, inject_app_css, render_sidebar_nav # noqa: F401
+from dashboard.page_views.accounts import page_accounts
+from dashboard.page_views.assets import page_assets
+from dashboard.page_views.categories import page_categories
+from dashboard.page_views.cross_transfer_review import page_cross_transfer_review
+from dashboard.page_views.imports import page_imports
+from dashboard.page_views.monthly_settlement import page_monthly_settlement
+from dashboard.page_views.overview import page_overview
+from dashboard.page_views.rules import page_rules
+from dashboard.page_views.transactions import page_transactions
+from dashboard.page_views.uncategorized import page_uncategorized
def main() -> None:
db_path = get_db_path()
inject_app_css()
-
pages = ["์์ ์ฐ", "์์ฝ", "์์ฐ/๋ง๊ฐ", "๊ฑฐ๋๋ด์ญ", "ํ์ธํ์ ํ", "๋ฏธ๋ถ๋ฅ", "๋ถ๋ฅ ๊ท์น", "์นดํ
๊ณ ๋ฆฌ", "๊ณ์ข", "Import"]
query_page = st.query_params.get("page", "์์ฝ")
page = query_page if query_page in pages else "์์ฝ"
@@ -2082,27 +40,19 @@ def main() -> None:
if st.button("์๋ก๊ณ ์นจ", key="refresh_button", use_container_width=True):
clear_cache()
st.rerun()
-
- if page == "์์ ์ฐ":
- page_monthly_settlement(db_path)
- elif page == "์์ฝ":
- page_overview(db_path)
- elif page == "์์ฐ/๋ง๊ฐ":
- page_assets(db_path)
- elif page == "๊ฑฐ๋๋ด์ญ":
- page_transactions(db_path)
- elif page == "ํ์ธํ์ ํ":
- page_cross_transfer_review(db_path)
- elif page == "๋ฏธ๋ถ๋ฅ":
- page_uncategorized(db_path)
- elif page == "๋ถ๋ฅ ๊ท์น":
- page_rules(db_path)
- elif page == "์นดํ
๊ณ ๋ฆฌ":
- page_categories(db_path)
- elif page == "๊ณ์ข":
- page_accounts(db_path)
- elif page == "Import":
- page_imports(db_path)
+ dispatch = {
+ "์์ ์ฐ": page_monthly_settlement,
+ "์์ฝ": page_overview,
+ "์์ฐ/๋ง๊ฐ": page_assets,
+ "๊ฑฐ๋๋ด์ญ": page_transactions,
+ "ํ์ธํ์ ํ": page_cross_transfer_review,
+ "๋ฏธ๋ถ๋ฅ": page_uncategorized,
+ "๋ถ๋ฅ ๊ท์น": page_rules,
+ "์นดํ
๊ณ ๋ฆฌ": page_categories,
+ "๊ณ์ข": page_accounts,
+ "Import": page_imports,
+ }
+ dispatch[page](db_path)
if __name__ == "__main__":
diff --git a/dashboard/common.py b/dashboard/common.py
new file mode 100644
index 0000000..b238d23
--- /dev/null
+++ b/dashboard/common.py
@@ -0,0 +1,608 @@
+from __future__ import annotations
+
+import sqlite3
+from datetime import datetime
+from html import escape
+from typing import Any
+from urllib.parse import quote
+import importlib
+import inspect
+from zoneinfo import ZoneInfo
+
+import pandas as pd
+import plotly.express as px
+import plotly.io as pio
+import streamlit as st
+
+from jarca_ledger_mcp.adapters.streamlit import accounts_gateway as accounts_gateway
+from jarca_ledger_mcp.adapters.streamlit import assets_gateway as assets_gateway
+from jarca_ledger_mcp.adapters.streamlit import categorization_gateway as categorization_gateway
+from jarca_ledger_mcp.adapters.streamlit import imports_gateway as imports_gateway
+from jarca_ledger_mcp.adapters.streamlit import overview_gateway as overview_gateway
+from jarca_ledger_mcp.adapters.streamlit import reconciliation_gateway as reconciliation_gateway
+from jarca_ledger_mcp.adapters.streamlit import transactions_gateway as transactions_gateway
+from jarca_ledger_mcp.adapters.streamlit import transfers_gateway as transfers_gateway
+from jarca_ledger_mcp.db import resolve_db_path
+
+JARCA_COLORS = {
+ "navy": "#0f172a",
+ "slate": "#475569",
+ "muted": "#64748b",
+ "border": "#e2e8f0",
+ "surface": "#ffffff",
+ "surface_soft": "#f8fafc",
+ "blue": "#2563eb",
+ "cyan": "#06b6d4",
+ "emerald": "#10b981",
+ "amber": "#f59e0b",
+ "rose": "#f43f5e",
+}
+
+JARCA_PLOTLY_TEMPLATE = "jarca_clean"
+pio.templates[JARCA_PLOTLY_TEMPLATE] = pio.templates["plotly_white"].update(
+ {
+ "layout": {
+ "font": {"family": "Inter, Pretendard, Noto Sans KR, Arial, sans-serif", "color": JARCA_COLORS["navy"]},
+ "paper_bgcolor": "rgba(0,0,0,0)",
+ "plot_bgcolor": "rgba(255,255,255,0)",
+ "colorway": [JARCA_COLORS["blue"], JARCA_COLORS["rose"], JARCA_COLORS["emerald"], JARCA_COLORS["amber"], JARCA_COLORS["cyan"]],
+ "title": {"font": {"size": 16, "color": JARCA_COLORS["navy"]}, "x": 0.02, "xanchor": "left"},
+ "legend": {"orientation": "h", "yanchor": "bottom", "y": 1.02, "xanchor": "right", "x": 1},
+ "margin": {"l": 12, "r": 12, "t": 56, "b": 28},
+ "xaxis": {"gridcolor": "rgba(148, 163, 184, 0.20)", "zerolinecolor": "rgba(148, 163, 184, 0.35)"},
+ "yaxis": {"gridcolor": "rgba(148, 163, 184, 0.20)", "zerolinecolor": "rgba(148, 163, 184, 0.35)"},
+ }
+ }
+)
+px.defaults.template = JARCA_PLOTLY_TEMPLATE
+
+DEFAULT_CATEGORIES = [
+ "", "์์
", "์ด์ฒด", "์๋น", "์ฃผ๊ฑฐ", "ํต์ ", "๊ตํต", "๊ต์ก", "์๋ฃ", "์ํ", "๋ฌธํ/์ฌ๊ฐ", "๊ธ์ต", "๊ฐ์กฑ/์ฉ๋", "๋ฏธ๋ถ๋ฅ"
+]
+DEFAULT_SUBCATEGORIES: dict[str, list[str]] = {
+ "์์
": ["", "๊ธ์ฌ", "์ด์", "ํ๊ธ", "๋ฐฐ๋น", "๊ธฐํ์์
"],
+ "์ด์ฒด": ["", "๋ณธ์ธ๊ณ์ข์ด๋", "ํ๊ธ์
์ถ๊ธ", "๊ฐ์กฑ์ก๊ธ", "ํฌ์์ด๋", "ํ์ธํ์"],
+ "์๋น": ["", "๋งํธ", "์ธ์", "์นดํ", "๋ฐฐ๋ฌ"],
+ "์ฃผ๊ฑฐ": ["", "๊ด๋ฆฌ๋น", "์ ๊ธฐ", "๊ฐ์ค", "์๋", "์ธํฐ๋ท"],
+ "ํต์ ": ["", "ํด๋ํฐ", "์ธํฐ๋ท", "ํต์ ์๊ธ"],
+ "๊ตํต": ["", "๋์ค๊ตํต", "ํ์", "์ฃผ์ ", "์ฃผ์ฐจ", "์ฐจ๋์ ๋น"],
+ "๊ต์ก": ["", "ํ์", "๊ต์ฌ", "ํ๊ต", "์์ฒด๋ฅ"],
+ "์๋ฃ": ["", "๋ณ์", "์ฝ๊ตญ", "์น๊ณผ", "๋ณดํ"],
+ "์ํ": ["", "์ํํ", "์๋ฅ", "๋ฏธ์ฉ", "์ธํ"],
+ "๋ฌธํ/์ฌ๊ฐ": ["", "์ฌํ", "์๋ฐ", "๊ณต์ฐ", "์ทจ๋ฏธ", "์คํฌ์ธ "],
+ "๊ธ์ต": ["", "์นด๋๋๊ธ", "๋์ถ์ํ", "์์๋ฃ", "๋ณดํ๋ฃ"],
+ "๊ฐ์กฑ/์ฉ๋": ["", "๋ถ๋ชจ๋", "์๋
", "๊ฐ์กฑ์ก๊ธ"],
+ "๋ฏธ๋ถ๋ฅ": ["", "ํ์ธํ์"],
+}
+CASHFLOW_KIND_OPTIONS = ["income", "expense", "transfer", "investment", "debt_payment", "exclude", "unknown"]
+CASHFLOW_KIND_LABELS = {
+ "income": "์ค์ ์์
",
+ "expense": "์ค์ ์ง์ถ",
+ "transfer": "์ด์ฒด/์ ์ธ",
+ "investment": "ํฌ์์ด๋/์ ์ธ",
+ "debt_payment": "๋ถ์ฑ์ํ/๋ณ๋",
+ "exclude": "๋ถ์ ์ ์ธ",
+ "unknown": "ํ์ธํ์",
+}
+STATEMENT_STATUS_OPTIONS = ["unknown", "import_supported", "manual_only", "partial_import"]
+STATEMENT_STATUS_LABELS = {
+ "unknown": "๋ฏธ์ ",
+ "import_supported": "๋ช
์ธ์ import ์ง์",
+ "manual_only": "์๋ ์ฆ๋น ์ ์ฉ",
+ "partial_import": "๋ถ๋ถ import + ์๋ ๋ณด์",
+}
+LIQUIDITY_BUCKET_OPTIONS = ["cash", "investment", "deposit", "real_estate", "loan", "other"]
+LIQUIDITY_BUCKET_LABELS = {
+ "cash": "ํ๊ธ์ฑ",
+ "investment": "ํฌ์์์ฐ",
+ "deposit": "์์ ๊ธ",
+ "real_estate": "๋ถ๋์ฐ",
+ "loan": "๋ถ์ฑ",
+ "other": "๊ธฐํ",
+}
+
+
+def format_krw(value: Any) -> str:
+ try:
+ if value is None:
+ return "-"
+ return f"{int(value):,}์"
+ except Exception:
+ return str(value)
+
+
+def current_kst_month() -> str:
+ return datetime.now(ZoneInfo("Asia/Seoul")).strftime("%Y-%m")
+
+
+def format_percent(value: Any) -> str:
+ if value is None:
+ return "-"
+ try:
+ return f"{float(value):.1%}"
+ except Exception:
+ return str(value)
+
+
+def get_transfer_review_items(payload: dict[str, Any] | None) -> list[dict[str, Any]]:
+ if not payload or not payload.get("success"):
+ return []
+ items = payload.get("review_items")
+ if isinstance(items, list):
+ return items
+ items = payload.get("transactions")
+ if isinstance(items, list):
+ return items
+ return []
+
+
+@st.cache_data(ttl=5)
+def read_sql(db_path: str, sql: str, params: tuple[Any, ...] = ()) -> pd.DataFrame:
+ with sqlite3.connect(db_path) as conn:
+ return pd.read_sql_query(sql, conn, params=params)
+
+
+def clear_cache() -> None:
+ read_sql.clear()
+
+
+def get_db_path() -> str:
+ return str(resolve_db_path())
+
+
+def get_service_callable(module, name: str, required_params: list[str] | None = None):
+ """Return a service function, reloading once for Streamlit hot-reload edge cases.
+
+ Streamlit can keep an already-imported service module in memory while the
+ dashboard script itself reloads. When a UI change starts passing a new
+ keyword argument, a stale service function may still exist but have the old
+ signature. In that case reload the service module before returning it.
+ """
+ required_params = required_params or []
+ fn = getattr(module, name, None)
+ if callable(fn):
+ try:
+ params = inspect.signature(fn).parameters
+ if all(param in params for param in required_params):
+ return fn
+ except (TypeError, ValueError):
+ if not required_params:
+ return fn
+ importlib.reload(module)
+ return getattr(module, name, None)
+
+
+def inject_app_css() -> None:
+ st.markdown(
+ """
+
+ """,
+ unsafe_allow_html=True,
+ )
+
+
+def render_section_label(icon: str, label: str) -> None:
+ st.markdown(f'{escape(icon)}{escape(label)}
', unsafe_allow_html=True)
+
+
+def render_sidebar_nav(pages: list[str], current_page: str) -> None:
+ items = []
+ for name in pages:
+ active = " active" if name == current_page else ""
+ href = f"?page={quote(name)}"
+ items.append(f'{escape(name)}')
+ st.markdown(f'{"".join(items)}
', unsafe_allow_html=True)
+
+
+def parse_optional_int(value: str, field_name: str) -> int | None:
+ value = (value or "").strip()
+ if not value:
+ return None
+ try:
+ return int(value)
+ except ValueError as exc:
+ raise ValueError(f"{field_name}๋ ์ ์์ฌ์ผ ํฉ๋๋ค.") from exc
+
+
+def get_category_options(db_path: str) -> tuple[list[str], dict[str, list[str]]]:
+ result = categorization_gateway.list_ledger_categories(active_only=True, include_usage=False, db_path=db_path)
+ if result.get("success") and result.get("count", 0) == 0:
+ categorization_gateway.seed_default_ledger_categories(db_path=db_path)
+ result = categorization_gateway.list_ledger_categories(active_only=True, include_usage=False, db_path=db_path)
+ grouped = result.get("grouped", {}) if result.get("success") else {}
+ categories = [""] + sorted(grouped.keys())
+ subcategories = {cat: [""] + sorted([s for s in subs if s]) for cat, subs in grouped.items()}
+ # Keep hardcoded defaults as fallback for fresh/broken DBs and for existing transaction values.
+ for cat in DEFAULT_CATEGORIES:
+ if cat and cat not in categories:
+ categories.append(cat)
+ for cat, subs in DEFAULT_SUBCATEGORIES.items():
+ subcategories.setdefault(cat, [""])
+ for sub in subs:
+ if sub not in subcategories[cat]:
+ subcategories[cat].append(sub)
+ return categories, subcategories
+
+
+def transaction_df(db_path: str, where: str = "", params: tuple[Any, ...] = (), limit: int = 1000) -> pd.DataFrame:
+ sql = f"""
+ SELECT t.id, t.transaction_datetime, t.transaction_date, t.type, t.amount, t.category, t.subcategory,
+ COALESCE(lc.cashflow_kind, 'unknown') AS cashflow_kind,
+ t.merchant, t.raw_summary, t.counterparty, t.person, t.is_fixed, t.balance_after, t.branch,
+ t.source, t.import_batch_id, t.memo, t.status
+ FROM transactions t
+ LEFT JOIN ledger_categories lc
+ ON lc.active=1 AND t.category=lc.category AND t.subcategory=lc.subcategory
+ {where}
+ ORDER BY COALESCE(NULLIF(t.transaction_datetime,''), t.transaction_date) DESC, t.created_at DESC
+ LIMIT ?
+ """
+ df = read_sql(db_path, sql, params + (limit,))
+ if not df.empty:
+ df["๊ธ์ก"] = df["amount"].map(format_krw)
+ df["๊ฑฐ๋ํ์์ก"] = df["balance_after"].map(format_krw)
+ df["๊ณ ์ ๋น"] = df["is_fixed"].fillna(0).astype(bool)
+ return df
+
+
+def update_transaction_form(tx: dict[str, Any], key_prefix: str, db_path: str | None = None) -> None:
+ db_path = db_path or get_db_path()
+ category_options, subcategory_options = get_category_options(db_path)
+
+ category_key = f"{key_prefix}_category"
+ subcategory_key = f"{key_prefix}_subcategory"
+ person_key = f"{key_prefix}_person"
+ fixed_key = f"{key_prefix}_fixed"
+ memo_key = f"{key_prefix}_memo"
+ save_rule_key = f"{key_prefix}_save_rule"
+ tx_marker_key = f"{key_prefix}_tx_id"
+
+ current_category = tx.get("category", "") or ""
+ if current_category and current_category not in category_options:
+ category_options = category_options + [current_category]
+
+ if st.session_state.get(tx_marker_key) != tx["id"]:
+ st.session_state[tx_marker_key] = tx["id"]
+ st.session_state[category_key] = current_category
+ st.session_state[subcategory_key] = tx.get("subcategory", "") or ""
+ st.session_state[person_key] = tx.get("person", "") or ""
+ st.session_state[fixed_key] = bool(tx.get("is_fixed") or False)
+ st.session_state[memo_key] = tx.get("memo", "") or ""
+ st.session_state[save_rule_key] = False
+
+ st.caption(f"๊ฑฐ๋ ID: `{tx['id']}`")
+ st.write(f"**{tx.get('transaction_datetime') or tx.get('transaction_date')}** ยท {tx.get('merchant')} ยท {format_krw(tx.get('amount'))}")
+
+ category = st.selectbox(
+ "๋๋ถ๋ฅ",
+ category_options,
+ index=category_options.index(st.session_state.get(category_key, current_category)) if st.session_state.get(category_key, current_category) in category_options else 0,
+ key=category_key,
+ )
+
+ sub_opts = subcategory_options.get(category, [""])
+ current_sub = st.session_state.get(subcategory_key, tx.get("subcategory", "") or "") or ""
+ if current_sub and current_sub not in sub_opts:
+ current_sub = ""
+ st.session_state[subcategory_key] = ""
+ subcategory = st.selectbox(
+ "์๋ถ๋ฅ",
+ sub_opts,
+ index=sub_opts.index(current_sub) if current_sub in sub_opts else 0,
+ key=subcategory_key,
+ )
+ person = st.text_input("๊ฐ์กฑ ๊ตฌ์ฑ์/person", key=person_key)
+ is_fixed = st.checkbox("๊ณ ์ ๋น", key=fixed_key)
+ memo = st.text_input("๋ฉ๋ชจ", key=memo_key)
+ save_rule = st.checkbox("์ด ์ฌ์ฉ์ฒ ํจํด์ ๊ท์น์ผ๋ก ์ ์ฅ", key=save_rule_key)
+ submitted = st.button("์ ์ฅ", type="primary", key=f"{key_prefix}_submit")
+
+ if submitted:
+ result = transactions_gateway.update_ledger_transaction(
+ tx["id"], category=category, subcategory=subcategory, person=person,
+ is_fixed=is_fixed, memo=memo, db_path=db_path,
+ )
+ if not result.get("success"):
+ st.error(result.get("error", result))
+ return
+ if save_rule and tx.get("merchant") and category:
+ rule = categorization_gateway.create_category_rule(
+ match_field="merchant",
+ pattern=tx["merchant"],
+ category=category,
+ subcategory=subcategory,
+ transaction_type=tx.get("type", ""),
+ person=person,
+ is_fixed=is_fixed,
+ priority=70,
+ memo="dashboard: user-confirmed rule",
+ db_path=db_path,
+ )
+ if not rule.get("success"):
+ st.warning(f"๊ฑฐ๋๋ ์ ์ฅ๋์ง๋ง ๊ท์น ์ ์ฅ ์คํจ: {rule.get('error')}")
+ clear_cache()
+ st.success("์ ์ฅํ์ต๋๋ค.")
+ st.rerun()
+
+
+@st.dialog("๊ฑฐ๋ ๋ถ๋ฅ", width="large")
+def transaction_edit_dialog(tx: dict[str, Any], key_prefix: str, db_path: str) -> None:
+ update_transaction_form(tx, key_prefix, db_path)
+
+
+@st.dialog("์ฐ๊ฒฐ ๊ฑฐ๋ ํ๋ณด", width="large")
+def linked_transfer_candidates_dialog(tx: dict[str, Any], db_path: str) -> None:
+ st.caption(f"๊ธฐ์ค ๊ฑฐ๋: `{tx['id']}`")
+ st.write(f"**{tx.get('transaction_datetime') or tx.get('transaction_date')}** ยท {tx.get('merchant')} ยท {format_krw(tx.get('amount'))}")
+ result = transfers_gateway.find_self_transfer_candidates(tx["id"], limit=20, db_path=db_path)
+ if not result.get("success"):
+ st.error(result.get("error", result))
+ return
+ candidates = result.get("candidates", [])
+ if not candidates:
+ st.info("๊ฐ์ ๊ธ์ก/๊ฐ๊น์ด ๋ ์ง ์กฐ๊ฑด์์ ์ฐ๊ฒฐ ํ๋ณด๋ฅผ ์ฐพ์ง ๋ชปํ์ต๋๋ค.")
+ return
+
+ st.success(f"์ฐ๊ฒฐ ํ๋ณด {result.get('count', len(candidates))}๊ฑด")
+ for idx, candidate in enumerate(candidates):
+ cols = st.columns([1.45, 0.95, 0.7, 1.2, 1.15, 0.95])
+ cols[0].write(candidate.get("transaction_datetime") or candidate.get("transaction_date") or "")
+ cols[1].write(candidate.get("source") or "")
+ cols[2].write(candidate.get("type") or "")
+ cols[3].write(format_krw(candidate.get("amount")))
+ cols[4].write(candidate.get("merchant") or candidate.get("counterparty") or "")
+ if cols[5].button("์ด ๊ฑฐ๋์ ์ฐ๊ฒฐ", key=f"link_pair_{tx['id']}_{candidate['id']}_{idx}", use_container_width=True, type="primary"):
+ link_res = transfers_gateway.link_self_transfer_pair(tx["id"], candidate["id"], db_path=db_path)
+ if not link_res.get("success"):
+ st.error(link_res.get("error", link_res))
+ else:
+ clear_cache()
+ st.success("์์ชฝ ๊ฑฐ๋๋ฅผ ๋ณธ์ธ๊ณ์ข์ด๋์ผ๋ก ์ฐ๊ฒฐํ์ต๋๋ค.")
+ st.rerun()
+ meta = []
+ if candidate.get("account_name"):
+ meta.append(f"๊ณ์ข: {candidate['account_name']}")
+ if candidate.get("category") or candidate.get("subcategory"):
+ meta.append(f"ํ์ฌ ๋ถ๋ฅ: {candidate.get('category', '')} / {candidate.get('subcategory', '')}")
+ if candidate.get("score") is not None:
+ meta.append(f"score={candidate['score']}")
+ if candidate.get("reason"):
+ meta.append(candidate["reason"])
+ st.caption(" ยท ".join(meta))
+ if idx < len(candidates) - 1:
+ st.divider()
+
+
+@st.dialog("์๊ฑฐ๋ ํ๋ณด", width="large")
+def cancel_original_candidates_dialog(tx: dict[str, Any], db_path: str) -> None:
+ st.caption(f"์ทจ์/ํ๊ธ ๊ฑฐ๋: `{tx['id']}`")
+ st.write(f"**{tx.get('transaction_datetime') or tx.get('transaction_date')}** ยท {tx.get('merchant')} ยท {format_krw(tx.get('amount'))}")
+ result = transfers_gateway.find_cancel_original_candidates(tx["id"], limit=20, db_path=db_path)
+ if not result.get("success"):
+ st.error(result.get("error", result))
+ return
+ candidates = result.get("candidates", [])
+ if not candidates:
+ st.info("๊ฐ์ ๊ธ์ก ๊ธฐ์ค์ผ๋ก ์ฐ๊ฒฐํ ์๊ฑฐ๋ ํ๋ณด๋ฅผ ์ฐพ์ง ๋ชปํ์ต๋๋ค.")
+ return
+
+ st.success(f"์๊ฑฐ๋ ํ๋ณด {result.get('count', len(candidates))}๊ฑด")
+ for idx, candidate in enumerate(candidates):
+ cols = st.columns([1.4, 0.95, 0.7, 1.25, 1.15, 0.95])
+ cols[0].write(candidate.get("transaction_datetime") or candidate.get("transaction_date") or "")
+ cols[1].write(candidate.get("source") or "")
+ cols[2].write(candidate.get("type") or "")
+ cols[3].write(format_krw(candidate.get("amount")))
+ cols[4].write(candidate.get("merchant") or candidate.get("counterparty") or "")
+ if cols[5].button("์ด ์๊ฑฐ๋์ ์ฐ๊ฒฐ", key=f"cancel_pair_{tx['id']}_{candidate['id']}_{idx}", use_container_width=True, type="primary"):
+ link_res = transfers_gateway.link_cancel_to_original(tx["id"], candidate["id"], db_path=db_path)
+ if not link_res.get("success"):
+ st.error(link_res.get("error", link_res))
+ else:
+ clear_cache()
+ st.success("์ทจ์/ํ๊ธ ๊ฑฐ๋๋ฅผ ์๊ฑฐ๋์ ์ฐ๊ฒฐํ์ต๋๋ค.")
+ st.rerun()
+ meta = []
+ if candidate.get("account_name"):
+ meta.append(f"๊ณ์ข: {candidate['account_name']}")
+ if candidate.get("category") or candidate.get("subcategory"):
+ meta.append(f"ํ์ฌ ๋ถ๋ฅ: {candidate.get('category', '')} / {candidate.get('subcategory', '')}")
+ if candidate.get("score") is not None:
+ meta.append(f"score={candidate['score']}")
+ if candidate.get("reason"):
+ meta.append(candidate["reason"])
+ st.caption(" ยท ".join(meta))
+ if idx < len(candidates) - 1:
+ st.divider()
+
+
+def render_review_decision_actions(gateway: Any, target_type: str, target_id: str, key_prefix: str, db_path: str, target_payload: dict[str, Any] | None = None) -> None:
+ decision_type = st.selectbox(
+ "ํ์ ",
+ ["unknown", "account_transfer", "real_expense", "real_income", "investment_transfer", "excluded_from_analysis", "duplicate_or_cancel"],
+ key=f"{key_prefix}_decision_type",
+ )
+ reason_code = st.text_input("์ฌ์ ์ฝ๋", value="operator_review", key=f"{key_prefix}_reason")
+ memo = st.text_input("๊ฒฐ์ ๋ฉ๋ชจ", value="", key=f"{key_prefix}_memo")
+ cols = st.columns(4)
+ if cols[0].button("๊ฒฐ์ ์ ์ฅ", key=f"{key_prefix}_decided", use_container_width=True, type="primary"):
+ res = gateway.upsert_review_decision(target_type, target_id, status="decided", decision_type=decision_type, reason_code=reason_code, memo=memo, target_payload=target_payload or {}, db_path=db_path)
+ clear_cache(); st.success("์ ์ฅํ์ต๋๋ค." if res.get("success") else res.get("error", res)); st.rerun()
+ if cols[1].button("๋ณด๋ฅ", key=f"{key_prefix}_held", use_container_width=True):
+ res = gateway.hold_review_decision(target_type, target_id, reason_code=reason_code or "held", memo=memo, db_path=db_path)
+ clear_cache(); st.success("๋ณด๋ฅํ์ต๋๋ค." if res.get("success") else res.get("error", res)); st.rerun()
+ if cols[2].button("์ฌ๊ฒํ ", key=f"{key_prefix}_recheck", use_container_width=True):
+ res = gateway.mark_review_recheck(target_type, target_id, reason_code=reason_code or "needs_recheck", memo=memo, db_path=db_path)
+ clear_cache(); st.success("์ฌ๊ฒํ ๋ก ํ์ํ์ต๋๋ค." if res.get("success") else res.get("error", res)); st.rerun()
+ if cols[3].button("ํด์ ", key=f"{key_prefix}_release", use_container_width=True):
+ res = gateway.release_review_decision(target_type, target_id, memo=memo, db_path=db_path)
+ clear_cache(); st.success("pending์ผ๋ก ํด์ ํ์ต๋๋ค." if res.get("success") else res.get("error", res)); st.rerun()
diff --git a/dashboard/components.py b/dashboard/components.py
new file mode 100644
index 0000000..bd3d7cb
--- /dev/null
+++ b/dashboard/components.py
@@ -0,0 +1,81 @@
+from __future__ import annotations
+
+from typing import Any, Callable
+
+import streamlit as st
+
+
+class JarcaList:
+ def __init__(
+ self,
+ *,
+ render_header: Callable[[list[dict[str, Any]]], None] | None,
+ render_item: Callable[[dict[str, Any]], None],
+ items: list[dict[str, Any]],
+ empty_message: str,
+ ) -> None:
+ self.render_header = render_header
+ self.render_item = render_item
+ self.items = items
+ self.empty_message = empty_message
+
+ def render(self) -> None:
+ st.markdown(
+ """
+
+ """,
+ unsafe_allow_html=True,
+ )
+
+ with st.container(key="jarca-list", border=False):
+ with st.container(key="jarca-list-header", border=False):
+ if self.render_header:
+ self.render_header(self.items)
+ with st.container(key="jarca-list-body", border=False):
+ for index, item in enumerate(self.items):
+ with st.container(key=f"jarca-list-item-{index}", border=False):
+ self.render_item(item)
+ if not self.items:
+ st.info(self.empty_message)
diff --git a/dashboard/page_views/__init__.py b/dashboard/page_views/__init__.py
new file mode 100644
index 0000000..e69de29
diff --git a/dashboard/page_views/accounts.py b/dashboard/page_views/accounts.py
new file mode 100644
index 0000000..62f0a3e
--- /dev/null
+++ b/dashboard/page_views/accounts.py
@@ -0,0 +1,243 @@
+from __future__ import annotations
+
+# ruff: noqa: F405
+
+from html import escape
+from typing import Any
+
+import streamlit as st
+
+from jarca_ledger_mcp.adapters.streamlit import accounts_gateway
+from dashboard.components import JarcaList
+from dashboard.common import * # noqa: F403, F405
+
+
+ACCOUNT_TYPE_LABELS = {
+ "bank": "์ํ",
+ "card": "์นด๋",
+ "cash": "ํ๊ธ",
+ "investment": "ํฌ์",
+ "loan": "๋์ถ",
+ "other": "๊ธฐํ",
+}
+
+ACCOUNT_TYPE_STYLES = {
+ "bank": "#0969da",
+ "card": "#bf3989",
+ "cash": "#1a7f37",
+ "investment": "#8250df",
+ "loan": "#bc4c00",
+ "other": "#57606a",
+}
+
+def render_accounts_list_header(items: list[dict[str, Any]]) -> None:
+ st.markdown(
+ f'{len(items)}๊ฐ ๊ณ์ข',
+ unsafe_allow_html=True,
+ )
+
+
+def render_account_item(account: dict[str, Any]) -> None:
+ active = bool(account.get("active", True))
+ status_label = ":blue-badge[ํ์ฑ]" if active else ":gray-badge[๋นํ์ฑ]"
+ account_type = ACCOUNT_TYPE_LABELS.get(account.get("type"), account.get("type") or "๊ธฐํ")
+ statement_status = STATEMENT_STATUS_LABELS.get(account.get("statement_status"), account.get("statement_status") or "-")
+ balance = format_krw(account.get("last_balance"))
+ synced_at = account.get("last_synced_at") or "๋ฏธ์
๋ ฅ"
+ memo = account.get("memo") or "๋ฉ๋ชจ ์์"
+ with st.container(gap=None):
+ columns = st.columns([9, 1], vertical_alignment="center", gap=None)
+ columns[0].markdown(
+ f"""
+ **{escape(str(account.get("name") or "์ด๋ฆ ์์"))}** {status_label}
+ """,
+ unsafe_allow_html=True,
+ )
+ if columns[1].button(" ", key=f"account_edit_{account['id']}", help="์์ ", icon=":material/edit:", type="tertiary", width="content"):
+ st.session_state["account_edit_id"] = account["id"]
+ columns = st.columns([2, 80, 18], vertical_alignment="center")
+ columns[1].markdown(
+ f"""
+ {escape(str(memo))}
+ #{escape(str(account["id"])[-6:])} ยท {escape(str(synced_at))} ๊ธฐ์ค ยท {escape(str(account.get("institution") or "๊ธฐ๊ด ๋ฏธ์
๋ ฅ"))} ยท {escape(str(account_type))} ยท {escape(str(statement_status))}
+ """,
+ unsafe_allow_html=True,
+ )
+ columns[2].text(escape(balance), width="stretch", text_alignment="right")
+
+def render_account_list(accounts: list[dict[str, Any]]) -> None:
+ st.markdown(
+ """
+
+ """,
+ unsafe_allow_html=True,
+ )
+ search = st.text_input("๊ณ์ข ๊ฒ์", placeholder="๊ณ์ข๋ฅผ ๊ฒ์ํ์ธ์", label_visibility="collapsed")
+ query = search.strip().lower()
+ visible_accounts = [
+ account
+ for account in accounts
+ if not query or query in " ".join(str(account.get(field) or "") for field in ("name", "institution", "type")).lower()
+ ]
+
+ JarcaList(
+ render_header=render_accounts_list_header,
+ render_item=render_account_item,
+ items=visible_accounts,
+ empty_message="๋ฑ๋ก๋ ๊ณ์ข๊ฐ ์์ต๋๋ค.",
+ ).render()
+
+
+@st.dialog("๊ณ์ข ์์ ", width="large")
+def account_edit_dialog(account: dict[str, Any], db_path: str) -> None:
+ st.caption(f"{account.get('institution') or '๊ธฐ๊ด ๋ฏธ์
๋ ฅ'} ยท ID: `{account['id']}`")
+ with st.form(f"account_edit_form_{account['id']}"):
+ identity_columns = st.columns(3)
+ name = identity_columns[0].text_input("๊ณ์ข๋ช
", value=account.get("name") or "")
+ institution = identity_columns[1].text_input("๊ธ์ต๊ธฐ๊ด", value=account.get("institution") or "")
+ current_type = account.get("type", "other") or "other"
+ account_type = identity_columns[2].selectbox(
+ "์ ํ",
+ list(ACCOUNT_TYPE_LABELS),
+ index=list(ACCOUNT_TYPE_LABELS).index(current_type) if current_type in ACCOUNT_TYPE_LABELS else 0,
+ format_func=lambda value: ACCOUNT_TYPE_LABELS[value],
+ )
+
+ balance_columns = st.columns(2)
+ last_balance_raw = balance_columns[0].text_input(
+ "๊ธฐ์ค ์์ก",
+ value="" if account.get("last_balance") is None else str(account.get("last_balance")),
+ help="์ ๋จ์ ์ ์",
+ )
+ last_synced_at = balance_columns[1].text_input(
+ "๊ธฐ์ค ์๊ฐ",
+ value=account.get("last_synced_at") or "",
+ help="์: 2026-08-20 ๋๋ 2026-08-20T23:59:00+09:00",
+ )
+
+ operating_columns = st.columns(3)
+ current_status = account.get("statement_status", "unknown") or "unknown"
+ statement_status = operating_columns[0].selectbox(
+ "๋ช
์ธ์ ์ํ",
+ STATEMENT_STATUS_OPTIONS,
+ index=STATEMENT_STATUS_OPTIONS.index(current_status) if current_status in STATEMENT_STATUS_OPTIONS else 0,
+ format_func=lambda value: STATEMENT_STATUS_LABELS[value],
+ )
+ current_bucket = account.get("liquidity_bucket", "other") or "other"
+ liquidity_bucket = operating_columns[1].selectbox(
+ "์ ๋์ฑ ๋ฒํท",
+ LIQUIDITY_BUCKET_OPTIONS,
+ index=LIQUIDITY_BUCKET_OPTIONS.index(current_bucket) if current_bucket in LIQUIDITY_BUCKET_OPTIONS else LIQUIDITY_BUCKET_OPTIONS.index("other"),
+ format_func=lambda value: LIQUIDITY_BUCKET_LABELS[value],
+ )
+ active = operating_columns[2].checkbox("ํ์ฑ", value=bool(account.get("active", True)))
+ memo = st.text_input("๋ฉ๋ชจ", value=account.get("memo") or "")
+ submitted = st.form_submit_button("๋ณ๊ฒฝ ์ ์ฅ", type="primary")
+
+ if submitted:
+ try:
+ last_balance = int(last_balance_raw.replace(",", "")) if last_balance_raw.strip() else None
+ except ValueError:
+ st.error("๊ธฐ์ค ์์ก์ ์ ๋จ์ ์ ์๋ก ์
๋ ฅํ์ธ์.")
+ return
+ result = accounts_gateway.update_ledger_account(
+ account["id"],
+ name=name,
+ institution=institution,
+ type=account_type,
+ last_balance=last_balance,
+ last_synced_at=last_synced_at,
+ statement_status=statement_status,
+ liquidity_bucket=liquidity_bucket,
+ active=active,
+ memo=memo,
+ db_path=db_path,
+ )
+ if not result.get("success"):
+ st.error(result.get("error", result))
+ return
+ st.session_state.pop("account_edit_id", None)
+ clear_cache()
+ st.rerun()
+
+
+def page_accounts(db_path: str) -> None:
+ accounts_res = accounts_gateway.list_ledger_accounts(active_only=False, db_path=db_path)
+ accounts_data = accounts_res.get("accounts", []) if accounts_res.get("success") else []
+ if accounts_data:
+ render_account_list(accounts_data)
+ account_edit_id = st.session_state.get("account_edit_id")
+ if account_edit_id:
+ account = next((item for item in accounts_data if item["id"] == account_edit_id), None)
+ if account:
+ account_edit_dialog(account, db_path)
+ else:
+ st.session_state.pop("account_edit_id", None)
+ st.rerun()
+ else:
+ st.info("๋ฑ๋ก๋ ๊ณ์ข๊ฐ ์์ต๋๋ค.")
diff --git a/dashboard/page_views/assets.py b/dashboard/page_views/assets.py
new file mode 100644
index 0000000..0ccefa2
--- /dev/null
+++ b/dashboard/page_views/assets.py
@@ -0,0 +1,107 @@
+from __future__ import annotations
+
+# ruff: noqa: F405
+
+
+import pandas as pd
+import streamlit as st
+
+from jarca_ledger_mcp.adapters.streamlit import assets_gateway
+from dashboard.common import * # noqa: F403, F405
+
+def page_assets(db_path: str) -> None:
+ st.caption("ํ์ ๊ธฐ์ค: preview๋ live_authority ์ถ์ ๊ฐ, close ์ดํ ์กฐํ๋ closed snapshot ๊ฐ์
๋๋ค. ์ค์ด์ ์ synthetic/validation DB์์ ๋จผ์ ๊ฒ์ฆํ์ธ์.")
+
+ render_section_label("โ๏ธ", "์๊ธฐ ์์ฐ/๋ถ์ฑ ๋ฑ๋ก")
+ with st.form("manual_asset_create"):
+ c1, c2, c3 = st.columns([1.3, 1, 1])
+ name = c1.text_input("์์ฐ/๋ถ์ฑ๋ช
", placeholder="์: ์ ์ธ๋ณด์ฆ๊ธ, ์ฃผํ๋ด๋ณด๋์ถ")
+ amount = c2.number_input("ํ๊ฐ๊ธ์ก(์)", min_value=0, value=0, step=10000)
+ valuation_date = c3.date_input("ํ๊ฐ ๊ธฐ์ค์ผ")
+ c4, c5 = st.columns([1, 2])
+ bucket = c4.selectbox("์ ๋์ฑ ๋ฒํท", LIQUIDITY_BUCKET_OPTIONS, format_func=lambda x: LIQUIDITY_BUCKET_LABELS.get(x, x))
+ source_note = c5.text_input("์ถ์ฒ ๋ฉ๋ชจ", placeholder="์: ์๋ง ์ฑ ์์ก, ๊ณ์ฝ์, synthetic")
+ memo = st.text_input("์ด์ ๋ฉ๋ชจ", value="")
+ submitted = st.form_submit_button("์๊ธฐ ์์ฐ ์ถ๊ฐ", type="primary")
+ if submitted:
+ res = assets_gateway.create_manual_asset(name, int(amount), valuation_date.isoformat(), bucket, source_note=source_note, memo=memo, db_path=db_path)
+ if res.get("success"):
+ clear_cache()
+ st.success("์๊ธฐ ์์ฐ์ ์ถ๊ฐํ์ต๋๋ค.")
+ st.rerun()
+ else:
+ st.error(res.get("error", res))
+
+ assets_res = assets_gateway.list_manual_assets(active_only=False, db_path=db_path)
+ assets = assets_res.get("manual_assets", []) if assets_res.get("success") else []
+ render_section_label("๐", "์๊ธฐ ์์ฐ/๋ถ์ฑ ๋ชฉ๋ก")
+ if assets:
+ df = pd.DataFrame(assets)
+ df["bucket_label"] = df["liquidity_bucket"].map(LIQUIDITY_BUCKET_LABELS).fillna(df["liquidity_bucket"])
+ df["valuation_display"] = df["valuation_amount"].map(format_krw)
+ st.dataframe(df[["name", "active", "bucket_label", "valuation_display", "valuation_date", "source_note", "memo", "id"]], width="stretch", height=220)
+ with st.form("manual_asset_update"):
+ asset_options = {f"{a['name']} ({a['id']})": a for a in assets}
+ selected_label = st.selectbox("์์ /๋นํ์ฑ ๋์", list(asset_options.keys()))
+ selected = asset_options[selected_label]
+ u1, u2, u3 = st.columns([1.3, 1, 1])
+ new_name = u1.text_input("์ด๋ฆ", value=selected.get("name", ""))
+ new_amount = u2.number_input("ํ๊ฐ๊ธ์ก(์)", min_value=0, value=int(selected.get("valuation_amount") or 0), step=10000, key="asset_update_amount")
+ new_date = u3.text_input("ํ๊ฐ ๊ธฐ์ค์ผ", value=selected.get("valuation_date", ""))
+ u4, u5 = st.columns([1, 2])
+ current_bucket = selected.get("liquidity_bucket", "other") if selected.get("liquidity_bucket", "other") in LIQUIDITY_BUCKET_OPTIONS else "other"
+ new_bucket = u4.selectbox("๋ฒํท", LIQUIDITY_BUCKET_OPTIONS, index=LIQUIDITY_BUCKET_OPTIONS.index(current_bucket), format_func=lambda x: LIQUIDITY_BUCKET_LABELS.get(x, x), key="asset_update_bucket")
+ new_source = u5.text_input("์ถ์ฒ ๋ฉ๋ชจ", value=selected.get("source_note", ""))
+ new_memo = st.text_input("๋ฉ๋ชจ", value=selected.get("memo", ""), key="asset_update_memo")
+ make_active = st.checkbox("ํ์ฑ", value=bool(selected.get("active", True)))
+ col_update, col_deactivate = st.columns(2)
+ do_update = col_update.form_submit_button("์ ํ ํญ๋ชฉ ์์ ", type="primary")
+ do_deactivate = col_deactivate.form_submit_button("์ ํ ํญ๋ชฉ ๋นํ์ฑ", type="secondary")
+ if do_update:
+ res = assets_gateway.update_manual_asset(selected["id"], name=new_name, valuation_amount=int(new_amount), valuation_date=new_date, liquidity_bucket=new_bucket, source_note=new_source, memo=new_memo, active=make_active, db_path=db_path)
+ if res.get("success"):
+ clear_cache(); st.success("์์ ํ์ต๋๋ค."); st.rerun()
+ else:
+ st.error(res.get("error", res))
+ if do_deactivate:
+ res = assets_gateway.deactivate_manual_asset(selected["id"], db_path=db_path)
+ if res.get("success"):
+ clear_cache(); st.warning("๋นํ์ฑ ์ฒ๋ฆฌํ์ต๋๋ค."); st.rerun()
+ else:
+ st.error(res.get("error", res))
+ else:
+ st.info("๋ฑ๋ก๋ ์๊ธฐ ์์ฐ/๋ถ์ฑ๊ฐ ์์ต๋๋ค.")
+
+ st.divider()
+ render_section_label("๐", "์๋ง ์ค๋
์ท Preview / Close")
+ st.caption("Preview๋ DB๋ฅผ ๋ณ๊ฒฝํ์ง ์์ต๋๋ค. ๊ฐ์ ๊ธฐ์ค์ผ์ ๋ค์ closeํ๋ฉด ์ ์ ์ฌ์ ๊ฐ ํ์ํ๋ฉฐ ๊ธฐ์กด ํ์ ๋ฎ์ด์ฐ์ง ์๊ณ ์ correction run์ appendํฉ๋๋ค.")
+ c1, c2, c3 = st.columns([1, 1.5, 1.5])
+ basis_date = c1.text_input("๋ง๊ฐ ๊ธฐ์ค์ผ", value=f"{current_kst_month()}-01", help="YYYY-MM-DD")
+ close_note = c2.text_input("Close ์ถ์ฒ ๋ฉ๋ชจ", value="month-end close")
+ correction_reason = c3.text_input("์ ์ ์ฌ์ (์ฌ๋ง๊ฐ ์ ํ์)", value="")
+ p1, p2 = st.columns(2)
+ if p1.button("์ค๋
์ท Preview", type="secondary"):
+ st.session_state["asset_snapshot_preview"] = assets_gateway.preview_asset_snapshot_close(basis_date, db_path=db_path)
+ if p2.button("์ค๋
์ท Close ์คํ", type="primary"):
+ st.session_state["asset_snapshot_close"] = assets_gateway.close_asset_snapshot(basis_date, source_note=close_note, correction_reason=correction_reason, db_path=db_path)
+ clear_cache()
+ preview = st.session_state.get("asset_snapshot_preview")
+ close_res = st.session_state.get("asset_snapshot_close")
+ if preview:
+ if preview.get("success"):
+ st.success(f"Preview: source={preview.get('source')}, closed={preview.get('is_closed_snapshot')}, warnings={len(preview.get('warnings', []))}")
+ m1, m2, m3 = st.columns(3)
+ m1.metric("์์ฐ ํฉ๊ณ", format_krw(preview.get("total_assets")))
+ m2.metric("์ด๋ถ์ฑ", format_krw(preview.get("total_liabilities")))
+ m3.metric("์์์ฐ", format_krw(preview.get("net_worth")))
+ st.dataframe(pd.DataFrame(preview.get("included_items", [])), width="stretch", height=220)
+ if preview.get("warnings"):
+ st.warning(preview.get("warnings"))
+ else:
+ st.error(preview.get("error", preview))
+ if close_res:
+ if close_res.get("success"):
+ st.success(f"Close ์๋ฃ: run={close_res.get('snapshot_run_id')}, correction={close_res.get('is_correction')}, inserted={close_res.get('inserted_count')}")
+ st.json({k: close_res.get(k) for k in ["basis_date", "source", "is_closed_snapshot", "snapshot_run_id", "correction_of_run_id", "correction_reason", "closed_at", "net_worth"]})
+ else:
+ st.error(close_res.get("error", close_res))
diff --git a/dashboard/page_views/categories.py b/dashboard/page_views/categories.py
new file mode 100644
index 0000000..3323971
--- /dev/null
+++ b/dashboard/page_views/categories.py
@@ -0,0 +1,72 @@
+from __future__ import annotations
+
+# ruff: noqa: F405
+
+
+import pandas as pd
+import streamlit as st
+
+from jarca_ledger_mcp.adapters.streamlit import categorization_gateway
+from dashboard.common import * # noqa: F403, F405
+
+def page_categories(db_path: str) -> None:
+ st.caption("์นดํ
๊ณ ๋ฆฌ ์ญ์ ๋ ๊ธฐ๋ณธ์ ์ผ๋ก ๋นํ์ฑํ ์ฒ๋ฆฌํฉ๋๋ค. ๊ธฐ์กด ๊ฑฐ๋์ ๊ณผ๊ฑฐ category ๊ฐ์ ๋ณด์กด๋ฉ๋๋ค.")
+
+ c1, c2 = st.columns(2)
+ if c1.button("๊ธฐ๋ณธ ์นดํ
๊ณ ๋ฆฌ seed/update"):
+ res = categorization_gateway.seed_default_ledger_categories(db_path=db_path)
+ clear_cache()
+ st.success(f"{res.get('created_or_updated', 0)}๊ฐ ์นดํ
๊ณ ๋ฆฌ๋ฅผ ์์ฑ/๊ฐฑ์ ํ์ต๋๋ค.")
+ st.rerun()
+ show_inactive = c2.checkbox("๋นํ์ฑ ์นดํ
๊ณ ๋ฆฌ๋ ๋ณด๊ธฐ", value=False)
+
+ render_section_label("โ", "์นดํ
๊ณ ๋ฆฌ ์ถ๊ฐ")
+ with st.form("create_ledger_category"):
+ cols = st.columns([2, 2, 2, 1, 3])
+ category = cols[0].text_input("๋๋ถ๋ฅ", placeholder="์: ๋ฐ๋ ค๋๋ฌผ")
+ subcategory = cols[1].text_input("์๋ถ๋ฅ", placeholder="์: ์ฌ๋ฃ")
+ cashflow_kind_label = cols[2].selectbox("ํ๊ธํ๋ฆ ์ฑ๊ฒฉ", [CASHFLOW_KIND_LABELS[k] for k in CASHFLOW_KIND_OPTIONS], index=1)
+ sort_order = cols[3].number_input("์ ๋ ฌ", value=100, step=10)
+ memo = cols[4].text_input("๋ฉ๋ชจ", value="dashboard: user category")
+ submitted = st.form_submit_button("์ถ๊ฐ/์ฌํ์ฑํ", type="primary")
+ if submitted:
+ cashflow_kind = next(k for k, v in CASHFLOW_KIND_LABELS.items() if v == cashflow_kind_label)
+ res = categorization_gateway.create_ledger_category(category, subcategory, int(sort_order), memo, cashflow_kind, db_path=db_path)
+ clear_cache()
+ if res.get("success"):
+ st.success("์นดํ
๊ณ ๋ฆฌ๋ฅผ ์ ์ฅํ์ต๋๋ค.")
+ st.rerun()
+ else:
+ st.error(res.get("error", res))
+
+ result = categorization_gateway.list_ledger_categories(active_only=not show_inactive, include_usage=True, db_path=db_path)
+ rows = result.get("categories", [])
+ render_section_label("๐๏ธ", "์นดํ
๊ณ ๋ฆฌ ๋ชฉ๋ก")
+ if rows:
+ df = pd.DataFrame(rows)
+ if "cashflow_kind" in df:
+ df["cashflow_kind_label"] = df["cashflow_kind"].map(CASHFLOW_KIND_LABELS).fillna(df["cashflow_kind"])
+ if "total_amount" in df:
+ df["total_amount_display"] = df["total_amount"].map(format_krw)
+ st.dataframe(df, width="stretch", height=360)
+ else:
+ st.info("๋ฑ๋ก๋ ์นดํ
๊ณ ๋ฆฌ๊ฐ ์์ต๋๋ค. ๊ธฐ๋ณธ ์นดํ
๊ณ ๋ฆฌ๋ฅผ seedํ๊ฑฐ๋ ์ ์นดํ
๊ณ ๋ฆฌ๋ฅผ ์ถ๊ฐํ์ธ์.")
+ return
+
+ render_section_label("๐ก๏ธ", "์นดํ
๊ณ ๋ฆฌ ์ญ์ /๋นํ์ฑํ")
+ options = {
+ f"{r['category']} / {r['subcategory'] or '-'} ยท ์ฌ์ฉ {r.get('transaction_count', 0)}๊ฑด ยท {'ํ์ฑ' if r.get('active') else '๋นํ์ฑ'}": r
+ for r in rows
+ }
+ selected = st.selectbox("๋์", list(options.keys()))
+ target = options[selected]
+ hard_delete = st.checkbox("DB์์ ์์ ์ญ์ ", value=False, help="๋ณดํต์ ๋๊ณ ๋นํ์ฑํ๋ง ํ์ธ์. ๊ฑฐ๋ ๊ธฐ๋ก์ ๋ณ๋ ๋ณด์กด๋ฉ๋๋ค.")
+ force = st.checkbox("์ฌ์ฉ ์ค์ด์ด๋ ๊ฐ์ ", value=False, help="๊ฑฐ๋/์์ฐ/๊ท์น์์ ์ฌ์ฉ ์ค์ธ ์นดํ
๊ณ ๋ฆฌ๋ ์ญ์ /๋นํ์ฑํํฉ๋๋ค. ๊ฑฐ๋ category ๊ฐ์ ๋ณ๊ฒฝํ์ง ์์ต๋๋ค.")
+ if st.button("์ญ์ /๋นํ์ฑํ ์คํ", type="secondary"):
+ res = categorization_gateway.delete_ledger_category(target["category"], target.get("subcategory", ""), hard_delete=hard_delete, force=force, db_path=db_path)
+ clear_cache()
+ if res.get("success"):
+ st.warning(f"์ฒ๋ฆฌํ์ต๋๋ค. ์ฌ์ฉ ๊ฑฐ๋ {res.get('transaction_count', 0)}๊ฑด")
+ st.rerun()
+ else:
+ st.error(res.get("error", res))
diff --git a/dashboard/page_views/cross_transfer_review.py b/dashboard/page_views/cross_transfer_review.py
new file mode 100644
index 0000000..4fb18f1
--- /dev/null
+++ b/dashboard/page_views/cross_transfer_review.py
@@ -0,0 +1,75 @@
+from __future__ import annotations
+
+# ruff: noqa: F405
+
+import sqlite3
+
+import pandas as pd
+import streamlit as st
+
+from jarca_ledger_mcp.adapters.streamlit import transfers_gateway
+from dashboard.common import * # noqa: F403, F405
+
+def page_cross_transfer_review(db_path: str) -> None:
+ c1, c2 = st.columns(2)
+ with c1:
+ if st.button("๋ฏธ๋ฆฌ๋ณด๊ธฐ(๋๋ผ์ด๋ฐ)"):
+ st.session_state["cross_transfer_preview"] = transfers_gateway.preview_cross_account_transfers(db_path=db_path)
+ with c2:
+ if st.button("์๋ ๋งค์นญ ์คํ"):
+ res = transfers_gateway.run_cross_account_transfer_match(db_path=db_path)
+ clear_cache()
+ st.session_state["cross_transfer_last_run"] = res
+ st.success(f"๋งค์นญ {res.get('matched_count', 0)}๊ฑด ์ ์ฉ, ํ์ธํ์ {res.get('review_count', 0)}๊ฑด")
+ st.rerun()
+
+ if "cross_transfer_preview" in st.session_state:
+ prev = st.session_state["cross_transfer_preview"]
+ render_section_label("๐งช", "๋ฏธ๋ฆฌ๋ณด๊ธฐ ๊ฒฐ๊ณผ")
+ st.info(f"์์ ๋งค์นญ {prev.get('matched_count', 0)}๊ฑด / ํ์ธํ์ {prev.get('review_count', 0)}๊ฑด")
+ if prev.get("matched_pairs"):
+ st.dataframe(pd.DataFrame(prev.get("matched_pairs")), width="stretch", height=250)
+ if prev.get("review_items"):
+ st.dataframe(pd.DataFrame(prev.get("review_items")), width="stretch", height=250)
+
+ st.divider()
+ render_section_label("๐", "ํ์ธํ์ ํ")
+ list_queue_fn = getattr(transfers_gateway, "list_cross_transfer_review_queue", None)
+ if callable(list_queue_fn):
+ queue = list_queue_fn(db_path=db_path)
+ rows = queue.get("transactions", [])
+ else:
+ # ํธํ์ฑ ๋ณดํธ: ๊ตฌ๋ฒ์ ์๋น์ค์์ ๋ฉ์๋๊ฐ ์์ ๋ ์ง์ ์กฐํ
+ st.info("ํ์ธํ์ ํ ์กฐํ ํจ์๊ฐ ํ์ฌ ์๋น์ค ๋ชจ๋์ ์์ด ์ง์ DB ์กฐํ๋ก ํ์ํฉ๋๋ค.")
+ with sqlite3.connect(db_path) as _conn:
+ _conn.row_factory = lambda cursor, row: {description[0]: row[idx] for idx, description in enumerate(cursor.description)}
+ _rows = _conn.execute(
+ """
+ SELECT * FROM transactions
+ WHERE status='active'
+ AND source IN ('kb_bank_pdf','toss_bank_pdf')
+ AND memo LIKE '%cross_transfer_review:%'
+ ORDER BY transaction_date DESC, created_at DESC
+ LIMIT 300
+ """
+ ).fetchall()
+ rows = [dict(r) for r in _rows]
+
+ if not rows:
+ st.success("ํ์ฌ ํ์ธํ์ ํ๊ฐ ๋น์ด ์์ต๋๋ค.")
+ return
+ df = pd.DataFrame(rows)
+ if "cashflow_kind" in df.columns:
+ df["cashflow_kind"] = df["cashflow_kind"].fillna("")
+ st.dataframe(df[["transaction_date", "source", "type", "amount", "counterparty", "category", "subcategory", "memo", "id"]].rename(columns={"amount": "๊ธ์ก"}), width="stretch", height=320)
+
+ options = {
+ f"{r.get('transaction_date')} ยท {r.get('source')} ยท {r.get('type')} ยท {int(r.get('amount', 0)):,}์ ยท {r.get('counterparty', '')}": r
+ for r in rows
+ }
+ selected = st.selectbox("์๋ ์์ ํ ๊ฑฐ๋", list(options.keys()))
+ selected_row = options[selected]
+ st.caption(f"ํ์ฌ review_status: {selected_row.get('review_status', 'pending')}")
+ with st.expander("Review decision ์ ์ฅ/๋ณด๋ฅ/์ฌ๊ฒํ /ํด์ ", expanded=False):
+ render_review_decision_actions(transfers_gateway, "cross_transfer", selected_row["id"], f"transfer_review_decision_{selected_row['id']}", db_path, target_payload=selected_row)
+ update_transaction_form(selected_row, "transfer_review", db_path)
diff --git a/dashboard/page_views/imports.py b/dashboard/page_views/imports.py
new file mode 100644
index 0000000..5bdfec9
--- /dev/null
+++ b/dashboard/page_views/imports.py
@@ -0,0 +1,95 @@
+from __future__ import annotations
+
+# ruff: noqa: F405
+
+from pathlib import Path
+
+import pandas as pd
+import streamlit as st
+
+from jarca_ledger_mcp.adapters.streamlit import imports_gateway
+from jarca_ledger_mcp.adapters.streamlit import overview_gateway
+from dashboard.common import * # noqa: F403, F405
+
+def page_imports(db_path: str) -> None:
+ render_section_label("๐", "ํ ์ค์ฆ๊ถ ๊ฑฐ๋๋ด์ญ PDF ๊ฐ์ ธ์ค๊ธฐ")
+ st.caption("ํ ์ค์ฆ๊ถ ๊ฑฐ๋๋ด์ญ์ PDF๋ฅผ ์
๋ก๋ํ๋ฉด ํฌ์๊ณ์ข ๊ฑฐ๋๋ก ๊ฐ์ ธ์ต๋๋ค. ๋งค์/๋งค๋/ํ์ /์
์ถ๊ธ์ `์ด์ฒด/ํฌ์์ด๋`, ๋ฐฐ๋น/์ด์๋ ์ค์ ์์
์ผ๋ก ๋ถ๋ฅํฉ๋๋ค.")
+ uploaded = st.file_uploader("ํ ์ค์ฆ๊ถ PDF", type=["pdf"], key="toss_pdf")
+ if uploaded is not None:
+ upload_dir = Path.home() / ".local" / "share" / "jarca" / "uploads"
+ upload_dir.mkdir(parents=True, exist_ok=True)
+ upload_path = upload_dir / uploaded.name
+ upload_path.write_bytes(uploaded.getbuffer())
+ c1, c2 = st.columns(2)
+ if c1.button("ํ ์ค์ฆ๊ถ Preview"):
+ res = imports_gateway.preview_toss_securities_pdf_import(str(upload_path), db_path=db_path)
+ st.session_state["toss_preview"] = res
+ if c2.button("ํ ์ค์ฆ๊ถ Import", type="primary"):
+ res = imports_gateway.import_toss_securities_pdf(str(upload_path), db_path=db_path)
+ clear_cache()
+ st.session_state["toss_import_result"] = res
+ st.rerun()
+ if "toss_preview" in st.session_state:
+ res = st.session_state["toss_preview"]
+ if res.get("success"):
+ st.success(f"Preview: {res.get('row_count', 0)}๊ฑด, ์ค๋ณต {res.get('duplicate_count', 0)}๊ฑด")
+ st.json({"metadata": res.get("metadata"), "by_action": res.get("by_action"), "validation": res.get("validation")})
+ st.dataframe(pd.DataFrame(res.get("preview", [])), width="stretch", height=300)
+ else:
+ st.error(res)
+ if "toss_import_result" in st.session_state:
+ res = st.session_state["toss_import_result"]
+ if res.get("success"):
+ st.success(f"Import ์๋ฃ: {res.get('inserted_count', 0)}๊ฑด ์ถ๊ฐ, ์ค๋ณต skip {res.get('skipped_duplicate_count', 0)}๊ฑด")
+ st.json(res)
+ else:
+ st.error(res)
+
+ st.divider()
+ render_section_label("๐ฆ", "ํ ์ค๋ฑ
ํฌ ๊ฑฐ๋๋ด์ญ PDF ๊ฐ์ ธ์ค๊ธฐ")
+ st.caption("ํ ์ค๋ฑ
ํฌ ๊ณ์ข ๊ฑฐ๋๋ด์ญ PDF๋ฅผ ์
๋ก๋ํ๋ฉด ์
์ถ๊ธ/์ด์ฒด/์นด๋๊ฒฐ์ ๋ฅผ ๊ฐ์ ธ์ต๋๋ค. ํ์ธ ์ด์ฒด/์ถ๊ธ์ `์ด์ฒด/ํ์ธํ์`๋ก ๋ถ๋ฅ๋ฉ๋๋ค.")
+ toss_bank_uploaded = st.file_uploader("ํ ์ค๋ฑ
ํฌ PDF", type=["pdf"], key="tossbank_pdf")
+ if toss_bank_uploaded is not None:
+ upload_dir = Path.home() / ".local" / "share" / "jarca" / "uploads"
+ upload_dir.mkdir(parents=True, exist_ok=True)
+ upload_path = upload_dir / toss_bank_uploaded.name
+ upload_path.write_bytes(toss_bank_uploaded.getbuffer())
+ c1, c2 = st.columns(2)
+ if c1.button("ํ ์ค๋ฑ
ํฌ Preview"):
+ res = imports_gateway.preview_toss_bank_pdf_import(str(upload_path), db_path=db_path)
+ st.session_state["toss_bank_preview"] = res
+ if c2.button("ํ ์ค๋ฑ
ํฌ Import", type="primary"):
+ res = imports_gateway.import_toss_bank_pdf(str(upload_path), db_path=db_path)
+ clear_cache()
+ st.session_state["toss_bank_import_result"] = res
+ st.rerun()
+ if "toss_bank_preview" in st.session_state:
+ res = st.session_state["toss_bank_preview"]
+ if res.get("success"):
+ st.success(f"Preview: {res.get('row_count', 0)}๊ฑด, ์ค๋ณต {res.get('duplicate_count', 0)}๊ฑด")
+ st.json({"metadata": res.get("metadata"), "by_action": res.get("by_action"), "validation": res.get("validation")})
+ st.dataframe(pd.DataFrame(res.get("preview", [])), width="stretch", height=300)
+ else:
+ st.error(res)
+ if "toss_bank_import_result" in st.session_state:
+ res = st.session_state["toss_bank_import_result"]
+ if res.get("success"):
+ st.success(f"Import ์๋ฃ: {res.get('inserted_count', 0)}๊ฑด ์ถ๊ฐ, ์ค๋ณต skip {res.get('skipped_duplicate_count', 0)}๊ฑด")
+ st.json(res)
+ else:
+ st.error(res)
+
+ st.divider()
+ render_section_label("๐งพ", "Import ์ด๋ ฅ")
+ batches = overview_gateway.list_ledger_import_batches(limit=100, db_path=db_path).get("import_batches", [])
+ st.dataframe(pd.DataFrame(batches), width="stretch", height=350)
+ if batches:
+ with st.form("rollback"):
+ batch_id = st.selectbox("Rollback batch", [b["id"] for b in batches])
+ hard = st.checkbox("Hard delete", value=False)
+ submitted = st.form_submit_button("Rollback ์คํ", type="secondary")
+ if submitted:
+ res = imports_gateway.rollback_ledger_import_batch(batch_id, hard_delete=hard, db_path=db_path)
+ clear_cache()
+ st.warning(res)
+ st.rerun()
diff --git a/dashboard/page_views/monthly_settlement.py b/dashboard/page_views/monthly_settlement.py
new file mode 100644
index 0000000..7d42730
--- /dev/null
+++ b/dashboard/page_views/monthly_settlement.py
@@ -0,0 +1,234 @@
+from __future__ import annotations
+
+# ruff: noqa: F405
+
+from datetime import datetime
+
+import pandas as pd
+import streamlit as st
+
+from jarca_ledger_mcp.adapters.streamlit import overview_gateway
+from jarca_ledger_mcp.adapters.streamlit import reconciliation_gateway
+from dashboard.common import * # noqa: F403, F405
+
+def page_monthly_settlement(db_path: str) -> None:
+ query_month = st.query_params.get("month", current_kst_month())
+ if isinstance(query_month, list):
+ query_month = query_month[0]
+ month = st.text_input("๋์ ์", value=str(query_month), key="monthly_settlement_month", help="YYYY-MM ํ์")
+ settlement = overview_gateway.get_monthly_settlement_summary(month, db_path=db_path)
+ if not settlement.get("success"):
+ st.error(settlement.get("error", settlement))
+ st.caption(f"์
๋ ฅํ ์: {month}")
+ return
+
+ selected_month = settlement.get("month") or settlement.get("selected_month") or month
+ basis_date = settlement.get("basis_date") or f"{selected_month}-01"
+ source_coverage = settlement.get("source_coverage", {}) if isinstance(settlement.get("source_coverage"), dict) else {}
+ unresolved_blockers = settlement.get("unresolved_blockers", {}) if isinstance(settlement.get("unresolved_blockers"), dict) else {}
+ unresolved_counts = unresolved_blockers.get("counts", {}) if isinstance(unresolved_blockers.get("counts"), dict) else {}
+ review_queue_res = overview_gateway.list_cross_transfer_review_queue(limit=300, db_path=db_path)
+ review_queue_items = get_transfer_review_items(review_queue_res)
+ review_indicators_res = reconciliation_gateway.get_review_operating_indicators(db_path=db_path)
+ review_indicators = review_indicators_res.get("indicators", {}) if review_indicators_res.get("success") else {}
+ reconciliation_res = reconciliation_gateway.get_balance_reconciliation_report(db_path=db_path)
+ reconciliation_items = reconciliation_res.get("items", []) if reconciliation_res.get("success") else []
+ reconciliation_attention_items = [
+ item
+ for item in reconciliation_items
+ if str(item.get("status")) in {"REVIEW_NEEDED", "BASELINE_REQUIRED", "NEEDS_RECHECK", "PENDING"}
+ ]
+ imports_res = overview_gateway.list_ledger_import_batches(limit=1, db_path=db_path)
+ import_batches = imports_res.get("import_batches", []) if imports_res.get("success") else []
+ asset_snapshot = overview_gateway.get_asset_snapshot_report(basis_date=basis_date, db_path=db_path)
+
+ read_only_stamp = datetime.now(ZoneInfo("Asia/Seoul")).strftime("%Y-%m-%d %H:%M:%S KST")
+ render_section_label("๐", "์์ ์ฐ ์
ธ")
+ st.caption(f"์ ํ ์: {selected_month} / ๊ธฐ์ค์ผ: {basis_date} / ๊ธฐ์ค ์๊ฐ: {read_only_stamp} / ๋ชจ๋: ์ฝ๊ธฐ ์ ์ฉ")
+ st.markdown(
+ ""
+ "๐ ์ฝ๊ธฐ ์ ์ฉ",
+ unsafe_allow_html=True,
+ )
+ st.info("๊ฑฐ๋/์์ฐ ์์ ์ ๊ฐ ์ด์ ํ๋ฉด(๊ฑฐ๋๋ด์ญ, ์์ฝ)์์ ์ฒ๋ฆฌํ์ธ์.")
+ st.markdown("- [๊ฑฐ๋๋ด์ญ์ผ๋ก ์ด๋](?page=๊ฑฐ๋๋ด์ญ)")
+ st.markdown("- [์์ฐ ํ์ธ์ผ๋ก ์ด๋](?page=์์ฝ)")
+
+ st.markdown("์๊ฐ ํต์ฌ KPI")
+ st.caption("์๊ฐ ํต์ฌ ์งํ๋ฅผ ํตํด ์์
ยท์ง์ถยท์๊ณ ํ๋ฆ์ ์ ๊ฒํ์ธ์.")
+ k1, k2, k3, k4 = st.columns(4)
+ k1.metric("์ค์ ์์
", format_krw(settlement.get("actual_income", 0)))
+ k2.metric("์ค์ ์ง์ถ", format_krw(settlement.get("actual_expense", 0)))
+ k3.metric("์ํ๊ธํ๋ฆ", format_krw(settlement.get("net_cashflow", 0)))
+ k4.metric("์ ์ถ๋ฅ ", format_percent(settlement.get("savings_rate")))
+
+ st.markdown("ํ๊ธํ๋ฆ / ์ง์ถ ๊ตฌ์กฐ")
+ render_section_label("๐ธ", "ํ๊ธํ๋ฆ / ์ง์ถ ๊ตฌ์กฐ")
+ cashflow = settlement.get("reports", {}).get("cashflow", {}) if isinstance(settlement.get("reports"), dict) else {}
+ if cashflow:
+ c1, c2 = st.columns(2)
+ by_type = cashflow.get("by_type", {}) or {}
+ by_type_df = pd.DataFrame(
+ [{"๊ตฌ๋ถ": str(key), "๊ธ์ก": int(value)} for key, value in by_type.items()]
+ )
+ if not by_type_df.empty:
+ by_type_df["๊ธ์ก"] = by_type_df["๊ธ์ก"].map(format_krw)
+ by_type_df = by_type_df.sort_values("๊ธ์ก", ascending=False)
+ c1.dataframe(by_type_df, width="stretch", height=170)
+
+ by_kind = cashflow.get("by_cashflow_kind", {}) or {}
+ by_kind_df = pd.DataFrame(
+ [{"ํ๊ธํ๋ฆ ๊ตฌ๋ถ": str(key), "๊ธ์ก": int(value)} for key, value in by_kind.items()]
+ )
+ if not by_kind_df.empty:
+ by_kind_df["๊ธ์ก"] = by_kind_df["๊ธ์ก"].map(format_krw)
+ c2.dataframe(by_kind_df, width="stretch", height=170)
+ else:
+ st.info("ํด๋น ์ ํ๊ธํ๋ฆ ์ ๋ณด๊ฐ ์์ง ์์ต๋๋ค.")
+
+ st.markdown("์์ฐ ๋๋น ์ ๊ฒ")
+ render_section_label("๐งฎ", "์์ฐ ๋๋น ์ ๊ฒ")
+ budget_report = settlement.get("reports", {}).get("budget", {}) if isinstance(settlement.get("reports"), dict) else {}
+ b1, b2, b3 = st.columns(3)
+ b1.metric("์์ฐ ํญ๋ชฉ ์", f"{int(budget_report.get('count', 0))}๊ฑด")
+ b2.metric("์ ์์ฐ", format_krw(budget_report.get("total_budget", 0)))
+ b3.metric("์์ฐ ์ด๊ณผ ํญ๋ชฉ", f"{int(settlement.get('budget_overrun_count', 0))}๊ฑด")
+
+ budget_items = budget_report.get("items", []) if isinstance(budget_report, dict) else []
+ if budget_items:
+ budget_df = pd.DataFrame(budget_items)
+ budget_df["์์ฐ"] = budget_df["budget"].map(format_krw)
+ budget_df["์ค์ "] = budget_df["actual"].map(format_krw)
+ budget_df["์์ฌ"] = budget_df["remaining"].map(format_krw)
+ budget_df["์ฌ์ฉ๋ฅ "] = budget_df["usage_rate"].map(format_percent)
+ st.dataframe(
+ budget_df[["category", "subcategory", "person", "์์ฐ", "์ค์ ", "์์ฌ", "์ฌ์ฉ๋ฅ ", "status"]],
+ width="stretch",
+ height=220,
+ )
+ else:
+ st.info(f"{selected_month} ์์ฐ ํญ๋ชฉ์ด ์์ต๋๋ค.")
+
+ render_section_label("โ ๏ธ", "์ ์ฐ ์ ์ด์ ๋ธ๋ก์ปค ์ ๊ฒ")
+ unresolved_blocker_count = int(settlement.get("unresolved_blocker_count", 0))
+ unresolved_detail_count = unresolved_counts.get("unresolved_count", unresolved_blocker_count)
+ st.caption(f"๋ฏธํด๊ฒฐ ๋ธ๋ก์ปค: {int(unresolved_detail_count)}๊ฑด")
+
+ render_section_label("๐งฏ", "์ ์ฐ ์ ๊ฒํ ")
+ review_count = int(unresolved_counts.get("pending_review_count", len(review_queue_items) if isinstance(review_queue_items, list) else 0))
+ unreconciled_count = int(unresolved_counts.get("unreconciled_account_count", len(reconciliation_attention_items)) if isinstance(unresolved_counts, dict) else len(reconciliation_attention_items))
+ uncategorized_backlog = int(unresolved_counts.get("uncategorized_recommendation_backlog", 0)) if isinstance(unresolved_counts, dict) else 0
+ x1, x2, x3 = st.columns(3)
+ x1.metric("๋ฏธ์ฒ๋ฆฌ review", f"{review_count}๊ฑด")
+ x2.metric("๋ฏธ๋์กฐ ๊ณ์ข", f"{unreconciled_count}๊ฑด")
+ x3.metric("๋ฏธ๋ถ๋ฅ ์ถ์ฒ ๋๊ธฐ", f"{uncategorized_backlog}๊ฑด")
+ if review_queue_items:
+ st.markdown("ํ์ธํ์ ํ ์ต๊ทผ ํญ๋ชฉ")
+ for item in review_queue_items[:5]:
+ tx_date = item.get("transaction_date") or item.get("reviewed_at") or "-"
+ merchant = item.get("merchant") or item.get("counterparty") or "-"
+ amount = format_krw(item.get("amount") if item.get("amount") is not None else item.get("difference"))
+ status = item.get("review_status") or item.get("status") or "pending"
+ st.markdown(f"- {tx_date} ยท {merchant} ยท {amount} ยท {status}")
+ else:
+ st.caption("ํ์ธํ์ ํ๋ ๋น์ด ์์ต๋๋ค.")
+ st.markdown("- [ํ์ธํ์ ํ๋ก ์ด๋](?page=ํ์ธํ์ ํ)")
+
+ render_section_label("๐ฅ", "๊ฑฐ๋ ๋ฐ์ / ๊ณ์ข ์ํ")
+ source_imported_at = source_coverage.get("imports", {}).get("latest_imported_at")
+ t1, t2, t3 = st.columns(3)
+ t1.metric("์ต๊ทผ Import ๊ฑด์", f"{int(source_coverage.get('imports', {}).get('recent_batch_count', 0))}๊ฑด")
+ t2.metric("์ต๊ทผ Import ์๊ฐ", source_imported_at or "์์")
+ t3.metric("๋ณด๋ฅ ํ ์ฒ๋ฆฌ", str(review_indicators.get("held_recheck_count", "-") or "0") + "๊ฑด")
+
+ if import_batches:
+ latest_import = import_batches[0]
+ st.caption(f"์ต๊ทผ Import: ์ถ์ฒ {latest_import.get('source', '์ ์ ์์')} / ์์ง์ผ {latest_import.get('imported_at', '๋ฏธ๊ธฐ๋ก')}")
+ else:
+ st.info("์ต๊ทผ Import ๋ฐฐ์น๊ฐ ์์ต๋๋ค.")
+
+ st.markdown("- [๊ณ์ข ํ๋ฉด์์ ์์ก๋์กฐ ํ์ธ](?page=๊ณ์ข)")
+ st.markdown("- [Import ํ๋ฉด์์ ์ด๋ ฅ ํ์ธ](?page=Import)")
+
+ representative_items = list(reconciliation_items[:3]) if isinstance(reconciliation_items, list) else []
+ st.markdown("๋ํ ๊ณ์ข ๋ฐ์ ์ํ")
+ if representative_items:
+ reconciliation_status_label = {
+ "OK": "์ ์",
+ "REVIEW_NEEDED": "๋ฏธ๋์กฐ",
+ "BASELINE_REQUIRED": "๊ธฐ์ค ์์ก ๋ฏธ์
๋ ฅ",
+ "NEEDS_RECHECK": "์ฌํ์ธ ํ์",
+ "PENDING": "๋๊ธฐ",
+ }
+ for idx, item in enumerate(representative_items, start=1):
+ raw_status = str(item.get("status", "-") or "-")
+ synced_at = item.get("last_synced_at") or item.get("baseline_at") or item.get("calculation_basis_at") or "-"
+ baseline_balance = item.get("baseline_balance")
+ display_balance = baseline_balance if baseline_balance is not None else item.get("calculated_balance")
+ item_line = (
+ f"- [{idx}] ๊ณ์ข๋ช
: {item.get('account_name', '-')}, "
+ f"๊ธฐ๊ด: {item.get('institution', item.get('bank', '๋ฏธ๊ธฐ๋ก'))}, "
+ f"์ ํ: {item.get('account_type', item.get('type', '๋ฏธ๊ธฐ๋ก'))}, "
+ f"์ต๊ทผ ๋๊ธฐํ: {synced_at}, "
+ f"์์ก: {format_krw(display_balance)}, "
+ f"์ํ: {reconciliation_status_label.get(raw_status, raw_status)}"
+ )
+ st.markdown(
+ item_line,
+ )
+ else:
+ st.caption("์์ก๋์กฐ ๋์ ๊ณ์ข๊ฐ ํ์ฌ ์์ต๋๋ค.")
+
+ st.markdown("- [๋ฏธ๋ถ๋ฅ ํ์ด์ง](?page=๋ฏธ๋ถ๋ฅ)")
+
+ render_section_label("๐ฆ", "์์ฐ / ์์์ฐ ๋ง๊ฐ ์ํ")
+ latest_asset_status = "๋ฏธํ์ธ"
+ if asset_snapshot.get("success"):
+ latest_asset_status = "๋ง๊ฐ ์๋ฃ" if bool(asset_snapshot.get("is_closed_snapshot")) else "๋ฏธ๋ง๊ฐ"
+ warnings = asset_snapshot.get("warnings") if isinstance(asset_snapshot, dict) else []
+ missing_valuation_items = asset_snapshot.get("missing_valuation_items") if isinstance(asset_snapshot, dict) else []
+ warning_text = "๊ฒฝ๊ณ ์์"
+ if warnings or missing_valuation_items:
+ warning_text = f"๊ฒฝ๊ณ {len(warnings or [])}๊ฑด / ์ ์ธ {len(missing_valuation_items or [])}๊ฑด"
+
+ m1, m2, m3, m4 = st.columns(4)
+ m1.metric("์์ฐ ํฉ๊ณ", format_krw(asset_snapshot.get("total_assets") if isinstance(asset_snapshot, dict) else source_coverage.get("assets", {}).get("total_assets")))
+ m2.metric("์ด๋ถ์ฑ", format_krw(asset_snapshot.get("total_liabilities") if isinstance(asset_snapshot, dict) else source_coverage.get("assets", {}).get("total_liabilities")))
+ m3.metric("์์์ฐ", format_krw(
+ asset_snapshot.get("net_worth")
+ if isinstance(asset_snapshot, dict)
+ else source_coverage.get("assets", {}).get("net_worth")
+ ))
+ m4.metric("์์ฐ ๋ง๊ฐ ์ํ", latest_asset_status)
+ st.caption(f"์์ฐ ์์ธ: {warning_text}")
+ st.caption(f"๋ง๊ฐ ๊ธฐ์ค์ผ: {asset_snapshot.get('basis_date', basis_date)}")
+ st.markdown("- [์์ฐ/๋ง๊ฐ ํ๋ฉด์์ ์ค๋
์ท ํ์ธ](?page=์์ฐ/๋ง๊ฐ)")
+
+ render_section_label("๐", "์์ ์ฐ ๊ฒฐ๊ณผ / Evidence")
+ st.markdown("### ์ฝ๊ธฐ ์ ์ฉ ๊ทผ๊ฑฐ ์์ฝ")
+ evidence_close_status = "๋ง๊ฐ ์๋ฃ" if bool(asset_snapshot.get("is_closed_snapshot")) else "๋ฏธ๋ง๊ฐ"
+ import_summary = source_coverage.get("imports", {}) if isinstance(source_coverage, dict) else {}
+ evidence_rows = [
+ f"- close ์ค๋น ์ํ: {evidence_close_status}",
+ f"- source coverage: ์ต๊ทผ Import ๋ฐฐ์น {int(import_summary.get('recent_batch_count', 0))}๊ฑด",
+ f"- ์ต๊ทผ Import: {import_summary.get('latest_imported_at') or '์์'}",
+ f"- ์์ฐ/๋ง๊ฐ: {evidence_close_status} ({asset_snapshot.get('basis_date', basis_date)})",
+ f"- ๋ฏธํด๊ฒฐ ๋ธ๋ก์ปค: {int(unresolved_detail_count)}๊ฑด",
+ ]
+ for row in evidence_rows:
+ st.markdown(row)
+
+ st.markdown("### ๋ค์ ์ก์
")
+ st.caption("์ด๋ฒ ๋ฒ์์๋ ์์ ์ฐ close ์ ์ฅ/์คํ์ด ํฌํจ๋์ง ์์ต๋๋ค")
+ action_candidates = [
+ "์์ฐ ์กฐ์ ํ๋ณด: ์๋ง ์์ฐ ์ด๊ณผ/๋ถ์กฑ ํญ๋ชฉ์ด ์๋์ง ์ ๊ฒํ๊ณ ์์ฝ ํ์ด์ง์์ ์์ฐ์ ๊ฐฑ์ ํ์ธ์.",
+ "๋ถ๋ฅ/๊ฒํ ๊ท์น ํ๋ณด: ๋ฏธ๋ถ๋ฅ ์ถ์ฒ ๋ฐ ํ์ธํ์ ํ์์ ๊ท์น ํ๋ณด๋ฅผ ๋จผ์ ์ ๊ฒํ์ธ์.",
+ "๋์กฐ/Import/๋ฐฑ์
ํ์ ์์
: ๊ณ์ข ์์ก๋์กฐ, ์ต๊ทผ Import ์ด๋ ฅ, ๋ฐฑ์
์ต์ ํ ์ํ๋ฅผ ํจ๊ป ํ์ธํ์ธ์.",
+ "์๊ธฐ ์์ฐ/๋ง๊ฐ ํ๋ฉด์์ ๊ธฐ์ค์ผยทํ๊ฐ ์ํ๋ฅผ ์ ํฉ์ฑ ํ์ธ ํ ์ด์ ์น์ธ ์ฌ๋ถ๋ฅผ ๊ฒฐ์ ํ์ธ์.",
+ ]
+ for action in action_candidates:
+ st.markdown(f"- {action}")
+
+ st.divider()
+ st.markdown("- [์์ฝ ํ์ด์ง๋ก ์ด๋](?page=์์ฝ)")
diff --git a/dashboard/page_views/overview.py b/dashboard/page_views/overview.py
new file mode 100644
index 0000000..223368e
--- /dev/null
+++ b/dashboard/page_views/overview.py
@@ -0,0 +1,103 @@
+from __future__ import annotations
+
+# ruff: noqa: F405
+
+
+import pandas as pd
+import plotly.express as px
+import streamlit as st
+
+from jarca_ledger_mcp.adapters.streamlit import overview_gateway
+from jarca_ledger_mcp.adapters.streamlit import reconciliation_gateway
+from dashboard.common import * # noqa: F403, F405
+
+def page_overview(db_path: str) -> None:
+ health = overview_gateway.health_check(db_path)
+ st.caption(f"DB: `{health.get('db_path')}`")
+ month = current_kst_month()
+ cashflow = overview_gateway.get_monthly_cashflow_report(month, db_path=db_path)
+ asset_report = overview_gateway.get_asset_liquidity_report(db_path=db_path)
+ budget_report = overview_gateway.get_budget_vs_actual_report(month, db_path=db_path)
+ uncategorized_res = overview_gateway.list_uncategorized_transactions(limit=1000, db_path=db_path)
+ transfer_queue_res = overview_gateway.list_cross_transfer_review_queue(limit=300, db_path=db_path)
+ reconciliation_res = reconciliation_gateway.get_balance_reconciliation_report(db_path=db_path)
+ review_indicators_res = reconciliation_gateway.get_review_operating_indicators(db_path=db_path)
+ review_indicators = review_indicators_res.get("indicators", {}) if review_indicators_res.get("success") else {}
+ backups_res = overview_gateway.list_ledger_backups(limit=1)
+ imports_res = overview_gateway.list_ledger_import_batches(limit=1, db_path=db_path)
+
+ render_section_label("๐", "์ด๋ฒ ๋ฌ ์์
/์ง์ถ ํํฉ")
+ st.caption(f"๊ธฐ์ค: KST ํ์ฌ ์ {month}, transactions.transaction_date, ledger_categories.cashflow_kind ์ค์ ํ๊ธํ๋ฆ")
+ c1, c2, c3, c4 = st.columns(4)
+ c1.metric("์ค์ ์์
", format_krw(cashflow.get("income", 0)))
+ c2.metric("์ค์ ์ง์ถ", format_krw(cashflow.get("expenses", 0)))
+ c3.metric("์ํ๊ธํ๋ฆ", format_krw(cashflow.get("net_cashflow", 0)))
+ c4.metric("์ ์ถ๋ฅ ", format_percent(cashflow.get("savings_rate")))
+
+ render_section_label("๐ฆ", "์์ฐ ์ ๋์ฑ ํํฉ")
+ st.caption("๊ธฐ์ค: service ๊ณตํต ํ๊ฐ ํฌํผ(accounts.last_balance + manual_assets ๋ณด๊ฐ). ๊ฑฐ๋ ํฉ๊ณ๋ก ๋น๊ฑฐ๋ ์์ฐ์ ์ถ์ ํ์ง ์์ต๋๋ค.")
+ a1, a2, a3 = st.columns(3)
+ a1.metric("์์ฐ ํฉ๊ณ(๋ถ์ฑ ์ ์ธ)", format_krw(asset_report.get("total_assets", 0)))
+ a2.metric("์ด๋ถ์ฑ", format_krw(asset_report.get("total_liabilities", 0)))
+ a3.metric("์์์ฐ", format_krw(asset_report.get("net_worth", 0)))
+ buckets_df = pd.DataFrame(asset_report.get("buckets", []))
+ if not buckets_df.empty:
+ buckets_df["label"] = buckets_df["liquidity_bucket"].map(LIQUIDITY_BUCKET_LABELS).fillna(buckets_df["liquidity_bucket"])
+ buckets_df["๊ธ์ก"] = buckets_df["amount"].map(format_krw)
+ buckets_df["๊ตฌ์ฑ๋น"] = buckets_df["asset_share"].map(format_percent)
+ st.dataframe(buckets_df[["label", "liquidity_bucket", "๊ธ์ก", "account_count", "๊ตฌ์ฑ๋น"]], width="stretch", height=250)
+ chart_df = buckets_df[(buckets_df["liquidity_bucket"] != "loan") & (buckets_df["amount"] != 0)]
+ if not chart_df.empty:
+ st.plotly_chart(px.pie(chart_df, names="label", values="amount", title="์์ฐ ๊ตฌ์ฑ(loan ์ ์ธ)"), width="stretch")
+
+ render_section_label("๐งญ", "์์ฐ ๊ตฌ์ฑ")
+ source_accounts = pd.DataFrame(asset_report.get("accounts", []))
+ manual_assets = pd.DataFrame(asset_report.get("manual_assets", []))
+ if not source_accounts.empty:
+ source_accounts["bucket_label"] = source_accounts["liquidity_bucket"].map(LIQUIDITY_BUCKET_LABELS).fillna(source_accounts["liquidity_bucket"])
+ source_accounts["last_balance_display"] = source_accounts["last_balance"].map(format_krw)
+ show_cols = ["name", "type", "bucket_label", "last_balance_display", "last_synced_at", "memo"]
+ st.dataframe(source_accounts[show_cols], width="stretch", height=220)
+ else:
+ st.info("last_balance๊ฐ ์
๋ ฅ๋ ํ์ฑ ๊ณ์ข/์์ฐ ํญ๋ชฉ์ด ์์ต๋๋ค.")
+ if not manual_assets.empty:
+ manual_assets["bucket_label"] = manual_assets["liquidity_bucket"].map(LIQUIDITY_BUCKET_LABELS).fillna(manual_assets["liquidity_bucket"])
+ manual_assets["valuation_display"] = manual_assets["amount"].map(format_krw)
+ st.caption("์๊ธฐ ์์ฐ/๋ถ์ฑ(manual_assets) โ ๋น๊ฑฐ๋ ์์ฐ ๊ถ์ ์์ค")
+ st.dataframe(manual_assets[["name", "bucket_label", "valuation_display", "valuation_date", "source_note", "memo"]], width="stretch", height=180)
+
+ render_section_label("๐", "์ง์ถ ๊ตฌ์กฐ/์์ฐ")
+ budgets = pd.DataFrame(budget_report.get("items", []))
+ if not budgets.empty:
+ over_count = int((budgets["status"] == "over").sum()) if "status" in budgets else 0
+ b1, b2, b3 = st.columns(3)
+ b1.metric("์ ์์ฐ", format_krw(budget_report.get("total_budget", 0)))
+ b2.metric("์์ฐ ๋์ ์ง์ถ", format_krw(budget_report.get("total_actual", 0)))
+ b3.metric("์ด๊ณผ ํญ๋ชฉ", f"{over_count}๊ฑด")
+ budgets["budget_display"] = budgets["budget"].map(format_krw)
+ budgets["actual_display"] = budgets["actual"].map(format_krw)
+ budgets["remaining_display"] = budgets["remaining"].map(format_krw)
+ budgets["usage_rate_display"] = budgets["usage_rate"].map(format_percent)
+ st.dataframe(budgets[["category", "subcategory", "person", "budget_display", "actual_display", "remaining_display", "usage_rate_display", "status"]], width="stretch", height=240)
+ else:
+ st.info(f"{month} ์์ฐ์ด ์์ต๋๋ค. ์์ฐ ๋ฉ๋ด์์ ์๋ณ ์์ฐ์ ๋ฑ๋กํ๋ฉด ์ด ์์ญ์ ํ์๋ฉ๋๋ค.")
+
+ render_section_label("โ ๏ธ", "์ด์ ๊ฒฝ๊ณ ")
+ review_items = get_transfer_review_items(transfer_queue_res)
+ reconciliation_items = reconciliation_res.get("items", []) if reconciliation_res.get("success") else []
+ reconciliation_review = [item for item in reconciliation_items if item.get("status") == "REVIEW_NEEDED"]
+ missing_balance = asset_report.get("missing_balance_accounts", [])
+ w1, w2, w3, w4, w5 = st.columns(5)
+ w1.metric("๋ฏธ์ฒ๋ฆฌ review", f"{review_indicators.get('pending_review_count', len(review_items)):,}๊ฑด")
+ w2.metric("๋ณด๋ฅ/์ฌ๊ฒํ ", f"{review_indicators.get('held_recheck_count', 0):,}๊ฑด")
+ w3.metric("๋ฏธ๋์กฐ ๊ณ์ข", f"{review_indicators.get('unreconciled_account_count', len(reconciliation_review)):,}๊ฐ")
+ w4.metric("์ต๊ทผ ๋์กฐ ๋ง๊ฐ", review_indicators.get("last_reconciliation_closed_at") or "-")
+ w5.metric("๋ฏธ๋ถ๋ฅ ์ถ์ฒ ๋๊ธฐ", f"{review_indicators.get('uncategorized_recommendation_backlog', uncategorized_res.get('count', 0)):,}๊ฑด")
+ latest_backup = (backups_res.get("backups") or [{}])[0] if backups_res.get("success") else {}
+ latest_import = (imports_res.get("import_batches") or [{}])[0] if imports_res.get("success") else {}
+ st.caption(
+ "์์ธ ์ด๋: ๋ฏธ๋ถ๋ฅ โ ๋ฏธ๋ถ๋ฅ, ํ์ธํ์ ํ โ ํ์ธํ์ ํ, ์์ก๋์กฐ ํ์/์์ก ๋ฏธ์
๋ ฅ โ ๊ณ์ข, "
+ f"์ต๊ทผ ๋ฐฑ์
: {latest_backup.get('name', '์์')}, ์ต๊ทผ import: {latest_import.get('source', '์์')} {latest_import.get('imported_at', '')}"
+ )
+ if missing_balance:
+ st.warning("last_balance๊ฐ ์๋ ๊ณ์ข/์์ฐ์ ์์ฐ ์ ๋์ฑ ๊ณ์ฐ์์ ์ ์ธํ์ต๋๋ค. ๊ณ์ข ํ๋ฉด์์ ๊ธฐ์ค ์์ก๊ณผ ๊ธฐ์ค ์๊ฐ์ ์
๋ ฅํ์ธ์.")
diff --git a/dashboard/page_views/rules.py b/dashboard/page_views/rules.py
new file mode 100644
index 0000000..e7e60a9
--- /dev/null
+++ b/dashboard/page_views/rules.py
@@ -0,0 +1,394 @@
+from __future__ import annotations
+
+# ruff: noqa: F405
+
+
+import pandas as pd
+import streamlit as st
+
+from jarca_ledger_mcp.adapters.streamlit import categorization_gateway
+from dashboard.common import * # noqa: F403, F405
+
+RULE_MATCH_FIELDS = ["merchant", "raw_summary", "counterparty", "payment_method", "branch", "description", "memo"]
+RULE_MATCH_TYPES = ["contains", "equals", "starts_with", "ends_with", "regex"]
+RULE_TX_TYPES = ["", "income", "expense", "transfer", "refund", "adjustment"]
+RULE_FIXED_LABELS = ["๋ณ๊ฒฝ์ํจ", "์", "์๋์ค"]
+
+
+@st.dialog("๋ถ๋ฅ ๊ท์น ์ถ๊ฐ", width="large")
+def category_rule_create_dialog(db_path: str) -> None:
+ category_options, _ = get_category_options(db_path)
+ editable_categories = [c for c in category_options if c]
+
+ with st.form("rule_create_dialog"):
+ cols = st.columns(4)
+ match_field = cols[0].selectbox("๋์ ํ๋", RULE_MATCH_FIELDS)
+ match_type = cols[1].selectbox("๋งค์นญ ๋ฐฉ์", RULE_MATCH_TYPES)
+ pattern = cols[2].text_input("ํจํด")
+ tx_type = cols[3].selectbox("๊ฑฐ๋ ์ ํ", RULE_TX_TYPES)
+
+ cols2 = st.columns(5)
+ category = cols2[0].selectbox("๋๋ถ๋ฅ", editable_categories)
+ subcategory = cols2[1].text_input("์๋ถ๋ฅ")
+ person = cols2[2].text_input("person")
+ priority = cols2[3].number_input("์ฐ์ ์์", value=100, step=10)
+ is_fixed = cols2[4].selectbox("๊ณ ์ ๋น", RULE_FIXED_LABELS)
+
+ cols3 = st.columns(3)
+ amount_min_text = cols3[0].text_input("์ต์ ๊ธ์ก(์ ํ)", placeholder="์: 1200000")
+ amount_max_text = cols3[1].text_input("์ต๋ ๊ธ์ก(์ ํ)", placeholder="์: 1200000")
+ day_of_month_text = cols3[2].text_input("๋งค์ ์ผ์(์ ํ)", placeholder="1~31")
+ memo = st.text_input("๋ฉ๋ชจ", value="dashboard: user rule")
+ submitted = st.form_submit_button("๊ท์น ์ ์ฅ", type="primary")
+
+ if submitted:
+ try:
+ amount_min = parse_optional_int(amount_min_text, "์ต์ ๊ธ์ก")
+ amount_max = parse_optional_int(amount_max_text, "์ต๋ ๊ธ์ก")
+ day_of_month = parse_optional_int(day_of_month_text, "๋งค์ ์ผ์")
+ except ValueError as exc:
+ st.error(str(exc))
+ else:
+ fixed_value = None if is_fixed == "๋ณ๊ฒฝ์ํจ" else is_fixed == "์"
+ res = categorization_gateway.create_category_rule(match_field, pattern, category, subcategory, tx_type, match_type, int(priority), person, fixed_value, 1.0, memo, amount_min, amount_max, day_of_month, db_path=db_path)
+ clear_cache()
+ if res.get("success"):
+ st.success("๊ท์น์ ์ ์ฅํ์ต๋๋ค.")
+ st.rerun()
+ else:
+ st.error(res.get("error", res))
+
+
+@st.dialog("๋ถ๋ฅ ๊ท์น ์์ ", width="large")
+def category_rule_edit_dialog(rule: dict[str, Any], db_path: str) -> None:
+ category_options, _ = get_category_options(db_path)
+ current_category = rule.get("category", "") or ""
+ editable_categories = [c for c in category_options if c]
+ if current_category and current_category not in editable_categories:
+ editable_categories.append(current_category)
+ fixed_value = "๋ณ๊ฒฝ์ํจ" if rule.get("is_fixed") is None else ("์" if rule.get("is_fixed") else "์๋์ค")
+
+ st.caption(f"๊ท์น ID: `{rule['id']}`")
+ st.write(f"**{rule.get('pattern') or '-'}**")
+
+ with st.form(f"rule_edit_dialog_{rule['id']}"):
+ cols = st.columns(4)
+ edit_match_field = cols[0].selectbox("๋์ ํ๋", RULE_MATCH_FIELDS, index=RULE_MATCH_FIELDS.index(rule.get("match_field", "merchant")) if rule.get("match_field", "merchant") in RULE_MATCH_FIELDS else 0)
+ edit_match_type = cols[1].selectbox("๋งค์นญ ๋ฐฉ์", RULE_MATCH_TYPES, index=RULE_MATCH_TYPES.index(rule.get("match_type", "contains")) if rule.get("match_type", "contains") in RULE_MATCH_TYPES else 0)
+ edit_pattern = cols[2].text_input("ํจํด", value=rule.get("pattern", "") or "")
+ edit_tx_type = cols[3].selectbox("๊ฑฐ๋ ์ ํ", RULE_TX_TYPES, index=RULE_TX_TYPES.index(rule.get("transaction_type", "") or "") if (rule.get("transaction_type", "") or "") in RULE_TX_TYPES else 0)
+
+ cols2 = st.columns(5)
+ edit_category = cols2[0].selectbox("๋๋ถ๋ฅ", editable_categories, index=editable_categories.index(current_category) if current_category in editable_categories else 0)
+ edit_subcategory = cols2[1].text_input("์๋ถ๋ฅ", value=rule.get("subcategory", "") or "")
+ edit_person = cols2[2].text_input("person", value=rule.get("person", "") or "")
+ edit_priority = cols2[3].number_input("์ฐ์ ์์", value=int(rule.get("priority", 100) or 100), step=10)
+ edit_is_fixed = cols2[4].selectbox("๊ณ ์ ๋น", RULE_FIXED_LABELS, index=RULE_FIXED_LABELS.index(fixed_value))
+
+ cols3 = st.columns(4)
+ edit_amount_min_text = cols3[0].text_input("์ต์ ๊ธ์ก(์ ํ)", value="" if rule.get("amount_min") is None else str(rule.get("amount_min")))
+ edit_amount_max_text = cols3[1].text_input("์ต๋ ๊ธ์ก(์ ํ)", value="" if rule.get("amount_max") is None else str(rule.get("amount_max")))
+ edit_day_of_month_text = cols3[2].text_input("๋งค์ ์ผ์(์ ํ)", value="" if rule.get("day_of_month") is None else str(rule.get("day_of_month")))
+ edit_active = cols3[3].checkbox("ํ์ฑ", value=bool(rule.get("active", True)))
+ edit_memo = st.text_input("๋ฉ๋ชจ", value=rule.get("memo", "") or "")
+ edit_submitted = st.form_submit_button("๊ท์น ์์ ", type="primary")
+
+ if edit_submitted:
+ try:
+ edit_amount_min = parse_optional_int(edit_amount_min_text, "์ต์ ๊ธ์ก")
+ edit_amount_max = parse_optional_int(edit_amount_max_text, "์ต๋ ๊ธ์ก")
+ edit_day_of_month = parse_optional_int(edit_day_of_month_text, "๋งค์ ์ผ์")
+ except ValueError as exc:
+ st.error(str(exc))
+ else:
+ edit_fixed_value = None if edit_is_fixed == "๋ณ๊ฒฝ์ํจ" else edit_is_fixed == "์"
+ update_rule = getattr(categorization_gateway, "update_category_rule", None)
+ if update_rule is None:
+ st.error("ํ์ฌ ์คํ ์ค์ธ ์๋น์ค์ update_category_rule ํจ์๊ฐ ์์ต๋๋ค. ์ฑ์ ์ฌ์์ํด ์ต์ ์ฝ๋๋ฅผ ๋ค์ ๋ถ๋ฌ์ค์ธ์.")
+ else:
+ res = update_rule(
+ rule["id"], edit_match_field, edit_pattern, edit_category, edit_subcategory,
+ edit_tx_type, edit_match_type, int(edit_priority), edit_person, edit_fixed_value,
+ 1.0, edit_memo, edit_amount_min, edit_amount_max, edit_day_of_month, edit_active,
+ db_path=db_path,
+ )
+ clear_cache()
+ if res.get("success"):
+ st.success("๊ท์น์ ์์ ํ์ต๋๋ค.")
+ st.rerun()
+ else:
+ st.error(res.get("error", res))
+
+
+@st.dialog("๊ท์น ์ ์ฉ ๋ฏธ๋ฆฌ๋ณด๊ธฐ", width="large")
+def category_rule_preview_dialog(rule: dict[str, Any], db_path: str) -> None:
+ rule_id = rule.get("id", "")
+ pattern = rule.get("pattern") or "-"
+ result_text = f"{rule.get('category') or '-'} / {rule.get('subcategory') or '-'}"
+ st.caption(f"๊ท์น ID: `{rule_id}`")
+ st.write(f"**{pattern}** โ **{result_text}**")
+
+ preview_fn = get_service_callable(categorization_gateway, "preview_category_rule_matches")
+ if not callable(preview_fn):
+ st.error("ํ์ฌ ์คํ ์ค์ธ ์๋น์ค์์ ๋จ์ผ ๊ท์น ๋ฏธ๋ฆฌ๋ณด๊ธฐ ํจ์๋ฅผ ์ฐพ์ง ๋ชปํ์ต๋๋ค. Streamlit ์๋ฒ๋ฅผ ์์ ํ ์ข
๋ฃํ ๋ค ๋ค์ ์์ํด ์ฃผ์ธ์.")
+ return
+ preview = preview_fn(rule_id, limit=10000, sample_limit=3, db_path=db_path)
+ if not preview.get("success"):
+ st.error(preview.get("error", preview))
+ return
+
+ m1, m2, m3 = st.columns(3)
+ m1.metric("์ค์บํ ํ์ฑ ๊ฑฐ๋", f"{preview.get('scanned', 0):,}๊ฑด")
+ m2.metric("์ด ๊ท์น๊ณผ ๋งค์นญ", f"{preview.get('matched_count', 0):,}๊ฑด")
+ m3.metric("๋ฏธ๋ถ๋ฅ ์ค ์ ์ฉ ๋์", f"{preview.get('uncategorized_matched_count', 0):,}๊ฑด")
+
+ sample = preview.get("sample", [])
+ if sample:
+ df = pd.DataFrame(sample)
+ if "amount" in df:
+ df["๊ธ์ก"] = df["amount"].map(format_krw)
+ display_cols = [
+ c for c in [
+ "transaction_date", "transaction_datetime", "type", "๊ธ์ก", "merchant", "raw_summary",
+ "counterparty", "current_category", "current_subcategory", "new_category", "new_subcategory",
+ ] if c in df.columns
+ ]
+ st.dataframe(df[display_cols], width="stretch", height=160)
+ else:
+ st.info("์ด ๊ท์น์ ๋ถํฉํ๋ ๊ฑฐ๋๊ฐ ์์ต๋๋ค.")
+
+def render_rule_list_items(rules: list[dict[str, Any]], db_path: str) -> None:
+ if not rules:
+ st.info("๋ฑ๋ก๋ ๊ท์น์ด ์์ต๋๋ค.")
+ return
+
+ st.markdown(
+ """
+
+ """,
+ unsafe_allow_html=True,
+ )
+
+ for idx, rule in enumerate(rules):
+ rule_id = rule.get("id", "")
+ active = bool(rule.get("active", True))
+ status_label = "ํ์ฑ" if active else "๋นํ์ฑ"
+ tx_type = rule.get("transaction_type") or "์ ์ฒด"
+ person = rule.get("person") or "-"
+ fixed = rule.get("is_fixed")
+ fixed_label = "์" if fixed is True else "์๋์ค" if fixed is False else "๋ณ๊ฒฝ์ํจ"
+ amount_bits = []
+ if rule.get("amount_min") is not None:
+ amount_bits.append(f"์ต์ {format_krw(rule.get('amount_min'))}")
+ if rule.get("amount_max") is not None:
+ amount_bits.append(f"์ต๋ {format_krw(rule.get('amount_max'))}")
+ if rule.get("day_of_month") is not None:
+ amount_bits.append(f"๋งค์ {rule.get('day_of_month')}์ผ")
+ condition_text = " ยท ".join(amount_bits) if amount_bits else "์ถ๊ฐ ์กฐ๊ฑด ์์"
+ match_field = rule.get("match_field") or "-"
+ match_type = rule.get("match_type") or "-"
+ pattern_text = rule.get("pattern") or "-"
+ result_text = f"{rule.get('category') or '-'} / {rule.get('subcategory') or '-'}"
+ memo = rule.get("memo") or ""
+ priority = rule.get("priority", "-")
+ summary = f"{'๐ข' if active else 'โช'} {pattern_text} ยท {match_field}/{match_type} โ {result_text} ยท p{priority} ยท {tx_type}"
+
+ with st.expander(summary, expanded=False):
+ st.markdown(
+ f"""
+
+
+
IF
+
{escape(match_field)} {escape(match_type)} โ{escape(pattern_text)}โ
+
+
โ
+
+
THEN
+
{escape(result_text)}
+
+
+
+ ์ํ: {escape(status_label)}
+ ์ฐ์ ์์: p{escape(str(priority))}
+ ๊ฑฐ๋์ ํ: {escape(tx_type)}
+ ์กฐ๊ฑด: {escape(condition_text)}
+ person: {escape(person)}
+ ๊ณ ์ ๋น: {escape(fixed_label)}
+ ID: {escape(rule_id)}
+
+ {f'๋ฉ๋ชจ: {escape(memo)}
' if memo else ''}
+ """,
+ unsafe_allow_html=True,
+ )
+ action_cols = st.columns([1.35, 0.9, 0.8, 1.4, 3.2], vertical_alignment="center")
+ if action_cols[0].button("๐ ์ ์ฉ(๋ฏธ๋ฆฌ๋ณด๊ธฐ)", key=f"rule_expander_preview_{rule_id}_{idx}", use_container_width=True):
+ category_rule_preview_dialog(rule, db_path)
+ if action_cols[1].button("โ
์ ์ฉ", key=f"rule_expander_apply_{rule_id}_{idx}", use_container_width=True):
+ apply_fn = get_service_callable(categorization_gateway, "apply_category_rule")
+ if not callable(apply_fn):
+ st.error("ํ์ฌ ์คํ ์ค์ธ ์๋น์ค์์ ๋จ์ผ ๊ท์น ์ ์ฉ ํจ์๋ฅผ ์ฐพ์ง ๋ชปํ์ต๋๋ค. Streamlit ์๋ฒ๋ฅผ ์์ ํ ์ข
๋ฃํ ๋ค ๋ค์ ์์ํด ์ฃผ์ธ์.")
+ else:
+ res = apply_fn(rule_id, limit=10000, db_path=db_path)
+ clear_cache()
+ if res.get("success"):
+ st.success(f"๋ฏธ๋ถ๋ฅ ๋งค์นญ {res.get('applied_count', 0):,}๊ฑด์ ๊ท์น์ ์ ์ฉํ์ต๋๋ค.")
+ st.rerun()
+ else:
+ st.error(res.get("error", res))
+ if action_cols[2].button("โ๏ธ ์์ ", key=f"rule_expander_edit_{rule_id}_{idx}", use_container_width=True):
+ category_rule_edit_dialog(rule, db_path)
+ with action_cols[3]:
+ st.markdown('์ญ์ ๋ณด๋ค ๋นํ์ฑํ๋ฅผ ๊ถ์ฅํฉ๋๋ค.
', unsafe_allow_html=True)
+ if action_cols[4].button("๐๏ธ ์ญ์ ", key=f"rule_expander_delete_{rule_id}_{idx}"):
+ delete_rule = getattr(categorization_gateway, "delete_category_rule", None)
+ if delete_rule is None:
+ st.error("ํ์ฌ ์คํ ์ค์ธ ์๋น์ค์ delete_category_rule ํจ์๊ฐ ์์ต๋๋ค. ์ฑ์ ์ฌ์์ํด ์ต์ ์ฝ๋๋ฅผ ๋ค์ ๋ถ๋ฌ์ค์ธ์.")
+ else:
+ res = delete_rule(rule_id, db_path=db_path)
+ clear_cache()
+ if res.get("success"):
+ if st.session_state.get("rules_edit_rule_id") == rule_id:
+ st.session_state.pop("rules_edit_rule_id", None)
+ st.success("๊ท์น์ ์ญ์ ํ์ต๋๋ค.")
+ st.rerun()
+ else:
+ st.error(res.get("error", res))
+
+
+def page_rules(db_path: str) -> None:
+ action_cols = st.columns([5, 1], vertical_alignment="center")
+ with action_cols[1]:
+ if st.button("์ถ๊ฐ", key="rule_add_dialog_open", use_container_width=True, type="primary"):
+ category_rule_create_dialog(db_path)
+ rules = categorization_gateway.list_category_rules(active_only=False, db_path=db_path).get("rules", [])
+ render_section_label("๐", "ํ์ฌ ๋ถ๋ฅ ๊ท์น")
+ render_rule_list_items(rules, db_path)
+
+ st.caption("๊ท์น ์ถ๊ฐ๋ ์ ๋ชฉ ์ค๋ฅธ์ชฝ `์ถ๊ฐ` ๋ฒํผ, ๊ธฐ์กด ๊ท์น ์์ ์ ๊ฐ ํญ๋ชฉ์ `โฎ โ ๋ณ๊ฒฝ` ํ์
์ ์ฌ์ฉํ์ธ์.")
+
+ c1, c2, c3 = st.columns(3)
+ if c1.button("๊ธฐ๋ณธ ๊ท์น seed/update"):
+ res = categorization_gateway.seed_default_category_rules(db_path=db_path)
+ clear_cache(); st.success(res)
+ if c2.button("๊ท์น preview"):
+ st.session_state["rule_preview"] = categorization_gateway.preview_categorization(limit=5000, db_path=db_path)
+ if c3.button("๊ท์น ์ ์ฉ"):
+ res = categorization_gateway.apply_categorization_rules(limit=5000, db_path=db_path)
+ clear_cache(); st.success(f"{res.get('applied_count', 0)}๊ฑด ์ ์ฉ")
+ if "rule_preview" in st.session_state:
+ prev = st.session_state["rule_preview"]
+ st.info(f"์ค์บ {prev.get('scanned')}๊ฑด ยท ๋งค์นญ {prev.get('matched_count')}๊ฑด ยท ๋ฏธ๋ถ๋ฅ {prev.get('unmatched_count')}๊ฑด")
+ st.write("์นดํ
๊ณ ๋ฆฌ๋ณ ์์")
+ st.dataframe(pd.DataFrame([{"category": k, **v} for k, v in prev.get("by_category", {}).items()]), width="stretch")
+ with st.expander("๋งค์นญ ์ํ"):
+ st.dataframe(pd.DataFrame(prev.get("matches", [])), width="stretch")
+ with st.expander("๋ฏธ๋งค์นญ ์ํ"):
+ st.dataframe(pd.DataFrame(prev.get("unmatched", [])), width="stretch")
diff --git a/dashboard/page_views/transactions.py b/dashboard/page_views/transactions.py
new file mode 100644
index 0000000..5b49871
--- /dev/null
+++ b/dashboard/page_views/transactions.py
@@ -0,0 +1,41 @@
+from __future__ import annotations
+
+# ruff: noqa: F405
+
+
+import streamlit as st
+
+from dashboard.common import * # noqa: F403, F405
+
+def page_transactions(db_path: str) -> None:
+ category_options, _ = get_category_options(db_path)
+ people_df = read_sql(db_path, "SELECT DISTINCT person FROM transactions WHERE status='active' AND person<>'' ORDER BY person")
+ people = people_df["person"].tolist() if not people_df.empty else []
+ col1, col2, col3, col4, col5 = st.columns(5)
+ start = col1.date_input("์์์ผ", value=None)
+ end = col2.date_input("์ข
๋ฃ์ผ", value=None)
+ category = col3.selectbox("์นดํ
๊ณ ๋ฆฌ", ["์ ์ฒด"] + [c for c in category_options if c])
+ person = col4.selectbox("์ธ์", ["์ ์ฒด"] + people)
+ text = col5.text_input("์ฌ์ฉ์ฒ ๊ฒ์")
+ clauses = ["t.status='active'"]
+ params: list[Any] = []
+ if start:
+ clauses.append("t.transaction_date>=?"); params.append(str(start))
+ if end:
+ clauses.append("t.transaction_date<=?"); params.append(str(end))
+ if category != "์ ์ฒด":
+ clauses.append("t.category=?"); params.append(category)
+ if person != "์ ์ฒด":
+ clauses.append("t.person=?"); params.append(person)
+ if text:
+ clauses.append("t.merchant LIKE ?"); params.append(f"%{text}%")
+ df = transaction_df(db_path, "WHERE " + " AND ".join(clauses), tuple(params), limit=2000)
+ st.dataframe(df.drop(columns=["id", "amount", "balance_after", "is_fixed"], errors="ignore"), width="stretch", height=500)
+
+ if not df.empty:
+ render_section_label("โ๏ธ", "์ ํ ๊ฑฐ๋ ์์ ")
+ options = {f"{r.transaction_datetime or r.transaction_date} ยท {r.merchant} ยท {format_krw(r.amount)} ยท {r.category or '๋ฏธ๋ถ๋ฅ'}": r.id for r in df.itertuples()}
+ label = st.selectbox("์์ ํ ๊ฑฐ๋", list(options.keys()))
+ tx_id = options[label]
+ tx = df[df["id"] == tx_id].iloc[0].to_dict()
+ update_transaction_form(tx, "tx", db_path)
diff --git a/dashboard/page_views/uncategorized.py b/dashboard/page_views/uncategorized.py
new file mode 100644
index 0000000..04d04df
--- /dev/null
+++ b/dashboard/page_views/uncategorized.py
@@ -0,0 +1,182 @@
+from __future__ import annotations
+
+# ruff: noqa: F405
+
+
+import streamlit as st
+
+from jarca_ledger_mcp.adapters.streamlit import categorization_gateway
+from dashboard.common import * # noqa: F403, F405
+
+def render_uncategorized_action_table(rows: list[dict[str, Any]], db_path: str) -> None:
+ header_cols = st.columns([1.45, 0.6, 0.8, 1.35, 1.3, 0.7, 1.0, 1.0])
+ headers = ["์ผ์", "๊ตฌ๋ถ", "๊ธ์ก", "์ฌ์ฉ์ฒ", "์์ฝ", "", "", ""]
+ for col, label in zip(header_cols, headers):
+ col.markdown(f"**{label}**")
+
+ for idx, row in enumerate(rows):
+ cols = st.columns([1.45, 0.6, 0.8, 1.35, 1.3, 0.7, 1.0, 1.0])
+ cols[0].write(row.get("transaction_datetime") or row.get("transaction_date") or "")
+ cols[1].write(row.get("type") or "")
+ cols[2].write(format_krw(row.get("amount")))
+ cols[3].write(row.get("merchant") or "")
+ cols[4].write(row.get("raw_summary") or "")
+ if cols[5].button("๋ถ๋ฅ", key=f"uncat_dialog_{row['id']}_{idx}", use_container_width=True, type="tertiary"):
+ transaction_edit_dialog(row, f"uncat_dialog_{row['id']}", db_path)
+ if cols[6].button("์ฐ๊ฒฐ๊ฑฐ๋์ฐพ๊ธฐ", key=f"uncat_link_{row['id']}_{idx}", use_container_width=True, type="tertiary"):
+ linked_transfer_candidates_dialog(row, db_path)
+ if cols[7].button("์๊ฑฐ๋์ฐพ๊ธฐ", key=f"uncat_cancel_{row['id']}_{idx}", use_container_width=True, type="tertiary"):
+ cancel_original_candidates_dialog(row, db_path)
+
+
+def recommendation_text(rec: dict[str, Any] | None) -> str:
+ rec = rec or {}
+ if not rec.get("category"):
+ return "์ถ์ฒ ์์"
+ pct = int(round(float(rec.get("confidence") or 0) * 100))
+ return f"{rec.get('category')} / {rec.get('subcategory') or '-'} ยท {pct}%"
+
+
+def save_merchant_rule_from_recommendation(merchant: str, tx_type: str, rec: dict[str, Any], db_path: str) -> dict[str, Any]:
+ if not merchant:
+ return {"success": False, "error": "merchant๊ฐ ๋น์ด ์์ด ๊ท์น์ ๋ง๋ค ์ ์์ต๋๋ค."}
+ if not rec.get("category"):
+ return {"success": False, "error": "์ถ์ฒ ๋ถ๋ฅ๊ฐ ์์ด ๊ท์น์ ๋ง๋ค ์ ์์ต๋๋ค."}
+ return categorization_gateway.create_category_rule(
+ match_field="merchant",
+ match_type="contains",
+ pattern=merchant,
+ transaction_type=tx_type or "",
+ category=rec.get("category", ""),
+ subcategory=rec.get("subcategory", ""),
+ priority=70,
+ confidence=float(rec.get("confidence") or 0.8),
+ memo="dashboard: uncategorized workbench rule",
+ db_path=db_path,
+ )
+
+
+def render_uncategorized_groups(groups: list[dict[str, Any]], db_path: str, repeated_only: bool = False) -> None:
+ visible = [g for g in groups if not repeated_only or int(g.get("count") or 0) >= 2]
+ visible = visible[:10]
+ if not visible:
+ st.info("๋ฐ๋ณต ํ๋ณด๊ฐ ์์ต๋๋ค.")
+ return
+ for idx, group in enumerate(visible):
+ rec = group.get("recommendation") or {}
+ cols = st.columns([2.0, 0.7, 1.0, 1.55, 1.0, 1.0], vertical_alignment="center")
+ merchant = group.get("merchant") or group.get("raw_summary") or "-"
+ cols[0].markdown(f"**{merchant}** \n{group.get('raw_summary') or '-'} ยท {group.get('type') or '-'}", unsafe_allow_html=True)
+ cols[1].write(f"{int(group.get('count') or 0):,}๊ฑด")
+ cols[2].write(format_krw(group.get("total_amount")))
+ cols[3].write(recommendation_text(rec))
+ apply_disabled = not bool(rec.get("category"))
+ if cols[4].button("๊ทธ๋ฃน ์ ์ฉ", key=f"uncat_group_apply_{idx}_{merchant}", disabled=apply_disabled, use_container_width=True):
+ res = categorization_gateway.apply_uncategorized_group_recommendation(
+ group.get("merchant", ""), group.get("raw_summary", ""), group.get("type", ""), db_path=db_path
+ )
+ clear_cache()
+ if res.get("success"):
+ st.success(f"๊ทธ๋ฃน {res.get('applied_count', 0):,}๊ฑด์ {recommendation_text(res.get('recommendation'))}๋ก ๋ถ๋ฅํ์ต๋๋ค.")
+ st.rerun()
+ else:
+ st.error(res.get("error", res))
+ if cols[5].button("๊ท์น ๋ง๋ค๊ธฐ", key=f"uncat_group_rule_{idx}_{merchant}", disabled=apply_disabled, use_container_width=True):
+ res = save_merchant_rule_from_recommendation(group.get("merchant", ""), group.get("type", ""), rec, db_path)
+ clear_cache()
+ if res.get("success"):
+ st.success("์ฌ์ฉ์ฒ ๊ท์น์ ์ ์ฅํ์ต๋๋ค.")
+ st.rerun()
+ else:
+ st.warning(res.get("error", res))
+ st.caption(f"์ต๊ทผ: {group.get('latest_date') or '-'} ยท ์ต๋๊ธ์ก: {format_krw(group.get('max_amount'))} ยท ๊ทผ๊ฑฐ: {rec.get('reason') or '์ถ์ฒ ์์'}")
+ if idx < len(visible) - 1:
+ st.divider()
+
+
+def render_uncategorized_work_items(rows: list[dict[str, Any]], db_path: str, recommended_only: bool = False) -> None:
+ visible = [r for r in rows if not recommended_only or r.get("recommended_category")]
+ if not visible:
+ st.info("์กฐ๊ฑด์ ๋ง๋ ๋ฏธ๋ถ๋ฅ ๊ฑฐ๋๊ฐ ์์ต๋๋ค.")
+ return
+ for idx, row in enumerate(visible):
+ rec = row.get("recommendation") or {}
+ date_text = row.get("transaction_datetime") or row.get("transaction_date") or ""
+ title = f"{date_text} ยท {format_krw(row.get('amount'))} ยท {row.get('merchant') or row.get('counterparty') or '-'} ยท ์ถ์ฒ: {recommendation_text(rec)}"
+ with st.expander(title, expanded=False):
+ c1, c2, c3, c4 = st.columns(4)
+ c1.metric("๊ฑฐ๋์ ํ", row.get("type") or "-")
+ c2.metric("๊ธ์ก", format_krw(row.get("amount")))
+ c3.metric("์ถ์ฒ", recommendation_text(rec))
+ c4.metric("์ถ์ฒ ๊ทผ๊ฑฐ", rec.get("source") or "none")
+ st.caption(f"์ฌ์ฉ์ฒ: {row.get('merchant') or '-'} ยท ์์ฝ: {row.get('raw_summary') or '-'} ยท ์๋: {row.get('counterparty') or '-'} ยท source: {row.get('source') or '-'} ยท ID: `{row.get('id')}`")
+ if rec.get("reason"):
+ st.info(rec.get("reason"))
+ action_cols = st.columns([1.0, 1.0, 1.0, 1.1, 1.1, 2.5], vertical_alignment="center")
+ apply_disabled = not bool(rec.get("category"))
+ if action_cols[0].button("์ถ์ฒ ์ ์ฉ", key=f"uncat_apply_rec_{row['id']}_{idx}", disabled=apply_disabled, use_container_width=True, type="primary"):
+ res = categorization_gateway.apply_uncategorized_recommendation(row["id"], db_path=db_path)
+ clear_cache()
+ if res.get("success"):
+ st.success(f"{recommendation_text(res.get('recommendation'))}๋ก ๋ถ๋ฅํ์ต๋๋ค.")
+ st.rerun()
+ else:
+ st.error(res.get("error", res))
+ if action_cols[1].button("์ง์ ๋ถ๋ฅ", key=f"uncat_direct_{row['id']}_{idx}", use_container_width=True):
+ transaction_edit_dialog(row, f"uncat_direct_{row['id']}", db_path)
+ if action_cols[2].button("๊ท์น ๋ง๋ค๊ธฐ", key=f"uncat_rule_{row['id']}_{idx}", disabled=apply_disabled, use_container_width=True):
+ res = save_merchant_rule_from_recommendation(row.get("merchant", ""), row.get("type", ""), rec, db_path)
+ clear_cache()
+ if res.get("success"):
+ st.success("์ฌ์ฉ์ฒ ๊ท์น์ ์ ์ฅํ์ต๋๋ค.")
+ st.rerun()
+ else:
+ st.warning(res.get("error", res))
+ if action_cols[3].button("์ฐ๊ฒฐ๊ฑฐ๋์ฐพ๊ธฐ", key=f"uncat_link_{row['id']}_{idx}", use_container_width=True):
+ linked_transfer_candidates_dialog(row, db_path)
+ if action_cols[4].button("์๊ฑฐ๋์ฐพ๊ธฐ", key=f"uncat_cancel_{row['id']}_{idx}", use_container_width=True):
+ cancel_original_candidates_dialog(row, db_path)
+ with st.expander(f"Review decision ยท ํ์ฌ {row.get('review_status', 'pending')}", expanded=False):
+ render_review_decision_actions(categorization_gateway, "uncategorized_transaction", row["id"], f"uncat_review_decision_{row['id']}_{idx}", db_path, target_payload=row)
+
+
+
+def page_uncategorized(db_path: str) -> None:
+ filter_cols = st.columns([1.0, 1.2, 1.15, 1.15, 1.25])
+ min_amount = filter_cols[0].number_input("์ต์ ๊ธ์ก", min_value=0, value=0, step=10000)
+ limit = filter_cols[1].slider("ํ์ ๊ฑด์", 10, 500, 100)
+ recommended_only = filter_cols[2].checkbox("์ถ์ฒ ์๋ ํญ๋ชฉ๋ง", value=False)
+ repeated_only = filter_cols[3].checkbox("๋ฐ๋ณต ํ๋ณด๋ง", value=False)
+ sort_label = filter_cols[4].selectbox("์์
๋ชฉ๋ก ์ ๋ ฌ", ["๊ธ์ก์", "๋ ์ง์(์ต์ ์)"], index=0)
+ sort_by = "date_desc" if sort_label == "๋ ์ง์(์ต์ ์)" else "amount_desc"
+
+ workbench_fn = get_service_callable(categorization_gateway, "get_uncategorized_workbench", required_params=["sort_by"])
+ if not callable(workbench_fn):
+ st.error("ํ์ฌ ์คํ ์ค์ธ ์๋น์ค์์ ๋ฏธ๋ถ๋ฅ ์์
๋ ํจ์๋ฅผ ์ฐพ์ง ๋ชปํ์ต๋๋ค. Streamlit ์๋ฒ๋ฅผ ์์ ํ ์ฌ์์ํด ์ฃผ์ธ์.")
+ return
+ result = workbench_fn(limit=limit, min_amount=int(min_amount), sort_by=sort_by, db_path=db_path)
+ if not result.get("success"):
+ st.error(result.get("error", result))
+ return
+
+ summary = result.get("summary", {})
+ metric_cols = st.columns(5)
+ metric_cols[0].metric("๋ฏธ๋ถ๋ฅ", f"{summary.get('uncategorized_count', 0):,}๊ฑด")
+ metric_cols[1].metric("๋ฏธ๋ถ๋ฅ ์ด์ก", format_krw(summary.get("uncategorized_total_amount")))
+ metric_cols[2].metric("์ฌ์ฉ์ฒ ๊ทธ๋ฃน", f"{summary.get('merchant_group_count', 0):,}๊ฐ")
+ metric_cols[3].metric("์ถ์ฒ ๊ฐ๋ฅ", f"{summary.get('recommendable_count', 0):,}๊ฑด")
+ metric_cols[4].metric("๋ฐ๋ณต ํ๋ณด", f"{summary.get('repeated_group_count', 0):,}๊ฐ")
+
+ rows = result.get("transactions", [])
+ groups = result.get("groups", [])
+ if not rows:
+ st.success("๋ฏธ๋ถ๋ฅ ๊ฑฐ๋๊ฐ ์์ต๋๋ค.")
+ return
+
+ render_section_label("๐", "๋ฐ๋ณต ์ฌ์ฉ์ฒ ํ๋ณด")
+ st.caption("๊ฐ์ ์ฌ์ฉ์ฒ/์์ฝ/๊ฑฐ๋์ ํ์ผ๋ก ๋ฌถ์ธ ๋ฏธ๋ถ๋ฅ ๊ฑฐ๋์
๋๋ค. ์ถ์ฒ์ด ์๋ ๊ทธ๋ฃน์ ํ ๋ฒ์ ์ ์ฉํ๊ฑฐ๋ ๊ท์น์ผ๋ก ์ ์ฅํ ์ ์์ต๋๋ค.")
+ render_uncategorized_groups(groups, db_path, repeated_only=repeated_only)
+
+ render_section_label("๐ ๏ธ", "๋ฏธ๋ถ๋ฅ ์์
๋ชฉ๋ก")
+ st.caption("๊ฑฐ๋๋ฅผ ํผ์น๋ฉด ์ถ์ฒ ๊ทผ๊ฑฐ์ ์์
๋ฒํผ์ด ๋ณด์
๋๋ค. ์ถ์ฒ ์ ์ฉ์ ์นดํ
๊ณ ๋ฆฌ๊ฐ ๋น์ด ์๋ ํ์ฌ ๊ฑฐ๋์๋ง ์ ์ฉ๋ฉ๋๋ค.")
+ render_uncategorized_work_items(rows, db_path, recommended_only=recommended_only)
diff --git a/tests/test_dashboard_app.py b/tests/test_dashboard_app.py
index e59ab11..ecdf1b3 100644
--- a/tests/test_dashboard_app.py
+++ b/tests/test_dashboard_app.py
@@ -46,11 +46,11 @@ def test_streamlit_apptest_core_pages_use_synthetic_fixture_db(tmp_path, monkeyp
app_path = Path(__file__).resolve().parents[1] / "dashboard" / "app.py"
page_expectations = {
- "์์ฝ": "๊ฐ๊ณ๋ถ ์ด์ ๋์๋ณด๋",
+ "์์ฝ": "์ด๋ฒ ๋ฌ ์์
/์ง์ถ ํํฉ",
"์์ฐ/๋ง๊ฐ": "์๋ง ์ค๋
์ท Preview / Close",
- "๋ถ๋ฅ ๊ท์น": "๋ถ๋ฅ ๊ท์น",
- "ํ์ธํ์ ํ": "KBโํ ์ค๋ฑ
ํฌ ํ์ธํ์ ํ",
- "์์ ์ฐ": "์์ ์ฐ",
+ "๋ถ๋ฅ ๊ท์น": "ํ์ฌ ๋ถ๋ฅ ๊ท์น",
+ "ํ์ธํ์ ํ": "ํ์ฌ review_status: pending",
+ "์์ ์ฐ": "์๊ฐ ํต์ฌ KPI",
}
for page, expected_text in page_expectations.items():
at = AppTest.from_file(str(app_path), default_timeout=10)
@@ -121,4 +121,4 @@ def test_streamlit_apptest_core_pages_use_synthetic_fixture_db(tmp_path, monkeyp
assert ".local/share/jarca/uploads" not in body_text
assert "/home/" not in body_text
assert "raw_payload" not in body_text
- assert "๐ ์ฝ๊ธฐ ์ ์ฉ" in body_text
\ No newline at end of file
+ assert "๐ ์ฝ๊ธฐ ์ ์ฉ" in body_text