245 lines
12 KiB
Python
245 lines
12 KiB
Python
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 _account_balance_basis_date(account: dict[str, Any]) -> str:
|
|
"""Return only an explicit evidence date, never a sync timestamp."""
|
|
return str(account.get("last_balance_basis_date") or "")[:10]
|
|
|
|
|
|
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>
|
|
[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;
|
|
}
|
|
</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(3)
|
|
last_balance_raw = balance_columns[0].text_input(
|
|
"기준 잔액",
|
|
value="" if account.get("last_balance") is None else str(account.get("last_balance")),
|
|
help="원 단위 정수",
|
|
)
|
|
last_balance_basis_date = balance_columns[1].text_input(
|
|
"잔액 기준일",
|
|
value=account.get("last_balance_basis_date") or "",
|
|
help="실제 증빙 기준일(YYYY-MM-DD). 미상인 경우 비워 둡니다.",
|
|
)
|
|
last_synced_at = balance_columns[2].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_balance_basis_date=last_balance_basis_date,
|
|
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)
|
|
selected_id = st.selectbox("평가 이력 계좌", [a["id"] for a in accounts_data], format_func=lambda value: next((a.get("name", value) for a in accounts_data if a["id"] == value), value))
|
|
selected_account = next(a for a in accounts_data if a["id"] == selected_id)
|
|
history = accounts_gateway.list_account_valuations(selected_id, db_path=db_path)
|
|
valuations = history.get("valuations", []) if history.get("success") else []
|
|
if valuations:
|
|
st.dataframe(valuations, width="stretch", hide_index=True)
|
|
# The list is historical insertion order; a rejected/corrected
|
|
# tail is not necessarily the effective current valuation.
|
|
basis = _account_balance_basis_date(selected_account)
|
|
selected = accounts_gateway.select_account_valuation(selected_id, basis, as_of=True, db_path=db_path) if basis else {}
|
|
selected_valuation = selected.get("valuation") if selected.get("success") else None
|
|
latest = selected_valuation or next(
|
|
(item for item in reversed(valuations) if item.get("verification_status") == "verified"), valuations[-1]
|
|
)
|
|
st.caption(f"최근 실제 기준일: {latest.get('basis_date', '-')} · 상태: {latest.get('verification_status', 'unknown')} · 종류: {latest.get('valuation_kind', 'unknown')}")
|
|
else:
|
|
st.warning("평가 이력이 없습니다.")
|
|
with st.expander("계좌 평가 등록"):
|
|
with st.form(f"account_valuation_register_{selected_id}"):
|
|
v_date = st.text_input("평가 기준일", value=_account_balance_basis_date(selected_account))
|
|
v_amount = st.number_input("평가 금액", value=int(selected_account.get("last_balance") or 0), step=1)
|
|
default_kind = "loan_balance" if selected_account.get("type") == "loan" else ("total_value" if selected_account.get("type") == "investment" else "cash_balance")
|
|
v_kind = st.selectbox("평가 종류", ["cash_balance", "balance", "total_value", "loan_balance", "loan"], index=["cash_balance", "balance", "total_value", "loan_balance", "loan"].index(default_kind))
|
|
v_status = st.selectbox("검증 상태", ["unverified", "verified", "rejected"])
|
|
v_definition = st.selectbox("대표 부채 정의(대출 필수)", ["", "principal_only", "principal_interest_fees"])
|
|
v_submit = st.form_submit_button("평가 등록")
|
|
if v_submit:
|
|
result = accounts_gateway.register_account_valuation(selected_id, v_date, int(v_amount), valuation_kind=v_kind, verification_status=v_status, representative_liability_definition=v_definition or None, db_path=db_path)
|
|
if result.get("success"):
|
|
clear_cache(); st.success("평가를 등록했습니다."); st.rerun()
|
|
else:
|
|
st.error(result.get("error", result))
|
|
with st.expander("계좌 평가 정정"):
|
|
with st.form(f"account_valuation_correct_{selected_id}"):
|
|
correction_id = st.text_input("정정 대상 평가 ID")
|
|
correction_amount = st.number_input("정정 금액", value=0, step=1, key=f"correction_amount_{selected_id}")
|
|
correction_reason = st.text_input("정정 사유")
|
|
correction_submit = st.form_submit_button("평가 정정")
|
|
if correction_submit:
|
|
result = accounts_gateway.correct_account_valuation(correction_id, int(correction_amount), correction_reason, db_path=db_path)
|
|
if result.get("success"):
|
|
clear_cache(); st.success("평가를 정정했습니다."); st.rerun()
|
|
else:
|
|
st.error(result.get("error", result))
|
|
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("등록된 계좌가 없습니다.")
|