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