Spaces:
Sleeping
Sleeping
| """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""" | |
| <div style="background:{cfg['bg']};border-radius:10px;padding:16px;text-align:center; | |
| border-left:4px solid {cfg['color']}"> | |
| <div style="font-size:28px;font-weight:700;color:{cfg['color']}">{count}</div> | |
| <div style="font-size:13px;color:#6B7280;margin-top:2px">{cfg['emoji']} {label}</div> | |
| </div> | |
| """, unsafe_allow_html=True) | |
| # Progress bar | |
| if total > 0: | |
| done_pct = completed / total * 100 | |
| active_pct = in_progress / total * 100 | |
| st.markdown(f""" | |
| <div style="margin:12px 0 4px 0"> | |
| <div style="display:flex;height:8px;border-radius:4px;overflow:hidden;background:#F3F4F6"> | |
| <div style="width:{done_pct}%;background:#10b981"></div> | |
| <div style="width:{active_pct}%;background:#f59e0b"></div> | |
| </div> | |
| <div style="display:flex;justify-content:space-between;font-size:11px;color:#9ca3af;margin-top:4px"> | |
| <span>์๋ฃ {done_pct:.0f}%</span> | |
| <span>์ ์ฒด {total}๊ฑด</span> | |
| </div> | |
| </div> | |
| """, unsafe_allow_html=True) | |
| def _render_empty_state(): | |
| """Render friendly empty state.""" | |
| st.markdown(""" | |
| <div style="text-align:center;padding:60px 20px;color:#9ca3af"> | |
| <div style="font-size:48px;margin-bottom:12px">๐</div> | |
| <div style="font-size:18px;font-weight:600;color:#6B7280;margin-bottom:8px"> | |
| ์ก์ ์์ดํ ์ด ์์ต๋๋ค | |
| </div> | |
| <div style="font-size:14px"> | |
| ์์ ๋ถ์ ์คํ ๋ฒํผ์ผ๋ก ํธ๋ฆฌ๊ฑฐ๋ฅผ ํ๊ฐํ๊ณ ์ ์ฅํด๋ณด์ธ์ | |
| </div> | |
| </div> | |
| """, 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""" | |
| <div style="background:white;border:1px solid #E5E7EB;border-radius:12px; | |
| padding:20px;margin-bottom:4px;border-left:4px solid {p_color}"> | |
| <div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:10px"> | |
| <div style="display:flex;gap:8px;align-items:center"> | |
| <span style="background:{p_bg};color:{p_color};padding:2px 10px; | |
| border-radius:12px;font-size:12px;font-weight:600">{p_label} {priority}</span> | |
| <span style="background:{cat_cfg['color']}15;color:{cat_cfg['color']};padding:2px 10px; | |
| border-radius:12px;font-size:12px">{cat_cfg['icon']} {category}</span> | |
| </div> | |
| <span style="font-size:11px;color:#9ca3af">{created}</span> | |
| </div> | |
| <div style="font-size:15px;font-weight:600;color:#1F2937;margin-bottom:6px;line-height:1.4"> | |
| {label} | |
| </div> | |
| {"<div style='font-size:13px;color:#6B7280;margin-bottom:6px;line-height:1.5'>" + evidence[:200] + ("..." if len(evidence) > 200 else "") + "</div>" if evidence else ""} | |
| {"<div style='background:#EFF6FF;border-radius:8px;padding:10px;font-size:13px;color:#1D4ED8;margin-bottom:4px'>๐ก " + llm_rec + "</div>" if llm_rec else ""} | |
| </div> | |
| """, 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"<div style='text-align:center;color:#9ca3af;padding:8px'>" | |
| f"{current_page} / {total_pages}</div>", | |
| 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() | |