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