GitHub Action
Sync from GitHub
ef78361
Raw
History Blame Contribute Delete
12.7 kB
"""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()