mirror of
https://github.com/fsolla/teqo.git
synced 2026-11-13 06:43:06 +00:00
Terceira variante da família de dossiês: recorte pela área canônica do acervo (SPEECH_TOPICS), com o pipeline C186/C187 generalizado para a forma subject via THEME_UNIT (generalização aditiva; C186/C187 byte-idênticos). - resolveSpeechTopic no dono speechFacets (fail-closed, sem slug inventado) - themeSnapshot + extract-theme-snapshot (THEME_REPORT_CONFIRM=1, read-only) - build-dossie-solla-tema (pack medido, PDF A4 + .md + boletim 1 página) - skill/comando/2 subagentes (researcher por era + redator) + specs - cenas theme-only no descritor (acervo universo/amostra, abrangência, lacunas, hierarquia de fontes, pills do boletim) certificadas pelo designer Closes #1143
2312 lines
101 KiB
JavaScript
2312 lines
101 KiB
JavaScript
/**
|
||
* Dossiê renderer (C186): the block model goes to print HTML (Chromium
|
||
* `page.pdf`, A4, one `.sheet` per page) and to the companion `.md`. Ports the
|
||
* approved hi-fi classes (`dossie-solla-cidade-ui-design.html`) class-by-class;
|
||
* shared escaping/source-token contract lives in `reportText.mjs`.
|
||
*/
|
||
|
||
import { formatDateBr, formatDateTimeBr, formatMoneyCompact } from './cityReportFormat.mjs'
|
||
import { dossierPhaseLabel, dossierSphereBadgeClass, dossierSphereLabel } from './dossieBlocks.mjs'
|
||
import { MUNICIPALITY_UNIT, isSubjectUnit, resolveDossierUnit } from './dossieUnit.mjs'
|
||
import { htmlEscape, moreItemsLabel, showingLabel, stripInlineSources } from './reportText.mjs'
|
||
|
||
/** Markdown column headers capitalize the sphere label (sentence case, as C187 shipped). */
|
||
const capitalize = (value) => value.charAt(0).toUpperCase() + value.slice(1)
|
||
|
||
const SOURCE_ICON =
|
||
'<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" aria-hidden="true"><path d="M15 3h6v6M10 14 21 3M18 13v6a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2h6"/></svg>'
|
||
|
||
const WARNING_ICON =
|
||
'<svg class="warn-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" aria-hidden="true"><path d="M10.3 2.9 1.8 17a2 2 0 0 0 1.7 3h17a2 2 0 0 0 1.7-3L13.7 2.9a2 2 0 0 0-3.4 0Z"/><path d="M12 9v4M12 17h.01"/></svg>'
|
||
|
||
const sourceLink = (url, label = '(fonte)') =>
|
||
url
|
||
? `<a class="source-link" href="${htmlEscape(url)}" aria-label="Abrir fonte">${SOURCE_ICON}${htmlEscape(label)}</a>`
|
||
: ''
|
||
|
||
const scopeBadge = (sphere, label = null, unit = MUNICIPALITY_UNIT) => {
|
||
const badgeClass = dossierSphereBadgeClass(sphere, unit)
|
||
return `<span class="scope${badgeClass ? ` ${badgeClass}` : ''}">${htmlEscape(label ?? dossierSphereLabel(sphere, unit))}</span>`
|
||
}
|
||
|
||
/** `dd/mm/aa` — the news table column is 9% wide (no room for a 4-digit year). */
|
||
const formatShortDateBr = (value) => {
|
||
const full = formatDateBr(value)
|
||
const parts = full.split('/')
|
||
return parts.length === 3 ? `${parts[0]}/${parts[1]}/${parts[2].slice(-2)}` : full
|
||
}
|
||
|
||
/**
|
||
* Print surfaces prefer the reformulated `brief` (short headline + note that fit
|
||
* the fixed A4 with no ellipsis) and fall back to the full `answer`/`details`
|
||
* record when an item has none — the builder's A4 fit guard then fails closed
|
||
* instead of silently dropping information.
|
||
*/
|
||
const copyHtml = (value) => htmlEscape(stripInlineSources(value))
|
||
const briefOr = (brief, fallback) => brief?.title ?? fallback
|
||
const noteOr = (brief, fallback) => brief?.note ?? fallback
|
||
|
||
const PHASE_MODIFIER = {
|
||
autorizado: 'phase-pending',
|
||
empenhado: 'phase-pending',
|
||
liquidado: 'phase-paid',
|
||
pago: 'phase-paid',
|
||
}
|
||
const phaseBadge = (phase) => {
|
||
const modifier = PHASE_MODIFIER[phase]
|
||
return `<span class="phase${modifier ? ` ${modifier}` : ''}">${htmlEscape(dossierPhaseLabel(phase))}</span>`
|
||
}
|
||
|
||
const placeholder = (label) =>
|
||
`<span class="placeholder-bar" aria-label="${htmlEscape(label)}"></span>`
|
||
|
||
const valueCell = (value, widthLabel = 'Valor desconhecido') =>
|
||
value === null || value === undefined || value === ''
|
||
? placeholder(widthLabel)
|
||
: `<strong>${htmlEscape(value)}</strong>`
|
||
|
||
const assetBox = (title, lines) =>
|
||
`<div class="asset-box"><p class="asset-title">${htmlEscape(title)}</p><p>${lines
|
||
.map((line) => htmlEscape(line))
|
||
.join('<br />')}</p></div>`
|
||
|
||
/** Total of sheets rendered so far — the shell's `pág. N/M` reads it. */
|
||
const pageCount = (report) => report.meta.pageTotal
|
||
|
||
const timelineGrid = (rows) => `
|
||
<div class="timeline-grid">
|
||
${rows
|
||
.map(
|
||
(row) => `<div class="timeline-cell">
|
||
<p class="timeline-period">${htmlEscape(row.period)}</p>
|
||
<p class="timeline-role">${htmlEscape(row.role)}</p>
|
||
<p class="timeline-recovery">${htmlEscape(row.recovery)}${row.uncertain ? ` · ${htmlEscape(row.uncertain)}` : ''}</p>
|
||
</div>`,
|
||
)
|
||
.join('')}
|
||
</div>`
|
||
|
||
const sheetHeader = (report, title, subtitle, pageNo) => `
|
||
<header class="report-header">
|
||
<div>
|
||
<p class="report-kicker">Dossiê de atuação pública · ${htmlEscape(report.meta.municipalityName)}</p>
|
||
<h2 class="sheet-title">${htmlEscape(title)}</h2>
|
||
${subtitle ? `<p class="sheet-sub">${htmlEscape(subtitle)}</p>` : ''}
|
||
</div>
|
||
<div class="report-meta">
|
||
<p><strong>Gerado em ${htmlEscape(formatDateTimeBr(report.meta.generatedAt))}</strong></p>
|
||
<p>INSUMO INTERNO</p>
|
||
<p>pág. ${pageNo}/${pageCount(report)}</p>
|
||
</div>
|
||
</header>`
|
||
|
||
const sheetFooter = (report, left, pageNo) => `
|
||
<footer class="report-footer">
|
||
<span>Dossiê Solla por cidade · ${htmlEscape(report.meta.municipalityName)} · ${htmlEscape(left)}</span>
|
||
<span>Gerado em ${htmlEscape(formatDateTimeBr(report.meta.generatedAt))} · pág. ${pageNo}/${pageCount(report)}</span>
|
||
</footer>`
|
||
|
||
const renderCover = (report) => `
|
||
<article class="sheet sheet-cover" data-page="capa" aria-label="Capa do dossiê">
|
||
<div class="accent-bar"></div>
|
||
<header class="cover-head">
|
||
<div>
|
||
<p class="report-kicker">${htmlEscape(report.cover.kicker)}</p>
|
||
<p class="cover-series">${htmlEscape(report.cover.series)}</p>
|
||
</div>
|
||
<div class="internal-note"><strong>${htmlEscape(report.cover.internalNote)}</strong><br />${htmlEscape(report.cover.internalNoteSub)}</div>
|
||
</header>
|
||
|
||
<div class="cover-block">
|
||
<p class="eyebrow">${htmlEscape(report.cover.eyebrow)}</p>
|
||
<h1 class="cover-title">${htmlEscape(report.cover.title)}</h1>
|
||
<p class="cover-sub">${htmlEscape(report.cover.subtitle)}</p>
|
||
</div>
|
||
|
||
<dl class="cover-dl">
|
||
<dt>Território de Identidade</dt><dd>${htmlEscape(report.cover.territory)}</dd>
|
||
<dt>Região / polo adotado</dt><dd>${htmlEscape(report.cover.pole)}</dd>
|
||
<dt>Data de geração</dt><dd class="tabular">${htmlEscape(formatDateTimeBr(report.meta.generatedAt))}</dd>
|
||
</dl>
|
||
|
||
<div class="cover-grid">
|
||
<div class="how-to"><p class="how-to-title">Como usar</p><p>${htmlEscape(report.cover.howToUse)}</p></div>
|
||
${assetBox('NEEDS ASSET', ['selo institucional autorizado', 'opcional — nunca marca de campanha'])}
|
||
</div>
|
||
|
||
<div class="cover-foot">
|
||
<div class="ink-rule"></div>
|
||
<div class="cover-foot-row">
|
||
<p class="cover-scope">${htmlEscape(report.cover.scope)}</p>
|
||
<p class="cover-version">${htmlEscape(report.cover.version)}</p>
|
||
</div>
|
||
</div>
|
||
</article>`
|
||
|
||
const renderTimeline = (report) => `
|
||
<section class="block">
|
||
<div class="block-head">
|
||
<div><p class="eyebrow">01 · trajetória</p><h3 class="section-title">Linha do tempo da carreira</h3></div>
|
||
<p class="meta">Período + papel + trilha de recuperação</p>
|
||
</div>
|
||
${timelineGrid(report.page1.timeline)}
|
||
</section>`
|
||
|
||
const renderTrajectory = (report, pageNo) => `
|
||
<article class="sheet" data-page="trajetoria" aria-label="Trajetória completa">
|
||
${sheetHeader(report, 'Trajetória completa', 'Todos os períodos da carreira e como cada um foi recuperado', pageNo)}
|
||
${timelineGrid(report.trajectory)}
|
||
<p class="meta">Períodos incertos ficam marcados na própria linha; o acervo interno de falas cobre 2011+ e o que falta vira lacuna explícita.</p>
|
||
${sheetFooter(report, 'Trajetória', pageNo)}
|
||
</article>`
|
||
|
||
const renderDeliveries = (report) => {
|
||
const deliveries = report.page1.deliveries
|
||
if (deliveries.items.length === 0) return ''
|
||
return `
|
||
<section class="block">
|
||
<div class="block-head">
|
||
<div><p class="eyebrow">02 · destaques com lastro</p><h3 class="section-title">Principais entregas localizadas</h3></div>
|
||
</div>
|
||
<div class="deliveries">
|
||
${deliveries.items
|
||
.map(
|
||
(row) => `<div class="delivery-row">
|
||
<div class="delivery-scope">${scopeBadge(row.sphere)}<p class="meta">Era ${htmlEscape(row.era)}${row.year ? ` · ${htmlEscape(row.year)}` : ''}</p></div>
|
||
<div>
|
||
<p class="delivery-title">${copyHtml(briefOr(row.brief, row.title))}</p>
|
||
${noteOr(row.brief, row.detail) ? `<p class="muted small">${copyHtml(noteOr(row.brief, row.detail))}</p>` : ''}
|
||
</div>
|
||
<div class="delivery-value">
|
||
<p class="value-big tabular">${valueCell(row.value, 'Valor não informado')}</p>
|
||
${row.phase ? phaseBadge(row.phase) : ''}
|
||
${sourceLink(row.sourceUrl)}
|
||
</div>
|
||
</div>`,
|
||
)
|
||
.join('')}
|
||
</div>
|
||
${
|
||
deliveries.remaining > 0
|
||
? `<p class="muted small strong">${htmlEscape(showingLabel(deliveries.items.length, deliveries.total, 'entrega localizada', 'entregas localizadas'))} — o restante está nas páginas das eras.</p>`
|
||
: ''
|
||
}
|
||
<p class="muted small strong">Guarda de leitura: autorizado ≠ empenhado ≠ liquidado ≠ pago. A fase acompanha cada valor.</p>
|
||
</section>`
|
||
}
|
||
|
||
const renderHooksAndPending = (report) => {
|
||
const hooks = report.page1.hooks
|
||
const pending = report.page1.pending
|
||
if (hooks.items.length === 0 && pending.items.length === 0) return ''
|
||
return `
|
||
<section class="page1-grid">
|
||
${
|
||
hooks.items.length
|
||
? `<div class="panel">
|
||
<p class="eyebrow">03 · ganchos para o boletim</p>
|
||
<ul class="tight-list">${hooks.items
|
||
.map(
|
||
(hook) =>
|
||
`<li><strong>Tema:</strong> ${htmlEscape(hook.topic)}. <strong>Ângulo:</strong> ${copyHtml(briefOr(hook.brief, hook.angle))} ${sourceLink(hook.sourceUrl)}</li>`,
|
||
)
|
||
.join('')}</ul>
|
||
${hooks.remaining > 0 ? `<p class="muted small">${htmlEscape(moreItemsLabel(hooks.remaining, 'gancho', 'ganchos'))} nas eras.</p>` : ''}
|
||
</div>`
|
||
: '<div></div>'
|
||
}
|
||
${
|
||
pending.items.length
|
||
? `<div class="panel panel-warning">
|
||
<p class="eyebrow warning-text">04 · o que falta</p>
|
||
<ul class="tight-list">${pending.items.map((item) => `<li>${htmlEscape(item)}</li>`).join('')}</ul>
|
||
${pending.remaining > 0 ? `<p class="warning-text small">${htmlEscape(moreItemsLabel(pending.remaining, 'ponto', 'pontos'))} sem leitura na tabela de lacunas.</p>` : ''}
|
||
<p class="warning-text small strong">Ausência é resultado: não completar por inferência.</p>
|
||
</div>`
|
||
: '<div></div>'
|
||
}
|
||
</section>`
|
||
}
|
||
|
||
const renderSummary = (report, pageNo) => `
|
||
<article class="sheet" data-page="resumo" aria-label="Resumo de uma olhada">
|
||
${sheetHeader(report, 'Resumo de uma olhada', `${report.meta.region} · informação datada e pronta para conferência`, pageNo)}
|
||
${renderTimeline(report)}
|
||
${renderDeliveries(report)}
|
||
${renderHooksAndPending(report)}
|
||
${sheetFooter(report, 'Resumo', pageNo)}
|
||
</article>`
|
||
|
||
/** Region cards (fixed sheet): counts per recorte, no combined total. */
|
||
const renderRegion = (report, pageNo) => `
|
||
<article class="sheet" data-page="regiao" aria-label="Recorte regional e polo">
|
||
${sheetHeader(report, 'Região / polo', 'Impacto compartilhado, apresentado fora da conta municipal', pageNo)}
|
||
|
||
<section class="rule-warning">
|
||
${WARNING_ICON}
|
||
<div>
|
||
<p class="rule-title">${htmlEscape(report.region.ruleTitle)}</p>
|
||
<p class="muted">${htmlEscape(report.region.ruleBody)}</p>
|
||
</div>
|
||
</section>
|
||
|
||
${
|
||
report.region.context.length
|
||
? `<p class="meta">Contexto municipal (IBGE): ${report.region.context
|
||
.map(
|
||
(item) => `${copyHtml(item.detail)} ${sourceLink(item.sourceUrl, `(${item.topic})`)}`,
|
||
)
|
||
.join(' · ')} — leitura relativa, nunca % estadual absoluto.</p>`
|
||
: ''
|
||
}
|
||
|
||
<section class="block">
|
||
<p class="eyebrow">Leitura lado a lado</p>
|
||
<h3 class="section-title">Duas listas, dois totais, nenhuma soma</h3>
|
||
<div class="scope-compare">
|
||
<div class="panel">
|
||
<div class="action-head">${scopeBadge('municipio', null, report.unit)}<span class="meta strong">${report.region.municipal.total} ${report.region.municipal.total === 1 ? 'item' : 'itens'} com fonte · DIRETO</span></div>
|
||
<p class="meta">lista completa nas páginas seguintes</p>
|
||
</div>
|
||
<div class="compare-mid" aria-hidden="true"><span>≠</span></div>
|
||
<div class="panel panel-region">
|
||
<div class="action-head">${scopeBadge('regiao', 'região / polo', report.unit)}<span class="warning-text meta strong">SEM TOTAL COMBINADO · ${report.region.regional.total} ${report.region.regional.total === 1 ? 'item' : 'itens'}</span></div>
|
||
<p class="meta">lista completa nas páginas seguintes</p>
|
||
</div>
|
||
</div>
|
||
</section>
|
||
|
||
${
|
||
report.region.hook || report.region.priorityGap
|
||
? `<section class="page1-grid">
|
||
${
|
||
report.region.hook
|
||
? `<div class="aside-accent"><p class="eyebrow">Gancho possível</p><p><strong>Tema:</strong> ${htmlEscape(report.region.hook.topic)}. <strong>Ângulo:</strong> ${copyHtml(briefOr(report.region.hook.brief, report.region.hook.angle))} ${sourceLink(report.region.hook.sourceUrl)}</p></div>`
|
||
: '<div></div>'
|
||
}
|
||
${
|
||
report.region.priorityGap
|
||
? `<div class="panel panel-warning"><p class="eyebrow warning-text">Lacuna prioritária</p><p>${htmlEscape(report.region.priorityGap)}</p></div>`
|
||
: '<div></div>'
|
||
}
|
||
</section>`
|
||
: ''
|
||
}
|
||
|
||
${sheetFooter(report, 'Região/polo · recortes não somáveis', pageNo)}
|
||
</article>`
|
||
|
||
/** Full lists of the region recorte, uncapped: each one flows across sheets. */
|
||
const regionListUnits = (report, list, probe) =>
|
||
list.items.map((item, index) => ({
|
||
layout: 'tr',
|
||
html: `<tr${unitAttrs(probe, index, 'tr')}>
|
||
<td>${copyHtml(briefOr(item.brief, item.answer))}</td>
|
||
<td>${scopeBadge(item.sphere, null, report.unit)}</td>
|
||
<td>${copyHtml(item.details ?? item.brief?.note ?? '—')}</td>
|
||
<td>${sourceLink(item.sourceUrl)}</td>
|
||
</tr>`,
|
||
}))
|
||
|
||
const renderRegionListSheet = ({ report, list, entry, probe, pageNo }) => `
|
||
<article class="sheet" data-page="${entry.anchor}"${probe ? ` data-pack-section="${entry.key}"` : ''} aria-label="${htmlEscape(`Região / polo · ${list.label}`)}">
|
||
${sheetHeader(
|
||
report,
|
||
`Região / polo · ${list.label}`,
|
||
entry.index === 0
|
||
? `${list.total} ${list.total === 1 ? 'item' : 'itens'} com fonte`
|
||
: `continuação ${entry.index + 1} · ${list.total} ${list.total === 1 ? 'item' : 'itens'} com fonte`,
|
||
pageNo,
|
||
)}
|
||
${
|
||
entry.index === 0
|
||
? `<section class="rule-warning">
|
||
${WARNING_ICON}
|
||
<div>
|
||
<p class="rule-title">${htmlEscape(report.region.ruleTitle)}</p>
|
||
<p class="muted">Cada linha informa sua esfera; região e polo não são somados ao município.</p>
|
||
</div>
|
||
</section>`
|
||
: ''
|
||
}
|
||
<section class="block">
|
||
<table class="document-table">
|
||
<caption class="sr-only">Itens do recorte com evidência de alcance e fonte</caption>
|
||
<colgroup><col style="width:30%" /><col style="width:15%" /><col style="width:34%" /><col style="width:21%" /></colgroup>
|
||
${tableHead(['Item', 'Esfera', 'Evidência de alcance', 'Fonte'])}
|
||
<tbody>${entry.chunk.map((unit) => unit.html).join('')}</tbody>
|
||
</table>
|
||
</section>
|
||
${sheetFooter(report, `Região/polo · ${list.label}`, pageNo)}
|
||
</article>`
|
||
|
||
const renderRegionListSheets = ({ report, key, anchor, list, pack, probe, nextPage }) => {
|
||
if (list.items.length === 0) return []
|
||
const units = regionListUnits(report, list, probe)
|
||
return packedSection({ key, anchor, units, pack, probe }).map((entry) =>
|
||
renderRegionListSheet({ report, list, entry, probe, pageNo: nextPage() }),
|
||
)
|
||
}
|
||
|
||
const PRINT_CSS = `
|
||
* { box-sizing: border-box; }
|
||
html, body { margin: 0; padding: 0; }
|
||
body { font-family: 'Fira Sans', 'DejaVu Sans', Arial, sans-serif; color: #172033; font-size: 10.5pt; line-height: 1.32; background: #fff; }
|
||
@page { size: A4; margin: 0; }
|
||
.sheet {
|
||
position: relative;
|
||
width: 210mm;
|
||
min-height: 297mm;
|
||
height: 297mm;
|
||
padding: 12mm 14mm 10mm;
|
||
background: #fff;
|
||
display: flex;
|
||
flex-direction: column;
|
||
overflow: hidden;
|
||
break-after: page;
|
||
}
|
||
.sheet:last-child { break-after: auto; }
|
||
.sheet p, .sheet ul, .sheet ol, .sheet dl { margin-top: 0; }
|
||
.sheet a { color: #245b78; text-decoration: underline; text-underline-offset: 1.5px; }
|
||
.accent-bar { position: absolute; left: 0; top: 0; width: 4mm; height: 297mm; background: #315c75; }
|
||
.report-header { display: flex; justify-content: space-between; gap: 8mm; align-items: flex-start; padding-bottom: 3mm; border-bottom: .45mm solid #172033; }
|
||
.report-kicker { margin: 0; color: #315c75; font-size: 8pt; font-weight: 700; letter-spacing: .13em; text-transform: uppercase; }
|
||
.sheet-title { margin: 1.2mm 0 0; font-size: 18pt; font-weight: 700; line-height: 1.1; letter-spacing: -.02em; }
|
||
.sheet-sub { margin: .8mm 0 0; font-size: 10.5pt; color: #435264; }
|
||
.report-meta { min-width: 43mm; margin: 0; color: #435264; font-size: 8pt; line-height: 1.45; text-align: right; font-variant-numeric: tabular-nums; }
|
||
.report-meta p { margin: 0; }
|
||
.report-footer { margin-top: auto; padding-top: 2.5mm; border-top: .25mm solid #cbd5dc; display: flex; justify-content: space-between; gap: 5mm; color: #435264; font-size: 8pt; line-height: 1.3; }
|
||
.section-title { margin: 0; font-size: 13.5pt; line-height: 1.18; letter-spacing: -.01em; }
|
||
.eyebrow { margin: 0 0 1.4mm; color: #315c75; font-size: 8pt; font-weight: 700; letter-spacing: .1em; text-transform: uppercase; }
|
||
.eyebrow.warning-text, .warning-text { color: #8a5a18; }
|
||
.muted { color: #435264; }
|
||
.small { font-size: 10.5pt; line-height: 1.28; }
|
||
.meta { font-size: 7.7pt; line-height: 1.2; color: #435264; }
|
||
.strong { font-weight: 600; }
|
||
.tabular { font-variant-numeric: tabular-nums; }
|
||
.nowrap { white-space: nowrap; }
|
||
.sr-only { position: absolute; width: 1px; height: 1px; overflow: hidden; clip: rect(0 0 0 0); }
|
||
.block { margin-top: 3.4mm; break-inside: avoid; }
|
||
.block-head { display: flex; align-items: flex-end; justify-content: space-between; gap: 4mm; }
|
||
.source-link { display: inline-flex; align-items: baseline; gap: 1mm; white-space: nowrap; font-size: 9pt; font-weight: 600; }
|
||
.source-link svg { width: 3mm; height: 3mm; transform: translateY(.35mm); }
|
||
.scope { display: inline-flex; align-items: center; min-height: 5mm; padding: .4mm 1.6mm; border: .25mm solid #9aacb7; border-radius: 99px; color: #304758; background: #f7fafb; font-size: 7.5pt; font-weight: 700; letter-spacing: .04em; text-transform: uppercase; }
|
||
.scope-region { border-color: #aa8752; color: #6b4918; background: #fff9ee; }
|
||
.phase { display: inline-block; padding: .5mm 1.4mm; border-radius: 1mm; background: #e9eef1; color: #334553; font-size: 7.5pt; font-weight: 700; text-transform: uppercase; letter-spacing: .035em; }
|
||
.document-table { width: 100%; table-layout: fixed; border-collapse: collapse; font-size: 10.5pt; line-height: 1.22; margin-top: 2.5mm; }
|
||
.document-table th { padding: 1.7mm 1.6mm; border-top: .25mm solid #cbd5dc; border-bottom: .35mm solid #9caab3; background: #f3f6f8; color: #435264; font-size: 7.5pt; font-weight: 700; letter-spacing: .045em; text-align: left; text-transform: uppercase; }
|
||
.document-table td { padding: 2mm 1.6mm; border-bottom: .25mm solid #dce3e7; vertical-align: top; overflow-wrap: anywhere; }
|
||
.document-table tbody tr:nth-child(even) { background: #fafcfd; }
|
||
.document-table .numeric { font-variant-numeric: tabular-nums; white-space: nowrap; }
|
||
.placeholder-bar { display: inline-block; width: 22mm; height: 2.6mm; border-radius: 1mm; background: #d8e0e5; vertical-align: middle; }
|
||
.asset-box { border: .35mm dashed #80919d; color: #4c5c68; background: #f7f9fa; padding: 3mm; display: flex; flex-direction: column; align-items: center; justify-content: center; text-align: center; min-height: 30mm; border-radius: .25rem; font-size: 8pt; font-weight: 600; }
|
||
.asset-box p { margin: 0; }
|
||
.asset-box .asset-title { text-transform: uppercase; letter-spacing: .08em; }
|
||
.timeline-grid { display: grid; grid-template-columns: repeat(3, 1fr); gap: 1.8mm; margin-top: 2.5mm; }
|
||
.timeline-cell { border-top: 1.6px solid #315c75; background: #f3f6f8; padding: 2mm; }
|
||
.timeline-period { margin: 0; font-size: 7.6pt; font-weight: 700; color: #315c75; }
|
||
.timeline-role { margin: .8mm 0 0; font-size: 10.5pt; font-weight: 600; line-height: 1.16; }
|
||
.timeline-recovery { margin: .7mm 0 0; font-size: 7.5pt; line-height: 1.16; color: #435264; }
|
||
.deliveries { border-top: .25mm solid #cbd5dc; border-bottom: .25mm solid #cbd5dc; margin-top: 1.8mm; }
|
||
.delivery-row { display: grid; grid-template-columns: 26mm 1fr 42mm; gap: 3mm; padding: 2mm 0; border-bottom: .25mm solid #cbd5dc; }
|
||
.delivery-row:last-child { border-bottom: none; }
|
||
.delivery-title { margin: 0; font-weight: 600; }
|
||
.delivery-value { text-align: right; }
|
||
.value-big { margin: 0; font-size: 14pt; font-weight: 600; }
|
||
.page1-grid { display: grid; grid-template-columns: 1.15fr .85fr; gap: 3mm; margin-top: 3.2mm; break-inside: avoid; }
|
||
.panel { border: .25mm solid #cbd5dc; border-radius: .25rem; padding: 3mm; break-inside: avoid; }
|
||
.panel-warning { border: none; border-left: 3mm solid #8a5a18; background: #fff7e8; }
|
||
.panel-region { border: .35mm solid #c9a66d; background: #fffbf3; }
|
||
.tight-list { margin: 0; padding-left: 4mm; }
|
||
.tight-list li { margin-bottom: 1mm; }
|
||
.aside-accent { border-radius: .25rem; background: #e8f0f4; padding: 3mm; font-size: 10.5pt; line-height: 1.35; }
|
||
.era-method { display: grid; grid-template-columns: 1fr 55mm; gap: 4mm; margin-top: 4mm; }
|
||
.action-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 3mm; margin-top: 3mm; }
|
||
.action-head { display: flex; align-items: center; justify-content: space-between; gap: 3mm; }
|
||
.action-title { margin: 2mm 0 0; font-size: 10.5pt; font-weight: 600; }
|
||
.honors { display: grid; grid-template-columns: 1fr 58mm; gap: 4mm; }
|
||
.rule-warning { display: flex; align-items: flex-start; gap: 3mm; border: .45mm solid #8a5a18; background: #fff7e8; border-radius: .25rem; padding: 4mm; margin-top: 4mm; }
|
||
.rule-warning .warn-icon { width: 5mm; height: 5mm; color: #8a5a18; flex-shrink: 0; margin-top: .5mm; }
|
||
.rule-title { margin: 0; font-size: 12pt; font-weight: 700; color: #8a5a18; }
|
||
.rule-warning .muted { margin-bottom: 0; }
|
||
.scope-compare { display: grid; grid-template-columns: 1fr 12mm 1fr; align-items: stretch; gap: 3mm; margin-top: 3mm; }
|
||
.compare-mid { display: flex; flex-direction: column; align-items: center; justify-content: center; color: #8a5a18; }
|
||
.compare-mid::before, .compare-mid::after { content: ''; width: 1px; flex: 1; background: #d8c09a; }
|
||
.compare-mid span { font-size: 18pt; font-weight: 700; margin: 2mm 0; }
|
||
.cover-head { display: flex; justify-content: space-between; align-items: flex-start; gap: 8mm; border-bottom: .25mm solid #cbd5dc; padding-bottom: 4mm; }
|
||
.cover-series { margin: 2mm 0 0; font-size: 10.5pt; font-weight: 600; letter-spacing: .08em; text-transform: uppercase; color: #435264; }
|
||
.internal-note { border: .25mm solid #cbd5dc; background: #f3f6f8; padding: 2mm 3mm; text-align: right; font-size: 8pt; line-height: 1.5; color: #435264; border-radius: .25rem; }
|
||
.cover-block { margin-top: 28mm; max-width: 150mm; }
|
||
.cover-title { margin: 0; font-size: 34pt; font-weight: 700; line-height: 1.03; letter-spacing: -.035em; }
|
||
.cover-sub { margin: 5mm 0 0; max-width: 125mm; font-size: 15pt; line-height: 1.4; color: #435264; }
|
||
.cover-dl { display: grid; grid-template-columns: 42mm 1fr; border-top: .25mm solid #cbd5dc; border-bottom: .25mm solid #cbd5dc; margin: 22mm 0 0; font-size: 11pt; }
|
||
.cover-dl dt { border-bottom: .25mm solid #cbd5dc; padding: 3mm 0; font-weight: 600; color: #435264; }
|
||
.cover-dl dd { margin: 0; border-bottom: .25mm solid #cbd5dc; padding: 3mm 0; font-weight: 600; }
|
||
.cover-dl dt:last-of-type, .cover-dl dd:last-of-type { border-bottom: none; }
|
||
.cover-grid { display: grid; grid-template-columns: 1fr 56mm; gap: 5mm; margin-top: 6mm; }
|
||
.how-to { border-radius: .25rem; background: #e8f0f4; padding: 4mm; }
|
||
.how-to .how-to-title { margin: 0; font-size: 10.5pt; font-weight: 700; text-transform: uppercase; letter-spacing: .08em; color: #315c75; }
|
||
.how-to p { margin: 2mm 0 0; font-size: 10.5pt; line-height: 1.35; color: #435264; }
|
||
.cover-foot { margin-top: auto; }
|
||
.cover-foot .ink-rule { height: .5mm; background: #172033; margin-bottom: 5mm; }
|
||
.cover-foot-row { display: flex; align-items: flex-end; justify-content: space-between; gap: 8mm; }
|
||
.cover-scope { margin: 0; max-width: 118mm; font-size: 10.5pt; line-height: 1.4; color: #435264; }
|
||
.cover-version { margin: 0; text-align: right; font-size: 8pt; font-weight: 600; text-transform: uppercase; letter-spacing: .08em; color: #315c75; }
|
||
.cover-accent { color: #315c75; }
|
||
.defeso { margin-top: auto; }
|
||
.chart-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 3mm; margin-top: 3mm; }
|
||
.chart { display: block; width: 100%; height: auto; }
|
||
.chart-card { border: .25mm solid #cbd5dc; border-radius: .25rem; padding: 3mm; break-inside: avoid; }
|
||
.chart-title { margin: 0 0 2mm; font-size: 9.5pt; font-weight: 600; }
|
||
.chart-label { font-size: 7.4pt; fill: #435264; }
|
||
.chart-value { font-size: 7.6pt; fill: #1f2933; font-weight: 700; }
|
||
.chart-legend { margin: 1.5mm 0 0; font-size: 7.6pt; line-height: 1.3; color: #435264; }
|
||
.synthesis-lines { margin: 0; padding-left: 4.5mm; font-size: 9.5pt; line-height: 1.35; }
|
||
.synthesis-lines li { margin: 0 0 1.8mm; }
|
||
.synthesis-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 3mm; margin-top: 3mm; }
|
||
.opening-paragraph { margin: 0 0 3.4mm; font-size: 11pt; line-height: 1.45; }
|
||
.opening-paragraph:last-child { margin-bottom: 0; }
|
||
.era-summary { margin-top: 3.4mm; border-left: 1.2mm solid #315c75; background: #f7fafb; padding: 2.6mm 3mm; }
|
||
.era-summary p { margin: 0; font-size: 10pt; line-height: 1.4; }
|
||
.value-list { list-style: none; margin: 0; padding: 0; }
|
||
.value-list li { display: grid; grid-template-columns: 1fr 20mm 18mm; gap: 1.6mm; align-items: center; padding: 0.9mm 0; border-bottom: .25mm solid #e3e9ec; font-size: 8.2pt; line-height: 1.22; }
|
||
.value-list li:last-child { border-bottom: 0; }
|
||
.value-list-bar { display: block; height: 2.2mm; background: #eef2f5; border-radius: 1mm; overflow: hidden; }
|
||
.value-list-bar span { display: block; height: 100%; background: #315c75; }
|
||
.value-list-value { text-align: right; font-weight: 700; font-variant-numeric: tabular-nums; white-space: nowrap; }
|
||
`
|
||
|
||
const renderMunicipalityDossierHtml = (report, { pack = null, probe = false } = {}) => {
|
||
const buildParts = () => {
|
||
let page = 0
|
||
const nextPage = () => ++page
|
||
const parts = []
|
||
parts.push(renderCover(report))
|
||
page += 1
|
||
const opening = renderUnitOpeningPage(report, page + 1)
|
||
if (opening) {
|
||
parts.push(opening)
|
||
page += 1
|
||
}
|
||
parts.push(renderTrajectory(report, nextPage()))
|
||
parts.push(renderSummary(report, nextPage()))
|
||
parts.push(renderUnitSynthesisPage(report, nextPage()))
|
||
parts.push(renderUnitChartsPage(report, nextPage()))
|
||
for (const era of report.eras) {
|
||
parts.push(...renderEraSheets({ report, era, pack, probe, nextPage, includeHonors: true }))
|
||
}
|
||
parts.push(renderRegion(report, nextPage()))
|
||
parts.push(
|
||
...renderRegionListSheets({
|
||
report,
|
||
key: 'region:municipio',
|
||
anchor: 'regiao-municipio',
|
||
list: report.region.municipal,
|
||
pack,
|
||
probe,
|
||
nextPage,
|
||
}),
|
||
)
|
||
parts.push(
|
||
...renderRegionListSheets({
|
||
report,
|
||
key: 'region:regional',
|
||
anchor: 'regiao-regional',
|
||
list: report.region.regional,
|
||
pack,
|
||
probe,
|
||
nextPage,
|
||
}),
|
||
)
|
||
parts.push(...renderGapSheets({ report, pack, probe, nextPage }))
|
||
parts.push(...renderNewsSheets({ report, pack, probe, nextPage }))
|
||
parts.push(...renderAcervoSheets({ report, pack, probe, nextPage }))
|
||
parts.push(renderUnitSourcesPage(report, nextPage()))
|
||
return parts
|
||
}
|
||
|
||
// Two passes: the shell's `pág. N/M` needs the final sheet count, and the
|
||
// count only exists after the pack plan is applied.
|
||
report.meta.pageTotal = 0
|
||
let parts = buildParts()
|
||
report.meta.pageTotal = parts.length
|
||
parts = buildParts()
|
||
|
||
return `<!doctype html>
|
||
<html lang="pt-BR">
|
||
<head><meta charset="utf-8"><title>${htmlEscape(report.meta.title)} — ${htmlEscape(report.meta.municipalityName)}</title><style>${PRINT_CSS}</style></head>
|
||
<body>
|
||
${parts.join('\n')}
|
||
</body>
|
||
</html>`
|
||
}
|
||
|
||
const mdEra = (era) => {
|
||
const lines = [
|
||
`## ${era.label} (${era.period})`,
|
||
'',
|
||
`_${era.method}_`,
|
||
'',
|
||
`Trilha: ${era.recovery}`,
|
||
]
|
||
if (era.narrative || era.summary) {
|
||
lines.push('', `> ${era.narrative ?? era.summary}`)
|
||
}
|
||
if (listOf(era.numbers).length) {
|
||
lines.push(
|
||
'',
|
||
'| Objeto | Valor | Ano | Fase | Esfera | Fonte |',
|
||
'| --- | --- | --- | --- | --- | --- |',
|
||
)
|
||
for (const row of listOf(era.numbers)) {
|
||
lines.push(
|
||
`| ${row.object} | ${row.value ?? '—'} | ${row.year ?? '—'} | ${dossierPhaseLabel(row.phase)} | ${row.sphere} | ${row.sourceUrl ?? '—'} |`,
|
||
)
|
||
}
|
||
}
|
||
for (const action of listOf(era.actions)) {
|
||
lines.push(
|
||
'',
|
||
`- **${stripInlineSources(action.title)}** (${action.sphere}${action.year ? ` · ${action.year}` : ''})${action.detail ? ` — ${stripInlineSources(action.detail)}` : ''} [fonte](${action.sourceUrl})`,
|
||
)
|
||
}
|
||
for (const honor of listOf(era.honors))
|
||
lines.push('', `- Honraria: ${stripInlineSources(honor.text)} [fonte](${honor.sourceUrl})`)
|
||
return lines.join('\n')
|
||
}
|
||
|
||
const renderMunicipalityDossierMd = (report) => {
|
||
const lines = [
|
||
`# ${report.meta.title} — ${report.meta.municipalityName}`,
|
||
'',
|
||
`**Território de identidade:** ${report.meta.region}`,
|
||
`**Gerado em:** ${formatDateTimeBr(report.meta.generatedAt)} · **Base Teqo (read-only) lida em:** ${report.meta.readAtLabel}`,
|
||
'',
|
||
'> INSUMO INTERNO — não circular, não publicar. Documento de trabalho.',
|
||
'',
|
||
]
|
||
if (report.opening?.paragraphs?.length) {
|
||
lines.push(`## ${report.opening.title}`, '')
|
||
for (const paragraph of report.opening.paragraphs) lines.push(paragraph, '')
|
||
lines.push(
|
||
`_Redação de síntese sobre os pontos com fonte; ${
|
||
report.opening.authored
|
||
? 'escrita a partir dos itens datados do dossiê'
|
||
: 'repetição da leitura dos números (sem redação autoral disponível)'
|
||
} — nada aqui acrescenta fato novo._`,
|
||
'',
|
||
)
|
||
}
|
||
lines.push('## Linha do tempo da carreira', '')
|
||
for (const row of report.trajectory) {
|
||
lines.push(
|
||
`- **${row.period}** — ${row.role}${row.uncertain ? ` _(${row.uncertain})_` : ''} · ${row.recovery}`,
|
||
)
|
||
}
|
||
|
||
if (report.page1.deliveries.items.length) {
|
||
lines.push('', '## Principais entregas localizadas', '')
|
||
for (const row of report.page1.deliveries.items) {
|
||
lines.push(
|
||
`- **${stripInlineSources(row.title)}** · _${row.sphere}_${row.value ? ` · ${row.value}` : ''}${row.phase ? ` (${dossierPhaseLabel(row.phase)})` : ''}${row.detail ? ` — ${stripInlineSources(row.detail)}` : ''} [fonte](${row.sourceUrl})`,
|
||
)
|
||
}
|
||
if (report.page1.deliveries.remaining > 0) {
|
||
lines.push(
|
||
'',
|
||
`_${showingLabel(report.page1.deliveries.items.length, report.page1.deliveries.total, 'entrega localizada', 'entregas localizadas')} — o restante está nas páginas das eras._`,
|
||
)
|
||
}
|
||
lines.push('', '_Autorizado ≠ empenhado ≠ liquidado ≠ pago. A fase acompanha cada valor._')
|
||
}
|
||
|
||
for (const era of report.eras) {
|
||
lines.push('', mdEra(era))
|
||
}
|
||
|
||
if (report.synthesis?.lines?.length) {
|
||
lines.push('', '## Síntese do que foi localizado', '')
|
||
for (const line of report.synthesis.lines) lines.push(`- ${line}`)
|
||
lines.push('', '### Números por recorte', '')
|
||
lines.push('| Era | Município | Região/polo | Total |', '| --- | --- | --- | --- |')
|
||
for (const row of report.synthesis.byEraSphere) {
|
||
const countFor = (key) => row.segments.find((segment) => segment.key === key)?.count ?? 0
|
||
lines.push(
|
||
`| ${row.label} | ${countFor('municipio')} | ${countFor('regiao') + countFor('polo')} | ${row.segments.reduce((sum, segment) => sum + segment.count, 0)} |`,
|
||
)
|
||
}
|
||
if (report.synthesis.moneyByPhase.length) {
|
||
lines.push('', '### Recursos por fase', '')
|
||
for (const row of report.synthesis.moneyByPhase) {
|
||
lines.push(`- ${row.label}: ${row.count} · ${formatMoneyCompact(row.amount)}`)
|
||
}
|
||
}
|
||
if (report.synthesis.byArea.length) {
|
||
lines.push('', '### Temas com mais registros', '')
|
||
for (const row of report.synthesis.byArea) lines.push(`- ${row.key}: ${row.count}`)
|
||
}
|
||
}
|
||
|
||
lines.push(
|
||
'',
|
||
'## Região / polo',
|
||
'',
|
||
`> ${report.region.ruleTitle}`,
|
||
`> ${report.region.ruleBody}`,
|
||
'',
|
||
)
|
||
lines.push('### Município', '')
|
||
for (const item of report.region.municipal.items) {
|
||
lines.push(
|
||
`- **${stripInlineSources(briefOr(item.brief, item.answer))}** (${item.sphere})${item.details ? ` — ${stripInlineSources(item.details)}` : ''} [fonte](${item.sourceUrl})`,
|
||
)
|
||
}
|
||
lines.push('', '### Região / polo (não somável)', '')
|
||
for (const item of report.region.regional.items) {
|
||
lines.push(
|
||
`- **${stripInlineSources(briefOr(item.brief, item.answer))}** (${item.sphere})${item.details ? ` — ${stripInlineSources(item.details)}` : ''} [fonte](${item.sourceUrl})`,
|
||
)
|
||
}
|
||
|
||
if (report.acervo?.items?.length) {
|
||
lines.push(
|
||
'',
|
||
'## Acervo interno (read-only)',
|
||
'',
|
||
`_Amostra de ${report.acervo.items.length} de ${report.acervo.recorte} falas do recorte, com link; o acervo completo fica na base Teqo. ${report.unit?.acervoNote ?? ''}_`,
|
||
'',
|
||
)
|
||
for (const row of report.acervo.items) {
|
||
lines.push(
|
||
`- **${row.period}** — ${stripInlineSources(row.text)}${row.excerpt ? ` _(${stripInlineSources(row.excerpt)})_` : ''} [fonte](${row.sourceUrl})`,
|
||
)
|
||
}
|
||
}
|
||
|
||
lines.push('', '## Lacunas explícitas', '')
|
||
if (report.gaps.length === 0) lines.push('_Nenhuma lacuna declarada._')
|
||
for (const gap of report.gaps) {
|
||
lines.push(`- **${gap.label}:** ${gap.reason} → ${gap.nextStep}`)
|
||
}
|
||
|
||
if (report.news.length) {
|
||
lines.push('', '## Notícias e documentos consultados', '')
|
||
for (const row of report.news) {
|
||
lines.push(`- ${formatDateBr(row.date)} · ${row.outlet} — ${row.title} — ${row.url}`)
|
||
}
|
||
}
|
||
|
||
lines.push(
|
||
'',
|
||
'## Limites de cobertura',
|
||
'',
|
||
...report.limits.coverage.map((item) => `- ${item}`),
|
||
'',
|
||
'## Regras para uso editorial',
|
||
'',
|
||
...report.limits.editorial.map((item) => `- ${item}`),
|
||
'',
|
||
'## Nota de defeso eleitoral 2026',
|
||
'',
|
||
'Insumo interno não é autorização para publicar. Publicidade institucional e propaganda eleitoral obedecem a regimes diferentes. Antes de adaptar qualquer trecho para peça externa, submeta texto, imagem, autoria, período e canal à revisão responsável.',
|
||
'',
|
||
`_Documento interno de campanha, datado (${formatDateTimeBr(report.meta.generatedAt)}). O dado envelhece: confira a data de leitura da base._`,
|
||
)
|
||
return `${lines.join('\n')}\n`
|
||
}
|
||
|
||
/* ------------------------------------------------------------------ *
|
||
* Institution renderer (C187) — ports the approved institutional *
|
||
* hi-fi (`dossie-solla-instituicao-ui-design.html`) class-by-class; *
|
||
* shares the print shell, source tokens and table CSS of the city *
|
||
* renderer (one owner). *
|
||
* ------------------------------------------------------------------ */
|
||
|
||
const INSTITUTION_PRINT_CSS = `
|
||
${PRINT_CSS}
|
||
.identity-badge { display: inline-flex; align-items: center; min-height: 5mm; padding: .45mm 1.6mm; border: .25mm solid #cbd5dc; border-radius: 99px; color: #435264; background: #f3f6f8; font-size: 7.4pt; line-height: 1; font-weight: 700; letter-spacing: .035em; text-transform: uppercase; }
|
||
.scope-sector { border-color: #9d8b64; color: #65501f; background: #fffaf0; }
|
||
.scope-network { border-color: #8f819c; color: #554362; background: #faf7fc; }
|
||
.phase-paid { color: #285338; background: #e6f2e9; }
|
||
.phase-pending { color: #6b4918; background: #fff2d9; }
|
||
.empty-panel { border: .45mm dashed #b18549; color: #684819; background: #fff7e8; border-radius: .25rem; padding: 5mm 8mm; text-align: center; }
|
||
.empty-panel .rule-title { margin-top: 1.5mm; }
|
||
.empty-panel .warn-icon { width: 8mm; height: 8mm; margin: 0 auto; color: #8a5a18; }
|
||
.timeline-item { position: relative; padding-left: 5mm; }
|
||
.timeline-item::before { content: ''; position: absolute; top: 1.5mm; left: 0; width: 2.6mm; height: 2.6mm; border: .5mm solid #fff; border-radius: 99px; background: #315c75; box-shadow: 0 0 0 .35mm #315c75; }
|
||
.id-card { border: .25mm solid #cbd5dc; border-radius: .25rem; background: #f3f6f8; padding: 3mm; }
|
||
.id-card dl { display: grid; grid-template-columns: 22mm 1fr; gap: 1mm 2mm; font-size: 9pt; line-height: 1.3; }
|
||
.id-card dt { font-weight: 600; color: #435264; }
|
||
.id-card dd { margin: 0; }
|
||
.summary-top { display: grid; grid-template-columns: 58mm 1fr; gap: 4mm; margin-top: 4mm; }
|
||
.vinc-timeline { display: grid; grid-template-columns: repeat(3, 1fr); gap: 2mm; border-top: .25mm solid #cbd5dc; padding-top: 3mm; margin-top: 3mm; }
|
||
.vinc-period { margin: 0; font-size: 8pt; font-weight: 700; color: #315c75; }
|
||
.vinc-text { margin: 1mm 0 0; font-size: 9pt; font-weight: 600; line-height: 1.25; }
|
||
.vinc-source { margin: 1mm 0 0; font-size: 8pt; line-height: 1.2; color: #435264; }
|
||
.scope-cards { display: grid; grid-template-columns: repeat(3, 1fr); gap: 3mm; margin-top: 4mm; }
|
||
.scope-card { border: .25mm solid #cbd5dc; border-radius: .25rem; padding: 3mm; }
|
||
.acervo-stat-grid { display: grid; grid-template-columns: repeat(2, 1fr); gap: 3mm; margin-top: 3mm; }
|
||
.scope-card.card-sector { border-color: #c9b483; background: #fffcf6; }
|
||
.scope-card.card-network { border-color: #b9acc3; background: #fcf9fd; }
|
||
.scope-count { margin: 3mm 0 0; font-size: 16pt; font-weight: 600; font-variant-numeric: tabular-nums; }
|
||
.scope-cards .tight-list { margin-top: 2mm; }
|
||
.more-note { margin: 1.5mm 0 0; font-size: 8pt; font-weight: 600; color: #8a5a18; }
|
||
`
|
||
|
||
/**
|
||
* Anchor → pack-section key of the flowing sheets. The builder maps an
|
||
* overflowing sheet back to its pack entry and shrinks that chunk (fallback of
|
||
* the measured pack plan).
|
||
*/
|
||
const SHARED_PACK_ANCHORS = {
|
||
'era-a': 'era:A',
|
||
'era-b': 'era:B',
|
||
'era-c': 'era:C',
|
||
lacunas: 'gaps',
|
||
noticias: 'news',
|
||
acervo: 'acervo',
|
||
}
|
||
|
||
const MUNICIPALITY_PACK_ANCHORS = {
|
||
...SHARED_PACK_ANCHORS,
|
||
'regiao-municipio': 'region:municipio',
|
||
'regiao-regional': 'region:regional',
|
||
}
|
||
|
||
export const INSTITUTION_PACK_ANCHORS = {
|
||
...SHARED_PACK_ANCHORS,
|
||
titulos: 'honors',
|
||
'abrangencia-instituicao': 'scope:instituicao',
|
||
'abrangencia-setor': 'scope:setor',
|
||
'abrangencia-rede': 'scope:rede',
|
||
}
|
||
|
||
const THEME_PACK_ANCHORS = {
|
||
...SHARED_PACK_ANCHORS,
|
||
'abrangencia-area': 'scope:area',
|
||
'abrangencia-segmento': 'scope:segmento',
|
||
'abrangencia-rede': 'scope:rede',
|
||
}
|
||
|
||
/**
|
||
* Probe-only CSS: the packed sections measure their true content height, with
|
||
* no page clamp and no inter-row gaps (the packer adds the card-row gap back).
|
||
*/
|
||
const INSTITUTION_PROBE_CSS = `
|
||
.sheet { height: auto; min-height: 0; overflow: visible; }
|
||
[data-pack-section] .block,
|
||
[data-pack-section] .action-grid,
|
||
[data-pack-section] .document-table,
|
||
[data-pack-section] .tight-list,
|
||
[data-pack-section] .era-method,
|
||
[data-pack-section] .page1-grid { margin-top: 0; }
|
||
[data-pack-section] .action-grid,
|
||
[data-pack-section] .era-method { gap: 0; }
|
||
[data-pack-section] .tight-list li { margin-bottom: 0; }
|
||
`
|
||
|
||
const instHeader = (report, title, subtitle, pageNo) => `
|
||
<header class="report-header">
|
||
<div>
|
||
<p class="report-kicker">${htmlEscape(report.unit.docLabel)} · ${htmlEscape(report.meta.subjectName)}</p>
|
||
<h2 class="sheet-title">${htmlEscape(title)}</h2>
|
||
${subtitle ? `<p class="sheet-sub">${htmlEscape(subtitle)}</p>` : ''}
|
||
</div>
|
||
<div class="report-meta">
|
||
<p><strong class="warning-text">INSUMO INTERNO — defeso 2026</strong></p>
|
||
<p>Gerado em ${htmlEscape(formatDateTimeBr(report.meta.generatedAt))}</p>
|
||
<p>pág. ${pageNo}/${report.meta.pageTotal}</p>
|
||
</div>
|
||
</header>`
|
||
|
||
/** Shared sheets (síntese, gráficos, carta, eras correntes) use the unit's shell. */
|
||
const unitHeader = (report, title, subtitle, pageNo) =>
|
||
isSubjectUnit(report.unit)
|
||
? instHeader(report, title, subtitle, pageNo)
|
||
: sheetHeader(report, title, subtitle, pageNo)
|
||
|
||
const unitFooter = (report, left, pageNo) =>
|
||
isSubjectUnit(report.unit) ? instFooter(report, left, pageNo) : sheetFooter(report, left, pageNo)
|
||
|
||
const instFooter = (report, left, pageNo) => `
|
||
<footer class="report-footer">
|
||
<span>${htmlEscape(report.unit.docLabel)} · ${htmlEscape(report.meta.subjectName)} · ${htmlEscape(left)}</span>
|
||
<span>Gerado em ${htmlEscape(formatDateTimeBr(report.meta.generatedAt))} · pág. ${pageNo}/${report.meta.pageTotal}</span>
|
||
</footer>`
|
||
|
||
/** Singular of the capped-list nouns (never derive it by chopping the "s"). */
|
||
const CAPPED_NOUNS = {
|
||
itens: 'item',
|
||
entregas: 'entrega',
|
||
vínculos: 'vínculo',
|
||
}
|
||
|
||
const moreNote = (
|
||
cappedList,
|
||
noun = 'itens',
|
||
hint = 'com fonte fora desta página — nada foi descartado.',
|
||
) => {
|
||
if (cappedList.omitted <= 0) return ''
|
||
const label = cappedList.omitted === 1 ? (CAPPED_NOUNS[noun] ?? noun) : noun
|
||
return `<p class="more-note">e mais ${cappedList.omitted} ${label} ${htmlEscape(hint)}</p>`
|
||
}
|
||
|
||
/* ---------------------------------------------------------------- *
|
||
* Flowing sheets (C187): packed sections render as many sheets as *
|
||
* the content needs — nothing is capped. In probe mode a section *
|
||
* renders one sheet with every unit tagged (`data-pack-unit`), so *
|
||
* the builder measures real heights and returns a pack plan. *
|
||
* ---------------------------------------------------------------- */
|
||
|
||
const splitUnits = (units, sizes) => {
|
||
const chunks = []
|
||
let offset = 0
|
||
for (const size of sizes) {
|
||
chunks.push(units.slice(offset, offset + size))
|
||
offset += size
|
||
}
|
||
if (offset < units.length) chunks.push(units.slice(offset))
|
||
return chunks
|
||
}
|
||
|
||
/** Sheet plans of one section: `[{ key, anchor, chunk, index }]`. */
|
||
const packedSection = ({ key, anchor, units, pack, probe }) => {
|
||
const sizes = probe ? null : pack?.[key]
|
||
const chunks = sizes ? splitUnits(units, sizes) : [units]
|
||
return chunks.map((chunk, index) => ({
|
||
key,
|
||
chunk,
|
||
index,
|
||
anchor: index === 0 ? anchor : `${anchor}-${index + 1}`,
|
||
}))
|
||
}
|
||
|
||
const unitAttrs = (probe, index, layout) =>
|
||
probe ? ` data-pack-unit="${index}" data-pack-layout="${layout}"` : ''
|
||
|
||
const groupUnits = (chunk) => {
|
||
const groups = []
|
||
for (const unit of chunk) {
|
||
const last = groups[groups.length - 1]
|
||
if (last && last.layout === unit.layout) last.units.push(unit)
|
||
else groups.push({ layout: unit.layout, units: [unit] })
|
||
}
|
||
return groups
|
||
}
|
||
|
||
const tableHead = (columns) =>
|
||
`<thead><tr>${columns.map((column) => `<th>${htmlEscape(column)}</th>`).join('')}</tr></thead>`
|
||
|
||
/* ---------------------------------------------------------------- *
|
||
* Synthesis charts (inline SVG, print-safe): aggregations of the *
|
||
* sourced rows only — never a new fact, never a combined total *
|
||
* across abrangências. *
|
||
* ---------------------------------------------------------------- */
|
||
|
||
const CHART_COLORS = {
|
||
municipio: '#315c75',
|
||
regiao: '#9d8b64',
|
||
polo: '#8f819c',
|
||
instituicao: '#315c75',
|
||
setor: '#9d8b64',
|
||
rede: '#8f819c',
|
||
empenhado: '#6b4918',
|
||
liquidado: '#4a6b7c',
|
||
pago: '#285338',
|
||
autorizado: '#8a5a18',
|
||
restos: '#5b6470',
|
||
nao_informado: '#8b96a2',
|
||
default: '#315c75',
|
||
}
|
||
|
||
const barChart = ({ rows, width = 330, rowHeight = 19, labelWidth = 118 }) => {
|
||
const max = Math.max(1, ...rows.map((row) => row.count))
|
||
const barMax = width - labelWidth - 26
|
||
const height = rows.length * rowHeight + 4
|
||
return `<svg class="chart" viewBox="0 0 ${width} ${height}" role="img">
|
||
${rows
|
||
.map((row, index) => {
|
||
const y = index * rowHeight + 3
|
||
const barWidth = Math.max(2, Math.round((row.count / max) * barMax))
|
||
return `<text x="0" y="${y + 11}" class="chart-label">${htmlEscape(row.label)}</text>
|
||
<rect x="${labelWidth}" y="${y + 2}" width="${barWidth}" height="${rowHeight - 7}" rx="1" fill="${row.color ?? CHART_COLORS.default}" />
|
||
<text x="${labelWidth + barWidth + 4}" y="${y + 11}" class="chart-value">${row.count}</text>`
|
||
})
|
||
.join('')}
|
||
</svg>`
|
||
}
|
||
|
||
/** HTML list with a proportional bar per row — long labels wrap, nothing overlaps. */
|
||
const valueList = ({ rows, format }) => {
|
||
const max = Math.max(1, ...rows.map((row) => row.value))
|
||
return `<ul class="value-list">
|
||
${rows
|
||
.map(
|
||
(row) => `<li>
|
||
<span>${htmlEscape(row.label)}</span>
|
||
<span class="value-list-bar"><span style="width:${Math.max(2, Math.round((row.value / max) * 100))}%"></span></span>
|
||
<span class="value-list-value">${htmlEscape(format(row))}</span>
|
||
</li>`,
|
||
)
|
||
.join('')}
|
||
</ul>`
|
||
}
|
||
|
||
/**
|
||
* Vertical columns with the value label above and the year label below. `fullYearLabel`
|
||
* prints the whole year (theme axis); the institution keeps the 2-digit label.
|
||
*/
|
||
const columnChart = ({
|
||
rows,
|
||
width = 330,
|
||
height = 132,
|
||
format = (row) => String(row.count),
|
||
fullYearLabel = false,
|
||
}) => {
|
||
const max = Math.max(1, ...rows.map((row) => row.value ?? row.count))
|
||
const baseY = height - 18
|
||
const plotHeight = baseY - 16
|
||
const columnWidth = (width - 8) / Math.max(1, rows.length)
|
||
return `<svg class="chart" viewBox="0 0 ${width} ${height + 20}" role="img">
|
||
${rows
|
||
.map((row, index) => {
|
||
const value = row.value ?? row.count
|
||
const barHeight = Math.max(2, Math.round((value / max) * plotHeight))
|
||
const x = 4 + index * columnWidth
|
||
const y = baseY - barHeight
|
||
return `<rect x="${x + 2}" y="${y}" width="${Math.max(2, columnWidth - 5)}" height="${barHeight}" fill="${row.color ?? CHART_COLORS.default}" />
|
||
<text x="${x + columnWidth / 2}" y="${y - 3}" class="chart-value" text-anchor="middle">${htmlEscape(format(row))}</text>
|
||
<text x="${x + columnWidth / 2}" y="${height + 12}" class="chart-label" text-anchor="middle">${htmlEscape(fullYearLabel ? String(row.key) : String(row.key).slice(-2))}</text>`
|
||
})
|
||
.join('')}
|
||
</svg>`
|
||
}
|
||
|
||
/** Vertical columns stacked by phase (money by year), total label above. */
|
||
const stackedColumnChart = ({ rows, width = 330, height = 132, format, fullYearLabel = false }) => {
|
||
const max = Math.max(1, ...rows.map((row) => row.value))
|
||
const baseY = height - 18
|
||
const plotHeight = baseY - 16
|
||
const columnWidth = (width - 8) / Math.max(1, rows.length)
|
||
return `<svg class="chart" viewBox="0 0 ${width} ${height + 20}" role="img">
|
||
${rows
|
||
.map((row, index) => {
|
||
const x = 4 + index * columnWidth
|
||
let y = baseY
|
||
const bars = row.segments
|
||
.map((segment) => {
|
||
const segmentHeight = Math.max(1, Math.round((segment.amount / max) * plotHeight))
|
||
y -= segmentHeight
|
||
return `<rect x="${x + 2}" y="${y}" width="${Math.max(2, columnWidth - 5)}" height="${segmentHeight}" fill="${CHART_COLORS[segment.key] ?? CHART_COLORS.default}" />`
|
||
})
|
||
.join('')
|
||
const top = baseY - Math.round((row.value / max) * plotHeight)
|
||
return `${bars}
|
||
<text x="${x + columnWidth / 2}" y="${top - 3}" class="chart-value" text-anchor="middle">${htmlEscape(format(row))}</text>
|
||
<text x="${x + columnWidth / 2}" y="${height + 12}" class="chart-label" text-anchor="middle">${htmlEscape(fullYearLabel ? String(row.key) : String(row.key).slice(-2))}</text>`
|
||
})
|
||
.join('')}
|
||
</svg>`
|
||
}
|
||
|
||
const identityBadgeList = (report) =>
|
||
report.meta.identityBadges
|
||
.map((badge) => `<span class="identity-badge">${htmlEscape(badge)}</span>`)
|
||
.join(' ')
|
||
|
||
const coverRowValue = (report, row) => {
|
||
if (row.badges) return `<dd>${identityBadgeList(report)}</dd>`
|
||
if (row.code) {
|
||
return `<dd><code>${htmlEscape(row.code)}</code>${row.note ? ` · ${htmlEscape(row.note)}` : ''}</dd>`
|
||
}
|
||
return `<dd${row.tabular ? ' class="tabular"' : ''}>${htmlEscape(row.text)}</dd>`
|
||
}
|
||
|
||
const renderSubjectCover = (report) => {
|
||
const rows = [
|
||
...report.unit.coverRows(report),
|
||
{ label: 'Data de geração', text: formatDateTimeBr(report.meta.generatedAt), tabular: true },
|
||
{
|
||
label: 'Escopo e versão',
|
||
text: `Eras A / B / C · ${report.unit.copy.coverScopeWords} · versão 01`,
|
||
},
|
||
]
|
||
return `
|
||
<article class="sheet sheet-cover" data-page="capa" aria-label="Capa do ${htmlEscape(report.unit.docLabel.toLowerCase())}">
|
||
<div class="accent-bar"></div>
|
||
<header class="cover-head">
|
||
<div>
|
||
<p class="report-kicker">${htmlEscape(report.cover.kicker)}</p>
|
||
<p class="cover-series">${htmlEscape(report.cover.series)}</p>
|
||
</div>
|
||
<div class="internal-note"><strong>${htmlEscape(report.cover.internalNote)}</strong><br />${htmlEscape(report.cover.internalNoteSub)}</div>
|
||
</header>
|
||
|
||
<div class="cover-block">
|
||
<p class="eyebrow">${htmlEscape(report.cover.eyebrow)}</p>
|
||
<h1 class="cover-title">Dossiê Solla —<br /><span class="cover-accent">${htmlEscape(report.cover.title)}</span></h1>
|
||
<p class="cover-sub">${htmlEscape(report.cover.subtitle)}</p>
|
||
</div>
|
||
|
||
<dl class="cover-dl">
|
||
${rows.map((row) => `<dt>${htmlEscape(row.label)}</dt>${coverRowValue(report, row)}`).join('\n ')}
|
||
</dl>
|
||
|
||
<div class="cover-grid">
|
||
<div class="how-to"><p class="how-to-title">Como usar</p><p>${htmlEscape(report.cover.howToUse)}</p></div>
|
||
${assetBox('NEEDS ASSET', report.unit.copy.coverAsset)}
|
||
</div>
|
||
|
||
<div class="cover-foot">
|
||
<div class="ink-rule"></div>
|
||
<div class="cover-foot-row">
|
||
<p class="cover-scope">${htmlEscape(report.cover.scope)}</p>
|
||
<p class="cover-version">Documento de trabalho<br />versão 01</p>
|
||
</div>
|
||
</div>
|
||
</article>`
|
||
}
|
||
|
||
const renderSubjectIdentity = (report) => {
|
||
const rows = report.unit.identityRows(report.meta.identity ?? {})
|
||
return `<div class="id-card">
|
||
<p class="eyebrow">Identificação</p>
|
||
<dl>${rows.map(([key, value]) => `<dt>${htmlEscape(key)}</dt><dd>${htmlEscape(value)}</dd>`).join('')}</dl>
|
||
</div>`
|
||
}
|
||
|
||
const renderSubjectVincTimeline = (report) => {
|
||
const timeline = report.page1.timeline
|
||
if (timeline.items.length === 0) {
|
||
return `<p class="muted small">${htmlEscape(report.unit.copy.timelineEmpty)}</p>`
|
||
}
|
||
return `
|
||
<div class="vinc-timeline">
|
||
${timeline.items
|
||
.map(
|
||
(row) => `<div class="timeline-item">
|
||
<p class="vinc-period">${htmlEscape(row.period || '—')}</p>
|
||
<p class="vinc-text">${copyHtml(briefOr(row.brief, row.role))}</p>
|
||
<p class="vinc-source">${htmlEscape(row.source ?? '')} ${sourceLink(row.url)}</p>
|
||
</div>`,
|
||
)
|
||
.join('')}
|
||
</div>
|
||
${moreNote(timeline, 'vínculos', 'com fonte — lista completa nas seções por era.')}`
|
||
}
|
||
|
||
const renderSubjectDeliveries = (report) => {
|
||
const deliveries = report.page1.deliveries
|
||
if (deliveries.items.length === 0) return ''
|
||
return `
|
||
<section class="block">
|
||
<div class="block-head">
|
||
<div><p class="eyebrow">02 · destaques com lastro</p><h3 class="section-title">Principais entregas localizadas</h3></div>
|
||
<p class="warning-text meta strong">Cada valor mantém a fase e a abrangência</p>
|
||
</div>
|
||
<div class="deliveries">
|
||
${deliveries.items
|
||
.map(
|
||
(row) => `<div class="delivery-row">
|
||
<div class="delivery-scope">${scopeBadge(row.sphere, null, report.unit)}<p class="meta">Era ${htmlEscape(row.era)}${row.year ? ` · ${htmlEscape(row.year)}` : ''}</p></div>
|
||
<div>
|
||
<p class="delivery-title">${copyHtml(briefOr(row.brief, row.title))}</p>
|
||
${noteOr(row.brief, row.detail) ? `<p class="muted small">${copyHtml(noteOr(row.brief, row.detail))}</p>` : ''}
|
||
</div>
|
||
<div class="delivery-value">
|
||
<p class="value-big tabular">${valueCell(row.value, 'Valor não informado')}</p>
|
||
${row.phase ? phaseBadge(row.phase) : ''}
|
||
${sourceLink(row.sourceUrl)}
|
||
</div>
|
||
</div>`,
|
||
)
|
||
.join('')}
|
||
</div>
|
||
${moreNote(deliveries, 'entregas', 'com fonte — lista completa nas seções por era e de abrangência.')}
|
||
<p class="muted small strong">Guarda de leitura: autorizado ≠ empenhado ≠ liquidado ≠ pago. A fase acompanha cada valor.</p>
|
||
</section>`
|
||
}
|
||
|
||
const renderSubjectHooksAndPending = (report) => {
|
||
const hooks = report.page1.hooks
|
||
const pending = report.page1.pending
|
||
if (hooks.items.length === 0 && pending.items.length === 0) return ''
|
||
return `
|
||
<section class="page1-grid">
|
||
${
|
||
hooks.items.length
|
||
? `<div class="panel">
|
||
<p class="eyebrow">Gancho para a agenda</p>
|
||
<ul class="tight-list">${hooks.items
|
||
.map(
|
||
(hook) =>
|
||
`<li><strong>${htmlEscape(hook.topic)}:</strong> ${copyHtml(briefOr(hook.brief, hook.angle))} ${sourceLink(hook.sourceUrl)}</li>`,
|
||
)
|
||
.join('')}</ul>
|
||
</div>`
|
||
: '<div></div>'
|
||
}
|
||
${
|
||
pending.items.length
|
||
? `<div class="panel panel-warning">
|
||
<p class="eyebrow warning-text">O que falta</p>
|
||
<ul class="tight-list">${pending.items.map((item) => `<li>${htmlEscape(item)}</li>`).join('')}</ul>
|
||
<p class="warning-text small strong">Ausência é resultado: não completar por inferência.</p>
|
||
</div>`
|
||
: '<div></div>'
|
||
}
|
||
</section>`
|
||
}
|
||
|
||
const renderSubjectSummary = (report, pageNo) => `
|
||
<article class="sheet" data-page="resumo" aria-label="Resumo de uma olhada do ${htmlEscape(report.unit.docLabel.toLowerCase())}">
|
||
${instHeader(report, 'Resumo de uma olhada', 'Informação datada e pronta para conferência', pageNo)}
|
||
<section class="summary-top">
|
||
${renderSubjectIdentity(report)}
|
||
<div>
|
||
<p class="eyebrow">${htmlEscape(report.unit.summaryLinkEyebrow)}</p>
|
||
<h3 class="section-title">Linha do tempo documentada</h3>
|
||
${renderSubjectVincTimeline(report)}
|
||
</div>
|
||
</section>
|
||
${renderSubjectDeliveries(report)}
|
||
${renderSubjectHooksAndPending(report)}
|
||
${instFooter(report, 'Resumo · INSUMO INTERNO', pageNo)}
|
||
</article>`
|
||
|
||
const renderEraEmptySheet = (report, era, pageNo) => {
|
||
const empty = report.unit.copy.eraEmpty
|
||
return `
|
||
<article class="sheet" data-page="era-${era.id.toLowerCase()}" aria-label="${htmlEscape(era.label)}">
|
||
${unitHeader(report, `${era.label} · Pesquisa sem evidência suficiente`, era.period, pageNo)}
|
||
<div class="era-method">
|
||
<div>
|
||
<p class="eyebrow">Recorte previsto</p>
|
||
<p class="muted">${htmlEscape(era.method)}</p>
|
||
</div>
|
||
<aside class="aside-accent"><strong>Trilha tentada</strong><br />${htmlEscape(era.recovery)}</aside>
|
||
</div>
|
||
<section class="empty-panel" style="margin-top: 5mm">
|
||
${WARNING_ICON}
|
||
<p class="eyebrow warning-text">Lacuna explícita · não é “zero”</p>
|
||
<h3 class="rule-title">Nenhuma fonte suficiente foi localizada para esta era.</h3>
|
||
<p class="muted">${htmlEscape(empty.body)}</p>
|
||
</section>
|
||
<section class="page1-grid">
|
||
<div class="panel">
|
||
<p class="eyebrow">O que foi feito</p>
|
||
<ul class="tight-list">
|
||
${empty.work.map((item) => `<li>${htmlEscape(item)}</li>`).join('\n ')}
|
||
</ul>
|
||
</div>
|
||
<div class="panel">
|
||
<p class="eyebrow">O que falta fazer</p>
|
||
<ul class="tight-list">
|
||
${empty.todo.map((item) => `<li>${htmlEscape(item)}</li>`).join('\n ')}
|
||
</ul>
|
||
</div>
|
||
</section>
|
||
<aside class="rule-warning">
|
||
${WARNING_ICON}
|
||
<div>
|
||
<p class="eyebrow warning-text">Regra editorial</p>
|
||
<p>${(() => {
|
||
const rule = empty.rule(era.label)
|
||
const splitAt = rule.indexOf('. ')
|
||
return splitAt === -1
|
||
? htmlEscape(rule)
|
||
: `<strong>${htmlEscape(rule.slice(0, splitAt + 1))}</strong> ${htmlEscape(rule.slice(splitAt + 2))}`
|
||
})()}</p>
|
||
</div>
|
||
</aside>
|
||
${unitFooter(report, `${era.label} · lacuna explícita`, pageNo)}
|
||
</article>`
|
||
}
|
||
|
||
/** Accepts the institution's capped objects and the city's plain arrays. */
|
||
const listOf = (value) => (Array.isArray(value) ? value : (value?.items ?? []))
|
||
|
||
/**
|
||
* Era units: value rows (table), action cards (2-col grid) and — city only, which
|
||
* keeps honors in the era sheet — the honor list items. Honors have their own
|
||
* sheet in the institution dossiê, so `includeHonors` stays false there.
|
||
*/
|
||
const eraUnits = (report, era, probe, { includeHonors = false } = {}) => {
|
||
const unit = report.unit ?? MUNICIPALITY_UNIT
|
||
const units = []
|
||
for (const row of listOf(era.numbers)) {
|
||
units.push({
|
||
layout: 'tr',
|
||
html: `<tr${unitAttrs(probe, units.length, 'tr')}>
|
||
<td>${htmlEscape(row.object)}</td>
|
||
<td class="numeric">${valueCell(row.value, 'Valor a recuperar')}</td>
|
||
<td class="numeric">${htmlEscape(row.year ?? '—')}</td>
|
||
<td>${phaseBadge(row.phase)}</td>
|
||
<td>${scopeBadge(row.sphere, null, unit)}</td>
|
||
<td>${sourceLink(row.sourceUrl)}${
|
||
row.sphere !== unit.defaultSphere
|
||
? `<br /><span class="meta">${htmlEscape(unit.notSummedInline ?? 'não somar')}</span>`
|
||
: ''
|
||
}</td>
|
||
</tr>`,
|
||
})
|
||
}
|
||
for (const action of listOf(era.actions)) {
|
||
units.push({
|
||
layout: 'card',
|
||
html: `<article class="panel"${unitAttrs(probe, units.length, 'card')}>
|
||
<div class="action-head">${scopeBadge(action.sphere, null, unit)}<span class="meta">${htmlEscape(action.year ?? '—')}</span></div>
|
||
<h4 class="action-title">${copyHtml(briefOr(action.brief, action.title))}</h4>
|
||
${noteOr(action.brief, action.detail) ? `<p class="muted small">${copyHtml(noteOr(action.brief, action.detail))}</p>` : ''}
|
||
${sourceLink(action.sourceUrl)}
|
||
</article>`,
|
||
})
|
||
}
|
||
if (includeHonors) {
|
||
for (const honor of listOf(era.honors)) {
|
||
units.push({
|
||
layout: 'li',
|
||
html: `<li${unitAttrs(probe, units.length, 'li')}>${copyHtml(briefOr(honor.brief, honor.text))} ${sourceLink(honor.sourceUrl)}</li>`,
|
||
})
|
||
}
|
||
}
|
||
return units
|
||
}
|
||
|
||
const eraNumbersTable = (units, unit) => `
|
||
<table class="document-table">
|
||
<caption class="sr-only">Valores por ano, fase, esfera e fonte</caption>
|
||
<colgroup><col style="width:26%" /><col style="width:15%" /><col style="width:10%" /><col style="width:17%" /><col style="width:12%" /><col style="width:20%" /></colgroup>
|
||
${tableHead(['Objeto', 'Valor', 'Ano', 'Fase', unit.sphereColumnLabel ?? 'Esfera', 'Fonte'])}
|
||
<tbody>${units.map((entry) => entry.html).join('')}</tbody>
|
||
</table>`
|
||
|
||
const eraHonorsPanel = (units, unit) => `
|
||
<section class="block honors">
|
||
<div class="panel">
|
||
<p class="eyebrow">${htmlEscape(unit.honorsTitle ?? 'Títulos, honrarias e vínculos')}</p>
|
||
<ul class="tight-list">${units.map((entry) => entry.html).join('')}</ul>
|
||
</div>
|
||
${assetBox('NEEDS ASSET', ['reprodução do ato ou clipping', 'somente com origem e licença'])}
|
||
</section>`
|
||
|
||
const renderEraSheet = ({ report, era, entry, probe, pageNo }) => {
|
||
const unit = report.unit ?? MUNICIPALITY_UNIT
|
||
const body = groupUnits(entry.chunk)
|
||
.map((group) => {
|
||
if (group.layout === 'tr') {
|
||
return `<section class="block">
|
||
<div class="block-head">
|
||
<div><p class="eyebrow">Entregas localizadas</p><h3 class="section-title">${htmlEscape(unit.eraNumbersTitle ?? 'Objeto, valor, fase e alcance')}</h3></div>
|
||
</div>
|
||
${eraNumbersTable(group.units, unit)}
|
||
<p class="warning-text small strong">Não consolidar fases como se fossem equivalentes. Um valor empenhado não é um valor pago.</p>
|
||
</section>`
|
||
}
|
||
if (group.layout === 'li') return eraHonorsPanel(group.units, unit)
|
||
return `<section class="block">
|
||
<p class="eyebrow">Atuação registrada</p>
|
||
<h3 class="section-title">${htmlEscape(unit.eraActionsTitle ?? 'Papéis com evidência visível')}</h3>
|
||
<div class="action-grid">${group.units.map((item) => item.html).join('')}</div>
|
||
</section>`
|
||
})
|
||
.join('')
|
||
return `
|
||
<article class="sheet" data-page="${entry.anchor}"${probe ? ` data-pack-section="era:${era.id}"` : ''} aria-label="${htmlEscape(era.label)}">
|
||
${unitHeader(report, era.label, entry.index === 0 ? `${era.period} · ${era.subtitle}` : `continuação ${entry.index + 1} · ${era.period}`, pageNo)}
|
||
|
||
${
|
||
entry.index === 0
|
||
? `<div class="era-method"${probe ? ' data-pack-fixed="method"' : ''}>
|
||
<div>
|
||
<p class="eyebrow">Recorte e método</p>
|
||
<p class="muted">${htmlEscape(era.method)}</p>
|
||
</div>
|
||
<aside class="aside-accent"><strong>Trilha de recuperação</strong><br />${htmlEscape(era.recovery)}</aside>
|
||
</div>`
|
||
: ''
|
||
}
|
||
|
||
${
|
||
entry.index === 0 && (era.narrative || era.summary)
|
||
? `<section class="era-summary"${probe ? ' data-pack-fixed="summary"' : ''}>
|
||
<p class="eyebrow">O que esta era entrega</p>
|
||
<p>${copyHtml(era.narrative ?? era.summary)}</p>
|
||
</section>`
|
||
: ''
|
||
}
|
||
|
||
${body}
|
||
|
||
${unitFooter(report, era.label, pageNo)}
|
||
</article>`
|
||
}
|
||
|
||
const renderEraSheets = ({ report, era, pack, probe, nextPage, includeHonors = false }) => {
|
||
if (era.empty) return [renderEraEmptySheet(report, era, nextPage())]
|
||
const units = eraUnits(report, era, probe, { includeHonors })
|
||
return packedSection({
|
||
key: `era:${era.id}`,
|
||
anchor: `era-${era.id.toLowerCase()}`,
|
||
units,
|
||
pack,
|
||
probe,
|
||
}).map((entry) => renderEraSheet({ report, era, entry, probe, pageNo: nextPage() }))
|
||
}
|
||
|
||
const PACK_ANCHORS_BY_UNIT = {
|
||
municipality: MUNICIPALITY_PACK_ANCHORS,
|
||
institution: INSTITUTION_PACK_ANCHORS,
|
||
theme: THEME_PACK_ANCHORS,
|
||
}
|
||
|
||
/** Anchor map of the unit's packed sections (the builder inverts it). */
|
||
export const dossierPackAnchors = (unit) =>
|
||
PACK_ANCHORS_BY_UNIT[resolveDossierUnit(unit).id] ?? MUNICIPALITY_PACK_ANCHORS
|
||
|
||
const listYearRange = (items) => {
|
||
const years = items
|
||
.map((item) => item.year)
|
||
.filter(Boolean)
|
||
.sort()
|
||
return years.length ? `${years[0]}–${years[years.length - 1]}` : null
|
||
}
|
||
|
||
const renderSubjectScope = (report, pageNo) => {
|
||
const reach = report.reach
|
||
const sphereTitle = report.unit.spheres
|
||
.map((sphere) => report.unit.sphereLabels[sphere])
|
||
.join(' × ')
|
||
return `
|
||
<article class="sheet" data-page="abrangencia" aria-label="${htmlEscape(report.unit.aria?.scope ?? 'Painel de abrangência institucional')}">
|
||
${instHeader(report, `Abrangência: ${sphereTitle}`, 'Três recortes, três leituras, nenhuma soma combinada', pageNo)}
|
||
|
||
<section class="rule-warning">
|
||
${WARNING_ICON}
|
||
<div>
|
||
<p class="rule-title">${htmlEscape(reach.ruleTitle)}</p>
|
||
<p class="muted">${htmlEscape(reach.ruleBody)}</p>
|
||
</div>
|
||
</section>
|
||
|
||
<section class="block">
|
||
<p class="eyebrow">Leitura lado a lado</p>
|
||
<h3 class="section-title">O alcance acompanha cada evidência</h3>
|
||
<div class="scope-cards">
|
||
${reach.lists
|
||
.map(
|
||
(list) => `<article class="scope-card ${report.unit.scopeCardClass[list.sphere] ?? ''}">
|
||
<div class="action-head">${scopeBadge(list.sphere, null, report.unit)}<span class="meta strong">${list.sphere === report.unit.defaultSphere ? 'DIRETO' : 'NÃO SOMAR'}</span></div>
|
||
<p class="scope-count tabular">${list.total} ${list.total === 1 ? 'item' : 'itens'}</p>
|
||
<p class="meta">com fonte${listYearRange(list.items) ? ` · ${listYearRange(list.items)}` : ''} · lista completa nas páginas seguintes</p>
|
||
<ul class="tight-list">${list.items
|
||
.slice(0, 2)
|
||
.map(
|
||
(item) =>
|
||
`<li>${copyHtml(briefOr(item.brief, item.title))} ${sourceLink(item.sourceUrl)}</li>`,
|
||
)
|
||
.join(
|
||
'',
|
||
)}${list.items.length === 0 ? '<li class="muted">Nenhum item com fonte.</li>' : ''}</ul>
|
||
</article>`,
|
||
)
|
||
.join('')}
|
||
</div>
|
||
</section>
|
||
|
||
${
|
||
reach.hook || reach.priorityGap
|
||
? `<section class="page1-grid">
|
||
${
|
||
reach.hook
|
||
? `<div class="aside-accent"><p class="eyebrow">Gancho possível</p><p><strong>${htmlEscape(reach.hook.topic)}:</strong> ${copyHtml(briefOr(reach.hook.brief, reach.hook.angle))} ${sourceLink(reach.hook.sourceUrl)}</p></div>`
|
||
: '<div></div>'
|
||
}
|
||
${
|
||
reach.priorityGap
|
||
? `<div class="panel panel-warning"><p class="eyebrow warning-text">Lacuna prioritária</p><p>${htmlEscape(reach.priorityGap)}</p></div>`
|
||
: '<div></div>'
|
||
}
|
||
</section>`
|
||
: ''
|
||
}
|
||
|
||
${instFooter(report, 'Abrangência · recortes não somáveis', pageNo)}
|
||
</article>`
|
||
}
|
||
|
||
/** Sphere lists are uncapped: each one flows across as many sheets as it needs. */
|
||
const subjectScopeUnits = (report, list, probe) =>
|
||
list.items.map((item, index) => {
|
||
const attrs = unitAttrs(probe, index, 'tr')
|
||
if (!report.unit.reachScene) {
|
||
return {
|
||
layout: 'tr',
|
||
html: `<tr${attrs}>
|
||
<td>${copyHtml(briefOr(item.brief, item.title))}</td>
|
||
<td>${scopeBadge(item.sphere, null, report.unit)}</td>
|
||
<td>${copyHtml(noteOr(item.brief, item.evidence))}</td>
|
||
<td>${sourceLink(item.sourceUrl)}</td>
|
||
</tr>`,
|
||
}
|
||
}
|
||
return {
|
||
layout: 'tr',
|
||
html: `<tr${attrs}>
|
||
<td>${copyHtml(briefOr(item.brief, item.title))}</td>
|
||
<td class="nowrap">Era ${htmlEscape(item.era ?? '—')}${item.year ? ` · ${htmlEscape(item.year)}` : ''}</td>
|
||
<td>${valueCell(item.value)}${item.phase ? ` ${phaseBadge(item.phase)}` : ''}</td>
|
||
<td>${copyHtml(noteOr(item.brief, item.evidence))}</td>
|
||
<td>${sourceLink(item.sourceUrl)}</td>
|
||
</tr>`,
|
||
}
|
||
})
|
||
|
||
const scopeListWord = (report, list) => report.unit.scopeListWords[list.key] ?? list.key
|
||
|
||
const renderSubjectScopeListSheet = ({ report, list, entry, probe, pageNo }) => {
|
||
const scene = report.unit.reachScene
|
||
const word = scopeListWord(report, list)
|
||
const notSummable = list.sphere !== report.unit.defaultSphere
|
||
const columns = scene ? scene.columns : ['Item', 'Abrangência', 'Evidência de alcance', 'Fonte']
|
||
const tableClass = scene ? `document-table ${scene.tableClass}` : 'document-table'
|
||
const colgroup = scene
|
||
? '<colgroup><col style="width:24%" /><col style="width:13%" /><col style="width:16%" /><col style="width:29%" /><col style="width:18%" /></colgroup>'
|
||
: '<colgroup><col style="width:26%" /><col style="width:14%" /><col style="width:40%" /><col style="width:20%" /></colgroup>'
|
||
return `
|
||
<article class="sheet" data-page="${entry.anchor}"${probe ? ` data-pack-section="scope:${word}"` : ''} aria-label="${htmlEscape(`Abrangência · ${list.label}`)}">
|
||
${instHeader(
|
||
report,
|
||
`Abrangência · ${list.label}`,
|
||
entry.index === 0
|
||
? `${list.total} ${list.total === 1 ? 'item' : 'itens'} com fonte · ${notSummable ? `não somar ${report.unit.notSummedTo}` : 'recorte direto'}`
|
||
: `continuação ${entry.index + 1} · ${list.total} ${list.total === 1 ? 'item' : 'itens'} com fonte`,
|
||
pageNo,
|
||
)}
|
||
${
|
||
entry.index === 0
|
||
? `<section class="rule-warning">
|
||
${WARNING_ICON}
|
||
<div>
|
||
<p class="rule-title">${htmlEscape(report.reach.ruleTitle)}</p>
|
||
<p class="muted">Cada linha informa sua abrangência; ${htmlEscape(report.unit.sumGuardNote)}</p>
|
||
</div>
|
||
</section>`
|
||
: ''
|
||
}
|
||
<section class="block">
|
||
<table class="${tableClass}">
|
||
<caption class="sr-only">Itens do recorte com evidência de alcance e fonte</caption>
|
||
${colgroup}
|
||
${tableHead(columns)}
|
||
<tbody>${entry.chunk.map((unit) => unit.html).join('')}</tbody>
|
||
</table>
|
||
</section>
|
||
${instFooter(report, `Abrangência · ${list.label}`, pageNo)}
|
||
</article>`
|
||
}
|
||
|
||
const renderSubjectScopeListSheets = ({ report, list, pack, probe, nextPage }) => {
|
||
if (list.items.length === 0) return []
|
||
const word = scopeListWord(report, list)
|
||
const units = subjectScopeUnits(report, list, probe)
|
||
return packedSection({
|
||
key: `scope:${word}`,
|
||
anchor: `abrangencia-${word}`,
|
||
units,
|
||
pack,
|
||
probe,
|
||
}).map((entry) => renderSubjectScopeListSheet({ report, list, entry, probe, pageNo: nextPage() }))
|
||
}
|
||
|
||
const honorDate = (honor) => {
|
||
if (honor.date) return formatDateBr(honor.date)
|
||
if (honor.year) return String(honor.year)
|
||
return '—'
|
||
}
|
||
|
||
const subjectHonors = (report) => report.eras.find((era) => era.id === 'C')?.honors?.items ?? []
|
||
|
||
const subjectHonorUnits = (probe) =>
|
||
subjectHonors(probe.report).map((honor, index) => ({
|
||
layout: 'tr',
|
||
html: `<tr${unitAttrs(probe.probe, index, 'tr')}>
|
||
<td class="numeric">${htmlEscape(honorDate(honor))}</td>
|
||
<td><span class="identity-badge">reconhecimento</span></td>
|
||
<td><strong>${copyHtml(briefOr(honor.brief, honor.text))}</strong>${noteOr(honor.brief, honor.detail) ? `<br /><span class="meta">${copyHtml(noteOr(honor.brief, honor.detail))}</span>` : ''}</td>
|
||
<td>${sourceLink(honor.sourceUrl)}</td>
|
||
</tr>`,
|
||
}))
|
||
|
||
const renderSubjectHonorSheet = ({ report, entry, probe, pageNo }) => {
|
||
const gap = report.gaps?.[0] ?? null
|
||
const hook = report.reach.hook ?? report.page1.hooks.items[0] ?? null
|
||
return `
|
||
<article class="sheet" data-page="${entry.anchor}"${probe ? ' data-pack-section="honors"' : ''} aria-label="Títulos, honrarias e vínculos institucionais">
|
||
${instHeader(
|
||
report,
|
||
'Títulos, honrarias e vínculos',
|
||
entry.index === 0
|
||
? 'Reconhecimento não substitui entrega; vínculo não prova resultado'
|
||
: `continuação ${entry.index + 1}`,
|
||
pageNo,
|
||
)}
|
||
<section class="block">
|
||
<p class="eyebrow">Registros confirmados</p>
|
||
<h3 class="section-title">Data, natureza e fonte primária</h3>
|
||
<table class="document-table">
|
||
<caption class="sr-only">Títulos, honrarias e vínculos por data e fonte</caption>
|
||
<colgroup><col style="width:14%" /><col style="width:20%" /><col style="width:35%" /><col style="width:31%" /></colgroup>
|
||
${tableHead(['Data', 'Natureza', 'Registro', 'Fonte'])}
|
||
<tbody>${entry.chunk.map((unit) => unit.html).join('')}</tbody>
|
||
</table>
|
||
</section>
|
||
${
|
||
entry.index === 0
|
||
? `<section class="block honors">
|
||
<div>
|
||
<p class="eyebrow">Como ler</p>
|
||
<div class="action-grid">
|
||
<div class="panel">
|
||
<h3 class="action-title">Vínculo documentado</h3>
|
||
<p class="muted small">Informa papel, unidade e período. Não autoriza atribuir toda ação posterior da instituição.</p>
|
||
</div>
|
||
<div class="panel">
|
||
<h3 class="action-title">Honraria documentada</h3>
|
||
<p class="muted small">Registra reconhecimento e órgão concedente. Não vira prova de recurso ou entrega.</p>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
${assetBox('NEEDS ASSET', ['reprodução do ato, diploma ou clipping', 'origem, autoria e licença'])}
|
||
</section>
|
||
<section class="page1-grid">
|
||
<div class="aside-accent">
|
||
<p class="eyebrow">Gancho para a agenda</p>
|
||
<p>${hook ? `<strong>${htmlEscape(hook.topic)}:</strong> ${copyHtml(briefOr(hook.brief, hook.angle))}` : 'Mencionar um vínculo só na formulação que a fonte sustenta e conectá-lo a uma entrega comprovada, se houver.'}</p>
|
||
</div>
|
||
<div class="panel panel-warning">
|
||
<p class="eyebrow warning-text">Pendência documental</p>
|
||
<p>${gap ? `${htmlEscape(gap.label)}: ${htmlEscape(gap.reason)}` : 'Nenhuma pendência declarada para os registros desta página.'}</p>
|
||
</div>
|
||
</section>`
|
||
: ''
|
||
}
|
||
${instFooter(report, 'Títulos e vínculos · INSUMO INTERNO', pageNo)}
|
||
</article>`
|
||
}
|
||
|
||
const renderSubjectHonorSheets = ({ report, pack, probe, nextPage }) => {
|
||
const units = subjectHonorUnits({ report, probe })
|
||
if (units.length === 0) return []
|
||
return packedSection({ key: 'honors', anchor: 'titulos', units, pack, probe }).map((entry) =>
|
||
renderSubjectHonorSheet({ report, entry, probe, pageNo: nextPage() }),
|
||
)
|
||
}
|
||
|
||
/**
|
||
* Gaps and news are long full-text tables: packed in continuation sheets, so
|
||
* the sources sheet keeps only the acervo, limits and defeso.
|
||
*/
|
||
const gapUnits = (report, probe) =>
|
||
report.gaps.map((gap, index) => {
|
||
const attrs = unitAttrs(probe, index, 'tr')
|
||
if (!report.unit.gapsScene) {
|
||
return {
|
||
layout: 'tr',
|
||
html: `<tr${attrs}>
|
||
<td><strong>${htmlEscape(gap.label)}</strong></td>
|
||
<td>${htmlEscape(gap.reason)}</td>
|
||
<td>Não completar por inferência.</td>
|
||
<td>${htmlEscape(gap.nextStep)}</td>
|
||
</tr>`,
|
||
}
|
||
}
|
||
const era = gap.era && gap.era !== 'Acervo' ? `Era ${gap.era}` : 'Acervo'
|
||
const status =
|
||
index === 0
|
||
? '<span class="phase phase-pending">prioridade</span>'
|
||
: '<span class="phase">aberta</span>'
|
||
return {
|
||
layout: 'tr',
|
||
html: `<tr${attrs}>
|
||
<td class="nowrap">${htmlEscape(era)}</td>
|
||
<td><strong>${htmlEscape(gap.label)}</strong></td>
|
||
<td>${htmlEscape(gap.reason)}</td>
|
||
<td>${htmlEscape(gap.nextStep)}</td>
|
||
<td>${status}</td>
|
||
</tr>`,
|
||
}
|
||
})
|
||
|
||
const renderGapSheet = ({ report, entry, probe, pageNo, isLast = false }) => {
|
||
const scene = report.unit.gapsScene
|
||
const columns = scene
|
||
? scene.columns
|
||
: ['Lacuna', 'Situação da busca', 'Limite editorial', 'Próximo passo']
|
||
const colgroup = scene
|
||
? '<colgroup><col style="width:12%" /><col style="width:26%" /><col style="width:24%" /><col style="width:22%" /><col style="width:16%" /></colgroup>'
|
||
: '<colgroup><col style="width:26%" /><col style="width:30%" /><col style="width:24%" /><col style="width:20%" /></colgroup>'
|
||
return `
|
||
<article class="sheet" data-page="${entry.anchor}"${probe ? ' data-pack-section="gaps"' : ''} aria-label="Lacunas explícitas">
|
||
${unitHeader(report, 'Lacunas explícitas', `O que não foi encontrado${entry.index > 0 ? ` · continuação ${entry.index + 1}` : ''}`, pageNo)}
|
||
<section class="block">
|
||
<div class="block-head"><div><p class="eyebrow">O que não foi encontrado</p><h3 class="section-title">Lacunas explícitas</h3></div><p class="warning-text meta strong">Não completar por memória ou inferência</p></div>
|
||
<table class="document-table">
|
||
<caption class="sr-only">Tabela de lacunas explícitas e próximos passos de apuração</caption>
|
||
${colgroup}
|
||
${tableHead(columns)}
|
||
<tbody>${
|
||
entry.chunk.length
|
||
? entry.chunk.map((unit) => unit.html).join('')
|
||
: `<tr><td colspan="${columns.length}" class="muted">Nenhuma lacuna declarada — confira a cobertura das eras.</td></tr>`
|
||
}</tbody>
|
||
</table>
|
||
${scene && isLast ? `<p class="warning-text small strong">${htmlEscape(scene.alert)}</p>` : ''}
|
||
</section>
|
||
${unitFooter(report, 'Lacunas explícitas · INSUMO INTERNO', pageNo)}
|
||
</article>`
|
||
}
|
||
|
||
const renderGapSheets = ({ report, pack, probe, nextPage }) => {
|
||
const units = gapUnits(report, probe)
|
||
if (units.length === 0) {
|
||
return [
|
||
renderGapSheet({
|
||
report,
|
||
entry: { anchor: 'lacunas', chunk: [], index: 0 },
|
||
probe: false,
|
||
pageNo: nextPage(),
|
||
isLast: true,
|
||
}),
|
||
]
|
||
}
|
||
const entries = packedSection({ key: 'gaps', anchor: 'lacunas', units, pack, probe })
|
||
return entries.map((entry, index) =>
|
||
renderGapSheet({
|
||
report,
|
||
entry,
|
||
probe,
|
||
pageNo: nextPage(),
|
||
isLast: index === entries.length - 1,
|
||
}),
|
||
)
|
||
}
|
||
|
||
const newsUnits = (report, probe) =>
|
||
report.news.map((row, index) => ({
|
||
layout: 'tr',
|
||
html: `<tr${unitAttrs(probe, index, 'tr')}>
|
||
<td class="numeric nowrap">${htmlEscape(formatShortDateBr(row.date))}</td>
|
||
<td>${htmlEscape(row.outlet)}</td>
|
||
<td>${htmlEscape(row.title)}</td>
|
||
<td>${sourceLink(row.url, '(fonte)')}</td>
|
||
</tr>`,
|
||
}))
|
||
|
||
const renderNewsSheet = ({ report, entry, probe, pageNo }) => `
|
||
<article class="sheet" data-page="${entry.anchor}"${probe ? ' data-pack-section="news"' : ''} aria-label="Notícias e documentos consultados">
|
||
${unitHeader(report, 'Notícias e documentos consultados', `Fontes externas datadas${entry.index > 0 ? ` · continuação ${entry.index + 1}` : ''}`, pageNo)}
|
||
<section class="block">
|
||
<p class="eyebrow">Lista clicável</p>
|
||
<h3 class="section-title">Notícias e documentos consultados</h3>
|
||
<table class="document-table">
|
||
<caption class="sr-only">Fontes consultadas: data 12%, veículo 13%, título 47% e link 28%</caption>
|
||
<colgroup><col style="width:12%" /><col style="width:13%" /><col style="width:47%" /><col style="width:28%" /></colgroup>
|
||
${tableHead(['Data', 'Veículo', 'Título', 'Link'])}
|
||
<tbody>${entry.chunk.map((unit) => unit.html).join('')}</tbody>
|
||
</table>
|
||
</section>
|
||
${unitFooter(report, 'Notícias e documentos · INSUMO INTERNO', pageNo)}
|
||
</article>`
|
||
|
||
const renderNewsSheets = ({ report, pack, probe, nextPage }) => {
|
||
const units = newsUnits(report, probe)
|
||
if (units.length === 0) return []
|
||
return packedSection({ key: 'news', anchor: 'noticias', units, pack, probe }).map((entry) =>
|
||
renderNewsSheet({ report, entry, probe, pageNo: nextPage() }),
|
||
)
|
||
}
|
||
|
||
/** Acervo sample (source panel): declared sample with the total carried by the header. */
|
||
const acervoUnits = (report, probe) =>
|
||
report.acervo.items.map((row, index) => {
|
||
const attrs = unitAttrs(probe, index, 'tr')
|
||
if (!report.unit.acervoScene) {
|
||
return {
|
||
layout: 'tr',
|
||
html: `<li${attrs}><strong>${htmlEscape(row.period)}</strong> — ${copyHtml(row.text)} ${sourceLink(row.sourceUrl)}</li>`,
|
||
}
|
||
}
|
||
const identity = report.meta.identity ?? {}
|
||
const canonical = [
|
||
identity.label,
|
||
identity.value ? `<code>${htmlEscape(identity.value)}</code>` : null,
|
||
]
|
||
.filter(Boolean)
|
||
.join(' ')
|
||
return {
|
||
layout: 'tr',
|
||
html: `<tr${attrs}>
|
||
<td class="numeric nowrap">${htmlEscape(row.period)}</td>
|
||
<td>${canonical}</td>
|
||
<td>${copyHtml(row.text)}${row.excerpt ? `<br /><span class="meta">${copyHtml(row.excerpt)}</span>` : ''}</td>
|
||
<td>${scopeBadge('area', null, report.unit)}</td>
|
||
<td>${sourceLink(row.sourceUrl)}</td>
|
||
</tr>`,
|
||
}
|
||
})
|
||
|
||
const acervoStatLabel = (scene, key, fallback) =>
|
||
scene.stats?.find((stat) => stat.key === key)?.label ?? fallback
|
||
|
||
const renderAcervoSheet = ({ report, entry, probe, pageNo, isLast }) => {
|
||
const scene = report.unit.acervoScene
|
||
return `
|
||
<article class="sheet" data-page="${entry.anchor}"${probe ? ' data-pack-section="acervo"' : ''} aria-label="Acervo interno de falas (amostra)">
|
||
${unitHeader(
|
||
report,
|
||
'Acervo interno de falas (amostra)',
|
||
entry.index === 0
|
||
? `Base Teqo 2011+ · ${report.acervo.items.length} de ${report.acervo.recorte} falas do recorte, com link · ${htmlEscape(report.unit.acervoNote ?? '')}`
|
||
: `continuação ${entry.index + 1}`,
|
||
pageNo,
|
||
)}
|
||
${
|
||
scene && entry.index === 0
|
||
? `<section class="acervo-stat-grid">
|
||
<div class="scope-card">
|
||
<p class="eyebrow">${htmlEscape(acervoStatLabel(scene, 'universe', 'Universo recuperado'))}</p>
|
||
<p class="scope-count tabular">${report.acervo.recorte}</p>
|
||
<p class="meta">Falas classificadas no tema canônico do recorte (2011+).</p>
|
||
</div>
|
||
<div class="scope-card card-sector">
|
||
<p class="eyebrow warning-text">${htmlEscape(acervoStatLabel(scene, 'sample', 'Amostra exibida'))}</p>
|
||
<p class="scope-count tabular">${report.acervo.items.length} de ${report.acervo.recorte}</p>
|
||
<p class="meta">Quantidade e critério explícitos; a amostra não finge exaustividade.</p>
|
||
</div>
|
||
</section>`
|
||
: ''
|
||
}
|
||
<section class="block">
|
||
<p class="eyebrow">Falas do mandato sobre o recorte</p>
|
||
<h3 class="section-title">Amostra com link direto</h3>
|
||
${
|
||
scene
|
||
? `<table class="document-table">
|
||
<caption class="sr-only">Amostra do acervo por data, tema canônico, trecho e abrangência</caption>
|
||
<colgroup><col style="width:11%" /><col style="width:20%" /><col style="width:37%" /><col style="width:12%" /><col style="width:20%" /></colgroup>
|
||
${tableHead(scene.columns)}
|
||
<tbody>${entry.chunk.map((unit) => unit.html).join('')}</tbody>
|
||
</table>`
|
||
: `<ul class="tight-list">${entry.chunk.map((unit) => unit.html).join('')}</ul>`
|
||
}
|
||
${
|
||
isLast
|
||
? `<p class="more-note">Amostra das falas mais recentes do recorte (${report.acervo.items.length} de ${report.acervo.recorte}); o acervo completo fica na base Teqo. ${htmlEscape(report.unit.acervoNote ?? '')}</p>`
|
||
: ''
|
||
}
|
||
${scene && isLast ? `<p class="warning-text small strong">${htmlEscape(scene.alert)}</p>` : ''}
|
||
</section>
|
||
${unitFooter(report, 'Acervo interno · amostra', pageNo)}
|
||
</article>`
|
||
}
|
||
|
||
const renderAcervoSheets = ({ report, pack, probe, nextPage }) => {
|
||
const units = acervoUnits(report, probe)
|
||
if (units.length === 0) return []
|
||
const entries = packedSection({ key: 'acervo', anchor: 'acervo', units, pack, probe })
|
||
return entries.map((entry, index) =>
|
||
renderAcervoSheet({
|
||
report,
|
||
entry,
|
||
probe,
|
||
pageNo: nextPage(),
|
||
isLast: index === entries.length - 1,
|
||
}),
|
||
)
|
||
}
|
||
|
||
const renderUnitSourcesPage = (report, pageNo) => `
|
||
<article class="sheet" data-page="fontes" aria-label="${htmlEscape(report.unit.aria?.sources ?? 'Fontes e limites do dossiê institucional')}">
|
||
${unitHeader(report, 'Fontes e limites', 'O que foi consultado e o que não se pode afirmar', pageNo)}
|
||
|
||
${
|
||
report.unit.sourcesHierarchy
|
||
? `<section class="block">
|
||
<p class="eyebrow">Hierarquia de fontes</p>
|
||
<h3 class="section-title">Lastro visível, item a item</h3>
|
||
<table class="document-table">
|
||
<caption class="sr-only">Camadas de fonte, seu uso e seu limite</caption>
|
||
<colgroup><col style="width:24%" /><col style="width:38%" /><col style="width:38%" /></colgroup>
|
||
${tableHead(['Camada', 'Uso', 'Limite'])}
|
||
<tbody>${report.unit.sourcesHierarchy
|
||
.map(
|
||
(row) =>
|
||
`<tr><td><strong>${htmlEscape(row.layer)}</strong></td><td>${htmlEscape(row.use)}</td><td>${htmlEscape(row.limit)}</td></tr>`,
|
||
)
|
||
.join('')}</tbody>
|
||
</table>
|
||
</section>`
|
||
: ''
|
||
}
|
||
|
||
${
|
||
report.acervo?.recorte
|
||
? `<section class="rule-warning">
|
||
${WARNING_ICON}
|
||
<div>
|
||
<p class="rule-title">Acervo interno: ${report.acervo.recorte} falas no recorte.</p>
|
||
<p class="muted">A amostra com link (${report.acervo.items.length} de ${report.acervo.recorte}) está na seção anterior; o acervo completo fica na base Teqo. ${htmlEscape(report.unit.acervoNote ?? '')}</p>
|
||
</div>
|
||
</section>`
|
||
: ''
|
||
}
|
||
|
||
<section class="page1-grid">
|
||
<div class="panel"><p class="eyebrow">Limites de cobertura</p><ul class="tight-list">${report.limits.coverage
|
||
.map((item) => `<li>${htmlEscape(item)}</li>`)
|
||
.join('')}</ul></div>
|
||
<div class="panel"><p class="eyebrow">Regras para uso editorial</p><ul class="tight-list">${report.limits.editorial
|
||
.map((item) => `<li>${htmlEscape(item)}</li>`)
|
||
.join('')}</ul></div>
|
||
</section>
|
||
|
||
<aside class="rule-warning defeso">
|
||
${WARNING_ICON}
|
||
<div>
|
||
<p class="eyebrow warning-text">Nota de defeso eleitoral 2026</p>
|
||
<h3 class="rule-title">Insumo interno não é autorização para publicar.</h3>
|
||
<p class="muted">Publicidade institucional e propaganda eleitoral obedecem a regimes diferentes. Antes de adaptar qualquer trecho para peça externa, submeta texto, imagem, autoria, período e canal à revisão responsável. Este dossiê organiza evidências; não substitui análise jurídica nem editorial.</p>
|
||
</div>
|
||
</aside>
|
||
|
||
${unitFooter(report, 'Fontes e limites · INSUMO INTERNO', pageNo)}
|
||
</article>`
|
||
|
||
const renderUnitSynthesisPage = (report, pageNo) => `
|
||
<article class="sheet" data-page="sintese" aria-label="${htmlEscape(report.unit.aria?.synthesis ?? 'Síntese da atuação institucional')}">
|
||
${unitHeader(report, 'Síntese do que foi localizado', 'Leitura dos números com lastro — contagens por recorte, nunca soma entre eles', pageNo)}
|
||
<section class="block">
|
||
<p class="eyebrow">Leitura rápida</p>
|
||
<h3 class="section-title">O que os pontos com fonte mostram</h3>
|
||
<ul class="synthesis-lines">${report.synthesis.lines.map((line) => `<li>${htmlEscape(line)}</li>`).join('')}</ul>
|
||
</section>
|
||
<section class="synthesis-grid">
|
||
<div class="panel">
|
||
<p class="eyebrow">Cobertura das eras</p>
|
||
<ul class="tight-list">${report.eras
|
||
.map(
|
||
(era) =>
|
||
`<li><strong>${htmlEscape(era.label)}</strong> — ${
|
||
era.empty
|
||
? 'sem evidência suficiente (lacuna explícita)'
|
||
: report.unit.eraCoverageFromLedger
|
||
? `${report.synthesis.byEra.find((row) => row.key === era.id)?.count ?? 0} pontos com fonte`
|
||
: `${listOf(era.numbers).length + listOf(era.actions).length} pontos nesta seção`
|
||
}</li>`,
|
||
)
|
||
.join('')}</ul>
|
||
</div>
|
||
<div class="panel panel-warning">
|
||
<p class="eyebrow warning-text">Guardas de leitura</p>
|
||
<ul class="tight-list">
|
||
<li>Contagens por recorte: ${htmlEscape(report.unit.copy?.synthesisGuard ?? 'setor e rede nunca somados à instituição.')}</li>
|
||
<li>Cada valor mantém a fase — empenho ≠ pagamento.</li>
|
||
<li>Sem fonte, não publica: ausência vira lacuna explícita.</li>
|
||
</ul>
|
||
</div>
|
||
</section>
|
||
|
||
${
|
||
report.synthesis.moneyByPhase.length
|
||
? `<section class="block">
|
||
<p class="eyebrow">Recursos por fase</p>
|
||
<h3 class="section-title">Valores localizados, sem consolidar fases</h3>
|
||
<table class="document-table">
|
||
<caption class="sr-only">Valores localizados por fase de execução</caption>
|
||
<colgroup><col style="width:40%" /><col style="width:20%" /><col style="width:40%" /></colgroup>
|
||
${tableHead(['Fase', 'Itens', 'Valor'])}
|
||
<tbody>${report.synthesis.moneyByPhase
|
||
.map(
|
||
(row) =>
|
||
`<tr><td>${phaseBadge(row.key)}</td><td class="numeric">${row.count}</td><td class="numeric">${htmlEscape(formatMoneyCompact(row.amount))}</td></tr>`,
|
||
)
|
||
.join('')}</tbody>
|
||
</table>
|
||
<p class="warning-text small strong">Soma por fase apenas; fases não são equivalentes entre si.</p>
|
||
</section>`
|
||
: ''
|
||
}
|
||
${unitFooter(report, 'Síntese · INSUMO INTERNO', pageNo)}
|
||
</article>`
|
||
|
||
const renderUnitChartsPage = (report, pageNo) => {
|
||
const synthesis = report.synthesis
|
||
const executedPhases = new Set(['pago', 'liquidado', 'autorizado', 'empenhado'])
|
||
const executedYears = synthesis.moneyByYear.filter((row) =>
|
||
row.segments.some((segment) => executedPhases.has(segment.key)),
|
||
)
|
||
return `
|
||
<article class="sheet" data-page="graficos" aria-label="${htmlEscape(report.unit.aria?.charts ?? 'Gráficos consolidados da atuação institucional')}">
|
||
${unitHeader(report, 'Gráficos consolidados', `Como os recursos e as entregas chegaram ${report.unit.notSummedTo ?? 'à instituição'} — valores sempre com a fase`, pageNo)}
|
||
<div class="chart-grid">
|
||
<div class="chart-card">
|
||
<p class="chart-title">Recursos com execução por ano (R$)</p>
|
||
${
|
||
executedYears.length
|
||
? stackedColumnChart({
|
||
rows: executedYears.map((row) => ({ ...row, value: row.amount })),
|
||
height: 136,
|
||
format: (row) => formatMoneyCompact(row.value),
|
||
fullYearLabel: Boolean(report.unit.fullYearAxis),
|
||
})
|
||
: '<p class="muted small">Nenhum valor com fase de execução informada.</p>'
|
||
}
|
||
<p class="chart-legend">verde: pago/liquidado · âmbar: autorizado/empenhado — empenho não é pagamento.</p>
|
||
</div>
|
||
<div class="chart-card">
|
||
<p class="chart-title">Propostas e articulações sem fase informada (R$)</p>
|
||
${
|
||
synthesis.moneyProposals.length
|
||
? valueList({
|
||
rows: synthesis.moneyProposals.map((row) => ({
|
||
label: `${row.year ?? '—'} · ${row.label}`,
|
||
value: row.amount,
|
||
})),
|
||
format: (row) => formatMoneyCompact(row.value),
|
||
})
|
||
: '<p class="muted small">Nenhuma proposta sem fase informada.</p>'
|
||
}
|
||
<p class="chart-legend">propostas e convênios citados nas fontes, sem fase de execução declarada.</p>
|
||
</div>
|
||
<div class="chart-card">
|
||
<p class="chart-title">Abrangência: ${report.unit.spheres
|
||
.map((sphere) => htmlEscape(report.unit.sphereLabels[sphere]))
|
||
.join(' × ')}</p>
|
||
${barChart({
|
||
rows: synthesis.bySphere.map((row) => ({
|
||
label: row.label,
|
||
count: row.count,
|
||
color: CHART_COLORS[row.key],
|
||
})),
|
||
})}
|
||
<p class="chart-legend">${htmlEscape(report.unit.sumGuardNote ?? '')}</p>
|
||
</div>
|
||
<div class="chart-card">
|
||
<p class="chart-title">${htmlEscape(report.unit.byYearTitle ?? 'Trajetória: pontos com fonte por ano (20xx)')}</p>
|
||
${columnChart({
|
||
rows: synthesis.byYear,
|
||
height: 156,
|
||
fullYearLabel: Boolean(report.unit.fullYearAxis),
|
||
})}
|
||
</div>
|
||
<div class="chart-card">
|
||
<p class="chart-title">${htmlEscape(report.unit.byAreaTitle ?? 'Áreas com mais registros')}</p>
|
||
${barChart({
|
||
rows: synthesis.byArea.slice(0, 6).map((row) => ({ label: row.key, count: row.count })),
|
||
})}
|
||
</div>
|
||
<div class="chart-card">
|
||
<p class="chart-title">Lacunas declaradas por era</p>
|
||
${barChart({
|
||
rows: synthesis.gapsByEra.map((row) => ({ label: row.label, count: row.count })),
|
||
})}
|
||
<p class="chart-legend">ausência é resultado: cada lacuna é uma tarefa de apuração.</p>
|
||
</div>
|
||
<div class="chart-card">
|
||
<p class="chart-title">Acervo interno (read-only)</p>
|
||
<p class="scope-count tabular">${synthesis.totals.acervoRecorte}</p>
|
||
<p class="meta">falas no recorte temático (2011+)</p>
|
||
<ul class="tight-list">
|
||
<li>Amostra de ${synthesis.totals.acervo} falas com link direto (seção do acervo).</li>
|
||
<li>Temas: ${report.acervo?.topics?.join(', ') || '—'} — ${htmlEscape(report.unit.acervoNote ?? '')}</li>
|
||
</ul>
|
||
</div>
|
||
</div>
|
||
${unitFooter(report, 'Gráficos · INSUMO INTERNO', pageNo)}
|
||
</article>`
|
||
}
|
||
|
||
const renderUnitOpeningPage = (report, pageNo) => {
|
||
const opening = report.opening
|
||
if (!opening?.paragraphs?.length) return ''
|
||
return `
|
||
<article class="sheet" data-page="carta" aria-label="A contribuição de Solla para a ${htmlEscape(report.unit.contributionLabel ?? 'instituição')}">
|
||
${unitHeader(report, opening.title, 'Redação de síntese sobre os pontos com fonte — nada aqui acrescenta fato novo', pageNo)}
|
||
<section class="block">
|
||
${opening.paragraphs.map((paragraph) => `<p class="opening-paragraph">${copyHtml(paragraph)}</p>`).join('')}
|
||
</section>
|
||
<aside class="rule-warning">
|
||
${WARNING_ICON}
|
||
<div>
|
||
<p class="eyebrow warning-text">Como ler</p>
|
||
<p class="muted">Cada afirmação desta redação tem lastro em um item datado do dossiê (seções por era e de abrangência). ${
|
||
opening.authored
|
||
? 'A redação foi escrita a partir dos itens com fonte.'
|
||
: 'Sem redação autoral disponível: o texto repete a leitura dos números.'
|
||
} Onde não há fonte, o dossiê registra lacuna — a redação não preenche por inferência.</p>
|
||
</div>
|
||
</aside>
|
||
${unitFooter(report, 'A contribuição · INSUMO INTERNO', pageNo)}
|
||
</article>`
|
||
}
|
||
|
||
const renderSubjectDossierHtml = (report, { pack = null, probe = false } = {}) => {
|
||
const buildParts = () => {
|
||
let page = 0
|
||
const nextPage = () => ++page
|
||
const parts = []
|
||
parts.push(renderSubjectCover(report))
|
||
page += 1
|
||
const opening = renderUnitOpeningPage(report, page + 1)
|
||
if (opening) {
|
||
parts.push(opening)
|
||
page += 1
|
||
}
|
||
parts.push(renderSubjectSummary(report, ++page))
|
||
parts.push(renderUnitSynthesisPage(report, nextPage()))
|
||
parts.push(renderUnitChartsPage(report, nextPage()))
|
||
for (const era of report.eras) {
|
||
parts.push(
|
||
...renderEraSheets({
|
||
report,
|
||
era,
|
||
pack,
|
||
probe,
|
||
nextPage,
|
||
includeHonors: !report.unit.honorsSheet,
|
||
}),
|
||
)
|
||
}
|
||
parts.push(renderSubjectScope(report, nextPage()))
|
||
for (const list of report.reach.lists) {
|
||
parts.push(...renderSubjectScopeListSheets({ report, list, pack, probe, nextPage }))
|
||
}
|
||
if (report.unit.honorsSheet) {
|
||
parts.push(...renderSubjectHonorSheets({ report, pack, probe, nextPage }))
|
||
}
|
||
parts.push(...renderGapSheets({ report, pack, probe, nextPage }))
|
||
parts.push(...renderNewsSheets({ report, pack, probe, nextPage }))
|
||
parts.push(...renderAcervoSheets({ report, pack, probe, nextPage }))
|
||
parts.push(renderUnitSourcesPage(report, nextPage()))
|
||
return parts
|
||
}
|
||
|
||
// Two passes: the shell's `pág. N/M` needs the final sheet count, and the
|
||
// count only exists after the pack plan is applied.
|
||
report.meta.pageTotal = 0
|
||
let parts = buildParts()
|
||
report.meta.pageTotal = parts.length
|
||
parts = buildParts()
|
||
|
||
return `<!doctype html>
|
||
<html lang="pt-BR">
|
||
<head><meta charset="utf-8"><title>${htmlEscape(report.meta.title)} — ${htmlEscape(report.meta.subjectName)}</title><style>${INSTITUTION_PRINT_CSS}</style>${probe ? `<style>${INSTITUTION_PROBE_CSS}</style>` : ''}</head>
|
||
<body>
|
||
${parts.join('\n')}
|
||
</body>
|
||
</html>`
|
||
}
|
||
|
||
const subjectMdEra = (era) => {
|
||
const lines = [
|
||
`## ${era.label} (${era.period})`,
|
||
'',
|
||
`_${era.method}_`,
|
||
'',
|
||
`Trilha: ${era.recovery}`,
|
||
]
|
||
if (era.narrative || era.summary) {
|
||
lines.push('', `> ${era.narrative ?? era.summary}`)
|
||
}
|
||
if (era.empty) {
|
||
lines.push(
|
||
'',
|
||
`> Lacuna explícita: nenhuma fonte suficiente foi localizada para a ${era.label}. Isso não prova ausência — prova que o dossiê ainda não pode afirmar.`,
|
||
)
|
||
return lines.join('\n')
|
||
}
|
||
if (era.numbers.items.length) {
|
||
lines.push(
|
||
'',
|
||
'| Objeto | Valor | Ano | Fase | Abrangência | Fonte |',
|
||
'| --- | --- | --- | --- | --- | --- |',
|
||
)
|
||
for (const row of era.numbers.items) {
|
||
lines.push(
|
||
`| ${row.object} | ${row.value ?? '—'} | ${row.year ?? '—'} | ${dossierPhaseLabel(row.phase)} | ${row.sphere} | ${row.sourceUrl ?? '—'} |`,
|
||
)
|
||
}
|
||
if (era.numbers.omitted > 0)
|
||
lines.push('', `_e mais ${era.numbers.omitted} item(ns) de valor com fonte._`)
|
||
}
|
||
for (const action of era.actions.items) {
|
||
lines.push(
|
||
'',
|
||
`- **${stripInlineSources(action.title)}** (${action.sphere}${action.year ? ` · ${action.year}` : ''})${action.detail ? ` — ${stripInlineSources(action.detail)}` : ''} [fonte](${action.sourceUrl})`,
|
||
)
|
||
}
|
||
if (era.actions.omitted > 0)
|
||
lines.push('', `_e mais ${era.actions.omitted} item(ns) de atuação com fonte._`)
|
||
for (const honor of era.honors.items)
|
||
lines.push('', `- Honraria: ${stripInlineSources(honor.text)} [fonte](${honor.sourceUrl})`)
|
||
if (era.honors.omitted > 0)
|
||
lines.push('', `_e mais ${era.honors.omitted} honraria(s) com fonte._`)
|
||
return lines.join('\n')
|
||
}
|
||
|
||
const renderSubjectDossierMd = (report) => {
|
||
const lines = [
|
||
`# ${report.meta.title} — ${report.meta.subjectName}`,
|
||
'',
|
||
`**Identificação:** ${report.meta.identityBadges.join(' · ')}`,
|
||
`**Gerado em:** ${formatDateTimeBr(report.meta.generatedAt)} · **Base Teqo (read-only) lida em:** ${report.meta.readAtLabel}`,
|
||
'',
|
||
'> INSUMO INTERNO — não circular, não publicar. Documento de trabalho.',
|
||
'',
|
||
`## Identificação`,
|
||
'',
|
||
...report.unit
|
||
.identityRows(report.meta.identity ?? {})
|
||
.map(([key, value]) => `- **${key}:** ${value}`),
|
||
'',
|
||
]
|
||
if (report.opening?.paragraphs?.length) {
|
||
lines.push(`## ${report.opening.title}`, '')
|
||
for (const paragraph of report.opening.paragraphs) lines.push(paragraph, '')
|
||
lines.push(
|
||
`_Redação de síntese sobre os pontos com fonte; ${
|
||
report.opening.authored
|
||
? 'escrita a partir dos itens datados do dossiê'
|
||
: 'repetição da leitura dos números (sem redação autoral disponível)'
|
||
} — nada aqui acrescenta fato novo._`,
|
||
'',
|
||
)
|
||
}
|
||
lines.push('## Linha do tempo documentada', '')
|
||
if (report.page1.timeline.items.length === 0) lines.push('_Nenhum vínculo datado com fonte._')
|
||
for (const row of report.page1.timeline.items) {
|
||
lines.push(
|
||
`- **${row.period || '—'}** — ${stripInlineSources(row.role)} (${row.source ?? ''}) [fonte](${row.url})`,
|
||
)
|
||
}
|
||
if (report.page1.timeline.omitted > 0)
|
||
lines.push(`- _e mais ${report.page1.timeline.omitted} vínculo(s) com fonte._`)
|
||
|
||
if (report.page1.deliveries.items.length) {
|
||
lines.push('', '## Principais entregas localizadas', '')
|
||
for (const row of report.page1.deliveries.items) {
|
||
lines.push(
|
||
`- **${stripInlineSources(row.title)}** · _${row.sphere}_${row.value ? ` · ${row.value}` : ''}${row.phase ? ` (${dossierPhaseLabel(row.phase)})` : ''}${row.detail ? ` — ${stripInlineSources(row.detail)}` : ''} [fonte](${row.sourceUrl})`,
|
||
)
|
||
}
|
||
if (report.page1.deliveries.omitted > 0)
|
||
lines.push(`- _e mais ${report.page1.deliveries.omitted} entrega(s) com fonte._`)
|
||
lines.push('', '_Autorizado ≠ empenhado ≠ liquidado ≠ pago. A fase acompanha cada valor._')
|
||
}
|
||
|
||
for (const era of report.eras) lines.push('', subjectMdEra(era))
|
||
|
||
if (report.synthesis?.lines?.length) {
|
||
lines.push('', '## Síntese do que foi localizado', '')
|
||
for (const line of report.synthesis.lines) lines.push(`- ${line}`)
|
||
lines.push('', '### Números por recorte', '')
|
||
lines.push(
|
||
`| Era | ${report.unit.spheres.map((sphere) => capitalize(report.unit.sphereLabels[sphere])).join(' | ')} | Total |`,
|
||
`| --- | ${report.unit.spheres.map(() => '---').join(' | ')} | --- |`,
|
||
)
|
||
for (const row of report.synthesis.byEraSphere) {
|
||
const countFor = (key) => row.segments.find((segment) => segment.key === key)?.count ?? 0
|
||
lines.push(
|
||
`| ${row.label} | ${report.unit.spheres.map((sphere) => countFor(sphere)).join(' | ')} | ${row.segments.reduce((sum, segment) => sum + segment.count, 0)} |`,
|
||
)
|
||
}
|
||
if (report.synthesis.byPhase.length) {
|
||
lines.push('', '### Itens com valor por fase', '')
|
||
for (const row of report.synthesis.byPhase) lines.push(`- ${row.label}: ${row.count}`)
|
||
}
|
||
if (report.synthesis.byArea.length) {
|
||
lines.push('', '### Temas com mais registros', '')
|
||
for (const row of report.synthesis.byArea) lines.push(`- ${row.key}: ${row.count}`)
|
||
}
|
||
if (report.synthesis.acervoByYear.length) {
|
||
lines.push('', '### Acervo interno por ano', '')
|
||
lines.push(report.synthesis.acervoByYear.map((row) => `${row.key}: ${row.count}`).join(' · '))
|
||
}
|
||
}
|
||
|
||
lines.push(
|
||
'',
|
||
`## Abrangência: ${report.unit.spheres.map((sphere) => report.unit.sphereLabels[sphere]).join(' × ')}`,
|
||
'',
|
||
)
|
||
lines.push(`> ${report.reach.ruleTitle}`, `> ${report.reach.ruleBody}`, '')
|
||
for (const list of report.reach.lists) {
|
||
lines.push(`### ${list.label}`, '')
|
||
if (list.items.length === 0) lines.push('- Nenhum item com fonte.')
|
||
for (const item of list.items) {
|
||
lines.push(
|
||
`- **${stripInlineSources(briefOr(item.brief, item.title))}** (${item.sphere}${item.year ? ` · ${item.year}` : ''})${noteOr(item.brief, item.evidence) ? ` — ${stripInlineSources(noteOr(item.brief, item.evidence))}` : ''} [fonte](${item.sourceUrl})`,
|
||
)
|
||
}
|
||
if (list.omitted > 0) lines.push(`- _e mais ${list.omitted} item(ns) com fonte._`)
|
||
lines.push('')
|
||
}
|
||
|
||
if (report.acervo?.items?.length) {
|
||
lines.push(
|
||
'',
|
||
`## Acervo interno (read-only)`,
|
||
'',
|
||
`_Amostra de ${report.acervo.items.length} de ${report.acervo.recorte} falas do recorte temático, com link; o acervo completo fica na base Teqo. ${report.unit.copy.mdAcervoNote}_`,
|
||
'',
|
||
)
|
||
for (const row of report.acervo.items) {
|
||
lines.push(
|
||
`- **${row.period}** — ${stripInlineSources(row.text)}${row.excerpt ? ` _(${stripInlineSources(row.excerpt)})_` : ''} [fonte](${row.sourceUrl})`,
|
||
)
|
||
}
|
||
if (report.acervo.omitted > 0)
|
||
lines.push(`- _e mais ${report.acervo.omitted} fala(s) com fonte na base._`)
|
||
}
|
||
|
||
lines.push('', '## Lacunas explícitas', '')
|
||
if (report.gaps.length === 0) lines.push('_Nenhuma lacuna declarada._')
|
||
for (const gap of report.gaps) {
|
||
lines.push(`- **${gap.label}:** ${gap.reason} → ${gap.nextStep}`)
|
||
}
|
||
|
||
if (report.news.length) {
|
||
lines.push('', '## Notícias e documentos consultados', '')
|
||
for (const row of report.news) {
|
||
lines.push(`- ${formatDateBr(row.date)} · ${row.outlet} — ${row.title} — ${row.url}`)
|
||
}
|
||
}
|
||
|
||
lines.push(
|
||
'',
|
||
'## Limites de cobertura',
|
||
'',
|
||
...report.limits.coverage.map((item) => `- ${item}`),
|
||
'',
|
||
'## Regras para uso editorial',
|
||
'',
|
||
...report.limits.editorial.map((item) => `- ${item}`),
|
||
'',
|
||
'## Nota de defeso eleitoral 2026',
|
||
'',
|
||
'Insumo interno não é autorização para publicar. Publicidade institucional e propaganda eleitoral obedecem a regimes diferentes. Antes de adaptar qualquer trecho para peça externa, submeta texto, imagem, autoria, período e canal à revisão responsável.',
|
||
'',
|
||
`_Documento interno de campanha, datado (${formatDateTimeBr(report.meta.generatedAt)}). O dado envelhece: confira a data de leitura da base._`,
|
||
)
|
||
return `${lines.join('\n')}\n`
|
||
}
|
||
|
||
/** Dispatch: subject-shaped reports (institution/theme) render with the shared subject sections. */
|
||
export const renderDossierHtml = (report, options) =>
|
||
isSubjectUnit(report.unit)
|
||
? renderSubjectDossierHtml(report, options)
|
||
: renderMunicipalityDossierHtml(report, options)
|
||
|
||
export const renderDossierMd = (report) =>
|
||
isSubjectUnit(report.unit) ? renderSubjectDossierMd(report) : renderMunicipalityDossierMd(report)
|