from __future__ import annotations import sqlite3 from datetime import datetime from html import escape 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 import service from jarca_ledger_mcp.db import resolve_db_path 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(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(service, 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(service) return getattr(service, 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 = service.list_ledger_categories(active_only=True, include_usage=False, db_path=db_path) if result.get("success") and result.get("count", 0) == 0: service.seed_default_ledger_categories(db_path=db_path) result = service.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 = service.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 = service.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 = service.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 = service.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 = service.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 = service.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() 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 = service.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(service, "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 service.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 = service.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 = service.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) @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("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_overview(db_path: str) -> None: health = service.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 = service.get_monthly_cashflow_report(month, db_path=db_path) asset_report = service.get_asset_liquidity_report(db_path=db_path) budget_report = service.get_budget_vs_actual_report(month, db_path=db_path) uncategorized_res = service.list_uncategorized_transactions(limit=1000, db_path=db_path) transfer_queue_res = service.list_cross_transfer_review_queue(limit=300, db_path=db_path) reconciliation_res = service.get_balance_reconciliation_report(db_path=db_path) backups_res = service.list_ledger_backups(limit=1) imports_res = service.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 = st.columns(4) w1.metric("๋ฏธ๋ถ„๋ฅ˜ ๊ฑฐ๋ž˜", f"{uncategorized_res.get('count', 0):,}๊ฑด") w2.metric("ํ™•์ธํ•„์š” ํ", f"{len(review_items):,}๊ฑด") w3.metric("์ž”์•ก๋Œ€์กฐ ํ•„์š”", f"{len(reconciliation_review):,}๊ฑด") w4.metric("์ž”์•ก ๋ฏธ์ž…๋ ฅ", f"{len(missing_balance):,}๊ฑด") 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 = service.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 = service.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 = service.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 = service.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"] = service.preview_asset_snapshot_close(basis_date, db_path=db_path) if p2.button("์Šค๋ƒ…์ƒท Close ์‹คํ–‰", type="primary"): st.session_state["asset_snapshot_close"] = service.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"] = service.preview_cross_account_transfers(db_path=db_path) with c2: if st.button("์ž๋™ ๋งค์นญ ์‹คํ–‰"): res = service.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(service, "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())) update_transaction_form(options[selected], "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("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("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(service, "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 = service.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 = service.seed_default_category_rules(db_path=db_path) clear_cache(); st.success(res) if c2.button("๊ทœ์น™ preview"): st.session_state["rule_preview"] = service.preview_categorization(limit=5000, db_path=db_path) if c3.button("๊ทœ์น™ ์ ์šฉ"): res = service.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 = service.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 = service.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 = service.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 = service.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 = service.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 = service.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"] = service.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}๊ฑด") 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 = service.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 = service.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 = service.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 = service.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 = service.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 = service.rollback_ledger_import_batch(batch_id, hard_delete=hard, db_path=db_path) clear_cache() st.warning(res) st.rerun() 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 "์š”์•ฝ" st.session_state["page"] = page with st.sidebar: st.markdown("## ๐Ÿ’ฐ Jarca Ledger") st.caption("๊ฐ€๊ณ„๋ถ€ ยท ๋ถ„๋ฅ˜ ยท Import ์šด์˜ ์ฝ˜์†”") st.markdown('
๋ฉ”๋‰ด
', unsafe_allow_html=True) render_sidebar_nav(pages, page) if st.button("์ƒˆ๋กœ๊ณ ์นจ", key="refresh_button", use_container_width=True): clear_cache() st.rerun() if 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) if __name__ == "__main__": main()