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