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()