na URL."""
fonte = ini.get("fonte", "")
tem_match = bool(ini.get("tambem_na_planilha"))
# Header info da iniciativa selecionada
fonte_l = _fonte_label(fonte)
match_tag = " · ↔ tambem no Master Roadmap" if tem_match else ""
st.markdown(f"**{_escape_html(ini.get('titulo', ''))}**")
st.caption(f"Fonte: {fonte_l}{match_tag}")
if fonte == "jira" and not tem_match:
st.warning(
"Sem match na planilha. Edicoes atualizam apenas o Jira. "
"Pra refletir tambem no Master Roadmap, adicione manualmente la.",
icon="⚠️",
)
elif fonte == "planilha":
st.info(
"Apenas na planilha. Edicoes atualizam apenas o Master Roadmap. "
"Pra refletir tambem no Jira, crie o epico manualmente la.",
icon="ℹ️",
)
titulo_default = ini.get("titulo", "")
di_str = ini.get("data_inicio")
df_str = ini.get("data_fim")
di_default = date.fromisoformat(di_str) if di_str else None
df_default = date.fromisoformat(df_str) if df_str else None
cat_default = ini.get("categoria", "?")
status_default = ini.get("status", "planejado")
cats = ["KR1.1", "KR1.2", "KR1.3", "DEBITO", "REGULATORIO", "?"]
with st.form(f"edit_modal_{_build_edit_id(ini)}"):
novo_titulo = st.text_input("Titulo", value=titulo_default)
col_d1, col_d2 = st.columns(2)
with col_d1:
nova_di = st.date_input(
"Data inicio", value=di_default,
help="Jira: customfield_10015 (start). Planilha: coluna inicial.",
)
with col_d2:
nova_df = st.date_input(
"Data fim", value=df_default,
help="Jira: duedate. Planilha: coluna final do merge.",
)
col_c1, col_c2 = st.columns(2)
with col_c1:
nova_cat = st.selectbox(
"Categoria / KR", options=cats,
index=cats.index(cat_default) if cat_default in cats else 5,
help="Jira: atualiza labels. Planilha: mover de linha nao suportado.",
)
with col_c2:
opts_status = ["planejado", "em-andamento", "atrasado", "done"]
novo_status = st.selectbox(
"Status", options=opts_status,
index=opts_status.index(status_default) if status_default in opts_status else 0,
format_func=_status_label,
help="Jira: tenta transition. 'Atrasado' eh derivado.",
)
col_btn1, col_btn2 = st.columns(2)
with col_btn1:
sub = st.form_submit_button("Salvar alteracoes", type="primary", use_container_width=True)
with col_btn2:
cancelar = st.form_submit_button("Cancelar", use_container_width=True)
if cancelar:
if "edit" in st.query_params:
del st.query_params["edit"]
st.rerun()
if sub:
ok = _executar_edicao(
ini, novo_titulo, nova_di, nova_df, nova_cat, novo_status,
)
if ok:
if "edit" in st.query_params:
del st.query_params["edit"]
_carregar_dados_cached.clear()
st.rerun()
def _executar_edicao(ini: dict, titulo: str, data_inicio: Optional[date],
data_fim: Optional[date], categoria: str,
status_alvo: str) -> bool:
"""Aplica edicao em Jira e/ou planilha. Toast em sucesso, st.error em
falha (modal mantem aberto pra ler erro). Retorna True se tudo OK."""
fonte = ini.get("fonte", "")
raw = ini.get("raw", {}) or {}
epic_key = raw.get("key")
pl_row = raw.get("row") if fonte == "planilha" else raw.get("planilha_row")
pl_col = raw.get("col") if fonte == "planilha" else raw.get("planilha_col")
# Detecta o que mudou (so envia o que mudou)
mudou_titulo = titulo and titulo != ini.get("titulo")
cur_di = date.fromisoformat(ini["data_inicio"]) if ini.get("data_inicio") else None
cur_df = date.fromisoformat(ini["data_fim"]) if ini.get("data_fim") else None
mudou_di = data_inicio and data_inicio != cur_di
mudou_df = data_fim and data_fim != cur_df
mudou_cat = categoria and categoria != ini.get("categoria")
mudou_status = status_alvo and status_alvo != ini.get("status")
if not any([mudou_titulo, mudou_di, mudou_df, mudou_cat, mudou_status]):
st.info("Nenhuma mudanca detectada.")
return False
overall_ok = True
# 1) Atualizar Jira (se aplicavel)
if epic_key:
with st.spinner(f"Atualizando Jira {epic_key}..."):
r_jira = _atualizar_jira_epico(
epic_key,
titulo=titulo if mudou_titulo else None,
data_inicio=data_inicio if mudou_di else None,
data_fim=data_fim if mudou_df else None,
categoria=categoria if mudou_cat else None,
status_alvo=status_alvo if mudou_status else None,
)
if r_jira["ok"]:
st.toast(f"✓ Jira {epic_key} atualizado", icon="✅")
else:
st.error(f"✗ Jira {epic_key}: {r_jira['erro']}")
overall_ok = False
# 2) Atualizar planilha (se aplicavel)
if pl_row is not None and pl_col is not None and (mudou_titulo or mudou_di or mudou_df):
with st.spinner("Atualizando planilha..."):
r_pl = _atualizar_planilha_iniciativa(
pl_row, pl_col,
titulo=titulo if mudou_titulo else ini.get("titulo"),
data_inicio=data_inicio if mudou_di else cur_di,
data_fim=data_fim if mudou_df else cur_df,
)
if r_pl["ok"]:
st.toast("✓ Master Roadmap atualizado", icon="✅")
else:
st.error(f"✗ Planilha: {r_pl['erro']}")
overall_ok = False
if mudou_cat and fonte == "planilha":
st.warning(
"Mudanca de categoria na planilha exige mover de linha — ainda nao "
"suportado. A categoria nova so vai refletir na visualizacao.",
icon="⚠️",
)
return overall_ok
# ---------------------------------------------------------------------------
# Swim lanes — grid CSS unico com 5 sections empilhadas
# ---------------------------------------------------------------------------
# Cores por swim lane (borda esquerda + cor do section header)
SWIM_LANE_CONFIG = [
("KR1.1", "KR1.1 — FPR (Reduzir impacto em clientes bons)", "#FE3E6D"),
("KR1.2", "KR1.2 — Detecção (Aumentar % de fraude detectada)", "#2563eb"),
("KR1.3", "KR1.3 — Chargeback (Manter CBK de LP < 1%)", "#059669"),
("DEBITO", "Débitos Técnicos — Manutenção operacional, scaling, migrações", "#6b7280"),
("REGULATORIO", "Regulatórios — R10-25, BACEN, LGPD, judiciais", "#f59e0b"),
]
def _render_swim_lanes(items_com_data: list[dict]):
"""Renderiza grid CSS com 5 swim lanes empilhadas — design rico inspirado
no roadmap da Emily Bustamante. Inclui: header sticky com meses, linha
vermelha vertical de TODAY, section headers coloridos por lane, badges
de status e fonte, cards visuais com cores de status."""
janela = _janela_meses()
hoje = date.today()
n_meses = len(janela)
col_label_w = 320
col_mes_w = 140
# Posicao da linha TODAY: % dentro da janela.
# Calcula o offset do mes atual + offset dentro do mes (dia/30).
today_idx = next((i for i, (a, m) in enumerate(janela) if (a, m) == (hoje.year, hoje.month)), None)
today_offset_px = None
if today_idx is not None:
# Posicao no header de meses + posicao dentro do mes
dia_pct = hoje.day / 30.0
today_offset_px = col_label_w + (today_idx + dia_pct) * col_mes_w
css = f"""
"""
# Linha de header (meses)
header_cells = ['Iniciativa
']
for ano, mes in janela:
cls = "rmap-mes" + (" atual" if (ano, mes) == (hoje.year, hoje.month) else "")
header_cells.append(f'{_label_mes(ano, mes)}
')
# Map cor → soft pra usar nos CSS vars
cor_soft_map = {
"#FE3E6D": "var(--rmap-pink-soft)",
"#2563eb": "var(--rmap-blue-soft)",
"#059669": "var(--rmap-green-soft)",
"#6b7280": "var(--rmap-gray-soft)",
"#f59e0b": "var(--rmap-amber-soft)",
}
# 5 sections empilhadas
section_html = []
for cat_key, cat_titulo, cor in SWIM_LANE_CONFIG:
items_cat = [i for i in items_com_data if i["categoria"] == cat_key]
# Section header
partes = cat_titulo.split(" — ", 1)
nome_lane = partes[0]
subtitulo = partes[1] if len(partes) > 1 else ""
soft = cor_soft_map.get(cor, "#fafafa")
section_html.append(
f''
f'{_escape_html(nome_lane)}'
f'{len(items_cat)}'
f'{_escape_html(subtitulo)}'
f'
'
)
if not items_cat:
section_html.append(
'Nenhuma iniciativa nesta categoria
'
)
continue
# Linhas de iniciativas (ordena por data)
items_cat = sorted(items_cat, key=lambda i: i["mes_planilha"])
for it in items_cat:
span = _calc_span_indices(it, janela)
if span is None:
continue
start_idx, end_idx = span
n_span = end_idx - start_idx + 1
titulo = _escape_html(it["titulo"])[:90]
link = it.get("link") or ""
fonte = it.get("fonte", "")
descricao = _escape_html((it.get("descricao") or "")[:120])
status = it.get("status", "planejado")
prioridade = it.get("prioridade")
# Titulo sempre texto puro (nao clicavel). Acesso a documentacao
# eh via CTA "Abrir documentacao" abaixo.
titulo_html = titulo
# Monta badges
badges = [f'{_status_label(status)}']
badges.append(
f'{_fonte_label(fonte)}'
)
if prioridade:
badges.append(
f'{_prio_label(prioridade)}'
)
if it.get("tambem_na_planilha"):
badges.append(
'↔ planilha'
)
n_tasks = it.get("n_tasks_filhas") or 0
if n_tasks > 0:
derived = " (data fim derivada)" if it.get("data_fim_derivada") else ""
badges.append(
f'{n_tasks}↳'
)
# GAPS — clicaveis abrem modal INLINE pra colar URL do doc/jira/MR.
# Sao apenas links (armazenados em session_state ate o usuario
# registrar permanentemente no sistema correspondente).
eid_for_link = _build_edit_id(it)
link_url_for_save = _build_app_url(eid_for_link)
if not it.get("link_drive") and fonte != "doc-regulatorio":
badges.append(
f'+ doc'
)
if fonte == "planilha" and not it.get("_user_linked_jira"):
badges.append(
f'+ Jira'
)
elif fonte == "jira" and not it.get("tambem_na_planilha"):
badges.append(
f'+ MR'
)
# CTA "Abrir documentacao" — aparece quando ha link Drive (pasta ou
# doc) OU quando a propria fonte eh um documento (doc-regulatorio).
ld = it.get("link_drive")
drive_cta = ""
row_cls = "rmap-row-label"
if ld:
row_cls += " has-drive"
nome_drive = _escape_html(ld.get("name", ""))
cls_drive = (
"drive-folder" if ld.get("_match_type") == "folder"
else "drive-file"
)
drive_cta = (
f''
f'↗ Abrir documentação'
)
elif fonte == "doc-regulatorio" and link:
row_cls += " has-drive"
drive_cta = (
f''
f'↗ Abrir documentação'
)
# Botao Editar — abre modal HTML INSTANTANEAMENTE via JS
# (sem reload). Data-attrs carregam os valores atuais do item;
# JS popula o modal e mostra. Save submete via navegacao
# window.parent (in-place reload).
edit_btn = ""
if fonte in ("jira", "planilha"):
eid = _build_edit_id(it)
edit_url = _build_app_url(eid)
tem_match = "true" if it.get("tambem_na_planilha") else "false"
data_inicio_attr = _escape_html(it.get("data_inicio") or "")
data_fim_attr = _escape_html(it.get("data_fim") or "")
cat_attr = _escape_html(it.get("categoria") or "?")
status_attr = _escape_html(it.get("status") or "planejado")
titulo_attr = _escape_html(it.get("titulo") or "")
edit_btn = (
f'✎ Editar'
)
# Label da iniciativa
label_html = (
f''
f'
{titulo_html}'
)
# Acoes (Editar + Drive) na mesma linha
if edit_btn or drive_cta:
label_html += f'
{edit_btn}{drive_cta}
'
if descricao:
label_html += f'
{descricao}'
label_html += f'
{"".join(badges)}
'
section_html.append(label_html)
# Celulas da timeline: vazias antes do start_idx.
desc_attr = _escape_html((it.get("descricao") or "")[:300])
for ji in range(start_idx):
ja, jm = janela[ji]
cell_cls = "rmap-cell" + (" atual" if (ja, jm) == (hoje.year, hoje.month) else "")
section_html.append(f'')
# Branch: item com sub_items (doc-regulatorio consolidado) -> mini-barras
# por mes, posicionadas pelo prazo de cada sub-item. Outros items
# mantem 1 barra spanning n_span colunas.
sub_items = it.get("sub_items") or []
if sub_items:
# Agrupa sub-items pelo mes do prazo (data_fim)
sub_por_mes: dict[tuple[int, int], list[dict]] = {}
for sub in sub_items:
df_str = sub.get("data_fim")
if not df_str:
continue
try:
sub_df = date.fromisoformat(df_str)
except Exception:
continue
sub_por_mes.setdefault((sub_df.year, sub_df.month), []).append(sub)
# Renderiza 1 cell por mes do span; se tem sub-item naquele mes,
# poe 1 ou mais mini-barras. Senao, cell vazia.
for ji in range(start_idx, end_idx + 1):
ja, jm = janela[ji]
cell_cls = "rmap-cell" + (" atual" if (ja, jm) == (hoje.year, hoje.month) else "")
subs_aqui = sub_por_mes.get((ja, jm), [])
if subs_aqui:
mini_bars = []
for sub in subs_aqui:
sub_titulo = sub.get("titulo") or ""
sub_codigo = sub.get("codigo") or ""
sub_status = sub.get("status") or "planejado"
sub_prio = sub.get("prioridade") or ""
# Label curto: codigo abreviado (AP.3) + titulo
label = sub_codigo.replace("AP.R10-25.", "AP.") if sub_codigo else "AP"
# Tooltip rico
tooltip_parts = [sub_titulo]
if sub.get("data_fim"):
tooltip_parts.append(f"Prazo: {sub['data_fim']}")
if sub_prio:
tooltip_parts.append(f"Prioridade: {sub_prio}")
tooltip = _escape_html(" | ".join(tooltip_parts))
prio_cls = f" prio-{sub_prio}" if sub_prio else ""
mini_bars.append(
f'{_escape_html(label)}
'
)
section_html.append(
f''
f'{"".join(mini_bars)}
'
)
else:
section_html.append(f'')
else:
bar = (
f''
f'{titulo[:60 if n_span > 1 else 35]}'
f'
{_status_label(status)}
'
f'
'
)
section_html.append(
f'{bar}
'
)
for ji in range(end_idx + 1, len(janela)):
ja, jm = janela[ji]
cell_cls = "rmap-cell" + (" atual" if (ja, jm) == (hoje.year, hoje.month) else "")
section_html.append(f'')
# Linha TODAY vertical (sobreposta no grid)
today_line = ""
if today_offset_px is not None:
today_line = f''
# Modal HTML