feat: modularize dashboard pages

This commit is contained in:
2026-08-21 10:48:21 +09:00
parent 99473caa1c
commit cd9b37b41f
16 changed files with 2270 additions and 2084 deletions
+1
View File
@@ -0,0 +1 @@
"""Jarca Ledger Streamlit dashboard package."""
+29 -2079
View File
File diff suppressed because it is too large Load Diff
+608
View File
@@ -0,0 +1,608 @@
from __future__ import annotations
import sqlite3
from datetime import datetime
from html import escape
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.adapters.streamlit import accounts_gateway as accounts_gateway
from jarca_ledger_mcp.adapters.streamlit import assets_gateway as assets_gateway
from jarca_ledger_mcp.adapters.streamlit import categorization_gateway as categorization_gateway
from jarca_ledger_mcp.adapters.streamlit import imports_gateway as imports_gateway
from jarca_ledger_mcp.adapters.streamlit import overview_gateway as overview_gateway
from jarca_ledger_mcp.adapters.streamlit import reconciliation_gateway as reconciliation_gateway
from jarca_ledger_mcp.adapters.streamlit import transactions_gateway as transactions_gateway
from jarca_ledger_mcp.adapters.streamlit import transfers_gateway as transfers_gateway
from jarca_ledger_mcp.db import resolve_db_path
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(module, 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(module, 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(module)
return getattr(module, name, None)
def inject_app_css() -> None:
st.markdown(
"""
<style>
@import url('https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700;800&display=swap');
:root {
--jarca-navy: #0f172a;
--jarca-slate: #475569;
--jarca-muted: #64748b;
--jarca-border: #e2e8f0;
--jarca-soft: #f8fafc;
--jarca-blue: #2563eb;
--jarca-cyan: #06b6d4;
--jarca-emerald: #10b981;
--jarca-amber: #f59e0b;
--jarca-rose: #f43f5e;
}
html, body, [class*="css"] {
font-family: Inter, Pretendard, "Noto Sans KR", -apple-system, BlinkMacSystemFont, sans-serif;
}
[data-testid="stAppViewContainer"] {
background:
radial-gradient(circle at top left, rgba(37, 99, 235, 0.10), transparent 30rem),
radial-gradient(circle at top right, rgba(6, 182, 212, 0.10), transparent 28rem),
linear-gradient(180deg, #f8fafc 0%, #ffffff 45%, #f8fafc 100%);
}
[data-testid="stHeader"] {
background: rgba(248, 250, 252, 0.78);
backdrop-filter: blur(16px);
}
.block-container {
/* Streamlit's fixed top toolbar overlays the app body. Keep the first
custom hero safely below it so the title block is not clipped. */
padding-top: 4.75rem;
padding-bottom: 2.5rem;
}
[data-testid="stSidebar"] {
background: linear-gradient(180deg, #ffffff 0%, #f8fafc 100%);
border-right: 1px solid rgba(148, 163, 184, 0.22);
}
[data-testid="stSidebar"] h2 {
letter-spacing: -0.04em;
}
[data-testid="stSidebar"] .jarca-nav-title {
font-size: 0.82rem;
font-weight: 700;
color: var(--jarca-muted);
margin: 0.4rem 0 1.15rem 0.1rem;
text-transform: uppercase;
letter-spacing: 0.04em;
}
[data-testid="stAppViewContainer"] h1 {
font-size: 1.68rem;
line-height: 1.25;
margin-top: 0.2rem;
margin-bottom: 0.65rem;
letter-spacing: -0.045em;
}
[data-testid="stAppViewContainer"] h2 {
font-size: 1.16rem;
line-height: 1.3;
margin-top: 0.15rem;
margin-bottom: 0.5rem;
letter-spacing: -0.025em;
}
[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.2rem;
}
[data-testid="stSidebar"] .jarca-nav-item {
display: block;
width: 100%;
border-radius: 14px;
padding: 0.62rem 0.88rem;
font-weight: 650;
font-size: 0.82rem;
line-height: 1.2;
color: var(--jarca-slate);
text-decoration: none;
background: transparent;
border: 1px solid transparent;
box-sizing: border-box;
transition: all 140ms ease;
}
[data-testid="stSidebar"] .jarca-nav-item:hover {
background: rgba(37, 99, 235, 0.07);
border-color: rgba(37, 99, 235, 0.12);
color: var(--jarca-blue);
}
[data-testid="stSidebar"] .jarca-nav-item.active {
background: linear-gradient(135deg, var(--jarca-blue), #0ea5e9);
color: white;
font-weight: 800;
box-shadow: 0 10px 24px rgba(37, 99, 235, 0.22);
}
div[data-testid="stMetric"] {
background: rgba(255,255,255,0.84);
border: 1px solid rgba(226, 232, 240, 0.92);
border-radius: 20px;
padding: 1rem 1.05rem;
box-shadow: 0 14px 32px rgba(15, 23, 42, 0.06);
}
div[data-testid="stMetric"] label {
color: var(--jarca-muted) !important;
font-weight: 700;
}
div[data-testid="stMetricValue"] {
color: var(--jarca-navy);
letter-spacing: -0.04em;
}
div[data-testid="stDataFrame"], div[data-testid="stDataFrameResizable"] {
border-radius: 18px;
overflow: hidden;
border: 1px solid rgba(226, 232, 240, 0.85);
box-shadow: 0 14px 28px rgba(15, 23, 42, 0.04);
}
div[data-testid="stPlotlyChart"] {
background: rgba(255,255,255,0.82);
border: 1px solid rgba(226, 232, 240, 0.82);
border-radius: 22px;
padding: 0.65rem 0.55rem 0.25rem;
box-shadow: 0 14px 32px rgba(15, 23, 42, 0.055);
}
.stButton > button {
border-radius: 14px;
font-weight: 750;
border-color: rgba(148, 163, 184, 0.32);
transition: transform 120ms ease, box-shadow 120ms ease, border-color 120ms ease;
}
.stButton > button:hover {
transform: translateY(-1px);
border-color: rgba(37, 99, 235, 0.48);
box-shadow: 0 10px 22px rgba(15, 23, 42, 0.10);
}
.jarca-card {
border: 1px solid rgba(226, 232, 240, 0.86);
border-radius: 22px;
padding: 1rem 1.1rem;
background: rgba(255,255,255,0.82);
box-shadow: 0 16px 34px rgba(15, 23, 42, 0.055);
}
.jarca-section-label {
display: inline-flex;
align-items: center;
gap: 0.42rem;
margin: 1.1rem 0 0.5rem 0;
color: var(--jarca-navy);
font-size: 1.02rem;
font-weight: 850;
letter-spacing: -0.03em;
}
.jarca-pill-row {
display: flex;
flex-wrap: wrap;
gap: 0.42rem;
margin-top: 0.72rem;
}
.jarca-pill {
border-radius: 999px;
padding: 0.28rem 0.7rem;
background: rgba(255,255,255,0.18);
color: rgba(255,255,255,0.90);
font-size: 0.76rem;
font-weight: 700;
}
</style>
""",
unsafe_allow_html=True,
)
def render_section_label(icon: str, label: str) -> None:
st.markdown(f'<div class="jarca-section-label"><span>{escape(icon)}</span><span>{escape(label)}</span></div>', 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 = categorization_gateway.list_ledger_categories(active_only=True, include_usage=False, db_path=db_path)
if result.get("success") and result.get("count", 0) == 0:
categorization_gateway.seed_default_ledger_categories(db_path=db_path)
result = categorization_gateway.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 = transactions_gateway.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 = categorization_gateway.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 = transfers_gateway.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 = transfers_gateway.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 = transfers_gateway.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 = transfers_gateway.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()
def render_review_decision_actions(gateway: Any, target_type: str, target_id: str, key_prefix: str, db_path: str, target_payload: dict[str, Any] | None = None) -> None:
decision_type = st.selectbox(
"판정",
["unknown", "account_transfer", "real_expense", "real_income", "investment_transfer", "excluded_from_analysis", "duplicate_or_cancel"],
key=f"{key_prefix}_decision_type",
)
reason_code = st.text_input("사유 코드", value="operator_review", key=f"{key_prefix}_reason")
memo = st.text_input("결정 메모", value="", key=f"{key_prefix}_memo")
cols = st.columns(4)
if cols[0].button("결정 저장", key=f"{key_prefix}_decided", use_container_width=True, type="primary"):
res = gateway.upsert_review_decision(target_type, target_id, status="decided", decision_type=decision_type, reason_code=reason_code, memo=memo, target_payload=target_payload or {}, db_path=db_path)
clear_cache(); st.success("저장했습니다." if res.get("success") else res.get("error", res)); st.rerun()
if cols[1].button("보류", key=f"{key_prefix}_held", use_container_width=True):
res = gateway.hold_review_decision(target_type, target_id, reason_code=reason_code or "held", memo=memo, db_path=db_path)
clear_cache(); st.success("보류했습니다." if res.get("success") else res.get("error", res)); st.rerun()
if cols[2].button("재검토", key=f"{key_prefix}_recheck", use_container_width=True):
res = gateway.mark_review_recheck(target_type, target_id, reason_code=reason_code or "needs_recheck", memo=memo, db_path=db_path)
clear_cache(); st.success("재검토로 표시했습니다." if res.get("success") else res.get("error", res)); st.rerun()
if cols[3].button("해제", key=f"{key_prefix}_release", use_container_width=True):
res = gateway.release_review_decision(target_type, target_id, memo=memo, db_path=db_path)
clear_cache(); st.success("pending으로 해제했습니다." if res.get("success") else res.get("error", res)); st.rerun()
+81
View File
@@ -0,0 +1,81 @@
from __future__ import annotations
from typing import Any, Callable
import streamlit as st
class JarcaList:
def __init__(
self,
*,
render_header: Callable[[list[dict[str, Any]]], None] | None,
render_item: Callable[[dict[str, Any]], None],
items: list[dict[str, Any]],
empty_message: str,
) -> None:
self.render_header = render_header
self.render_item = render_item
self.items = items
self.empty_message = empty_message
def render(self) -> None:
st.markdown(
"""
<style>
.jarca-list {
border: 0.0625rem solid #d1d9e0;
border-radius: 0.375rem;
padding: 0;
margin: 1rem 0 0;
gap: 0;
}
.st-key-jarca-list {
border: 0.0625rem solid #d1d9e0;
border-radius: 0.375rem;
padding: 0;
margin: 0;
gap: 0;
}
.st-key-jarca-list-header {
margin: 0;
background-color: #f6f8fa;
padding: 1rem;
border-top-left-radius: 0.375rem;
border-top-right-radius: 0.375rem;
gap: 0;
}
.st-key-jarca-list-body {
background-color: #ffffff;
border-top: 0.0625rem solid #d1d9e0;
border-bottom-left-radius: 0.375rem;
border-bottom-right-radius: 0.375rem;
margin: 0;
padding: 0;
gap: 0;
}
[class^="st-key-jarca-list-item-"],
[class*=" st-key-jarca-list-item-"] {
border-top: 0.0625rem solid #d1d9e0;
margin: 0;
padding: 1rem;
gap: 0;
}
.st-key-jarca-list-item-0 {
border-top: 0;
}
</style>
""",
unsafe_allow_html=True,
)
with st.container(key="jarca-list", border=False):
with st.container(key="jarca-list-header", border=False):
if self.render_header:
self.render_header(self.items)
with st.container(key="jarca-list-body", border=False):
for index, item in enumerate(self.items):
with st.container(key=f"jarca-list-item-{index}", border=False):
self.render_item(item)
if not self.items:
st.info(self.empty_message)
View File
+243
View File
@@ -0,0 +1,243 @@
from __future__ import annotations
# ruff: noqa: F405
from html import escape
from typing import Any
import streamlit as st
from jarca_ledger_mcp.adapters.streamlit import accounts_gateway
from dashboard.components import JarcaList
from dashboard.common import * # noqa: F403, F405
ACCOUNT_TYPE_LABELS = {
"bank": "은행",
"card": "카드",
"cash": "현금",
"investment": "투자",
"loan": "대출",
"other": "기타",
}
ACCOUNT_TYPE_STYLES = {
"bank": "#0969da",
"card": "#bf3989",
"cash": "#1a7f37",
"investment": "#8250df",
"loan": "#bc4c00",
"other": "#57606a",
}
def render_accounts_list_header(items: list[dict[str, Any]]) -> None:
st.markdown(
f'{len(items)}개 계좌',
unsafe_allow_html=True,
)
def render_account_item(account: dict[str, Any]) -> None:
active = bool(account.get("active", True))
status_label = ":blue-badge[활성]" if active else ":gray-badge[비활성]"
account_type = ACCOUNT_TYPE_LABELS.get(account.get("type"), account.get("type") or "기타")
statement_status = STATEMENT_STATUS_LABELS.get(account.get("statement_status"), account.get("statement_status") or "-")
balance = format_krw(account.get("last_balance"))
synced_at = account.get("last_synced_at") or "미입력"
memo = account.get("memo") or "메모 없음"
with st.container(gap=None):
columns = st.columns([9, 1], vertical_alignment="center", gap=None)
columns[0].markdown(
f"""
**{escape(str(account.get("name") or "이름 없음"))}** {status_label}
""",
unsafe_allow_html=True,
)
if columns[1].button(" ", key=f"account_edit_{account['id']}", help="수정", icon=":material/edit:", type="tertiary", width="content"):
st.session_state["account_edit_id"] = account["id"]
columns = st.columns([2, 80, 18], vertical_alignment="center")
columns[1].markdown(
f"""
{escape(str(memo))}<br>
<small>#{escape(str(account["id"])[-6:])} · {escape(str(synced_at))} 기준 · {escape(str(account.get("institution") or "기관 미입력"))} · {escape(str(account_type))} · {escape(str(statement_status))}</small>
""",
unsafe_allow_html=True,
)
columns[2].text(escape(balance), width="stretch", text_alignment="right")
def render_account_list(accounts: list[dict[str, Any]]) -> None:
st.markdown(
"""
<style>
.stVerticalBlock:has(.jarca-list-header) {
padding: 0px;
background-color: #f6f8fa;
border-radius: 4px;
}
.jarca-account-pr-status {
margin-top: 0.05rem;
color: #1a7f37;
font-size: 1.2rem;
font-weight: 800;
line-height: 1;
}
.jarca-account-pr-status.inactive { color: #8250df; }
.jarca-account-pr-title {
color: #1f2328;
font-size: 0.98rem;
font-weight: 700;
line-height: 1.35;
}
.jarca-account-pr-title-row {
display: flex;
align-items: center;
align-vertical: center;
gap: 0.45rem;
}
.jarca-account-pr-description {
margin-top: 0.16rem;
color: #57606a;
font-size: 0.78rem;
line-height: 1.45;
}
.jarca-account-pr-meta {
display: flex;
flex-wrap: wrap;
gap: 0.7rem;
margin-top: 0.5rem;
color: #656d76;
font-size: 0.72rem;
}
.jarca-account-pr-badge {
display: inline-flex;
align-items: center;
padding: 0.14rem 0.42rem;
border: 1px solid #d0d7de;
border-radius: 999px;
color: #57606a;
font-size: 0.7rem;
font-weight: 600;
white-space: nowrap;
}
[class*="st-key-account_edit_"] {
width: fit-content;
margin-left: auto;
}
[class*="st-key-account_edit_"] [data-testid="stBaseButton-tertiary"] {
width: auto;
margin: 0;
padding: 0;
}
.stVerticalBlock:has(.jarca-account-pr-title) hr {
margin: 0px;
padding: 0px;
}
</style>
""",
unsafe_allow_html=True,
)
search = st.text_input("계좌 검색", placeholder="계좌를 검색하세요", label_visibility="collapsed")
query = search.strip().lower()
visible_accounts = [
account
for account in accounts
if not query or query in " ".join(str(account.get(field) or "") for field in ("name", "institution", "type")).lower()
]
JarcaList(
render_header=render_accounts_list_header,
render_item=render_account_item,
items=visible_accounts,
empty_message="등록된 계좌가 없습니다.",
).render()
@st.dialog("계좌 수정", width="large")
def account_edit_dialog(account: dict[str, Any], db_path: str) -> None:
st.caption(f"{account.get('institution') or '기관 미입력'} · ID: `{account['id']}`")
with st.form(f"account_edit_form_{account['id']}"):
identity_columns = st.columns(3)
name = identity_columns[0].text_input("계좌명", value=account.get("name") or "")
institution = identity_columns[1].text_input("금융기관", value=account.get("institution") or "")
current_type = account.get("type", "other") or "other"
account_type = identity_columns[2].selectbox(
"유형",
list(ACCOUNT_TYPE_LABELS),
index=list(ACCOUNT_TYPE_LABELS).index(current_type) if current_type in ACCOUNT_TYPE_LABELS else 0,
format_func=lambda value: ACCOUNT_TYPE_LABELS[value],
)
balance_columns = st.columns(2)
last_balance_raw = balance_columns[0].text_input(
"기준 잔액",
value="" if account.get("last_balance") is None else str(account.get("last_balance")),
help="원 단위 정수",
)
last_synced_at = balance_columns[1].text_input(
"기준 시각",
value=account.get("last_synced_at") or "",
help="예: 2026-08-20 또는 2026-08-20T23:59:00+09:00",
)
operating_columns = st.columns(3)
current_status = account.get("statement_status", "unknown") or "unknown"
statement_status = operating_columns[0].selectbox(
"명세서 상태",
STATEMENT_STATUS_OPTIONS,
index=STATEMENT_STATUS_OPTIONS.index(current_status) if current_status in STATEMENT_STATUS_OPTIONS else 0,
format_func=lambda value: STATEMENT_STATUS_LABELS[value],
)
current_bucket = account.get("liquidity_bucket", "other") or "other"
liquidity_bucket = operating_columns[1].selectbox(
"유동성 버킷",
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 value: LIQUIDITY_BUCKET_LABELS[value],
)
active = operating_columns[2].checkbox("활성", value=bool(account.get("active", True)))
memo = st.text_input("메모", value=account.get("memo") or "")
submitted = st.form_submit_button("변경 저장", type="primary")
if submitted:
try:
last_balance = int(last_balance_raw.replace(",", "")) if last_balance_raw.strip() else None
except ValueError:
st.error("기준 잔액은 원 단위 정수로 입력하세요.")
return
result = accounts_gateway.update_ledger_account(
account["id"],
name=name,
institution=institution,
type=account_type,
last_balance=last_balance,
last_synced_at=last_synced_at,
statement_status=statement_status,
liquidity_bucket=liquidity_bucket,
active=active,
memo=memo,
db_path=db_path,
)
if not result.get("success"):
st.error(result.get("error", result))
return
st.session_state.pop("account_edit_id", None)
clear_cache()
st.rerun()
def page_accounts(db_path: str) -> None:
accounts_res = accounts_gateway.list_ledger_accounts(active_only=False, db_path=db_path)
accounts_data = accounts_res.get("accounts", []) if accounts_res.get("success") else []
if accounts_data:
render_account_list(accounts_data)
account_edit_id = st.session_state.get("account_edit_id")
if account_edit_id:
account = next((item for item in accounts_data if item["id"] == account_edit_id), None)
if account:
account_edit_dialog(account, db_path)
else:
st.session_state.pop("account_edit_id", None)
st.rerun()
else:
st.info("등록된 계좌가 없습니다.")
+107
View File
@@ -0,0 +1,107 @@
from __future__ import annotations
# ruff: noqa: F405
import pandas as pd
import streamlit as st
from jarca_ledger_mcp.adapters.streamlit import assets_gateway
from dashboard.common import * # noqa: F403, F405
def page_assets(db_path: str) -> None:
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 = assets_gateway.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 = assets_gateway.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 = assets_gateway.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 = assets_gateway.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"] = assets_gateway.preview_asset_snapshot_close(basis_date, db_path=db_path)
if p2.button("스냅샷 Close 실행", type="primary"):
st.session_state["asset_snapshot_close"] = assets_gateway.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))
+72
View File
@@ -0,0 +1,72 @@
from __future__ import annotations
# ruff: noqa: F405
import pandas as pd
import streamlit as st
from jarca_ledger_mcp.adapters.streamlit import categorization_gateway
from dashboard.common import * # noqa: F403, F405
def page_categories(db_path: str) -> None:
st.caption("카테고리 삭제는 기본적으로 비활성화 처리합니다. 기존 거래의 과거 category 값은 보존됩니다.")
c1, c2 = st.columns(2)
if c1.button("기본 카테고리 seed/update"):
res = categorization_gateway.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 = categorization_gateway.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 = categorization_gateway.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 = categorization_gateway.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))
@@ -0,0 +1,75 @@
from __future__ import annotations
# ruff: noqa: F405
import sqlite3
import pandas as pd
import streamlit as st
from jarca_ledger_mcp.adapters.streamlit import transfers_gateway
from dashboard.common import * # noqa: F403, F405
def page_cross_transfer_review(db_path: str) -> None:
c1, c2 = st.columns(2)
with c1:
if st.button("미리보기(드라이런)"):
st.session_state["cross_transfer_preview"] = transfers_gateway.preview_cross_account_transfers(db_path=db_path)
with c2:
if st.button("자동 매칭 실행"):
res = transfers_gateway.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(transfers_gateway, "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()))
selected_row = options[selected]
st.caption(f"현재 review_status: {selected_row.get('review_status', 'pending')}")
with st.expander("Review decision 저장/보류/재검토/해제", expanded=False):
render_review_decision_actions(transfers_gateway, "cross_transfer", selected_row["id"], f"transfer_review_decision_{selected_row['id']}", db_path, target_payload=selected_row)
update_transaction_form(selected_row, "transfer_review", db_path)
+95
View File
@@ -0,0 +1,95 @@
from __future__ import annotations
# ruff: noqa: F405
from pathlib import Path
import pandas as pd
import streamlit as st
from jarca_ledger_mcp.adapters.streamlit import imports_gateway
from jarca_ledger_mcp.adapters.streamlit import overview_gateway
from dashboard.common import * # noqa: F403, F405
def page_imports(db_path: str) -> None:
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 = imports_gateway.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 = imports_gateway.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 = imports_gateway.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 = imports_gateway.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 = overview_gateway.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 = imports_gateway.rollback_ledger_import_batch(batch_id, hard_delete=hard, db_path=db_path)
clear_cache()
st.warning(res)
st.rerun()
+234
View File
@@ -0,0 +1,234 @@
from __future__ import annotations
# ruff: noqa: F405
from datetime import datetime
import pandas as pd
import streamlit as st
from jarca_ledger_mcp.adapters.streamlit import overview_gateway
from jarca_ledger_mcp.adapters.streamlit import reconciliation_gateway
from dashboard.common import * # noqa: F403, F405
def page_monthly_settlement(db_path: str) -> None:
query_month = st.query_params.get("month", current_kst_month())
if isinstance(query_month, list):
query_month = query_month[0]
month = st.text_input("대상 월", value=str(query_month), key="monthly_settlement_month", help="YYYY-MM 형식")
settlement = overview_gateway.get_monthly_settlement_summary(month, db_path=db_path)
if not settlement.get("success"):
st.error(settlement.get("error", settlement))
st.caption(f"입력한 월: {month}")
return
selected_month = settlement.get("month") or settlement.get("selected_month") or month
basis_date = settlement.get("basis_date") or f"{selected_month}-01"
source_coverage = settlement.get("source_coverage", {}) if isinstance(settlement.get("source_coverage"), dict) else {}
unresolved_blockers = settlement.get("unresolved_blockers", {}) if isinstance(settlement.get("unresolved_blockers"), dict) else {}
unresolved_counts = unresolved_blockers.get("counts", {}) if isinstance(unresolved_blockers.get("counts"), dict) else {}
review_queue_res = overview_gateway.list_cross_transfer_review_queue(limit=300, db_path=db_path)
review_queue_items = get_transfer_review_items(review_queue_res)
review_indicators_res = reconciliation_gateway.get_review_operating_indicators(db_path=db_path)
review_indicators = review_indicators_res.get("indicators", {}) if review_indicators_res.get("success") else {}
reconciliation_res = reconciliation_gateway.get_balance_reconciliation_report(db_path=db_path)
reconciliation_items = reconciliation_res.get("items", []) if reconciliation_res.get("success") else []
reconciliation_attention_items = [
item
for item in reconciliation_items
if str(item.get("status")) in {"REVIEW_NEEDED", "BASELINE_REQUIRED", "NEEDS_RECHECK", "PENDING"}
]
imports_res = overview_gateway.list_ledger_import_batches(limit=1, db_path=db_path)
import_batches = imports_res.get("import_batches", []) if imports_res.get("success") else []
asset_snapshot = overview_gateway.get_asset_snapshot_report(basis_date=basis_date, db_path=db_path)
read_only_stamp = datetime.now(ZoneInfo("Asia/Seoul")).strftime("%Y-%m-%d %H:%M:%S KST")
render_section_label("📘", "월정산 셸")
st.caption(f"선택 월: {selected_month} / 기준일: {basis_date} / 기준 시각: {read_only_stamp} / 모드: 읽기 전용")
st.markdown(
"<span style='display:inline-block; padding:0.15rem 0.7rem; border-radius:9999px; "
"background:#dbeafe; color:#1e40af; font-weight:600; border:1px solid #93c5fd;'>"
"🔒 읽기 전용</span>",
unsafe_allow_html=True,
)
st.info("거래/예산 수정은 각 운영 화면(거래내역, 요약)에서 처리하세요.")
st.markdown("- [거래내역으로 이동](?page=거래내역)")
st.markdown("- [예산 확인으로 이동](?page=요약)")
st.markdown("월간 핵심 KPI")
st.caption("월간 핵심 지표를 통해 수입·지출·잔고 흐름을 점검하세요.")
k1, k2, k3, k4 = st.columns(4)
k1.metric("실제 수입", format_krw(settlement.get("actual_income", 0)))
k2.metric("실제 지출", format_krw(settlement.get("actual_expense", 0)))
k3.metric("순현금흐름", format_krw(settlement.get("net_cashflow", 0)))
k4.metric("저축률", format_percent(settlement.get("savings_rate")))
st.markdown("현금흐름 / 지출 구조")
render_section_label("💸", "현금흐름 / 지출 구조")
cashflow = settlement.get("reports", {}).get("cashflow", {}) if isinstance(settlement.get("reports"), dict) else {}
if cashflow:
c1, c2 = st.columns(2)
by_type = cashflow.get("by_type", {}) or {}
by_type_df = pd.DataFrame(
[{"구분": str(key), "금액": int(value)} for key, value in by_type.items()]
)
if not by_type_df.empty:
by_type_df["금액"] = by_type_df["금액"].map(format_krw)
by_type_df = by_type_df.sort_values("금액", ascending=False)
c1.dataframe(by_type_df, width="stretch", height=170)
by_kind = cashflow.get("by_cashflow_kind", {}) or {}
by_kind_df = pd.DataFrame(
[{"현금흐름 구분": str(key), "금액": int(value)} for key, value in by_kind.items()]
)
if not by_kind_df.empty:
by_kind_df["금액"] = by_kind_df["금액"].map(format_krw)
c2.dataframe(by_kind_df, width="stretch", height=170)
else:
st.info("해당 월 현금흐름 정보가 아직 없습니다.")
st.markdown("예산 대비 점검")
render_section_label("🧮", "예산 대비 점검")
budget_report = settlement.get("reports", {}).get("budget", {}) if isinstance(settlement.get("reports"), dict) else {}
b1, b2, b3 = st.columns(3)
b1.metric("예산 항목 수", f"{int(budget_report.get('count', 0))}건")
b2.metric("월 예산", format_krw(budget_report.get("total_budget", 0)))
b3.metric("예산 초과 항목", f"{int(settlement.get('budget_overrun_count', 0))}건")
budget_items = budget_report.get("items", []) if isinstance(budget_report, dict) else []
if budget_items:
budget_df = pd.DataFrame(budget_items)
budget_df["예산"] = budget_df["budget"].map(format_krw)
budget_df["실제"] = budget_df["actual"].map(format_krw)
budget_df["잔여"] = budget_df["remaining"].map(format_krw)
budget_df["사용률"] = budget_df["usage_rate"].map(format_percent)
st.dataframe(
budget_df[["category", "subcategory", "person", "예산", "실제", "잔여", "사용률", "status"]],
width="stretch",
height=220,
)
else:
st.info(f"{selected_month} 예산 항목이 없습니다.")
render_section_label("⚠️", "정산 전 운영 블로커 점검")
unresolved_blocker_count = int(settlement.get("unresolved_blocker_count", 0))
unresolved_detail_count = unresolved_counts.get("unresolved_count", unresolved_blocker_count)
st.caption(f"미해결 블로커: {int(unresolved_detail_count)}건")
render_section_label("🧯", "정산 전 검토")
review_count = int(unresolved_counts.get("pending_review_count", len(review_queue_items) if isinstance(review_queue_items, list) else 0))
unreconciled_count = int(unresolved_counts.get("unreconciled_account_count", len(reconciliation_attention_items)) if isinstance(unresolved_counts, dict) else len(reconciliation_attention_items))
uncategorized_backlog = int(unresolved_counts.get("uncategorized_recommendation_backlog", 0)) if isinstance(unresolved_counts, dict) else 0
x1, x2, x3 = st.columns(3)
x1.metric("미처리 review", f"{review_count}건")
x2.metric("미대조 계좌", f"{unreconciled_count}건")
x3.metric("미분류 추천 대기", f"{uncategorized_backlog}건")
if review_queue_items:
st.markdown("확인필요 큐 최근 항목")
for item in review_queue_items[:5]:
tx_date = item.get("transaction_date") or item.get("reviewed_at") or "-"
merchant = item.get("merchant") or item.get("counterparty") or "-"
amount = format_krw(item.get("amount") if item.get("amount") is not None else item.get("difference"))
status = item.get("review_status") or item.get("status") or "pending"
st.markdown(f"- {tx_date} · {merchant} · {amount} · {status}")
else:
st.caption("확인필요 큐는 비어 있습니다.")
st.markdown("- [확인필요 큐로 이동](?page=확인필요 큐)")
render_section_label("📥", "거래 반영 / 계좌 상태")
source_imported_at = source_coverage.get("imports", {}).get("latest_imported_at")
t1, t2, t3 = st.columns(3)
t1.metric("최근 Import 건수", f"{int(source_coverage.get('imports', {}).get('recent_batch_count', 0))}건")
t2.metric("최근 Import 시각", source_imported_at or "없음")
t3.metric("보류 후 처리", str(review_indicators.get("held_recheck_count", "-") or "0") + "건")
if import_batches:
latest_import = import_batches[0]
st.caption(f"최근 Import: 출처 {latest_import.get('source', '알 수 없음')} / 수집일 {latest_import.get('imported_at', '미기록')}")
else:
st.info("최근 Import 배치가 없습니다.")
st.markdown("- [계좌 화면에서 잔액대조 확인](?page=계좌)")
st.markdown("- [Import 화면에서 이력 확인](?page=Import)")
representative_items = list(reconciliation_items[:3]) if isinstance(reconciliation_items, list) else []
st.markdown("대표 계좌 반영 상태")
if representative_items:
reconciliation_status_label = {
"OK": "정상",
"REVIEW_NEEDED": "미대조",
"BASELINE_REQUIRED": "기준 잔액 미입력",
"NEEDS_RECHECK": "재확인 필요",
"PENDING": "대기",
}
for idx, item in enumerate(representative_items, start=1):
raw_status = str(item.get("status", "-") or "-")
synced_at = item.get("last_synced_at") or item.get("baseline_at") or item.get("calculation_basis_at") or "-"
baseline_balance = item.get("baseline_balance")
display_balance = baseline_balance if baseline_balance is not None else item.get("calculated_balance")
item_line = (
f"- [{idx}] 계좌명: {item.get('account_name', '-')}, "
f"기관: {item.get('institution', item.get('bank', '미기록'))}, "
f"유형: {item.get('account_type', item.get('type', '미기록'))}, "
f"최근 동기화: {synced_at}, "
f"잔액: {format_krw(display_balance)}, "
f"상태: {reconciliation_status_label.get(raw_status, raw_status)}"
)
st.markdown(
item_line,
)
else:
st.caption("잔액대조 대상 계좌가 현재 없습니다.")
st.markdown("- [미분류 페이지](?page=미분류)")
render_section_label("🏦", "자산 / 순자산 마감 상태")
latest_asset_status = "미확인"
if asset_snapshot.get("success"):
latest_asset_status = "마감 완료" if bool(asset_snapshot.get("is_closed_snapshot")) else "미마감"
warnings = asset_snapshot.get("warnings") if isinstance(asset_snapshot, dict) else []
missing_valuation_items = asset_snapshot.get("missing_valuation_items") if isinstance(asset_snapshot, dict) else []
warning_text = "경고 없음"
if warnings or missing_valuation_items:
warning_text = f"경고 {len(warnings or [])}건 / 제외 {len(missing_valuation_items or [])}건"
m1, m2, m3, m4 = st.columns(4)
m1.metric("자산 합계", format_krw(asset_snapshot.get("total_assets") if isinstance(asset_snapshot, dict) else source_coverage.get("assets", {}).get("total_assets")))
m2.metric("총부채", format_krw(asset_snapshot.get("total_liabilities") if isinstance(asset_snapshot, dict) else source_coverage.get("assets", {}).get("total_liabilities")))
m3.metric("순자산", format_krw(
asset_snapshot.get("net_worth")
if isinstance(asset_snapshot, dict)
else source_coverage.get("assets", {}).get("net_worth")
))
m4.metric("자산 마감 상태", latest_asset_status)
st.caption(f"자산 상세: {warning_text}")
st.caption(f"마감 기준일: {asset_snapshot.get('basis_date', basis_date)}")
st.markdown("- [자산/마감 화면에서 스냅샷 확인](?page=자산/마감)")
render_section_label("📜", "월정산 결과 / Evidence")
st.markdown("### 읽기 전용 근거 요약")
evidence_close_status = "마감 완료" if bool(asset_snapshot.get("is_closed_snapshot")) else "미마감"
import_summary = source_coverage.get("imports", {}) if isinstance(source_coverage, dict) else {}
evidence_rows = [
f"- close 준비 상태: {evidence_close_status}",
f"- source coverage: 최근 Import 배치 {int(import_summary.get('recent_batch_count', 0))}건",
f"- 최근 Import: {import_summary.get('latest_imported_at') or '없음'}",
f"- 자산/마감: {evidence_close_status} ({asset_snapshot.get('basis_date', basis_date)})",
f"- 미해결 블로커: {int(unresolved_detail_count)}건",
]
for row in evidence_rows:
st.markdown(row)
st.markdown("### 다음 액션")
st.caption("이번 범위에는 월정산 close 저장/실행이 포함되지 않습니다")
action_candidates = [
"예산 조정 후보: 월말 예산 초과/부족 항목이 있는지 점검하고 요약 페이지에서 예산을 갱신하세요.",
"분류/검토 규칙 후보: 미분류 추천 및 확인필요 큐에서 규칙 후보를 먼저 점검하세요.",
"대조/Import/백업 후속 작업: 계좌 잔액대조, 최근 Import 이력, 백업 최신화 상태를 함께 확인하세요.",
"수기 자산/마감 화면에서 기준일·평가 상태를 정합성 확인 후 운영 승인 여부를 결정하세요.",
]
for action in action_candidates:
st.markdown(f"- {action}")
st.divider()
st.markdown("- [요약 페이지로 이동](?page=요약)")
+103
View File
@@ -0,0 +1,103 @@
from __future__ import annotations
# ruff: noqa: F405
import pandas as pd
import plotly.express as px
import streamlit as st
from jarca_ledger_mcp.adapters.streamlit import overview_gateway
from jarca_ledger_mcp.adapters.streamlit import reconciliation_gateway
from dashboard.common import * # noqa: F403, F405
def page_overview(db_path: str) -> None:
health = overview_gateway.health_check(db_path)
st.caption(f"DB: `{health.get('db_path')}`")
month = current_kst_month()
cashflow = overview_gateway.get_monthly_cashflow_report(month, db_path=db_path)
asset_report = overview_gateway.get_asset_liquidity_report(db_path=db_path)
budget_report = overview_gateway.get_budget_vs_actual_report(month, db_path=db_path)
uncategorized_res = overview_gateway.list_uncategorized_transactions(limit=1000, db_path=db_path)
transfer_queue_res = overview_gateway.list_cross_transfer_review_queue(limit=300, db_path=db_path)
reconciliation_res = reconciliation_gateway.get_balance_reconciliation_report(db_path=db_path)
review_indicators_res = reconciliation_gateway.get_review_operating_indicators(db_path=db_path)
review_indicators = review_indicators_res.get("indicators", {}) if review_indicators_res.get("success") else {}
backups_res = overview_gateway.list_ledger_backups(limit=1)
imports_res = overview_gateway.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, w5 = st.columns(5)
w1.metric("미처리 review", f"{review_indicators.get('pending_review_count', len(review_items)):,}건")
w2.metric("보류/재검토", f"{review_indicators.get('held_recheck_count', 0):,}건")
w3.metric("미대조 계좌", f"{review_indicators.get('unreconciled_account_count', len(reconciliation_review)):,}개")
w4.metric("최근 대조 마감", review_indicators.get("last_reconciliation_closed_at") or "-")
w5.metric("미분류 추천 대기", f"{review_indicators.get('uncategorized_recommendation_backlog', uncategorized_res.get('count', 0)):,}건")
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가 없는 계좌/자산은 자산 유동성 계산에서 제외했습니다. 계좌 화면에서 기준 잔액과 기준 시각을 입력하세요.")
+394
View File
@@ -0,0 +1,394 @@
from __future__ import annotations
# ruff: noqa: F405
import pandas as pd
import streamlit as st
from jarca_ledger_mcp.adapters.streamlit import categorization_gateway
from dashboard.common import * # noqa: F403, F405
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 = categorization_gateway.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(categorization_gateway, "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))
@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(categorization_gateway, "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 render_rule_list_items(rules: list[dict[str, Any]], db_path: str) -> None:
if not rules:
st.info("등록된 규칙이 없습니다.")
return
st.markdown(
"""
<style>
div[data-testid="stExpander"] {
border: 0 !important;
border-radius: 0 !important;
background: transparent !important;
box-shadow: none !important;
margin: 0 !important;
}
div[data-testid="stExpander"] details {
border: 0 !important;
border-radius: 0 !important;
background: transparent !important;
box-shadow: none !important;
border-bottom: 1px solid rgba(226, 232, 240, 0.92) !important;
}
div[data-testid="stExpander"] details > summary {
min-height: 2.55rem;
padding: 0.38rem 0.35rem !important;
background: transparent !important;
border-radius: 0 !important;
}
div[data-testid="stExpander"] details > summary:hover {
background: rgba(37,99,235,0.045) !important;
}
div[data-testid="stExpander"] details[open] > summary {
border-bottom: 1px solid rgba(226,232,240,0.82);
background: rgba(248,250,252,0.72) !important;
}
div[data-testid="stExpander"] summary p {
font-size: 0.84rem;
font-weight: 700;
color: #0f172a;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.rule-detail-grid {
display: grid;
grid-template-columns: minmax(0, 1fr) auto minmax(0, 1fr);
align-items: stretch;
gap: 0.62rem;
margin: 0.25rem 0 0.72rem;
}
.rule-detail-box {
border: 1px solid rgba(226,232,240,0.9);
border-radius: 12px;
padding: 0.58rem 0.72rem;
background: rgba(248,250,252,0.68);
}
.rule-detail-label {
color: #64748b;
font-size: 0.66rem;
font-weight: 850;
letter-spacing: 0.04em;
text-transform: uppercase;
margin-bottom: 0.18rem;
}
.rule-detail-value {
color: #0f172a;
font-size: 0.86rem;
font-weight: 760;
line-height: 1.3;
word-break: break-word;
}
.rule-detail-arrow {
display: flex;
align-items: center;
color: #2563eb;
font-weight: 900;
font-size: 1.1rem;
}
.rule-meta-row {
display: flex;
flex-wrap: wrap;
gap: 0.36rem;
margin-bottom: 0.62rem;
}
.rule-meta-chip {
display: inline-flex;
align-items: center;
border-radius: 999px;
padding: 0.25rem 0.58rem;
color: #475569;
background: rgba(241,245,249,0.94);
border: 1px solid rgba(226,232,240,0.85);
font-size: 0.72rem;
font-weight: 720;
line-height: 1.1;
}
.rule-memo {
color: #64748b;
font-size: 0.74rem;
line-height: 1.4;
margin: 0.1rem 0 0.65rem;
}
.rule-danger-note {
color: #ef4444;
background: rgba(239,68,68,0.07);
border: 1px solid rgba(239,68,68,0.16);
border-radius: 12px;
padding: 0.42rem 0.54rem;
font-size: 0.68rem;
line-height: 1.28;
}
@media (max-width: 900px) {
.rule-detail-grid { grid-template-columns: 1fr; }
.rule-detail-arrow { justify-content: center; transform: rotate(90deg); }
}
</style>
""",
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"""
<div class="rule-detail-grid">
<div class="rule-detail-box">
<div class="rule-detail-label">IF</div>
<div class="rule-detail-value">{escape(match_field)} {escape(match_type)} “{escape(pattern_text)}”</div>
</div>
<div class="rule-detail-arrow">→</div>
<div class="rule-detail-box">
<div class="rule-detail-label">THEN</div>
<div class="rule-detail-value">{escape(result_text)}</div>
</div>
</div>
<div class="rule-meta-row">
<span class="rule-meta-chip">상태: {escape(status_label)}</span>
<span class="rule-meta-chip">우선순위: p{escape(str(priority))}</span>
<span class="rule-meta-chip">거래유형: {escape(tx_type)}</span>
<span class="rule-meta-chip">조건: {escape(condition_text)}</span>
<span class="rule-meta-chip">person: {escape(person)}</span>
<span class="rule-meta-chip">고정비: {escape(fixed_label)}</span>
<span class="rule-meta-chip">ID: {escape(rule_id)}</span>
</div>
{f'<div class="rule-memo">메모: {escape(memo)}</div>' 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(categorization_gateway, "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('<div class="rule-danger-note">삭제보다 비활성화를 권장합니다.</div>', unsafe_allow_html=True)
if action_cols[4].button("🗑️ 삭제", key=f"rule_expander_delete_{rule_id}_{idx}"):
delete_rule = getattr(categorization_gateway, "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:
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 = categorization_gateway.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 = categorization_gateway.seed_default_category_rules(db_path=db_path)
clear_cache(); st.success(res)
if c2.button("규칙 preview"):
st.session_state["rule_preview"] = categorization_gateway.preview_categorization(limit=5000, db_path=db_path)
if c3.button("규칙 적용"):
res = categorization_gateway.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")
+41
View File
@@ -0,0 +1,41 @@
from __future__ import annotations
# ruff: noqa: F405
import streamlit as st
from dashboard.common import * # noqa: F403, F405
def page_transactions(db_path: str) -> None:
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)
+182
View File
@@ -0,0 +1,182 @@
from __future__ import annotations
# ruff: noqa: F405
import streamlit as st
from jarca_ledger_mcp.adapters.streamlit import categorization_gateway
from dashboard.common import * # noqa: F403, F405
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 categorization_gateway.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<span style='color:#64748b;font-size:0.82rem'>{group.get('raw_summary') or '-'} · {group.get('type') or '-'}</span>", 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 = categorization_gateway.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 = categorization_gateway.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)
with st.expander(f"Review decision · 현재 {row.get('review_status', 'pending')}", expanded=False):
render_review_decision_actions(categorization_gateway, "uncategorized_transaction", row["id"], f"uncat_review_decision_{row['id']}_{idx}", db_path, target_payload=row)
def page_uncategorized(db_path: str) -> None:
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(categorization_gateway, "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)
+4 -4
View File
@@ -46,11 +46,11 @@ def test_streamlit_apptest_core_pages_use_synthetic_fixture_db(tmp_path, monkeyp
app_path = Path(__file__).resolve().parents[1] / "dashboard" / "app.py"
page_expectations = {
"요약": "가계부 운영 대시보드",
"요약": "이번 달 수입/지출 현황",
"자산/마감": "월말 스냅샷 Preview / Close",
"분류 규칙": "분류 규칙",
"확인필요 큐": "KB↔토스뱅크 확인필요 큐",
"월정산": "월정산",
"분류 규칙": "현재 분류 규칙",
"확인필요 큐": "현재 review_status: pending",
"월정산": "월간 핵심 KPI",
}
for page, expected_text in page_expectations.items():
at = AppTest.from_file(str(app_path), default_timeout=10)