TSX770 linhas36.776 bytes
apps/web/src/app/admin/premiacao/page.tsx
SHA-256
8f3bf8329b971eb6210f7ee0d39218e0ada383eb7da2d71478cf2b16c15ee2cc
Somente leiturafonte-0531042d9f3e
'use client'
import { useCallback, useEffect, useMemo, useState } from 'react'
const API = process.env.NEXT_PUBLIC_API_URL ?? 'https://api.observasenado.org'
const SECRET_KEY = 'observa_admin_secret_v1'
const BASE = `${API}/api/premiacao/admin`
type Status = 'rascunho' | 'apurando' | 'apurada' | 'aprovada' | 'divulgada'
type Eixo = 'grande_premio' | 'regional' | 'estadual' | 'tematico'
interface Edicao {
id: string
tipo: 'legislatura' | 'ano'
titulo: string
subtitulo: string
periodo: { inicio: string; fim: string }
periodoEmCurso: boolean
status: Status
fonte: string | null
apuracaoSha256: string | null
erro: string | null
apuradoEm: string | null
aprovadoEm: string | null
divulgadoEm: string | null
regua: { mesesMinReferencia: number; mesesMinVitoria: number; minLinhas: number }
}
interface Possivel {
id: string
tipo: 'legislatura' | 'ano'
titulo: string
periodo: { inicio: string; fim: string }
}
interface Premiado {
posicao: number
senadorCod: string
nome: string
partido: string
uf: string
valor: number
idsTotal: number
mesesAtivos: number
indicador: { rotulo: string; valor: number; unidade: string; detalhe: string }
}
interface Categoria {
id: string
eixo: Eixo
titulo: string
descricao: string
elegiveis: number
vencedor: Premiado
podio: Premiado[]
houveDesempate: boolean
}
interface Apuracao {
metodologiaVersao?: string
snapshot: string
apuradoEm: string
coorte: { avaliados: number; elegiveis: number; concorrentes: number; mesesMinimos: number; mesesMinimosVitoria: number }
categorias: Categoria[]
}
interface CertResumo {
codigo: string
categoria: string
eixo: Eixo
pdfGerado: boolean
pdfSha256: string | null
enviadoEm: string | null
envios: number
envioErro: string | null
revogadoEm: string | null
motivoRevogacao: string | null
}
interface SenadorPremiado {
senadorCod: string
nome: string
tratamento: string
partido: string
uf: string
email: string | null
emailOficial: string | null
certificados: CertResumo[]
}
interface Snapshot {
computedAt: string
total: number
apto: boolean
}
interface Detalhe {
edicao: Edicao
apuracao: Apuracao | null
snapshots: Snapshot[]
senadores: SenadorPremiado[]
}
const EIXOS: { id: Eixo; nome: string }[] = [
{ id: 'grande_premio', nome: 'Grande Prêmio' },
{ id: 'regional', nome: 'Regionais' },
{ id: 'estadual', nome: 'Estaduais' },
{ id: 'tematico', nome: 'Temáticos' },
]
const STATUS_COR: Record<Status, string> = {
rascunho: 'bg-gray-100 text-gray-700',
apurando: 'bg-blue-100 text-blue-800',
apurada: 'bg-amber-100 text-amber-800',
aprovada: 'bg-green-100 text-green-800',
divulgada: 'bg-primary text-white',
}
function fmtData(s: string | null | undefined): string {
if (!s) return '—'
const d = new Date(s.length === 10 ? `${s}T12:00:00` : s)
return d.toLocaleDateString('pt-BR') + (s.length > 10 ? ' ' + d.toLocaleTimeString('pt-BR', { hour: '2-digit', minute: '2-digit' }) : '')
}
function fmtNum(v: number): string {
return v.toLocaleString('pt-BR', { maximumFractionDigits: 1 })
}
function Badge({ status }: { status: Status }) {
return <span className={`inline-block rounded px-2 py-0.5 text-[11px] font-medium ${STATUS_COR[status]}`}>{status}</span>
}
function Botao({
children, onClick, tom = 'primario', disabled,
}: { children: React.ReactNode; onClick: () => void; tom?: 'primario' | 'secundario' | 'perigo'; disabled?: boolean }) {
const cls = {
primario: 'bg-primary text-white hover:bg-primary-hover',
secundario: 'border border-border bg-surface text-ink hover:border-primary',
perigo: 'border border-danger text-danger hover:bg-danger hover:text-white',
}[tom]
return (
<button
onClick={onClick}
disabled={disabled}
className={`rounded-sm px-3 py-1.5 text-xs font-medium uppercase tracking-wider transition-colors disabled:opacity-40 disabled:cursor-not-allowed ${cls}`}
>
{children}
</button>
)
}
export default function AdminPremiacaoPage() {
const [secret, setSecret] = useState('')
const [authed, setAuthed] = useState(false)
const [edicoes, setEdicoes] = useState<Edicao[]>([])
const [possiveis, setPossiveis] = useState<Possivel[]>([])
const [selecionada, setSelecionada] = useState<string | null>(null)
const [detalhe, setDetalhe] = useState<Detalhe | null>(null)
const [snapshot, setSnapshot] = useState('')
const [eixo, setEixo] = useState<Eixo>('grande_premio')
const [mensagem, setMensagem] = useState('')
const [emailTeste, setEmailTeste] = useState('')
const [emails, setEmails] = useState<Record<string, string>>({})
const [ocupado, setOcupado] = useState<string | null>(null)
const [log, setLog] = useState('')
const [previa, setPrevia] = useState<{ assunto: string; html: string; para: string | null; anexos: string[] } | null>(null)
const chamar = useCallback(
async (path: string, init: RequestInit = {}) => {
const r = await fetch(`${BASE}${path}`, {
...init,
headers: { 'Content-Type': 'application/json', 'X-Admin-Secret': secret, ...(init.headers ?? {}) },
})
const data = await r.json().catch(() => ({}))
if (!r.ok) throw new Error((data as { error?: string }).error ?? `HTTP ${r.status}`)
return data
},
[secret],
)
async function entrar(s: string): Promise<boolean> {
const r = await fetch(`${BASE}/edicoes`, { headers: { 'X-Admin-Secret': s } }).catch(() => null)
if (!r?.ok) return false
const data = await r.json()
setEdicoes(data.edicoes)
setPossiveis(data.possiveis)
setAuthed(true)
localStorage.setItem(SECRET_KEY, s)
if (data.edicoes[0]) setSelecionada((atual) => atual ?? data.edicoes[0].id)
return true
}
useEffect(() => {
const s = localStorage.getItem(SECRET_KEY)
if (s) {
setSecret(s)
void entrar(s)
}
}, [])
const carregarLista = useCallback(async () => {
const data = await chamar('/edicoes')
setEdicoes(data.edicoes)
setPossiveis(data.possiveis)
}, [chamar])
const carregarDetalhe = useCallback(async (id: string) => {
const data = (await chamar(`/edicoes/${id}`)) as Detalhe
setDetalhe(data)
setEmails(Object.fromEntries(data.senadores.map((s) => [s.senadorCod, s.email ?? ''])))
return data
}, [chamar])
useEffect(() => {
if (authed && selecionada) void carregarDetalhe(selecionada).catch((e) => setLog(String(e)))
}, [authed, selecionada, carregarDetalhe])
// Apuração em andamento: acompanha até terminar.
useEffect(() => {
if (detalhe?.edicao.status !== 'apurando') return
const t = setInterval(async () => {
const d = await carregarDetalhe(detalhe.edicao.id).catch(() => null)
if (d && d.edicao.status !== 'apurando') {
void carregarLista()
setLog(d.edicao.erro ? `Apuração falhou: ${d.edicao.erro}` : 'Apuração concluída. Revise os vencedores antes de aprovar.')
}
}, 3000)
return () => clearInterval(t)
}, [detalhe?.edicao.status, detalhe?.edicao.id, carregarDetalhe, carregarLista])
// Certificados acabaram de ser aprovados: os PDFs saem em segundo plano.
const pdfsPendentes = detalhe?.senadores.some((s) => s.certificados.some((c) => !c.pdfGerado)) ?? false
useEffect(() => {
if (!pdfsPendentes || !detalhe) return
const t = setInterval(() => void carregarDetalhe(detalhe.edicao.id).catch(() => null), 5000)
return () => clearInterval(t)
}, [pdfsPendentes, detalhe?.edicao.id, carregarDetalhe])
async function acao(rotulo: string, fn: () => Promise<unknown>, sucesso?: string) {
setOcupado(rotulo)
setLog('')
try {
await fn()
if (sucesso) setLog(sucesso)
if (selecionada) await carregarDetalhe(selecionada)
await carregarLista()
} catch (err) {
setLog(`Erro: ${(err as Error).message}`)
} finally {
setOcupado(null)
}
}
const ed = detalhe?.edicao
const categoriasDoEixo = useMemo(
() => detalhe?.apuracao?.categorias.filter((c) => c.eixo === eixo) ?? [],
[detalhe, eixo],
)
const totalCerts = detalhe?.senadores.reduce((a, s) => a + s.certificados.length, 0) ?? 0
const enviados = detalhe?.senadores.reduce((a, s) => a + s.certificados.filter((c) => c.enviadoEm).length, 0) ?? 0
const semEmail = detalhe?.senadores.filter((s) => !s.email).length ?? 0
async function abrirPdf(codigo: string) {
const r = await fetch(`${BASE}/certificados/${codigo}/pdf`, { headers: { 'X-Admin-Secret': secret } })
if (!r.ok) {
setLog(`Erro ao gerar o PDF: HTTP ${r.status}`)
return
}
window.open(URL.createObjectURL(await r.blob()), '_blank')
if (selecionada) void carregarDetalhe(selecionada)
}
async function verPrevia(s: SenadorPremiado) {
try {
const q = new URLSearchParams({ senador: s.senadorCod })
if (mensagem.trim()) q.set('mensagem', mensagem.trim())
if (s.certificados.some((c) => c.enviadoEm)) q.set('reenvio', '1')
setPrevia(await chamar(`/edicoes/${ed!.id}/email-previa?${q}`))
} catch (err) {
setLog(`Erro: ${(err as Error).message}`)
}
}
if (!authed) {
return (
<div className="max-w-md mx-auto py-12">
<p className="eyebrow">Restrito</p>
<h1 className="font-serif text-2xl text-ink mt-2">Admin · Premiação</h1>
<p className="mt-2 text-sm text-muted">Informe o ADMIN_SECRET para continuar.</p>
<form
onSubmit={async (e) => {
e.preventDefault()
if (!(await entrar(secret))) alert('Secret inválido.')
}}
className="mt-6 flex flex-col gap-3"
>
<input
type="password"
value={secret}
onChange={(e) => setSecret(e.target.value)}
placeholder="ADMIN_SECRET"
className="rounded-sm border border-border bg-surface px-3 py-2 text-sm font-mono focus:border-primary focus:outline-none"
autoFocus
/>
<button type="submit" className="rounded-sm bg-primary px-4 py-2 text-xs font-medium uppercase tracking-wider text-white hover:bg-primary-hover">
Entrar
</button>
</form>
</div>
)
}
return (
<div className="max-w-7xl mx-auto py-6">
<div className="flex items-center justify-between border-b border-border pb-4 mb-6">
<div>
<p className="eyebrow">Admin</p>
<h1 className="font-serif text-2xl text-ink mt-1">Prêmio Observatório do Senado</h1>
</div>
<div className="flex items-center gap-4 text-xs">
<a href="/admin/newsletter/" className="text-muted hover:text-primary underline">newsletter</a>
<button
onClick={() => {
localStorage.removeItem(SECRET_KEY)
setAuthed(false)
setSecret('')
}}
className="text-muted hover:text-danger underline"
>
sair
</button>
</div>
</div>
<div className="grid grid-cols-1 lg:grid-cols-[240px_1fr] gap-8">
{/* ── Edições ─────────────────────────────────────────── */}
<aside className="space-y-4">
<div>
<p className="eyebrow mb-2">Edições</p>
{edicoes.length === 0 && <p className="text-sm text-muted">Nenhuma edição criada.</p>}
<ul className="space-y-1">
{edicoes.map((e) => (
<li key={e.id}>
<button
onClick={() => setSelecionada(e.id)}
className={`w-full text-left rounded-sm px-3 py-2 text-sm border transition-colors ${
selecionada === e.id ? 'border-primary bg-panel' : 'border-transparent hover:border-border'
}`}
>
<span className="block text-ink">{e.titulo}</span>
<span className="mt-1 flex items-center gap-2">
<Badge status={e.status} />
{e.periodoEmCurso && <span className="text-[11px] text-warning">em curso</span>}
</span>
</button>
</li>
))}
</ul>
</div>
{possiveis.length > 0 && (
<div className="border-t border-border pt-4">
<p className="eyebrow mb-2">Nova edição</p>
<div className="space-y-1">
{possiveis.map((p) => (
<button
key={p.id}
onClick={() =>
void acao(`criar-${p.id}`, async () => {
await chamar('/edicoes', { method: 'POST', body: JSON.stringify({ id: p.id }) })
setSelecionada(p.id)
})
}
className="w-full text-left text-sm text-primary hover:underline"
>
+ {p.titulo}
</button>
))}
</div>
</div>
)}
</aside>
{/* ── Edição selecionada ─────────────────────────────── */}
<main className="min-w-0">
{!ed && <p className="text-sm text-muted">Crie ou selecione uma edição.</p>}
{ed && (
<div className="space-y-8">
<section className="rounded-sm border border-border bg-surface p-5">
<div className="flex flex-wrap items-start justify-between gap-4">
<div>
<h2 className="font-serif text-xl text-ink">{ed.titulo}</h2>
<p className="text-sm text-muted">{ed.subtitulo}</p>
<p className="mt-2 text-xs text-subtle">
Período {fmtData(ed.periodo.inicio)} a {fmtData(ed.periodo.fim)} · coorte com {ed.regua.mesesMinReferencia}+ meses ·
disputa com {ed.regua.mesesMinVitoria}+ meses de exercício
</p>
</div>
<Badge status={ed.status} />
</div>
{ed.periodoEmCurso && (
<p className="mt-4 rounded-sm bg-amber-50 border border-amber-200 px-3 py-2 text-xs text-amber-900">
O período desta edição termina em {fmtData(ed.periodo.fim)}. Uma apuração agora mede um período incompleto.
</p>
)}
{ed.erro && (
<p className="mt-4 rounded-sm bg-red-50 border border-red-200 px-3 py-2 text-xs text-red-900">{ed.erro}</p>
)}
<dl className="mt-4 grid grid-cols-2 md:grid-cols-4 gap-3 text-xs">
<div><dt className="text-subtle">Apurada</dt><dd className="text-ink">{fmtData(ed.apuradoEm)}</dd></div>
<div><dt className="text-subtle">Aprovada</dt><dd className="text-ink">{fmtData(ed.aprovadoEm)}</dd></div>
<div><dt className="text-subtle">Divulgada</dt><dd className="text-ink">{fmtData(ed.divulgadoEm)}</dd></div>
<div><dt className="text-subtle">Fonte</dt><dd className="text-ink font-mono break-all">{ed.fonte ?? '—'}</dd></div>
{detalhe?.apuracao && (
<div><dt className="text-subtle">Metodologia</dt><dd className="text-ink">IDS {detalhe.apuracao.metodologiaVersao ?? 'v4'}</dd></div>
)}
{ed.apuracaoSha256 && (
<div className="col-span-2 md:col-span-4">
<dt className="text-subtle">SHA-256 do resultado</dt>
<dd className="text-ink font-mono break-all">{ed.apuracaoSha256}</dd>
</div>
)}
</dl>
<div className="mt-5 flex flex-wrap items-center gap-3">
{(ed.status === 'rascunho' || ed.status === 'apurada') && (
<>
{ed.tipo === 'legislatura' && detalhe!.snapshots.length > 0 && (
<select
value={snapshot}
onChange={(e) => setSnapshot(e.target.value)}
className="rounded-sm border border-border bg-surface px-2 py-1.5 text-xs"
>
<option value="">Snapshot mais recente apto</option>
{detalhe!.snapshots.map((s) => (
<option key={s.computedAt} value={s.computedAt} disabled={!s.apto}>
{fmtData(s.computedAt)} · {s.total} senadores{s.apto ? '' : ' (incompleto)'}
</option>
))}
</select>
)}
<Botao
disabled={ocupado !== null}
onClick={() =>
void acao('apurar', () =>
chamar(`/edicoes/${ed.id}/apurar`, { method: 'POST', body: JSON.stringify({ snapshot: snapshot || undefined }) }),
ed.tipo === 'ano' ? 'Apuração iniciada. O IDS do ano é recalculado do zero e pode levar alguns minutos.' : undefined,
)
}
>
{ed.status === 'apurada' ? 'Reapurar' : 'Apurar'}
</Botao>
</>
)}
{ed.status === 'apurando' && <p className="text-sm text-info">Apurando… a página atualiza sozinha.</p>}
{ed.status === 'apurada' && (
<Botao
disabled={ocupado !== null}
onClick={() => {
const aviso = ed.periodoEmCurso ? '\n\nATENÇÃO: o período ainda não terminou.' : ''
if (!confirm(`Aprovar o resultado de "${ed.titulo}"?\n\nO resultado fica travado e ${detalhe!.apuracao!.categorias.length} certificados são emitidos com código de verificação.${aviso}`)) return
void acao('aprovar', () => chamar(`/edicoes/${ed.id}/aprovar`, { method: 'POST' }),
'Resultado aprovado. Os PDFs estão sendo gerados; confira os e-mails antes de enviar.')
}}
>
Aprovar resultado
</Botao>
)}
{ed.status === 'aprovada' && (
<>
<Botao
disabled={ocupado !== null}
onClick={() => {
if (!confirm('Divulgar o resultado publicamente? A API pública passa a mostrar os vencedores desta edição.')) return
void acao('divulgar', () => chamar(`/edicoes/${ed.id}/divulgar`, { method: 'POST', body: JSON.stringify({ divulgar: true }) }))
}}
>
Divulgar resultado
</Botao>
<Botao
tom="perigo"
disabled={ocupado !== null || enviados > 0}
onClick={() => {
if (!confirm('Cancelar a aprovação? Os certificados emitidos são apagados (só é possível enquanto nenhum foi enviado).')) return
void acao('cancelar', () => chamar(`/edicoes/${ed.id}/cancelar-aprovacao`, { method: 'POST' }))
}}
>
Cancelar aprovação
</Botao>
</>
)}
{ed.status === 'divulgada' && (
<Botao
tom="secundario"
disabled={ocupado !== null}
onClick={() => void acao('recolher', () => chamar(`/edicoes/${ed.id}/divulgar`, { method: 'POST', body: JSON.stringify({ divulgar: false }) }))}
>
Recolher divulgação
</Botao>
)}
</div>
{log && <pre className="mt-4 whitespace-pre-wrap rounded-sm bg-panel border border-border px-3 py-2 text-xs text-ink">{log}</pre>}
</section>
{/* ── Resultado ────────────────────────────────────── */}
{detalhe!.apuracao && (
<section>
<div className="flex flex-wrap items-end justify-between gap-3 mb-3">
<div>
<p className="eyebrow">Resultado {ed.status === 'apurada' ? '(para revisão)' : ''}</p>
<p className="text-xs text-muted mt-1">
{detalhe!.apuracao.coorte.avaliados} avaliados · {detalhe!.apuracao.coorte.elegiveis} na coorte ·{' '}
{detalhe!.apuracao.coorte.concorrentes} concorrentes · {detalhe!.apuracao.categorias.length} categorias
</p>
</div>
<div className="flex gap-1">
{EIXOS.map((e) => (
<button
key={e.id}
onClick={() => setEixo(e.id)}
className={`px-3 py-1 text-xs border-b-2 ${eixo === e.id ? 'border-primary text-primary' : 'border-transparent text-muted hover:text-ink'}`}
>
{e.nome}
</button>
))}
</div>
</div>
<div className="overflow-x-auto">
<table className="w-full text-sm border-collapse">
<thead>
<tr className="border-b border-border text-left text-xs uppercase tracking-wider text-muted">
<th className="py-2 pr-3 font-medium">Categoria</th>
<th className="py-2 pr-3 font-medium">Vencedor</th>
<th className="py-2 pr-3 font-medium">Nota</th>
<th className="py-2 pr-3 font-medium">Evidência</th>
<th className="py-2 pr-3 font-medium">2º e 3º</th>
</tr>
</thead>
<tbody>
{categoriasDoEixo.map((c) => (
<tr key={c.id} className="border-b border-border align-top">
<td className="py-2 pr-3">
<span className="text-ink">{c.titulo}</span>
<span className="block text-[11px] text-subtle">{c.elegiveis} concorrentes</span>
</td>
<td className="py-2 pr-3">
<span className="text-ink font-medium">{c.vencedor.nome}</span>
<span className="block text-[11px] text-muted">{c.vencedor.partido}-{c.vencedor.uf} · {c.vencedor.mesesAtivos} meses</span>
</td>
<td className="py-2 pr-3 tabular-nums">
{fmtNum(c.vencedor.valor)}
{c.houveDesempate && (
<span className="block text-[11px] text-warning" title="Empate na nota; decidido pelo indicador bruto da dimensão">desempate</span>
)}
</td>
<td className="py-2 pr-3 text-xs text-muted">
{fmtNum(c.vencedor.indicador.valor)} {c.vencedor.indicador.unidade}
<span className="block">{c.vencedor.indicador.detalhe}</span>
</td>
<td className="py-2 pr-3 text-xs text-muted">
{c.podio.slice(1).map((p) => (
<span key={p.senadorCod} className="block">
{p.posicao}º {p.nome} ({p.partido}-{p.uf}) · {fmtNum(p.valor)}
</span>
))}
</td>
</tr>
))}
</tbody>
</table>
</div>
</section>
)}
{/* ── Certificados e envio ─────────────────────────── */}
{detalhe!.senadores.length > 0 && (
<section className="space-y-4">
<div>
<p className="eyebrow">Certificados</p>
<p className="text-xs text-muted mt-1">
{totalCerts} certificados para {detalhe!.senadores.length} senadores · {enviados} enviados ·{' '}
{semEmail > 0 ? <span className="text-warning">{semEmail} sem e-mail</span> : 'todos com e-mail'}
{pdfsPendentes && ' · gerando PDFs…'}
</p>
<p className="text-xs text-subtle mt-1">
O e-mail vem preenchido com o endereço institucional publicado pelo Senado. Troque se quiser enviar para outro endereço do gabinete.
</p>
</div>
<div className="grid grid-cols-1 md:grid-cols-[1fr_280px] gap-4 rounded-sm border border-border bg-panel p-4">
<label className="text-xs text-muted">
Parágrafo adicional no e-mail (opcional)
<textarea
value={mensagem}
onChange={(e) => setMensagem(e.target.value)}
rows={3}
placeholder="Ex.: O resultado será divulgado publicamente em 1º de fevereiro de 2027."
className="mt-1 w-full rounded-sm border border-border bg-surface px-2 py-1.5 text-sm text-ink"
/>
</label>
<div className="space-y-2">
<label className="block text-xs text-muted">
E-mail para testes
<input
type="email"
value={emailTeste}
onChange={(e) => setEmailTeste(e.target.value)}
placeholder="seu endereço"
className="mt-1 w-full rounded-sm border border-border bg-surface px-2 py-1.5 text-sm text-ink"
/>
</label>
<Botao
disabled={ocupado !== null}
onClick={() => {
const pend = detalhe!.senadores.filter((s) => s.email && s.certificados.some((c) => !c.enviadoEm && !c.revogadoEm)).length
if (pend === 0) {
setLog('Nenhum senador com e-mail e certificado pendente.')
return
}
if (!confirm(`Enviar os certificados a ${pend} gabinetes que ainda não receberam?`)) return
void acao('enviar-todos', async () => {
const r = await chamar(`/edicoes/${ed.id}/enviar-pendentes`, { method: 'POST', body: JSON.stringify({ mensagem: mensagem.trim() || undefined }) })
setLog(`${r.enviados} enviados, ${r.falhas} falhas.${r.falhas ? '\n' + JSON.stringify(r.resultados.filter((x: { ok: boolean }) => !x.ok), null, 2) : ''}`)
})
}}
>
Enviar a todos os pendentes
</Botao>
</div>
</div>
<div className="space-y-3">
{detalhe!.senadores.map((s) => {
const algumEnviado = s.certificados.some((c) => c.enviadoEm)
const alterado = (emails[s.senadorCod] ?? '') !== (s.email ?? '')
return (
<div key={s.senadorCod} className="rounded-sm border border-border bg-surface p-4">
<div className="flex flex-wrap items-start justify-between gap-3">
<div>
<p className="text-ink font-medium">{s.tratamento} {s.nome}</p>
<p className="text-xs text-muted">{s.partido}-{s.uf} · {s.certificados.length} {s.certificados.length === 1 ? 'certificado' : 'certificados'}</p>
</div>
<div className="flex flex-wrap items-center gap-2">
<input
type="email"
value={emails[s.senadorCod] ?? ''}
onChange={(e) => setEmails({ ...emails, [s.senadorCod]: e.target.value })}
placeholder="e-mail do gabinete"
className="w-64 rounded-sm border border-border bg-surface px-2 py-1 text-sm"
/>
{alterado && (
<Botao
tom="secundario"
disabled={ocupado !== null}
onClick={() =>
void acao(`email-${s.senadorCod}`, () =>
chamar(`/edicoes/${ed.id}/email`, { method: 'POST', body: JSON.stringify({ senadorCod: s.senadorCod, email: emails[s.senadorCod] }) }),
'E-mail salvo.',
)
}
>
Salvar
</Botao>
)}
<Botao tom="secundario" onClick={() => void verPrevia(s)}>Prévia</Botao>
<Botao
tom="secundario"
disabled={ocupado !== null || !emailTeste.trim()}
onClick={() =>
void acao(`teste-${s.senadorCod}`, () =>
chamar(`/edicoes/${ed.id}/enviar`, {
method: 'POST',
body: JSON.stringify({ senadorCod: s.senadorCod, mensagem: mensagem.trim() || undefined, paraTeste: emailTeste.trim() }),
}),
`Teste enviado para ${emailTeste.trim()}.`,
)
}
>
Teste
</Botao>
<Botao
disabled={ocupado !== null || !s.email || alterado}
onClick={() => {
if (!confirm(`${algumEnviado ? 'Reenviar' : 'Enviar'} ${s.certificados.length} certificado(s) para ${s.email}?`)) return
void acao(`enviar-${s.senadorCod}`, () =>
chamar(`/edicoes/${ed.id}/enviar`, { method: 'POST', body: JSON.stringify({ senadorCod: s.senadorCod, mensagem: mensagem.trim() || undefined }) }),
`Enviado para ${s.email}.`,
)
}}
>
{algumEnviado ? 'Reenviar' : 'Enviar'}
</Botao>
</div>
</div>
{s.emailOficial && s.email !== s.emailOficial && (
<p className="mt-1 text-[11px] text-subtle">Endereço oficial no Senado: {s.emailOficial}</p>
)}
<ul className="mt-3 divide-y divide-border border-t border-border">
{s.certificados.map((c) => (
<li key={c.codigo} className="flex flex-wrap items-center justify-between gap-2 py-2 text-xs">
<span className="min-w-0">
<span className="text-ink">{c.categoria}</span>
<span className="ml-2 font-mono text-muted">{c.codigo}</span>
</span>
<span className="flex items-center gap-3">
{c.revogadoEm ? (
<span className="text-danger" title={c.motivoRevogacao ?? ''}>revogado em {fmtData(c.revogadoEm)}</span>
) : c.enviadoEm ? (
<span className="text-success">enviado {fmtData(c.enviadoEm)}{c.envios > 1 ? ` (${c.envios}×)` : ''}</span>
) : c.envioErro ? (
<span className="text-danger" title={c.envioErro}>falhou</span>
) : (
<span className="text-subtle">não enviado</span>
)}
<button onClick={() => void abrirPdf(c.codigo)} className="text-primary underline">
{c.pdfGerado ? 'PDF' : 'gerar PDF'}
</button>
<a href={`/premiacao/verificar/?codigo=${c.codigo}`} target="_blank" rel="noreferrer" className="text-primary underline">
verificação
</a>
{!c.revogadoEm && (
<button
onClick={() => {
const motivo = prompt(`Motivo da revogação de ${c.codigo} (aparece na verificação pública):`)
if (!motivo?.trim()) return
void acao(`revogar-${c.codigo}`, () =>
chamar(`/certificados/${c.codigo}/revogar`, { method: 'POST', body: JSON.stringify({ motivo }) }),
`Certificado ${c.codigo} revogado.`,
)
}}
className="text-danger underline"
>
revogar
</button>
)}
</span>
</li>
))}
</ul>
</div>
)
})}
</div>
</section>
)}
</div>
)}
</main>
</div>
{previa && (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/40 p-4" onClick={() => setPrevia(null)}>
<div className="w-full max-w-3xl max-h-[90vh] flex flex-col rounded-sm bg-surface shadow-xl" onClick={(e) => e.stopPropagation()}>
<div className="flex items-start justify-between gap-4 border-b border-border p-4">
<div className="text-xs">
<p><span className="text-subtle">Para:</span> {previa.para ?? '(sem e-mail)'}</p>
<p className="mt-1"><span className="text-subtle">Assunto:</span> <span className="text-ink">{previa.assunto}</span></p>
<p className="mt-1"><span className="text-subtle">Anexos:</span> {previa.anexos.join(', ')}</p>
</div>
<button onClick={() => setPrevia(null)} className="text-xs text-muted underline">fechar</button>
</div>
<iframe title="Prévia do e-mail" srcDoc={previa.html} className="flex-1 min-h-[60vh] w-full" sandbox="" />
</div>
</div>
)}
</div>
)
}