Files
jarca-ledger-mcp/dashboard/app.py
T

1041 lines
50 KiB
Python

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(
"""
<style>
[data-testid="stSidebar"] .jarca-nav-title {
font-size: 0.82rem;
font-weight: 700;
color: #6b7280;
margin: 0.4rem 0 1.15rem 0.1rem;
text-transform: uppercase;
letter-spacing: 0.04em;
}
[data-testid="stAppViewContainer"] h1 {
font-size: 1.45rem;
line-height: 1.25;
margin-top: 0.2rem;
margin-bottom: 0.65rem;
}
[data-testid="stAppViewContainer"] h2 {
font-size: 1.12rem;
line-height: 1.3;
margin-top: 0.15rem;
margin-bottom: 0.5rem;
}
[data-testid="stAppViewContainer"] h3 {
font-size: 0.95rem;
line-height: 1.35;
margin-top: 0.1rem;
margin-bottom: 0.4rem;
}
[data-testid="stSidebar"] .jarca-nav-list {
display: flex;
flex-direction: column;
gap: 0.14rem;
}
[data-testid="stSidebar"] .jarca-nav-item {
display: block;
width: 100%;
border-radius: 12px;
padding: 0.48rem 0.85rem;
font-weight: 500;
font-size: 0.76rem;
line-height: 1.2;
color: #111827;
text-decoration: none;
background: transparent;
box-sizing: border-box;
}
[data-testid="stSidebar"] .jarca-nav-item:hover {
background: rgba(15, 23, 42, 0.06);
color: #111827;
}
[data-testid="stSidebar"] .jarca-nav-item.active {
background: #e5e7eb;
color: #111827;
font-weight: 700;
}
</style>
""",
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'<a class="jarca-nav-item{active}" href="{href}" target="_self">{escape(name)}</a>')
st.markdown(f'<div class="jarca-nav-list">{"".join(items)}</div>', 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(
"""
<style>
.rule-title-line {
display: flex;
align-items: center;
gap: 0.38rem;
flex-wrap: wrap;
font-weight: 600;
line-height: 1.2;
word-break: break-word;
}
.rule-priority {
font-size: 0.72rem;
line-height: 1;
color: #6b7280;
border: 1px solid rgba(120,120,120,0.26);
border-radius: 999px;
padding: 0.14rem 0.34rem;
white-space: nowrap;
}
.rule-body {
margin-top: 0.08rem;
}
.rule-body-line {
font-size: 0.76rem;
line-height: 1.15;
color: #6b7280;
margin: 0.06rem 0 0 0;
}
.rule-body-grid {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
column-gap: 0.6rem;
row-gap: 0.08rem;
margin-top: 0.08rem;
}
.rule-body-meta {
font-size: 0.74rem;
line-height: 1.12;
color: #6b7280;
margin: 0;
}
</style>
""",
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'<div class="rule-title-line"><span>{escape(pattern_text)}</span><span class="rule-priority">p{escape(str(rule.get("priority", "-")))}</span></div>',
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"""
<div class="rule-body">
<div class="rule-body-line">대상: {escape(target_text)}</div>
<div class="rule-body-line">추가 조건: {escape(condition_text)}</div>
<div class="rule-body-line">분류 결과: {escape(result_text)}</div>
<div class="rule-body-grid">
<div class="rule-body-meta">거래유형: {escape(tx_type)}</div>
<div class="rule-body-meta">person: {escape(person)}</div>
<div class="rule-body-meta">고정비: {escape(fixed_label)}</div>
<div class="rule-body-meta">상태: {escape(status_label)}</div>
<div class="rule-body-meta">ID: {escape(rule_id)}</div>
{f'<div class="rule-body-meta">메모: {escape(memo)}</div>' if memo else '<div class="rule-body-meta"></div>'}
</div>
</div>
""",
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('<div class="jarca-nav-title">메뉴</div>', 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()