from __future__ import annotations import sqlite3 from html import escape from pathlib import Path from typing import Any from urllib.parse import quote import pandas as pd import plotly.express as px 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") 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": "ํ™•์ธํ•„์š”", } def format_krw(value: Any) -> str: try: if value is None: return "-" return f"{int(value):,}์›" except Exception: return str(value) @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 inject_sidebar_nav_css() -> None: st.markdown( """ """, 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, ) 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", ) 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 page_overview(db_path: str) -> None: st.title("๐Ÿ“Š ์š”์•ฝ") health = service.health_check(db_path) st.caption(f"DB: `{health.get('db_path')}` ยท schema v{health.get('schema_version')}") df = read_sql(db_path, "SELECT * FROM transactions WHERE status='active'") accounts = read_sql(db_path, "SELECT * FROM accounts WHERE active=1") categories = read_sql(db_path, "SELECT category, subcategory, cashflow_kind FROM ledger_categories WHERE active=1") if df.empty: st.info("๊ฑฐ๋ž˜๊ฐ€ ์—†์Šต๋‹ˆ๋‹ค.") return df["month"] = pd.to_datetime(df["transaction_date"]).dt.strftime("%Y-%m") if not categories.empty: df = df.merge(categories, how="left", on=["category", "subcategory"]) else: df["cashflow_kind"] = None df["cashflow_kind"] = df["cashflow_kind"].fillna("unknown") account_in = int(df.loc[df["type"] == "income", "amount"].sum()) account_out = int(df.loc[df["type"] == "expense", "amount"].sum()) account_net = account_in - account_out actual_income = int(df.loc[df["cashflow_kind"] == "income", "amount"].sum()) actual_expense = int(df.loc[df["cashflow_kind"] == "expense", "amount"].sum()) actual_net = actual_income - actual_expense savings_rate = actual_net / actual_income if actual_income else None transfer_like = int(df.loc[df["cashflow_kind"].isin(["transfer", "investment", "exclude"]), "amount"].sum()) unknown_amount = int(df.loc[df["cashflow_kind"] == "unknown", "amount"].sum()) uncategorized = int((df["category"].fillna("") == "").sum()) st.subheader("์‹ค์ œ ๊ฐ€๊ณ„๋ถ€ ๊ธฐ์ค€") c1, c2, c3, c4, c5 = st.columns(5) c1.metric("์‹ค์ œ ์ˆ˜์ž…", format_krw(actual_income)) c2.metric("์‹ค์ œ ์ง€์ถœ", format_krw(actual_expense)) c3.metric("์‹ค์ œ ์ˆœํ˜„๊ธˆํ๋ฆ„", format_krw(actual_net)) c4.metric("์ €์ถ•๋ฅ ", f"{savings_rate:.1%}" if savings_rate is not None else "-") c5.metric("ํ™•์ธํ•„์š”", format_krw(unknown_amount)) st.subheader("๊ณ„์ขŒ ์ž…์ถœ๊ธˆ ํ๋ฆ„") b1, b2, b3, b4, b5 = st.columns(5) b1.metric("๊ณ„์ขŒ", len(accounts)) b2.metric("๊ฑฐ๋ž˜", len(df)) b3.metric("์ด ์ž…๊ธˆ", format_krw(account_in)) b4.metric("์ด ์ถœ๊ธˆ", format_krw(account_out)) b5.metric("๊ณ„์ขŒ ์ˆœ์ฆ๊ฐ", format_krw(account_net)) st.caption(f"์ด์ฒด/ํˆฌ์ž/๋ถ„์„์ œ์™ธ์„ฑ ๊ฑฐ๋ž˜ ํ•ฉ๊ณ„: {format_krw(transfer_like)} ยท ๋ฏธ๋ถ„๋ฅ˜ ๊ฑฐ๋ž˜: {uncategorized:,}๊ฑด") monthly = df.pivot_table(index="month", columns="cashflow_kind", values="amount", aggfunc="sum", fill_value=0).reset_index() for col in ["income", "expense"]: if col not in monthly.columns: monthly[col] = 0 monthly["actual_net_cashflow"] = monthly["income"] - monthly["expense"] st.plotly_chart(px.bar(monthly, x="month", y=["income", "expense", "actual_net_cashflow"], barmode="group", title="์›”๋ณ„ ์‹ค์ œ ์ˆ˜์ž…/์ง€์ถœ"), width="stretch") account_monthly = df.pivot_table(index="month", columns="type", values="amount", aggfunc="sum", fill_value=0).reset_index() for col in ["income", "expense"]: if col not in account_monthly.columns: account_monthly[col] = 0 account_monthly["account_net"] = account_monthly["income"] - account_monthly["expense"] st.plotly_chart(px.line(account_monthly, x="month", y=["income", "expense", "account_net"], title="์›”๋ณ„ ๊ณ„์ขŒ ์ž…์ถœ๊ธˆ ํ๋ฆ„"), width="stretch") person_summary = df[df["cashflow_kind"].isin(["income", "expense"])].pivot_table(index="person", columns="cashflow_kind", values="amount", aggfunc="sum", fill_value=0).reset_index() if not person_summary.empty: person_summary["person"] = person_summary["person"].replace("", "๋ฏธ์ง€์ •") for col in ["income", "expense"]: if col not in person_summary.columns: person_summary[col] = 0 person_summary["net_cashflow"] = person_summary["income"] - person_summary["expense"] st.subheader("์ธ์›๋ณ„ ์‹ค์ œ ์ˆ˜์ž…/์ง€์ถœ") st.dataframe(person_summary, width="stretch", height=220) st.plotly_chart(px.bar(person_summary, x="person", y=["income", "expense", "net_cashflow"], barmode="group", title="์ธ์›๋ณ„ ์‹ค์ œ ์ˆ˜์ž…/์ง€์ถœ"), width="stretch") cat = df[df["cashflow_kind"] == "expense"].groupby(["category", "subcategory"], dropna=False)["amount"].sum().reset_index() cat["label"] = cat.apply(lambda r: f"{r['category'] or '๋ฏธ๋ถ„๋ฅ˜'} / {r['subcategory'] or '-'}", axis=1) cat = cat.sort_values("amount", ascending=False).head(20) st.plotly_chart(px.bar(cat, x="amount", y="label", orientation="h", title="์‹ค์ œ ์ง€์ถœ ์นดํ…Œ๊ณ ๋ฆฌ Top 20"), width="stretch") def page_transactions(db_path: str) -> None: st.title("๐Ÿ“’ ๊ฑฐ๋ž˜๋‚ด์—ญ") 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: st.subheader("์„ ํƒ ๊ฑฐ๋ž˜ ์ˆ˜์ •") 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: st.header("๐Ÿ” KBโ†”ํ† ์Šค๋ฑ…ํฌ ํ™•์ธํ•„์š” ํ") st.caption("KB์™€ ํ† ์Šค๋ฑ…ํฌ ๊ฑฐ๋ž˜๋ฅผ ๋‚ ์งœยท๊ธˆ์•กยท์ƒ๋Œ€ ๊ธฐ์ค€์œผ๋กœ ์ž๋™ ๋Œ€์กฐํ•ฉ๋‹ˆ๋‹ค. ๋จผ์ € ๋ฏธ๋ฆฌ๋ณด๊ธฐ(๋“œ๋ผ์ด๋Ÿฐ)๋กœ ํ›„๋ณด๋ฅผ ํ™•์ธํ•œ ๋’ค ์‹คํ–‰ํ•˜์„ธ์š”.") 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"] st.subheader("๋ฏธ๋ฆฌ๋ณด๊ธฐ ๊ฒฐ๊ณผ") 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() st.subheader("ํ™•์ธํ•„์š” ํ") 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: st.title("๐Ÿงฉ ๋ฏธ๋ถ„๋ฅ˜ ๊ฑฐ๋ž˜") col1, col2 = st.columns([1, 3]) min_amount = col1.number_input("์ตœ์†Œ ๊ธˆ์•ก", min_value=0, value=0, step=10000) limit = col2.slider("ํ‘œ์‹œ ๊ฑด์ˆ˜", 10, 500, 100) summary = service.get_uncategorized_merchant_summary(limit=50) st.header("๋ฏธ๋ถ„๋ฅ˜ ์‚ฌ์šฉ์ฒ˜ ์š”์•ฝ") st.dataframe(pd.DataFrame(summary.get("items", [])), width="stretch", height=280) result = service.list_uncategorized_transactions(limit=limit, min_amount=int(min_amount)) rows = result.get("transactions", []) if not rows: st.success("๋ฏธ๋ถ„๋ฅ˜ ๊ฑฐ๋ž˜๊ฐ€ ์—†์Šต๋‹ˆ๋‹ค.") return df = pd.DataFrame(rows) df["๊ธˆ์•ก"] = df["amount"].map(format_krw) st.subheader("๋ฏธ๋ถ„๋ฅ˜ ๊ฑฐ๋ž˜ ์ˆ˜์ •") render_uncategorized_action_table(rows, db_path) 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, ) wrapper = st.container(border=True) with wrapper: 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 "์ถ”๊ฐ€ ์กฐ๊ฑด ์—†์Œ" target_text = f"{rule.get('match_field') or '-'} / {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 "" title_cols = st.columns([16, 1], gap="small", vertical_alignment="center") with title_cols[0]: st.markdown( f'
{escape(pattern_text)}p{escape(str(rule.get("priority", "-")))}
', unsafe_allow_html=True, ) with title_cols[1]: with st.popover("โ‹ฎ"): if st.button("๋ณ€๊ฒฝ", key=f"rule_menu_edit_{rule_id}", use_container_width=True): category_rule_edit_dialog(rule, db_path) if st.button("์‚ญ์ œ", key=f"rule_menu_delete_{rule_id}", use_container_width=True): 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)) st.markdown( f"""
๋Œ€์ƒ: {escape(target_text)}
์ถ”๊ฐ€ ์กฐ๊ฑด: {escape(condition_text)}
๋ถ„๋ฅ˜ ๊ฒฐ๊ณผ: {escape(result_text)}
๊ฑฐ๋ž˜์œ ํ˜•: {escape(tx_type)}
person: {escape(person)}
๊ณ ์ •๋น„: {escape(fixed_label)}
์ƒํƒœ: {escape(status_label)}
ID: {escape(rule_id)}
{f'
๋ฉ”๋ชจ: {escape(memo)}
' if memo else '
'}
""", unsafe_allow_html=True, ) if idx < len(rules) - 1: st.divider() def page_rules(db_path: str) -> None: title_cols = st.columns([8, 1], vertical_alignment="center") with title_cols[0]: st.title("โš™๏ธ ๋ถ„๋ฅ˜ ๊ทœ์น™") with title_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", []) st.subheader("ํ˜„์žฌ ๋ถ„๋ฅ˜ ๊ทœ์น™") 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() clear_cache(); st.success(res) if c2.button("๊ทœ์น™ preview"): st.session_state["rule_preview"] = service.preview_categorization(limit=5000) if c3.button("๊ทœ์น™ ์ ์šฉ"): res = service.apply_categorization_rules(limit=5000) 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: st.header("๐Ÿท๏ธ ์นดํ…Œ๊ณ ๋ฆฌ ๊ด€๋ฆฌ") 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) st.subheader("์นดํ…Œ๊ณ ๋ฆฌ ์ถ”๊ฐ€") 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", []) st.subheader("์นดํ…Œ๊ณ ๋ฆฌ ๋ชฉ๋ก") 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 st.subheader("์นดํ…Œ๊ณ ๋ฆฌ ์‚ญ์ œ/๋น„ํ™œ์„ฑํ™”") 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: st.header("๐Ÿฆ ๊ณ„์ขŒ") accounts = read_sql(db_path, "SELECT * FROM accounts ORDER BY created_at DESC") if not accounts.empty: accounts["last_balance_display"] = accounts["last_balance"].map(format_krw) st.dataframe(accounts, width="stretch") def page_imports(db_path: str) -> None: st.header("๐Ÿ“ฅ Import") st.subheader("ํ† ์Šค์ฆ๊ถŒ ๊ฑฐ๋ž˜๋‚ด์—ญ 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() st.subheader("ํ† ์Šค๋ฑ…ํฌ ๊ฑฐ๋ž˜๋‚ด์—ญ 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() st.subheader("Import ์ด๋ ฅ") batches = service.list_ledger_import_batches(limit=100).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) clear_cache() st.warning(res) st.rerun() def main() -> None: db_path = get_db_path() inject_sidebar_nav_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.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_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()