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