"""Action Items overview — card-based UI with stats, filters, and inline editing.""" import re from datetime import date, timedelta import streamlit as st from core.supabase_client import ( get_action_items, get_action_item_stats, update_action_item_status, delete_action_item, get_campaign_date_range, ) from .analysis import render_analysis_section from .trend_charts import ( render_visibility_trend, render_citation_type_trend, render_negative_rate_trend, render_action_items_history, ) from .utils import ( STATUS_CONFIG, STATUS_OPTIONS, STATUS_LABELS, CATEGORY_CONFIG, priority_level, ) def _render_stats(stats: dict): """Render stats summary bar.""" total = stats.get("total", 0) pending = stats.get("pending", 0) in_progress = stats.get("in_progress", 0) completed = stats.get("completed", 0) if total == 0: return cols = st.columns(4) items = [ ("pending", "대기", pending), ("in_progress", "진행 중", in_progress), ("completed", "완료", completed), ("archived", "보관", stats.get("archived", 0)), ] for col, (key, label, count) in zip(cols, items): cfg = STATUS_CONFIG[key] col.markdown(f"""
{count}
{cfg['emoji']} {label}
""", unsafe_allow_html=True) # Progress bar if total > 0: done_pct = completed / total * 100 active_pct = in_progress / total * 100 st.markdown(f"""
완료 {done_pct:.0f}% 전체 {total}건
""", unsafe_allow_html=True) def _render_empty_state(): """Render friendly empty state.""" st.markdown("""
📋
액션아이템이 없습니다
위의 분석 실행 버튼으로 트리거를 평가하고 저장해보세요
""", unsafe_allow_html=True) def _render_card(item: dict, idx: int, base_ctx: dict | None = None): """Render a single action item card.""" item_id = item["id"] current_status = item.get("status", "pending") priority = item.get("priority", 50) category = item.get("category", "") label = item.get("label", "") evidence = item.get("evidence") or "" llm_rec = item.get("llm_recommendation") or "" created = (item.get("created_at") or "")[:10] p_label, p_color, p_bg = priority_level(priority) cat_cfg = CATEGORY_CONFIG.get(category, {"color": "#666", "icon": "📌"}) # Card header HTML st.markdown(f"""
{p_label} {priority} {cat_cfg['icon']} {category}
{created}
{label}
{"
" + evidence[:200] + ("..." if len(evidence) > 200 else "") + "
" if evidence else ""} {"
💡 " + llm_rec + "
" if llm_rec else ""}
""", unsafe_allow_html=True) # Interactive controls (Streamlit widgets, can't be inside HTML) ctrl_cols = st.columns([2, 1, 1, 1]) with ctrl_cols[0]: new_status = st.selectbox( "상태", options=STATUS_OPTIONS, index=STATUS_OPTIONS.index(current_status), format_func=lambda s: STATUS_LABELS.get(s, s), key=f"ai_st_{item_id}", label_visibility="collapsed", ) if new_status != current_status: if update_action_item_status(item_id, new_status): st.rerun() with ctrl_cols[1]: assignee = item.get("assignee_email") or "" if assignee: st.caption(f"👤 {assignee.split('@')[0]}") with ctrl_cols[2]: export_key = f"ai_export_html_{item_id}" cached = st.session_state.get(export_key) if cached: st.download_button( "📥 다운로드", data=b'\xef\xbb\xbf' + cached["content"].encode("utf-8"), file_name=cached["file_name"], mime="text/html; charset=utf-8", key=f"ai_dl_{item_id}", use_container_width=True, ) else: if st.button("📄 HTML", key=f"ai_export_{item_id}", type="secondary"): with st.spinner("HTML 생성 중..."): try: from core.api_client import ChainShiftClient ctx = base_ctx or {} client = ChainShiftClient( api_key=ctx.get("api_key"), access_token=ctx.get("access_token"), ) resp = client.export_action_item_html(item_id) data = resp.get("data", {}) html_content = data.get("html_content", "") if isinstance(data, dict) else "" if html_content: safe_label = re.sub(r'[^\w\-]', '_', (label or "action_item")[:30]) st.session_state[export_key] = { "content": html_content, "file_name": f"{safe_label}_{item_id[:8]}.html", } st.rerun() else: st.error("HTML 생성 결과가 비어있습니다.") except Exception as e: st.error(f"HTML 생성 실패: {e}") with ctrl_cols[3]: if st.button("🗑️ 삭제", key=f"ai_del_{item_id}", type="secondary"): if delete_action_item(item_id): st.rerun() def _render_trend_section(campaign_id: int): """Render trigger metric trend charts in an expander.""" with st.expander("📊 지표 추이", expanded=False): # Reuse existing date range from session state start_date = st.session_state.get("ai_trigger_start") end_date = st.session_state.get("ai_trigger_end") if not start_date or not end_date: date_range = get_campaign_date_range(campaign_id) if not date_range: st.info("캠페인 데이터가 아직 동기화되지 않았습니다.") return _, last_date_str = date_range last_date = date.fromisoformat(last_date_str) end_date = last_date start_date = last_date - timedelta(days=13) s = str(start_date) e = str(end_date) col1, col2 = st.columns(2) with col1: render_visibility_trend(campaign_id, s, e) with col2: render_citation_type_trend(campaign_id, s, e) col3, col4 = st.columns(2) with col3: render_negative_rate_trend(campaign_id, s, e) with col4: render_action_items_history(campaign_id) PAGE_SIZE = 10 def render(base_ctx): """Render action items overview with card-based UI.""" campaign_id = base_ctx.get("campaign_id") if not campaign_id: st.warning("캠페인을 선택해주세요.") return # ── Stats ── stats = get_action_item_stats(campaign_id) _render_stats(stats) st.markdown("") # spacer # ── Trigger Analysis ── render_analysis_section(campaign_id, base_ctx) # ── Trend Charts ── _render_trend_section(campaign_id) st.markdown("---") # ── Filters ── filter_cols = st.columns([1, 1, 1]) with filter_cols[0]: status_filter = st.selectbox( "상태 필터", options=["전체"] + [STATUS_LABELS[s] for s in STATUS_OPTIONS], index=0, key="ai_status_filter", ) with filter_cols[1]: cat_options = ["전체"] + list(CATEGORY_CONFIG.keys()) category_filter = st.selectbox( "카테고리 필터", options=cat_options, index=0, key="ai_category_filter", ) with filter_cols[2]: SORT_OPTIONS = { "최신순": ("created_at", True), "오래된순": ("created_at", False), "우선순위 높은순": ("priority", True), "우선순위 낮은순": ("priority", False), } sort_choice = st.selectbox( "정렬", options=list(SORT_OPTIONS.keys()), index=0, key="ai_sort", ) sort_col, sort_desc = SORT_OPTIONS[sort_choice] # Resolve filter values selected_status = None if status_filter != "전체": for k, v in STATUS_LABELS.items(): if v == status_filter: selected_status = k break selected_category = None if category_filter == "전체" else category_filter # ── Pagination state ── page_key = "ai_page" if page_key not in st.session_state: st.session_state[page_key] = 1 current_page = st.session_state[page_key] # ── Fetch items ── items, total = get_action_items( campaign_id, status=selected_status, category=selected_category, page=current_page, page_size=PAGE_SIZE, order_by=sort_col, desc=sort_desc, ) if not items and current_page == 1: _render_empty_state() return total_pages = max(1, -(-total // PAGE_SIZE)) # ceil division st.caption(f"총 **{total}**건 · 페이지 {current_page}/{total_pages}") # ── Card grid (2 columns) ── for i in range(0, len(items), 2): cols = st.columns(2) for col_idx, col in enumerate(cols): item_idx = i + col_idx if item_idx < len(items): with col: _render_card(items[item_idx], item_idx, base_ctx) # ── Pagination controls ── if total_pages > 1: st.markdown("") nav_cols = st.columns([1, 2, 1]) with nav_cols[0]: if current_page > 1: if st.button("← 이전", key="ai_prev", use_container_width=True): st.session_state[page_key] = current_page - 1 st.rerun() with nav_cols[1]: st.markdown( f"
" f"{current_page} / {total_pages}
", unsafe_allow_html=True, ) with nav_cols[2]: if current_page < total_pages: if st.button("다음 →", key="ai_next", use_container_width=True): st.session_state[page_key] = current_page + 1 st.rerun()