diff --git a/dashboard/page_views/uncategorized.py b/dashboard/page_views/uncategorized.py
index 04d04df..7bd50c3 100644
--- a/dashboard/page_views/uncategorized.py
+++ b/dashboard/page_views/uncategorized.py
@@ -6,6 +6,7 @@ from __future__ import annotations
import streamlit as st
from jarca_ledger_mcp.adapters.streamlit import categorization_gateway
+from dashboard.components import JarcaList
from dashboard.common import * # noqa: F403, F405
def render_uncategorized_action_table(rows: list[dict[str, Any]], db_path: str) -> None:
@@ -56,42 +57,57 @@ def save_merchant_rule_from_recommendation(merchant: str, tx_type: str, rec: dic
)
+def render_uncategorized_groups_header(groups: list[dict[str, Any]]) -> None:
+ st.markdown(f"{len(groups)}개 사용처 그룹")
+
+
+def render_uncategorized_group(group: dict[str, Any], db_path: str) -> None:
+ rec = group.get("recommendation") or {}
+ cols = st.columns([2.0, 0.7, 1.0, 1.55, 1.0], vertical_alignment="center")
+ merchant = group.get("merchant") or group.get("raw_summary") or "-"
+ group_key = group.get("sample_transaction_id") or merchant
+ cols[0].markdown(f"**{merchant}** \n{group.get('raw_summary') or '-'} · {group.get('type') or '-'}", 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"))
+ action = cols[4].menu_button(
+ "작업",
+ options=["그룹 적용", "규칙 만들기"],
+ key=f"uncat_group_action_{group_key}",
+ disabled=apply_disabled,
+ width="stretch",
+ )
+ if action == "그룹 적용":
+ 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))
+ elif action == "규칙 만들기":
+ 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 '추천 없음'}")
+
+
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{group.get('raw_summary') or '-'} · {group.get('type') or '-'}", 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()
+ visible = [g for g in groups if not repeated_only or int(g.get("count") or 0) >= 2][:10]
+ JarcaList(
+ render_header=render_uncategorized_groups_header,
+ render_item=lambda group: render_uncategorized_group(group, db_path),
+ items=visible,
+ empty_message="반복 후보가 없습니다.",
+ ).render()
def render_uncategorized_work_items(rows: list[dict[str, Any]], db_path: str, recommended_only: bool = False) -> None: