const { useEffect, useMemo, useRef, useState } = React; const API_BASE = window.DIARIO_API_BASE || 'https://andersonstaub.com/apps/diario/api'; const WMS_SOURCES = Array.isArray(window.DIARIO_WMS_SOURCES) ? window.DIARIO_WMS_SOURCES : []; // CAR (SICAR) só tem camada POR ESTADO, sem uma camada nacional única // (confirmado via GetCapabilities) — e pedir os 27 estados numa MESMA // requisição WMS leva ~50s (testado ao vivo), inviável pra tile de mapa. // Por isso vira um grupo: 27 fontes WMS próprias, Leaflet desenha cada // uma em paralelo, mas no painel aparece como um botão só. const _CAR_UFS = ['ac', 'al', 'am', 'ap', 'ba', 'ce', 'df', 'es', 'go', 'ma', 'mg', 'ms', 'mt', 'pa', 'pb', 'pe', 'pi', 'pr', 'rj', 'rn', 'ro', 'rr', 'rs', 'sc', 'se', 'sp', 'to']; const _CAR_WMS_URL = 'https://geoserver.car.gov.br/geoserver/sicar/wms'; // Desmatamento (DETER, INPE/TerraBrasilis) — pedido do Anderson // 2026-09-06. Testado ao vivo: GeoServer do TerraBrasilis serve WFS E // WMS a partir do MESMO layer, então segue o padrão já usado pro CAR // (tile WMS, não busca de feature por bbox) em vez do padrão da camada // "Autos de infração" (proxy backend + GeoJSON por ponto) — desmatamento // é POLÍGONO denso, mais parecido com CAR do que com um ponto de // infração. DETER só cobre Amazônia Legal e Cerrado (os dois únicos // biomas com programa de alerta quase-em-tempo-real do INPE — PRODES/os // outros biomas não têm esse tipo de camada) — 2 fontes, uma por bioma, // mesmo espírito "nacional dentro do que existe" do resto do produto. const _DETER_URL = 'https://terrabrasilis.dpi.inpe.br/geoserver'; // Grupos de camadas WMS do mapa geral — cada grupo é UM botão no painel, // mesmo contendo várias fontes WMS por baixo. const WMS_GROUPS = [ { chave: 'wmsgroup:car', nome: 'CAR — imóveis rurais (todos os estados)', curto: 'CAR', origem: 'Camada padrão do sistema', fontes: _CAR_UFS.map((uf) => ({ nome: `CAR ${uf.toUpperCase()}`, url: _CAR_WMS_URL, layers: `sicar:sicar_imoveis_${uf}`, })), }, { chave: 'wmsgroup:desmatamento', nome: 'Desmatamento (DETER — Amazônia + Cerrado)', curto: 'Desmatamento', origem: 'INPE / TerraBrasilis', fontes: [ { nome: 'DETER Amazônia Legal', url: `${_DETER_URL}/deter-amz/wms`, layers: 'deter-amz:deter_amz' }, { nome: 'DETER Cerrado', url: `${_DETER_URL}/deter-cerrado/wms`, layers: 'deter-cerrado:deter_cerrado' }, ], }, ]; // Camada WMS do licenciamento do IMA-SC — não vira um botão próprio no // painel: liga junto com a camada de pontos "Licenças ambientais" (ver // POINT_LAYERS abaixo, campo wmsFonte), pra aparecer como uma coisa só // pro usuário, mesmo sendo dois mecanismos de desenho diferentes. const IMA_SC_LICENCIAMENTO_WMS = { nome: 'IMA-SC Licenciamento (experimental)', url: 'https://geo.ima.sc.gov.br/geoserver/ima/wms', layers: 'ima:sinfat_licenciamento_ima_pontos', }; // Camadas de PONTOS do banco próprio (não são WMS externo — o site busca // GeoJSON via geo/, filtrado por bbox, ver diarios/CLAUDE.md). const POINT_LAYERS = [ { id: 'empresas', nome: 'Empresas', curto: 'Empresas', origem: 'Base própria (Receita + coordenada CTF/APP)', endpoint: 'geo/empresas', cor: '#3f74b8', popup: (p) => [ `${p.razao_social || p.nome_fantasia || 'Empresa'}`, p.nome_fantasia && p.nome_fantasia !== p.razao_social ? p.nome_fantasia : '', [p.municipio, p.uf].filter(Boolean).join(' / '), p.cnae_descricao || '', p.situacao_cadastral || '', p.cnpj || '', ], }, { id: 'licencas', nome: 'Licenças ambientais', curto: 'Licenças', origem: 'Base própria + IMA-SC (SC)', endpoint: 'geo/licencas', cor: '#1f8c4e', wmsFonte: IMA_SC_LICENCIAMENTO_WMS, popup: (p) => [ `${p.nome_empreendedor || 'Nome não informado'}`, [p.municipio, p.uf].filter(Boolean).join(' / '), p.tipo_licenca || p.tipologia || '', p.situacao || '', p.cnpj_cpf || '', `Fonte: ${p.fonte || '-'}`, ], }, { id: 'ibama_infracao', nome: 'Autos de infração (IBAMA)', curto: 'Infrações', origem: 'Consulta ao vivo (PAMGIA/IBAMA)', endpoint: 'geo/ibama-infracao', cor: '#c43f3f', popup: (p) => [ `${p.nome_infrator || 'Infrator não informado'}`, p.cpf_cnpj_infrator || '', [p.municipio, p.uf].filter(Boolean).join(' / '), p.des_infracao || p.tipo_auto || '', p.val_auto_infracao ? `Multa: R$ ${Number(p.val_auto_infracao).toLocaleString('pt-BR', { minimumFractionDigits: 2 })}` : '', p.dat_hora_auto_infracao ? `Data: ${new Date(p.dat_hora_auto_infracao).toLocaleDateString('pt-BR')}` : '', p.des_status_formulario || '', ], }, ]; const THEME_STORAGE_KEY = 'diario_theme_pref'; // Etapas mostradas girando durante a geração do relatório em PDF // (2026-09-08) — refletem o pipeline real (geo/overlap.py + // diarios/pdf_report.py, ver dashboard.py::api_pdf_geo no projeto news), // não são só decoração genérica. const PDF_ETAPAS = [ 'Consultando DETER, PRODES e IBAMA...', 'Verificando CAR e ANM...', 'Buscando imagem de satélite (Sentinel-2)...', 'Montando o PDF...', ]; async function apiRequest(path, { method = 'GET', token = '', body = null } = {}) { const headers = {}; if (token) headers.Authorization = `Bearer ${token}`; if (body !== null) headers['Content-Type'] = 'application/json'; const response = await fetch(`${API_BASE}/${path}`, { method, headers, body: body !== null ? JSON.stringify(body) : undefined, }); const data = await response.json().catch(() => ({ status: 'error', message: 'Resposta inválida da API.' })); if (!response.ok || data.status !== 'ok') { throw new Error(data.message || 'Falha na requisição.'); } return data.data; } function formatMoney(cents) { return `R$ ${(Number(cents || 0) / 100).toFixed(2).replace('.', ',')}`; } // Id da matéria vindo do link do canal de WhatsApp: /?materia=123 function getMateriaIdFromUrl() { const raw = new URLSearchParams(window.location.search).get('materia'); const id = Number.parseInt(raw || '', 10); return Number.isInteger(id) && id > 0 ? id : null; } // Referência externa do pagamento ao voltar do Checkout Pro do Mercado // Pago: /?checkout=retorno&ref=diario_123_... function getCheckoutRefFromUrl() { const params = new URLSearchParams(window.location.search); if (params.get('checkout') !== 'retorno') return null; const ref = params.get('ref'); return ref ? ref : null; } // Preço com "de/por" (pré-lançamento, 2026-09-07) — reaproveitado no hero // deslogado, na aba Compras e na seção Valores do Sobre. function PrecoComDesconto({ produto }) { const temDesconto = produto.preco_original_centavos && produto.preco_original_centavos > produto.preco_centavos; return (
{temDesconto && ( {formatMoney(produto.preco_original_centavos)} )} {formatMoney(produto.preco_centavos)}
); } function toDisplayDateOnly(value) { if (!value) return ''; const parsed = new Date(value.includes('T') ? value : value.replace(' ', 'T')); if (Number.isNaN(parsed.getTime())) return value; return parsed.toLocaleDateString('pt-BR'); } // Card da matéria. A leitura é aberta; o cadastro é convite no rodapé, não muro. function MateriaCard({ materia, onFechar }) { if (!materia) return null; const rodape = [materia.fonte, toDisplayDateOnly(materia.data_publicacao)].filter(Boolean).join(' · '); const paragrafos = String(materia.materia || '').split(/\n{2,}/).filter((p) => p.trim()); // url_origem e o PDF/edicao exata; fonte_url e o portal e serve de fallback // para as materias geradas antes de a origem passar a ser gravada. const origemUrl = materia.url_origem || materia.fonte_url || ''; return (
{materia.alta_relevancia && Alta relevância}

{materia.titulo}

{rodape &&
{rodape}
}
{onFechar && ( )}
{materia.resumo &&

{materia.resumo}

}
{paragrafos.map((p, i) =>

{p}

)} {Array.isArray(materia.tags) && materia.tags.length > 0 && (
{materia.tags.map((t) => `#${t}`).join(' ')}
)} {origemUrl && (
Ler no diário oficial
Publicação de origem{materia.fonte ? ` (${materia.fonte})` : ''}. O texto acima é um resumo gerado por IA; em caso de divergência, vale o documento oficial.
)} {!materia.logado && (
Crie sua conta para acompanhar CNPJs, nomes e coordenadas nos diários oficiais e receber aviso assim que aparecerem.
)}
); } function toDisplayDate(value) { if (!value) return '-'; const parsed = new Date(value.includes('T') ? value : value.replace(' ', 'T')); if (Number.isNaN(parsed.getTime())) return value; return parsed.toLocaleString('pt-BR'); } function resolveTheme(pref) { if (pref === 'light' || pref === 'dark') return pref; return window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light'; } // Área aproximada do polígono desenhado, em km² — mesmo cálculo // (projeção local + shoelace) que o backend refaz e valida de verdade; // aqui é só pra avisar o usuário na hora, sem esperar o round-trip. function areaPoligonoKm2(pontos) { const n = pontos.length; if (n < 3) return 0; const latMedia = pontos.reduce((s, p) => s + p.lat, 0) / n; const mPorGrauLat = 111320; const mPorGrauLon = 111320 * Math.cos((latMedia * Math.PI) / 180); const xy = pontos.map((p) => [p.lon * mPorGrauLon, p.lat * mPorGrauLat]); let area = 0; for (let i = 0; i < n; i++) { const [x1, y1] = xy[i]; const [x2, y2] = xy[(i + 1) % n]; area += x1 * y2 - x2 * y1; } return Math.abs(area) / 2 / 1000000; } function StatusBadge({ status }) { if (status === 'pago' || status === 'concluida' || status === 'ativo') { return {status}; } if (status === 'pendente' || status === 'inativo') { return {status}; } return {status || '-'}; } function GeoMap({ latitude, longitude, wmsSources, wmsOpacity, onCenterChange, big = false, pointLayersAtivos = {}, token = '', poligono = null, onPoligonoChange = null, }) { const mapRef = useRef(null); const markerRef = useRef(null); const wmsLayerRefs = useRef([]); const pointLayerGroupsRef = useRef({}); const pointLayersAtivosRef = useRef(pointLayersAtivos); const tokenRef = useRef(token); // Desenho de polígono (2026-09-08) — mesmo mecanismo do LocationSearchMap // (leaflet-draw), adicionado aqui porque o GeoMap é quem tem as camadas // WMS que o relatório em PDF do "Mapa geral" precisa mostrar. Diferente // de LocationSearchMap, o ícone de desenho fica NO PRÓPRIO mapa // (L.Control.Draw, canto superior esquerdo) em vez de um botão React // externo — pedido do Anderson depois de ver o botão duplicando o que o // leaflet-draw já oferece pronto. const drawnItemsRef = useRef(null); const onPoligonoChangeRef = useRef(onPoligonoChange); useEffect(() => { onPoligonoChangeRef.current = onPoligonoChange; }, [onPoligonoChange]); useEffect(() => { if (mapRef.current) return; const map = L.map('diario-map-canvas').setView([latitude, longitude], 10); mapRef.current = map; L.tileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', { maxZoom: 19, attribution: '© OpenStreetMap contributors', }).addTo(map); // Só o ponto (2026-09-08, pedido do Anderson: "parar de exibir o // círculo... exibir apenas o ponto") — o círculo de raio some daqui; // quando a busca é por área desenhada, o próprio polígono já aparece // no mapa via drawnItems abaixo. markerRef.current = L.marker([latitude, longitude]).addTo(map); POINT_LAYERS.forEach((def) => { pointLayerGroupsRef.current[def.id] = L.layerGroup(); }); const drawnItems = new L.FeatureGroup(); map.addLayer(drawnItems); drawnItemsRef.current = drawnItems; const drawControl = new L.Control.Draw({ position: 'topleft', draw: { polygon: { showArea: true, shapeOptions: { color: '#3f74b8' } }, marker: false, circle: false, circlemarker: false, rectangle: false, polyline: false, }, edit: false, }); map.addControl(drawControl); map.on('moveend', () => { const center = map.getCenter(); onCenterChange({ latitude: Number(center.lat.toFixed(6)), longitude: Number(center.lng.toFixed(6)) }); POINT_LAYERS.forEach((def) => { if (pointLayersAtivosRef.current[def.id]) refreshPointLayer(def); }); }); map.on(L.Draw.Event.CREATED, (e) => { drawnItems.clearLayers(); drawnItems.addLayer(e.layer); const pontos = e.layer.getLatLngs()[0].map((p) => ({ lat: Number(p.lat.toFixed(6)), lon: Number(p.lng.toFixed(6)) })); if (onPoligonoChangeRef.current) onPoligonoChangeRef.current(pontos); }); }, []); useEffect(() => { if (!poligono && drawnItemsRef.current) { drawnItemsRef.current.clearLayers(); } }, [poligono]); async function refreshPointLayer(def) { const map = mapRef.current; const layer = pointLayerGroupsRef.current[def.id]; if (!map || !layer) return; try { const b = map.getBounds(); const bbox = [b.getWest(), b.getSouth(), b.getEast(), b.getNorth()].map((v) => v.toFixed(6)).join(','); const data = await apiRequest(`${def.endpoint}?bbox=${bbox}`, { token: tokenRef.current }); const features = (data.geojson && data.geojson.features) || []; layer.clearLayers(); features.forEach((f) => { const [lon, lat] = f.geometry.coordinates; const p = f.properties || {}; const marker = L.circleMarker([lat, lon], { radius: 6, color: def.cor, fillColor: def.cor, fillOpacity: 0.7, weight: 1, }); marker.bindPopup(def.popup(p).filter(Boolean).join('
')); marker.addTo(layer); }); } catch (err) { // Silencioso: camada opcional do mapa, não deve travar o resto da tela. } } useEffect(() => { tokenRef.current = token; }, [token]); useEffect(() => { pointLayersAtivosRef.current = pointLayersAtivos; if (!mapRef.current) return; POINT_LAYERS.forEach((def) => { const layer = pointLayerGroupsRef.current[def.id]; if (!layer) return; if (pointLayersAtivos[def.id]) { if (!mapRef.current.hasLayer(layer)) layer.addTo(mapRef.current); refreshPointLayer(def); } else if (mapRef.current.hasLayer(layer)) { layer.clearLayers(); mapRef.current.removeLayer(layer); } }); }, [JSON.stringify(pointLayersAtivos)]); useEffect(() => { if (!mapRef.current || !markerRef.current) return; markerRef.current.setLatLng([latitude, longitude]); }, [latitude, longitude]); useEffect(() => { if (!mapRef.current) return; wmsLayerRefs.current.forEach((layer) => { mapRef.current.removeLayer(layer); }); wmsLayerRefs.current = []; (wmsSources || []).forEach((source) => { if (!source || !source.url || !source.layers) return; const layer = L.tileLayer.wms(source.url, { layers: source.layers, format: 'image/png', transparent: true, opacity: wmsOpacity, }).addTo(mapRef.current); wmsLayerRefs.current.push(layer); }); }, [wmsSources, wmsOpacity]); return
; } // Mapa da "busca por localização" (Consultas) — SEM nenhuma camada WMS // ligada por padrão (pedido do Anderson: "apenas para pessoa apontar"), // clique no mapa move o pino, e o usuário pode desenhar uma área em vez // de usar o raio (leaflet-draw). Deliberadamente um componente separado // do GeoMap: aquele é usado em mais dois lugares (Consultas antigo, // Mapa geral) sem essa complexidade de desenho. function LocationSearchMap({ latitude, longitude, raioM, onCenterChange, poligono, onPoligonoChange, desenhando }) { const mapRef = useRef(null); const markerRef = useRef(null); const circleRef = useRef(null); const drawnItemsRef = useRef(null); const drawControlRef = useRef(null); const onCenterChangeRef = useRef(onCenterChange); const onPoligonoChangeRef = useRef(onPoligonoChange); const desenhandoRef = useRef(desenhando); useEffect(() => { onCenterChangeRef.current = onCenterChange; }, [onCenterChange]); useEffect(() => { onPoligonoChangeRef.current = onPoligonoChange; }, [onPoligonoChange]); useEffect(() => { desenhandoRef.current = desenhando; }, [desenhando]); useEffect(() => { if (mapRef.current) return; const map = L.map('diario-location-map-canvas').setView([latitude, longitude], 15); mapRef.current = map; L.tileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', { maxZoom: 19, attribution: '© OpenStreetMap contributors', }).addTo(map); markerRef.current = L.marker([latitude, longitude]).addTo(map); circleRef.current = L.circle([latitude, longitude], { radius: raioM, color: '#da8c38' }).addTo(map); const drawnItems = new L.FeatureGroup(); map.addLayer(drawnItems); drawnItemsRef.current = drawnItems; map.on('click', (e) => { if (desenhandoRef.current) return; onCenterChangeRef.current({ latitude: Number(e.latlng.lat.toFixed(6)), longitude: Number(e.latlng.lng.toFixed(6)) }); }); map.on(L.Draw.Event.CREATED, (e) => { drawnItems.clearLayers(); drawnItems.addLayer(e.layer); const pontos = e.layer.getLatLngs()[0].map((p) => ({ lat: Number(p.lat.toFixed(6)), lon: Number(p.lng.toFixed(6)) })); onPoligonoChangeRef.current(pontos); }); }, []); useEffect(() => { if (!mapRef.current || !markerRef.current || !circleRef.current) return; markerRef.current.setLatLng([latitude, longitude]); circleRef.current.setLatLng([latitude, longitude]); circleRef.current.setRadius(raioM); }, [latitude, longitude, raioM]); useEffect(() => { if (!mapRef.current) return; if (desenhando) { if (!drawControlRef.current) { drawControlRef.current = new L.Draw.Polygon(mapRef.current, { showArea: true, shapeOptions: { color: '#3f74b8' }, }); } drawControlRef.current.enable(); } else if (drawControlRef.current) { drawControlRef.current.disable(); } }, [desenhando]); useEffect(() => { if (!poligono && drawnItemsRef.current) { drawnItemsRef.current.clearLayers(); } }, [poligono]); return
; } // snake_case da fonte (WFS/ArcGIS/licenças) -> rótulo legível — genérico de // propósito (2026-09-08): cada camada do "Mapa geral" tem um conjunto de // campos diferente (IBAMA tem multa/infrator, CAR tem módulo fiscal, ANM // tem substância/fase...) e o pedido foi "todos os dados", não uma lista // curada campo a campo por camada (isso já existe, mais enxuto, só no PDF). function formatarChaveGenerica(chave) { const texto = String(chave || '').replace(/_/g, ' ').trim(); return texto.charAt(0).toUpperCase() + texto.slice(1); } // Todas as propriedades de UM registro encontrado numa camada do "Mapa // geral" — sem curadoria de campo (pedido do Anderson: "todos os dados do // item encontrado, não só os quantitativos e título"). function ExemploOverlay({ exemplo }) { const entradas = Object.entries(exemplo || {}).filter( ([chave, valor]) => !chave.startsWith('_') && valor !== null && valor !== '' ); if (entradas.length === 0) return null; return (
{entradas.map(([chave, valor]) => (
{formatarChaveGenerica(chave)} {String(valor)}
))}
); } // Linha de citação/diário reaproveitada pelos 3 resultados que trazem // "matérias relacionadas" (CNPJ, nome, geo) — mesmas colunas nos 3 casos. function TabelaCitacoes({ itens }) { if (!itens || itens.length === 0) return null; return (
{itens.map((c) => ( ))}
Publicação Local Data Fonte
{c.titulo} {[c.municipio, c.estado].filter(Boolean).join(' / ') || '-'} {toDisplayDateOnly(c.data_publicacao)} {c.fonte_nome || '-'} {c.url && Ler}
); } function ResultadoCnpj({ resultado }) { if (!resultado) return null; const { cnpj, entidade, citacoes = [], banco_proprio } = resultado; const empresa = banco_proprio && banco_proprio.empresa; const licencas = (banco_proprio && banco_proprio.licencas) || []; const nadaEncontrado = !entidade && citacoes.length === 0 && !empresa && licencas.length === 0; return (

Resultado da busca por CNPJ

{cnpj}
{nadaEncontrado &&

Nenhuma informação encontrada para este CNPJ.

} {empresa && (
{empresa.razao_social || empresa.nome_fantasia || 'Empresa'} {empresa.nome_fantasia && empresa.nome_fantasia !== empresa.razao_social && (
{empresa.nome_fantasia}
)}
{[empresa.cnae_descricao, empresa.situacao_cadastral].filter(Boolean).join(' · ') || '-'}
{[empresa.logradouro, empresa.municipio, empresa.uf].filter(Boolean).join(', ') || '-'}
)} {entidade && (
Citada em diários oficiais {entidade.total_citacoes} vez(es), última publicação em{' '} {toDisplayDateOnly(entidade.ultima_publicacao)}.
)} {licencas.length > 0 && (
{licencas.map((l, i) => ( ))}
Licença ambiental Tipo Situação Local
{l.fonte} {l.tipo_licenca || l.tipologia || '-'} {l.situacao || '-'} {[l.municipio, l.uf].filter(Boolean).join(' / ') || '-'}
)}
); } function ResultadoNome({ resultado }) { if (!resultado) return null; const { nome_buscado, entidades = [], citacoes = [] } = resultado; const vazio = entidades.length === 0 && citacoes.length === 0; return (

Resultado da busca por nome

{nome_buscado}
{vazio &&

Nenhum resultado encontrado.

} {entidades.length > 0 && (
{entidades.map((e) => ( ))}
Nome CNPJ Tipo Citações Última publicação
{e.nome} {e.cnpj || '-'} {e.tipo_entidade || '-'} {e.total_citacoes} {toDisplayDateOnly(e.ultima_publicacao)}
)}
); } function ResultadoGeo({ resultado }) { if (!resultado) return null; const { consulta, municipios_no_buffer = [], diarios_relacionados = [] } = resultado; const vazio = municipios_no_buffer.length === 0 && diarios_relacionados.length === 0; return (

Resultado da consulta geográfica

{consulta && (
{consulta.latitude}, {consulta.longitude} · raio {consulta.raio_km} km · {consulta.wms_total} camadas WMS verificadas
)} {vazio &&

Nenhum município ou diário relacionado encontrado neste raio.

} {municipios_no_buffer.length > 0 && (
Municípios no raio
{municipios_no_buffer.map((m) => ( ))}
Município UF Distância
{m.nome} {m.estado} {m.distancia_km} km
)} {diarios_relacionados.length > 0 && (
Diários relacionados
)}
); } function ResultadoProblematicos({ resultado }) { if (!resultado) return null; const { criterio, itens = [] } = resultado; return (

CNPJs com possíveis problemas

{criterio}
{itens.length === 0 &&

Nenhum item encontrado.

} {itens.length > 0 && (
{itens.map((it, i) => ( ))}
CNPJ Nome Menções Última publicação
{it.cnpj} {it.nome} {it.total_mencoes} {toDisplayDateOnly(it.ultima_publicacao)}
)}
); } // Nome completo -> UF, como tb_fontes/tb_conteudos (MariaDB) guardam o // estado por extenso — diferente de `licencas` (Postgres), que já usa // sigla. Página "Sobre" (2026-09-06) precisa das duas cruzadas. const SOBRE_UF_POR_NOME = { 'Acre': 'AC', 'Alagoas': 'AL', 'Amapá': 'AP', 'Amazonas': 'AM', 'Bahia': 'BA', 'Ceará': 'CE', 'Distrito Federal': 'DF', 'Espírito Santo': 'ES', 'Goiás': 'GO', 'Maranhão': 'MA', 'Mato Grosso': 'MT', 'Mato Grosso do Sul': 'MS', 'Minas Gerais': 'MG', 'Pará': 'PA', 'Paraíba': 'PB', 'Paraná': 'PR', 'Pernambuco': 'PE', 'Piauí': 'PI', 'Rio de Janeiro': 'RJ', 'Rio Grande do Norte': 'RN', 'Rio Grande do Sul': 'RS', 'Rondônia': 'RO', 'Roraima': 'RR', 'Santa Catarina': 'SC', 'São Paulo': 'SP', 'Sergipe': 'SE', 'Tocantins': 'TO', }; const SOBRE_ESTADOS = Object.entries(SOBRE_UF_POR_NOME) .map(([nome, uf]) => ({ nome, uf })) .sort((a, b) => a.nome.localeCompare(b.nome, 'pt-BR')); const SOBRE_COR_COMPLETO = '#1f8c4e'; const SOBRE_COR_PARCIAL = '#f3b163'; const SOBRE_COR_SEM_COBERTURA = '#8a94a6'; // tb_conteudos.estado/tb_fontes.estado vêm inconsistentes na origem — // às vezes nome completo ("Ceará"), às vezes já a sigla ("CE"). Sem // isso a sigla batia em nada no dicionário por nome e sumia da conta. function resolverUf(estado) { if (!estado) return null; const valor = String(estado).trim(); if (/^[A-Za-z]{2}$/.test(valor)) return valor.toUpperCase(); return SOBRE_UF_POR_NOME[valor] || null; } // Um CNAE (principal, secundário ou informado direto) com o que já // sabemos de licenciamento/CTF pra ele — sub-componente de // ConsultaEnquadramento, feito pra repetir sem duplicar JSX por CNAE // quando a consulta é por CNPJ (principal + N secundários). function ResultadoCnaeEnquadramento({ item }) { const ROTULOS = { principal: 'CNAE principal', secundario: 'CNAE secundário', informado: 'CNAE consultado' }; const rotulo = ROTULOS[item.papel] || 'CNAE'; const semDado = item.licenciamento.length === 0 && item.ctf.length === 0; return (
{rotulo}

{item.cnae}{item.cnae_descricao ? ` — ${item.cnae_descricao}` : ''}

{semDado && (
Ainda não temos dado de licenciamento/CTF carregado para este CNAE (cobertura em expansão).
)} {item.licenciamento.map((l, i) => (
{l.uf}:{' '} {l.licenciavel === true && 'costuma exigir licenciamento ambiental'} {l.licenciavel === false && 'geralmente não exige licenciamento ambiental'} {l.licenciavel === null && 'situação não determinada nessa fonte'} {l.fator_excludente ? ` (exceto: ${l.fator_excludente})` : ''} {l.orgao_licenciador ? ` — órgão: ${l.orgao_licenciador}` : ''} {l.atividade_nome ? ` — atividade: ${l.atividade_nome}` : ''} {l.confianca === 'revisar' ? ' [dado em revisão]' : ''}
))} {item.ctf.length > 0 && (
CTF/IBAMA:{' '} {item.ctf[0].categoria_nome || item.ctf[0].categoria_codigo} {item.ctf[0].atividade_nome ? ` — ${item.ctf[0].atividade_nome}` : ''} {item.ctf[0].confianca === 'revisar' ? ' [amostra pequena, considere revisar]' : ''}
)}
); } // Consulta gratuita e pública de enquadramento legal (Parte 8.1/8.2, plano // delightful-launching-glacier) — pedido do Anderson, 2026-09-16: expor os // dados já carregados (licenciamento estadual/federal + CTF/IBAMA) direto // na home, sem login. Dois modos: escolher um CNAE da lista, ou informar um // CNPJ (resolve CNAE principal + secundários no backend). Rate limit // dedicado no PHP (RATE_LIMIT_ENQUADRAMENTO) — aqui é só UI. function ConsultaEnquadramento() { const [modo, setModo] = useState('cnae'); const [listaCnaes, setListaCnaes] = useState([]); const [cnaeInput, setCnaeInput] = useState(''); const [cnpjInput, setCnpjInput] = useState(''); const [resultado, setResultado] = useState(null); const [carregando, setCarregando] = useState(false); const [erro, setErro] = useState(''); useEffect(() => { apiRequest('enquadramento/cnaes') .then((data) => setListaCnaes(data.cnaes || [])) .catch(() => {}); // lista é só conveniência do ; sem ela o campo ainda aceita código digitado }, []); const consultar = async (evento) => { evento.preventDefault(); setErro(''); setResultado(null); let query = ''; if (modo === 'cnae') { // aceita tanto o texto do datalist ("4930202 - Transporte...") quanto o código cru digitado const casado = listaCnaes.find((c) => cnaeInput.trim().startsWith(c.cnae)); const cnaeFinal = casado ? casado.cnae : cnaeInput.replace(/\D/g, ''); if (cnaeFinal.length < 5) { setErro('Selecione um CNAE da lista ou digite o código completo.'); return; } query = `cnae=${encodeURIComponent(cnaeFinal)}`; } else { const cnpjLimpo = cnpjInput.replace(/\D/g, ''); if (cnpjLimpo.length !== 14) { setErro('CNPJ deve ter 14 dígitos.'); return; } query = `cnpj=${encodeURIComponent(cnpjLimpo)}`; } setCarregando(true); try { const data = await apiRequest(`enquadramento/obrigacoes?${query}`); setResultado(data); } catch (err) { setErro(err.message || 'Falha na consulta.'); } finally { setCarregando(false); } }; return (

A sua atividade precisa de licença ambiental?

Consulta gratuita: veja se a atividade costuma exigir licenciamento ambiental estadual/federal e inscrição no Cadastro Técnico Federal (CTF/IBAMA). Cobertura ainda parcial (alguns estados), em expansão.

{modo === 'cnae' ? ( <> setCnaeInput(e.target.value)} /> {listaCnaes.map((c) => ( ) : ( setCnpjInput(e.target.value)} /> )}
{erro &&
{erro}
} {resultado && (
{resultado.empresa && (
{resultado.empresa.razao_social || 'Empresa'} {resultado.empresa.municipio ? ` — ${resultado.empresa.municipio}/${resultado.empresa.uf || ''}` : ''}
)} {resultado.cnaes.length === 0 && (
Não foi possível resolver um CNAE para essa consulta.
)} {resultado.cnaes.map((item) => ( ))}
)}
); } // Página "Sobre" (transparência de cobertura, pedido do Anderson // 2026-09-06) — pública, sem exigir login (mesma régua de status/ // cobertura no PHP). Junta licenças (Postgres/VPS, via bridge) com // diários oficiais (MariaDB local) por estado, mais um resumo nacional. function SobrePage() { const [dados, setDados] = useState(null); const [carregando, setCarregando] = useState(true); const [erroCarregar, setErroCarregar] = useState(''); const [svgTexto, setSvgTexto] = useState(''); const [produtosValores, setProdutosValores] = useState([]); const svgContainerRef = useRef(null); useEffect(() => { let cancelado = false; setCarregando(true); apiRequest('status/cobertura') .then((data) => { if (!cancelado) setDados(data); }) .catch((err) => { if (!cancelado) setErroCarregar(err.message); }) .finally(() => { if (!cancelado) setCarregando(false); }); // catalog/products é público (sem login) — a página Sobre também é. apiRequest('catalog/products') .then((data) => { if (!cancelado) setProdutosValores(data.produtos || []); }) .catch(() => {}); fetch('/assets/img/mapa-brasil.svg') .then((r) => r.text()) .then((texto) => { if (!cancelado) setSvgTexto(texto); }) .catch(() => {}); return () => { cancelado = true; }; }, []); // Data em texto (ISO-ish, "2026-09-06 12:59:56...") -> "06/09/2026". // Aceita null/undefined/vazio sem quebrar (fonte que ainda não leu nada). const formatarData = (valor) => { if (!valor) return null; const dataParte = String(valor).split(' ')[0].split('T')[0]; const partes = dataParte.split('-'); if (partes.length !== 3) return null; const [ano, mes, dia] = partes; return `${dia}/${mes}/${ano}`; }; const maisRecente = (a, b) => { if (!a) return b || null; if (!b) return a; return a > b ? a : b; // strings ISO comparam corretamente }; // Todo fonte 'ibama_*' é SINAFLOR — autorização de corte nacional, não // entra na conta por estado (mesmo espírito de honestidade do resto do // produto: exibir 2x o mesmo dado, uma vez nacional e outra por estado, // ia inflar a leitura de cobertura). Isso cresceu de 1 fonte (só ASV) // pra 10 em 2026-09-06 — o filtro usa prefixo pra não precisar listar // cada uma à mão daqui pra frente. const ehSinaflor = (fonte) => (fonte || '').startsWith('ibama_'); const resumoPorUf = useMemo(() => { const mapa = {}; SOBRE_ESTADOS.forEach(({ uf }) => { mapa[uf] = { licencas: 0, diarioConteudo: 0, licencasAtualizado: null, diarioAtualizado: null }; }); if (!dados) return mapa; (dados.licencas_por_fonte_uf || []).forEach((r) => { if (ehSinaflor(r.fonte)) return; // nacional, não entra na conta estadual if (!mapa[r.uf]) return; mapa[r.uf].licencas += Number(r.total || 0); mapa[r.uf].licencasAtualizado = maisRecente(mapa[r.uf].licencasAtualizado, r.ultima_leitura); }); (dados.diario_conteudo_por_estado || []).forEach((r) => { const uf = resolverUf(r.estado); if (!uf || !mapa[uf]) return; mapa[uf].diarioConteudo += Number(r.total || 0); mapa[uf].diarioAtualizado = maisRecente(mapa[uf].diarioAtualizado, r.ultima_publicacao); }); return mapa; }, [dados]); const autorizacaoCorte = useMemo(() => { if (!dados) return { total: null, ultima: null }; const linhas = (dados.licencas_por_fonte_uf || []).filter((r) => ehSinaflor(r.fonte)); return { total: linhas.reduce((acc, r) => acc + Number(r.total || 0), 0), ultima: linhas.reduce((acc, r) => maisRecente(acc, r.ultima_leitura), null), }; }, [dados]); useEffect(() => { if (!svgTexto || !dados || !svgContainerRef.current) return; SOBRE_ESTADOS.forEach(({ uf }) => { const el = svgContainerRef.current.querySelector(`#${uf}`); if (!el) return; const r = resumoPorUf[uf]; const temLicenca = r && r.licencas > 0; const temDiario = r && r.diarioConteudo > 0; const cor = temLicenca && temDiario ? SOBRE_COR_COMPLETO : (temLicenca || temDiario) ? SOBRE_COR_PARCIAL : SOBRE_COR_SEM_COBERTURA; el.style.fill = cor; // Tooltip nativo do navegador (sigla + quantitativo, pedido do // Anderson) — dentro de um elemento SVG já vira tooltip // sozinho, sem precisar de lib/JS extra pro hover. let tituloEl = el.querySelector('title'); if (!tituloEl) { tituloEl = document.createElementNS('http://www.w3.org/2000/svg', 'title'); el.appendChild(tituloEl); } const licTxt = r && r.licencas ? r.licencas.toLocaleString('pt-BR') : '0'; const diaTxt = r && r.diarioConteudo ? r.diarioConteudo.toLocaleString('pt-BR') : '0'; tituloEl.textContent = `${uf} — ${licTxt} licenças ambientais, ${diaTxt} matérias de diário oficial`; }); }, [svgTexto, dados, resumoPorUf]); return ( <section> {produtosValores.length > 0 && ( <div className="app-card diario-valores-card"> <span className="diario-chip diario-chip-lancamento">Pré-lançamento</span> <h3 className="mt-2 mb-1">Valores</h3> <p className="diario-note mb-3"> Preços de pré-lançamento, por tempo limitado. Sem contrato de fidelidade. </p> <div className="diario-grid"> {produtosValores.map((produto) => ( <div className="diario-card" key={produto.codigo}> <span className="diario-tag">{produto.tipo_cobranca}</span> <h4 className="mt-2 mb-1">{produto.nome}</h4> <div className="diario-note">{produto.descricao}</div> <PrecoComDesconto produto={produto} /> </div> ))} </div> </div> )} <ConsultaEnquadramento /> <h3 className="mb-2">Sobre a cobertura de dados</h3> <p className="diario-note mb-3"> Transparência sobre o que o GEOPJ já cobre hoje, em nível nacional e por estado. Números direto do banco, atualizados a cada carregamento desta página. </p> {carregando && <p className="diario-note">Carregando...</p>} {erroCarregar && <div className="alert alert-danger">{erroCarregar}</div>} {dados && ( <> <div className="app-card"> <h4>Cobertura nacional</h4> <div className="diario-table-wrap"> <table className="diario-table"> <thead> <tr> <th>Fonte</th> <th>Abrangência</th> <th>Status</th> <th>Atualizado em</th> </tr> </thead> <tbody> <tr> <td data-label="Fonte">Desmatamento (DETER / PRODES)</td> <td data-label="Abrangência">Nacional (Amazônia + Cerrado)</td> <td data-label="Status"><span className="diario-tag">Camada externa (INPE)</span></td> <td data-label="Atualizado em">Tempo real</td> </tr> <tr> <td data-label="Fonte">Autos de infração (IBAMA)</td> <td data-label="Abrangência">Nacional</td> <td data-label="Status"><span className="diario-tag">Consulta ao vivo (PAMGIA)</span></td> <td data-label="Atualizado em">Tempo real</td> </tr> <tr> <td data-label="Fonte">Autorização de corte (SINAFLOR)</td> <td data-label="Abrangência">Nacional</td> <td data-label="Status" className="diario-status-ok"> {autorizacaoCorte.total !== null ? autorizacaoCorte.total.toLocaleString('pt-BR') : '-'} registros </td> <td data-label="Atualizado em">{formatarData(autorizacaoCorte.ultima) || '-'}</td> </tr> <tr> <td data-label="Fonte">Empresas (CNPJ)</td> <td data-label="Abrangência">Nacional</td> <td data-label="Status" className="diario-status-ok"> {dados.cnpj?.total_aproximado ? Number(dados.cnpj.total_aproximado).toLocaleString('pt-BR') : '-'}{' '} cadastradas, {dados.cnpj?.com_coordenada ? Number(dados.cnpj.com_coordenada).toLocaleString('pt-BR') : '-'}{' '} com coordenada </td> <td data-label="Atualizado em">Atualização contínua</td> </tr> <tr> <td data-label="Fonte">CAR — imóveis rurais</td> <td data-label="Abrangência">Nacional (27 estados)</td> <td data-label="Status"><span className="diario-tag">Camada externa (SICAR)</span></td> <td data-label="Atualizado em">Tempo real</td> </tr> <tr> <td data-label="Fonte">Diário Oficial da União</td> <td data-label="Abrangência">Nacional</td> {dados.diario_uniao?.total > 0 ? ( <> <td data-label="Status" className="diario-status-ok">{Number(dados.diario_uniao.total).toLocaleString('pt-BR')} matérias</td> <td data-label="Atualizado em">{formatarData(dados.diario_uniao.ultima_publicacao) || '-'}</td> </> ) : ( <> <td data-label="Status" className="diario-status-erro">Não coberto ainda</td> <td data-label="Atualizado em">-</td> </> )} </tr> </tbody> </table> </div> </div> <div className="diario-map-layout"> <div className="diario-layers-panel"> <div ref={svgContainerRef} className="diario-sobre-mapa" dangerouslySetInnerHTML={{ __html: svgTexto }} /> <div className="diario-note mt-2"> <span style={{ color: SOBRE_COR_COMPLETO }}>■</span> licenças e diário{' '} <span style={{ color: SOBRE_COR_PARCIAL }}>■</span> só um dos dois{' '} <span style={{ color: SOBRE_COR_SEM_COBERTURA }}>■</span> nenhum ainda </div> </div> <div className="app-card"> <h4>Cobertura por estado</h4> <div className="diario-table-wrap"> <table className="diario-table"> <thead> <tr> <th>Estado</th> <th>Licenças ambientais</th> <th>Atualizado em</th> <th>Diário oficial (matérias)</th> <th>Atualizado em</th> </tr> </thead> <tbody> {SOBRE_ESTADOS.map(({ nome, uf }) => ( <tr key={uf}> <td data-label="Estado">{nome} ({uf})</td> <td data-label="Licenças ambientais">{resumoPorUf[uf]?.licencas ? resumoPorUf[uf].licencas.toLocaleString('pt-BR') : '-'}</td> <td data-label="Atualizado (licenças)">{formatarData(resumoPorUf[uf]?.licencasAtualizado) || '-'}</td> <td data-label="Diário oficial (matérias)">{resumoPorUf[uf]?.diarioConteudo ? resumoPorUf[uf].diarioConteudo.toLocaleString('pt-BR') : '-'}</td> <td data-label="Atualizado (diário)">{formatarData(resumoPorUf[uf]?.diarioAtualizado) || '-'}</td> </tr> ))} </tbody> </table> </div> <div className="diario-note mt-2"> Licenças ambientais aqui não inclui autorização de corte (SINAFLOR), que é nacional e já aparece na tabela acima. </div> </div> </div> </> )} </section> ); } function DiarioApp() { const [token, setToken] = useState(localStorage.getItem('diario_token') || ''); const [usuario, setUsuario] = useState(null); const [perfilPrivado, setPerfilPrivado] = useState(null); const [telefones, setTelefones] = useState([]); const [geoCamadas, setGeoCamadas] = useState([]); const [planoAtivo, setPlanoAtivo] = useState(null); // Sobre virou rota de verdade (2026-09-06, pedido do Anderson: "inclua // o sobre na página pública") — /sobre carrega direto nela (reload, // refresh, link compartilhado), não só um botão que reseta ao // atualizar a página. Rewrite correspondente em .htaccess. Raiz-relativo // desde a mudança de domínio (2026-09-16, geopj.com.br) — antes assumia // montagem em /apps/diario/. const [view, setView] = useState( window.location.pathname.replace(/\/+$/, '').endsWith('/sobre') ? 'sobre' : 'compras' ); const navegarSobre = (ativo) => { const path = ativo ? '/sobre' : '/'; window.history.pushState({}, '', path); setView(ativo ? 'sobre' : 'compras'); }; // Qualquer navegação pra fora do Sobre desfaz a URL /sobre, senão um // refresh depois de trocar de aba pelo menu voltaria pro Sobre por // engano (URL e `view` ficariam dessincronizados). const irPara = (id) => { if (id === 'sobre') { navegarSobre(true); return; } if (window.location.pathname.replace(/\/+$/, '').endsWith('/sobre')) { window.history.pushState({}, '', '/'); } setView(id); }; const [advancedMenu, setAdvancedMenu] = useState(false); // Menu suspenso do hamburguer no mobile (2026-09-08, navbar substituiu a // sidebar) — junta mainViews+advancedViews num só painel, ao contrário // do antigo sidebarCollapsed (que só encolhia/expandia a coluna fixa). const [mobileNavOpen, setMobileNavOpen] = useState(false); const [navBusca, setNavBusca] = useState(''); const [produtos, setProdutos] = useState([]); const [extrato, setExtrato] = useState([]); const [monitoramentos, setMonitoramentos] = useState([]); const [radarItems, setRadarItems] = useState([]); const [notifications, setNotifications] = useState([]); const [unreadCount, setUnreadCount] = useState(0); const [lastPaymentByProduct, setLastPaymentByProduct] = useState({}); const [loading, setLoading] = useState(false); const [message, setMessage] = useState(''); const [error, setError] = useState(''); const [themePref, setThemePref] = useState(localStorage.getItem(THEME_STORAGE_KEY) || 'dark'); const [effectiveTheme, setEffectiveTheme] = useState(resolveTheme(localStorage.getItem(THEME_STORAGE_KEY) || 'dark')); const [loginPanelOpen, setLoginPanelOpen] = useState(false); const [authMode, setAuthMode] = useState('login'); // login | register | forgot const [authForm, setAuthForm] = useState({ nome: '', email: '', senha: '', cpf: '', telefone: '' }); // "Esqueci minha senha" (2026-09-16) — passo 'request' pede o email e // dispara o código; passo 'code' recebe o código + a nova senha. const [forgotStep, setForgotStep] = useState('request'); const [resetForm, setResetForm] = useState({ email: '', codigo: '', novaSenha: '' }); const [modoConsulta, setModoConsulta] = useState('busca'); const [buscaForm, setBuscaForm] = useState({ termo: '' }); const [geoForm, setGeoForm] = useState({ latitude: -27.5969, longitude: -48.5495, wms_opacity: 0.75 }); const [mapsLinkInput, setMapsLinkInput] = useState(''); const [raioMetros, setRaioMetros] = useState(500); const [poligonoPontos, setPoligonoPontos] = useState(null); const [desenhandoPoligono, setDesenhandoPoligono] = useState(false); // Polígono do "Mapa geral" (2026-09-08, pedido do Anderson: relatório em // PDF hoje só usa um ponto, agora aceita área desenhada) — estado // PRÓPRIO, separado do poligonoPontos de Consultas>Por localização // acima: são duas telas/propósitos diferentes (citações por área vs. // relatório geoespacial), compartilhar o mesmo estado vazaria um pro // outro ao trocar de aba sem limpar. const [mapaPoligonoPontos, setMapaPoligonoPontos] = useState(null); // Raio só entra quando NÃO há polígono desenhado (2026-09-08, pedido do // Anderson: "quando o relatório for apenas ponto, vamos pedir qual o // raio" — antes era fixo em 1 km). Mesma faixa (10-1000m) do raioMetros // de Consultas > Por localização, mesmo teto de DIARIO_GEO_RAIO_MAXIMO_KM. const [mapaRaioMetros, setMapaRaioMetros] = useState(1000); // Animação de "reunindo informações" (2026-09-08, pedido do Anderson) — // o relatório em PDF consulta várias fontes em série no backend e pode // levar ~1-2min; gerandoPdf liga o spinner, pdfEtapaIndex faz o texto // girar entre as etapas reais do pipeline (geo/overlap.py + // diarios/pdf_report.py) pra dar a sensação de progresso sem inventar // uma barra de porcentagem que não existe de verdade no backend. const [gerandoPdf, setGerandoPdf] = useState(false); const [pdfEtapaIndex, setPdfEtapaIndex] = useState(0); // Prévia em tela do "Mapa geral" (2026-09-08) — mesma busca do PDF // (geo/overlap), só que devolvida como JSON pra mostrar na hora, sem // esperar o PDF inteiro. null = nunca consultado ainda (esconde o card). const [sobreposicoesResultado, setSobreposicoesResultado] = useState(null); const [consultandoSobreposicoes, setConsultandoSobreposicoes] = useState(false); const [listaForm, setListaForm] = useState({ limite: 5 }); const [mapaLayersAtivas, setMapaLayersAtivas] = useState({}); const [pointLayersAtivos, setPointLayersAtivos] = useState({}); const [monitorForm, setMonitorForm] = useState({ tipo_alvo: 'cnpj', valor_alvo: '', palavras_chave: '', raio_km: '' }); const [radarForm, setRadarForm] = useState({ tipo_alvo: 'cnpj', valor_alvo: '', nome_alvo: '', auto_cadastro: true }); const [perfilForm, setPerfilForm] = useState({ nome: '', empresa: '' }); const [novoTelefone, setNovoTelefone] = useState(''); const [novaGeoCamada, setNovaGeoCamada] = useState({ nome: '', wfs_url: '', typename: '' }); const [resultadoBusca, setResultadoBusca] = useState(null); const [resultadoGeo, setResultadoGeo] = useState(null); const [resultadoProblematicos, setResultadoProblematicos] = useState(null); const [materiaId, setMateriaId] = useState(() => getMateriaIdFromUrl()); const [materia, setMateria] = useState(null); const [checkoutRef, setCheckoutRef] = useState(() => getCheckoutRefFromUrl()); useEffect(() => { loadProdutos(); }, []); // Retorno do Checkout Pro do Mercado Pago (?checkout=retorno&ref=...) — // só consulta o que o webhook já confirmou, nunca confia na URL de // retorno sozinha (o MP manda os back_urls antes de qualquer confirmação // real). Espera o token estar pronto (login pode ainda estar // restaurando do localStorage). useEffect(() => { if (!checkoutRef || !token) return; let cancelado = false; apiRequest(`billing/status?ref=${encodeURIComponent(checkoutRef)}`, { token }) .then((data) => { if (cancelado) return; if (data.status === 'pago') { setMessage(`Pagamento de ${data.produto_nome} confirmado. Obrigado!`); } else if (data.status === 'pendente') { setMessage(`Pagamento de ${data.produto_nome} ainda em processamento. Assim que confirmar, avisamos por notificação.`); } else { setError(`Pagamento de ${data.produto_nome} não foi concluído (${data.status_detalhe || data.status}).`); } loadExtrato(); loadMe(); loadNotifications(); }) .catch((err) => { if (!cancelado) setError(err.message); }) .finally(() => { if (!cancelado) { setCheckoutRef(null); window.history.replaceState({}, '', window.location.pathname); } }); return () => { cancelado = true; }; }, [checkoutRef, token]); // Recarrega ao logar: a mesma matéria volta destravada, sem o leitor perder o link. useEffect(() => { if (!materiaId) { setMateria(null); return; } let cancelado = false; apiRequest(`materias/${materiaId}`, { token }) .then((data) => { if (!cancelado) setMateria(data.materia || null); }) .catch((err) => { if (!cancelado) { setMateria(null); setError(err.message); } }); return () => { cancelado = true; }; }, [materiaId, token]); function fecharMateria() { setMateriaId(null); setMateria(null); // Tira o ?materia= da barra de endereços sem recarregar a página. window.history.replaceState({}, '', window.location.pathname); } useEffect(() => { const listener = () => { setEffectiveTheme(resolveTheme(themePref)); }; const media = window.matchMedia('(prefers-color-scheme: dark)'); media.addEventListener('change', listener); return () => media.removeEventListener('change', listener); }, [themePref]); useEffect(() => { const resolved = resolveTheme(themePref); setEffectiveTheme(resolved); document.documentElement.setAttribute('data-diario-theme', resolved); localStorage.setItem(THEME_STORAGE_KEY, themePref); }, [themePref]); useEffect(() => { if (!token) { setUsuario(null); setPerfilPrivado(null); setTelefones([]); setGeoCamadas([]); setPlanoAtivo(null); return; } loadMe(); loadExtrato(); loadMonitoramentos(); loadRadar(); loadNotifications(); }, [token]); async function loadProdutos() { try { const data = await apiRequest('catalog/products'); setProdutos(data.produtos || []); } catch (err) { setError(err.message); } } async function loadMe() { try { const data = await apiRequest('auth/me', { token }); setUsuario(data.usuario || null); setPerfilPrivado(data.perfil_privado || null); setTelefones(data.telefones || []); setGeoCamadas(data.geo_camadas || []); setPerfilForm({ nome: data.usuario?.nome || '', empresa: data.perfil_privado?.empresa || '' }); setPlanoAtivo(data.plano_ativo || null); } catch (err) { setToken(''); localStorage.removeItem('diario_token'); setError(err.message); } } async function loadExtrato() { if (!token) return; try { const data = await apiRequest('history/extrato?pagina=1&por_pagina=30', { token }); setExtrato(data.items || []); } catch (err) { setError(err.message); } } async function loadMonitoramentos() { if (!token) return; try { const data = await apiRequest('monitor/list', { token }); setMonitoramentos(data.monitoramentos || []); } catch (err) { setError(err.message); } } async function loadRadar() { if (!token) return; try { const data = await apiRequest('radar/list?limite=80', { token }); setRadarItems(data.items || []); } catch (err) { setError(err.message); } } async function loadNotifications() { if (!token) return; try { const data = await apiRequest('notifications/list?limite=50', { token }); setNotifications(data.items || []); setUnreadCount(Number(data.nao_lidas || 0)); } catch (err) { setError(err.message); } } function logout() { setToken(''); setUsuario(null); setPerfilPrivado(null); localStorage.removeItem('diario_token'); setMessage('Sessão finalizada.'); setView('compras'); } async function doAuth(mode) { setLoading(true); setError(''); setMessage(''); try { const payload = mode === 'register' ? { nome: authForm.nome, email: authForm.email, senha: authForm.senha, cpf: authForm.cpf, telefone: authForm.telefone, } : { email: authForm.email, senha: authForm.senha }; const data = await apiRequest(`auth/${mode}`, { method: 'POST', body: payload, }); localStorage.setItem('diario_token', data.token); setToken(data.token); setMessage(mode === 'register' ? 'Conta criada com segurança reforçada.' : 'Login realizado com sucesso.'); setView('compras'); } catch (err) { setError(err.message); } finally { setLoading(false); } } async function solicitarCodigoReset() { setLoading(true); setError(''); setMessage(''); try { const data = await apiRequest('auth/forgot', { method: 'POST', body: { email: resetForm.email }, }); setMessage(data.message || 'Se existir uma conta com esse email, enviamos um código de verificação.'); setForgotStep('code'); } catch (err) { setError(err.message); } finally { setLoading(false); } } async function redefinirSenha() { setLoading(true); setError(''); setMessage(''); try { const data = await apiRequest('auth/reset', { method: 'POST', body: { email: resetForm.email, codigo: resetForm.codigo, nova_senha: resetForm.novaSenha, }, }); setMessage(data.message || 'Senha alterada com sucesso. Faça login com a nova senha.'); setAuthForm({ ...authForm, email: resetForm.email, senha: '' }); setResetForm({ email: '', codigo: '', novaSenha: '' }); setForgotStep('request'); setAuthMode('login'); } catch (err) { setError(err.message); } finally { setLoading(false); } } async function salvarPerfil() { setLoading(true); setError(''); setMessage(''); try { await apiRequest('auth/profile', { method: 'POST', token, body: { nome: perfilForm.nome, empresa: perfilForm.empresa }, }); setMessage('Perfil atualizado com sucesso.'); loadMe(); } catch (err) { setError(err.message); } finally { setLoading(false); } } async function adicionarTelefone() { if (!novoTelefone.trim()) return; setLoading(true); setError(''); setMessage(''); try { await apiRequest('auth/telefones', { method: 'POST', token, body: { telefone: novoTelefone }, }); setNovoTelefone(''); setMessage('Telefone adicionado.'); loadMe(); } catch (err) { setError(err.message); } finally { setLoading(false); } } async function enviarLogo(file) { if (!file) return; if (file.size > 1024 * 1024) { setError('Logo muito grande (máx. 1MB).'); return; } setLoading(true); setError(''); setMessage(''); try { const dataUrl = await new Promise((resolve, reject) => { const reader = new FileReader(); reader.onload = () => resolve(reader.result); reader.onerror = () => reject(new Error('Não foi possível ler o arquivo selecionado.')); reader.readAsDataURL(file); }); const match = /^data:([^;]+);base64,(.*)$/s.exec(dataUrl); if (!match) throw new Error('Não foi possível ler o arquivo selecionado.'); await apiRequest('auth/logo', { method: 'POST', token, body: { base64: match[2], mimetype: match[1] }, }); setMessage('Logo atualizada com sucesso.'); loadMe(); } catch (err) { setError(err.message); } finally { setLoading(false); } } async function removerLogo() { if (!window.confirm('Remover a logo?')) return; setLoading(true); setError(''); setMessage(''); try { await apiRequest('auth/logo/remover', { method: 'POST', token }); setMessage('Logo removida.'); loadMe(); } catch (err) { setError(err.message); } finally { setLoading(false); } } async function removerTelefone(idTelefone) { if (!window.confirm('Remover este telefone?')) return; setLoading(true); setError(''); setMessage(''); try { await apiRequest('auth/telefones/remover', { method: 'POST', token, body: { id_telefone: idTelefone }, }); setMessage('Telefone removido.'); loadMe(); } catch (err) { setError(err.message); } finally { setLoading(false); } } async function adicionarGeoCamada() { if (!novaGeoCamada.nome.trim() || !novaGeoCamada.wfs_url.trim() || !novaGeoCamada.typename.trim()) return; setLoading(true); setError(''); setMessage(''); try { await apiRequest('geo/camadas', { method: 'POST', token, body: novaGeoCamada, }); setNovaGeoCamada({ nome: '', wfs_url: '', typename: '' }); setMessage('Camada adicionada.'); loadMe(); } catch (err) { setError(err.message); } finally { setLoading(false); } } async function removerGeoCamada(idCamada) { if (!window.confirm('Remover esta camada?')) return; setLoading(true); setError(''); setMessage(''); try { await apiRequest('geo/camadas/remover', { method: 'POST', token, body: { id_camada: idCamada }, }); setMessage('Camada removida.'); loadMe(); } catch (err) { setError(err.message); } finally { setLoading(false); } } async function createPayment(produtoCodigo) { setLoading(true); setError(''); setMessage(''); try { const data = await apiRequest('billing/create', { method: 'POST', token, body: { produto_codigo: produtoCodigo }, }); const pagamento = data.pagamento; setLastPaymentByProduct((prev) => ({ ...prev, [produtoCodigo]: pagamento.id_pagamento })); setMessage(`Pagamento #${pagamento.id_pagamento} criado (${pagamento.modo}).`); loadNotifications(); if (pagamento.checkout_url) { window.open(pagamento.checkout_url, '_blank', 'noopener,noreferrer'); } } catch (err) { setError(err.message); } finally { setLoading(false); } } // Assinatura recorrente de verdade (Preapproval, 2026-09-07) — usada só // pra produto tipo_cobranca === 'assinatura'. checkout_url aqui é a // página do MP onde o cliente autoriza a cobrança recorrente no // cartão; abre em nova aba, mesmo padrão do createPayment. async function createSubscription(produtoCodigo) { setLoading(true); setError(''); setMessage(''); try { const data = await apiRequest('billing/subscribe', { method: 'POST', token, body: { produto_codigo: produtoCodigo }, }); const assinatura = data.assinatura; setMessage(`Assinatura #${assinatura.id_assinatura} criada. Autorize a cobrança recorrente na aba que abriu.`); loadNotifications(); if (assinatura.checkout_url) { window.open(assinatura.checkout_url, '_blank', 'noopener,noreferrer'); } } catch (err) { setError(err.message); } finally { setLoading(false); } } async function cancelSubscription(idAssinatura) { setLoading(true); setError(''); setMessage(''); try { await apiRequest('billing/subscription/cancel', { method: 'POST', token, body: { id_assinatura: idAssinatura }, }); setMessage('Assinatura cancelada. O acesso continua até o fim do período já pago.'); loadMe(); loadNotifications(); } catch (err) { setError(err.message); } finally { setLoading(false); } } async function approveMock(produtoCodigo) { const idPagamento = lastPaymentByProduct[produtoCodigo]; if (!idPagamento) { setError('Crie o pagamento primeiro.'); return; } setLoading(true); setError(''); try { await apiRequest('billing/mock/approve', { method: 'POST', token, body: { id_pagamento: idPagamento }, }); setMessage(`Pagamento #${idPagamento} marcado como pago (modo mock).`); loadExtrato(); loadMe(); loadNotifications(); } catch (err) { setError(err.message); } finally { setLoading(false); } } // Busca unificada (CNPJ ou nome) — o backend decide o tipo (14 // alfanuméricos com dígito = CNPJ, cobre o formato novo alfanumérico da // Receita também; senão nome). Mesma régua usada pelo WhatsApp. // termoOverride (2026-09-08, busca da navbar): setBuscaForm é // assíncrono, então chamar runBuscaQuery() logo em seguida ainda leria o // termo antigo do closure — passar o valor direto evita essa corrida. async function runBuscaQuery(termoOverride) { setLoading(true); setError(''); try { const termo = String(termoOverride ?? buscaForm.termo ?? '').trim(); if (termo.length < 3) { throw new Error('Digite um CNPJ ou um nome para buscar.'); } const data = await apiRequest('queries/busca', { method: 'POST', token, body: { termo }, }); setResultadoBusca(data); setMessage(data.tipo === 'cnpj' ? 'Consulta por CNPJ concluída.' : 'Consulta por nome concluída.'); loadExtrato(); } catch (err) { setError(err.message); } finally { setLoading(false); } } // Busca da navbar (2026-09-08, substitui a sidebar) — mesma busca da // aba Consultas, só que acessível de qualquer tela. function runBuscaFromNavbar() { const termo = navBusca; setBuscaForm({ termo }); setModoConsulta('busca'); irPara('consultas'); setMobileNavOpen(false); runBuscaQuery(termo); } // Mesmo formato aceito pelo comando de localizacao no WhatsApp: extrai // lat/lon de um link colado do Google Maps (.../@lat,lon,... ou ?q=lat,lon). function preencherPorLinkMaps() { setError(''); const link = String(mapsLinkInput || '').trim(); const arroba = link.match(/@(-?\d+\.\d+),(-?\d+\.\d+)/); const query = link.match(/[?&]q=(-?\d+\.\d+),(-?\d+\.\d+)/); const par = arroba || query; if (!par) { setError('Nao foi possivel encontrar coordenadas nesse link. Cole um link de local do Google Maps.'); return; } setGeoForm((prev) => ({ ...prev, latitude: Number(par[1]), longitude: Number(par[2]) })); setMessage('Coordenadas preenchidas a partir do link.'); } function toggleMapaLayer(chave) { setMapaLayersAtivas((prev) => ({ ...prev, [chave]: !prev[chave] })); } async function runGeoQuery() { setLoading(true); setError(''); try { const wmsFontes = WMS_SOURCES.map((source) => ({ nome: source.nome, url: source.url, layers: source.layers, })); const body = { latitude: Number(geoForm.latitude), longitude: Number(geoForm.longitude), wms_fontes: wmsFontes, }; if (poligonoPontos && poligonoPontos.length >= 3) { body.poligono = poligonoPontos; } else { body.raio_km = Number(raioMetros) / 1000; } const data = await apiRequest('queries/geo', { method: 'POST', token, body, }); setResultadoGeo(data); setMessage('Consulta geográfica concluída com todas as camadas WMS.'); loadExtrato(); } catch (err) { setError(err.message); } finally { setLoading(false); } } function limparPoligono() { setPoligonoPontos(null); setDesenhandoPoligono(false); } function limparMapaPoligono() { setMapaPoligonoPontos(null); } useEffect(() => { if (!gerandoPdf) { setPdfEtapaIndex(0); return; } const intervalo = setInterval(() => { setPdfEtapaIndex((i) => (i + 1) % PDF_ETAPAS.length); }, 4500); return () => clearInterval(intervalo); }, [gerandoPdf]); // Prévia em tela (2026-09-08) — MESMA busca/derivação de ponto-ou- // polígono do baixarPdfGeo logo abaixo, só que devolve JSON (rota // geo/overlap) em vez de PDF — bem mais rápido (segundos, não ~1min), // pensado pra rodar ANTES de decidir baixar o relatório formatado. async function consultarSobreposicoes() { setLoading(true); setConsultandoSobreposicoes(true); setError(''); try { const body = { latitude: Number(geoForm.latitude), longitude: Number(geoForm.longitude), }; if (mapaPoligonoPontos && mapaPoligonoPontos.length >= 3) { body.poligono = mapaPoligonoPontos; } else { body.raio_km = mapaRaioMetros / 1000; } const data = await apiRequest('geo/overlap', { method: 'POST', token, body }); setSobreposicoesResultado(data.resultados || []); } catch (err) { setError(err.message); } finally { setLoading(false); setConsultandoSobreposicoes(false); } } // Mesmo relatorio que o "1" do WhatsApp gera, so que baixado direto pelo // site. Nao usa apiRequest porque a resposta e um PDF binario, nao JSON. async function baixarPdfGeo() { setLoading(true); setGerandoPdf(true); setError(''); setMessage(''); try { const body = { latitude: Number(geoForm.latitude), longitude: Number(geoForm.longitude), }; // Área desenhada substitui o ponto — mesmo padrão de runGeoQuery // (Consultas > Por localização): o backend deriva centroide+raio do // polígono e valida o limite de 3 km², latitude/longitude aqui viram // só o fallback quando não há polígono. if (mapaPoligonoPontos && mapaPoligonoPontos.length >= 3) { body.poligono = mapaPoligonoPontos; } else { body.raio_km = mapaRaioMetros / 1000; } const response = await fetch(`${API_BASE}/pdf/geo`, { method: 'POST', headers: { Authorization: `Bearer ${token}`, 'Content-Type': 'application/json' }, body: JSON.stringify(body), }); if (!response.ok) { const data = await response.json().catch(() => ({})); throw new Error(data.message || 'Falha ao gerar o relatorio em PDF.'); } const blob = await response.blob(); const url = URL.createObjectURL(blob); const a = document.createElement('a'); a.href = url; a.download = 'relatorio-geoespacial.pdf'; document.body.appendChild(a); a.click(); a.remove(); URL.revokeObjectURL(url); setMessage('Relatório em PDF gerado com sucesso.'); } catch (err) { setError(err.message); } finally { setLoading(false); setGerandoPdf(false); } } async function runProblematicQuery() { setLoading(true); setError(''); try { const data = await apiRequest('queries/problematic-cnpjs', { method: 'POST', token, body: { limite: Number(listaForm.limite), }, }); setResultadoProblematicos(data); setMessage('Lista de CNPJs gerada com sucesso.'); loadExtrato(); } catch (err) { setError(err.message); } finally { setLoading(false); } } async function createMonitoramento() { setLoading(true); setError(''); try { await apiRequest('monitor/create', { method: 'POST', token, body: { tipo_alvo: monitorForm.tipo_alvo, valor_alvo: monitorForm.valor_alvo, palavras_chave: monitorForm.palavras_chave, raio_km: monitorForm.raio_km ? Number(monitorForm.raio_km) : null, }, }); setMessage('Monitoramento criado.'); setMonitorForm({ tipo_alvo: 'cnpj', valor_alvo: '', palavras_chave: '', raio_km: '' }); loadMonitoramentos(); } catch (err) { setError(err.message); } finally { setLoading(false); } } async function toggleMonitoramento(idMonitoramento, status) { setLoading(true); setError(''); try { await apiRequest('monitor/toggle', { method: 'POST', token, body: { id_monitoramento: idMonitoramento, status: status === 'ativo' ? 'inativo' : 'ativo' }, }); loadMonitoramentos(); } catch (err) { setError(err.message); } finally { setLoading(false); } } async function createRadarItem() { setLoading(true); setError(''); try { await apiRequest('radar/create', { method: 'POST', token, body: { tipo_alvo: radarForm.tipo_alvo, valor_alvo: radarForm.valor_alvo, nome_alvo: radarForm.nome_alvo, auto_cadastro: radarForm.auto_cadastro, }, }); setMessage('Item incluído no Radar com sucesso.'); setRadarForm({ tipo_alvo: radarForm.tipo_alvo, valor_alvo: '', nome_alvo: '', auto_cadastro: true }); loadRadar(); loadNotifications(); } catch (err) { setError(err.message); } finally { setLoading(false); } } async function toggleRadarItem(idRadar, status) { setLoading(true); setError(''); try { await apiRequest('radar/toggle', { method: 'POST', token, body: { id_radar: idRadar, status: status === 'ativo' ? 'inativo' : 'ativo', }, }); loadRadar(); loadNotifications(); } catch (err) { setError(err.message); } finally { setLoading(false); } } async function markNotificationAsRead(idNotificacao) { try { await apiRequest('notifications/mark-read', { method: 'POST', token, body: { id_notificacao: idNotificacao }, }); loadNotifications(); } catch (err) { setError(err.message); } } async function markAllNotificationsAsRead() { try { await apiRequest('notifications/mark-all-read', { method: 'POST', token, body: {}, }); loadNotifications(); setMessage('Notificações marcadas como lidas.'); } catch (err) { setError(err.message); } } async function sendNotificationTest() { try { await apiRequest('notifications/test', { method: 'POST', token, body: {}, }); loadNotifications(); setMessage('Notificação de teste enviada.'); } catch (err) { setError(err.message); } } const activeWmsSources = useMemo( () => WMS_SOURCES.filter((source) => source && source.url && source.layers), [] ); const isPurchaseExempt = ['admin', 'ilimitado'].includes(String(usuario?.perfil || '').toLowerCase()); const mainViews = [ { id: 'compras', label: 'Compras' }, { id: 'consultas', label: 'Consultas' }, { id: 'mapa', label: 'Mapa' }, { id: 'alertas', label: 'Alertas IA' }, { id: 'radar', label: 'Radar' }, { id: 'notificacoes', label: 'Notificações', badge: unreadCount }, ]; const camadasDisponiveis = useMemo(() => { const globais = WMS_GROUPS.map((g) => ({ chave: g.chave, nome: g.nome, curto: g.curto, origem: g.origem, fontes: g.fontes, })); const proprias = (geoCamadas || []).map((c) => ({ chave: `geo:${c.id_camada}`, nome: c.nome, origem: 'Camada própria (Configurações)', fontes: [{ nome: c.nome, url: c.wfs_url, layers: c.typename }], })); return [...globais, ...proprias]; }, [geoCamadas]); // Memoizado à parte (não junto do JSX) pra não recriar a lista (e o // GeoMap não redesenhar as camadas WMS do zero, o CAR sozinho já é 27 // fontes) a cada tecla digitada em latitude/longitude — só muda quando // as camadas ativas realmente mudam. const mapaWmsSourcesAtivas = useMemo(() => { return [ ...camadasDisponiveis.filter((c) => mapaLayersAtivas[c.chave]).flatMap((c) => c.fontes), ...POINT_LAYERS.filter((def) => pointLayersAtivos[def.id] && def.wmsFonte).map((def) => def.wmsFonte), ]; }, [camadasDisponiveis, mapaLayersAtivas, pointLayersAtivos]); const advancedViews = [ { id: 'extrato', label: 'Extrato' }, { id: 'monitoramento', label: 'Monitoramento' }, { id: 'configuracoes', label: 'Configurações' }, { id: 'enquadramento', label: 'Enquadramento' }, { id: 'sobre', label: 'Sobre' }, ]; const header = ( <header className="diario-header"> <div className="diario-header-row"> {/* Hamburguer só existe logado — abre o menu suspenso mobile que substitui a antiga sidebar (2026-09-08). */} {token && ( <button type="button" className="diario-header-hamburger" title={mobileNavOpen ? 'Fechar menu' : 'Abrir menu'} onClick={() => setMobileNavOpen((s) => !s)} > <i className={`fas ${mobileNavOpen ? 'fa-xmark' : 'fa-bars'}`}></i> </button> )} <a href="/" className="diario-header-brand" style={{ textDecoration: 'none' }}> <img src="/assets/img/logo_completa.png" alt="GeoPJ" /> </a> {/* Sem link "Sobre" separado pro visitante: a cobertura/valores agora vivem na própria home (2026-09-07, pedido do Anderson). O botão do hero ("Ver cobertura e valores") rola até a seção. */} {/* Busca da navbar (2026-09-08) — reaproveita a mesma busca por CNPJ/nome da aba Consultas (runBuscaFromNavbar), só logado. */} {token && ( <form className="diario-header-search" onSubmit={(e) => { e.preventDefault(); runBuscaFromNavbar(); }}> <i className="fas fa-magnifying-glass"></i> <input type="text" placeholder="Buscar CNPJ, nome ou local..." value={navBusca} onChange={(e) => setNavBusca(e.target.value)} /> </form> )} <div className="diario-header-actions"> <a href="/noticias/" className="diario-prelaunch-badge diario-nav-badge-link" title="Notícias"> <i className="fas fa-newspaper"></i> <span className="diario-nav-badge-text"> Notícias</span> </a> {token && ( <button type="button" className="diario-theme-toggle" title={effectiveTheme === 'dark' ? 'Mudar para tema claro' : 'Mudar para tema escuro'} onClick={() => setThemePref(effectiveTheme === 'dark' ? 'light' : 'dark')} > <i className={effectiveTheme === 'dark' ? 'fas fa-sun' : 'fas fa-moon'}></i> </button> )} {token ? ( <> <span className="diario-prelaunch-badge"> <span className="diario-prelaunch-badge-full">Versão de </span>Pré-lançamento </span> <span className="diario-user-chip"> <span className="diario-user-avatar">{(usuario?.nome || 'U').trim().charAt(0).toUpperCase()}</span> <span className="diario-header-user">{usuario?.nome || 'usuário'}</span> </span> <button className="btn btn-light btn-sm" onClick={logout}>Sair</button> </> ) : ( <> <span className="diario-prelaunch-badge"> <span className="diario-prelaunch-badge-full">Versão de </span>Pré-lançamento </span> <button className="btn btn-primary btn-sm" onClick={() => setLoginPanelOpen((s) => !s)}> Entrar </button> </> )} {!token && loginPanelOpen && ( <div className="diario-login-panel"> {authMode !== 'forgot' && ( <div className="d-flex gap-2 mb-3"> <button className={`btn btn-sm ${authMode === 'login' ? 'btn-primary' : 'btn-light'}`} onClick={() => { setAuthMode('login'); setError(''); setMessage(''); }} > Entrar </button> <button className={`btn btn-sm ${authMode === 'register' ? 'btn-primary' : 'btn-light'}`} onClick={() => { setAuthMode('register'); setError(''); setMessage(''); }} > Criar conta </button> </div> )} {(authMode === 'login' || authMode === 'register') && ( <> <div className="d-grid gap-2"> {authMode === 'register' && ( <input className="form-control" placeholder="Nome" value={authForm.nome} onChange={(e) => setAuthForm({ ...authForm, nome: e.target.value })} /> )} <input className="form-control" placeholder="Email" value={authForm.email} onChange={(e) => setAuthForm({ ...authForm, email: e.target.value })} /> <input className="form-control" type="password" placeholder="Senha" value={authForm.senha} onChange={(e) => setAuthForm({ ...authForm, senha: e.target.value })} /> {authMode === 'register' && ( <> <input className="form-control" placeholder="CPF (opcional)" value={authForm.cpf} onChange={(e) => setAuthForm({ ...authForm, cpf: e.target.value })} /> <input className="form-control" placeholder="Telefone (opcional)" value={authForm.telefone} onChange={(e) => setAuthForm({ ...authForm, telefone: e.target.value })} /> </> )} </div> <button className="btn btn-primary w-100 mt-3" disabled={loading} onClick={() => doAuth(authMode)}> {authMode === 'login' ? 'Entrar' : 'Criar conta'} </button> {authMode === 'login' && ( <button type="button" className="btn btn-link btn-sm p-0 mt-2" onClick={() => { setResetForm({ email: authForm.email, codigo: '', novaSenha: '' }); setForgotStep('request'); setAuthMode('forgot'); setError(''); setMessage(''); }} > Esqueci minha senha </button> )} <p className="diario-note mt-2 mb-0" style={{ fontSize: '0.78rem' }}> Senha com política forte. Dados sensíveis ficam protegidos por criptografia no backend. </p> </> )} {authMode === 'forgot' && forgotStep === 'request' && ( <div className="d-grid gap-2"> <p className="diario-note mt-0 mb-1" style={{ fontSize: '0.78rem' }}> Informe o email da sua conta. Vamos enviar um código de verificação. </p> <input className="form-control" placeholder="Email" value={resetForm.email} onChange={(e) => setResetForm({ ...resetForm, email: e.target.value })} /> <button className="btn btn-primary w-100 mt-1" disabled={loading || !resetForm.email} onClick={solicitarCodigoReset}> Enviar código </button> <button type="button" className="btn btn-link btn-sm p-0" onClick={() => { setAuthMode('login'); setError(''); setMessage(''); }}> Voltar para o login </button> </div> )} {authMode === 'forgot' && forgotStep === 'code' && ( <div className="d-grid gap-2"> <p className="diario-note mt-0 mb-1" style={{ fontSize: '0.78rem' }}> Enviamos um código para {resetForm.email}. Confira também o spam — ele expira em 15 minutos. </p> <input className="form-control" placeholder="Código de 6 dígitos" inputMode="numeric" maxLength={6} value={resetForm.codigo} onChange={(e) => setResetForm({ ...resetForm, codigo: e.target.value.replace(/\D/g, '').slice(0, 6) })} /> <input className="form-control" type="password" placeholder="Nova senha" value={resetForm.novaSenha} onChange={(e) => setResetForm({ ...resetForm, novaSenha: e.target.value })} /> <button className="btn btn-primary w-100 mt-1" disabled={loading || resetForm.codigo.length !== 6 || !resetForm.novaSenha} onClick={redefinirSenha}> Redefinir senha </button> <button type="button" className="btn btn-link btn-sm p-0" onClick={() => { setForgotStep('request'); setError(''); setMessage(''); }}> Reenviar código </button> </div> )} {message && <div className="alert alert-success mt-2 mb-0 py-2">{message}</div>} {error && <div className="alert alert-danger mt-2 mb-0 py-2">{error}</div>} </div> )} </div> </div> {/* Linha 2 — abas horizontais + "···" com opções avançadas, no lugar da sidebar (2026-09-08, referência visual Organic "Application"): só telas >= diario-header-subrow (ver CSS, escondida no mobile — lá quem navega é o drawer do hamburguer). */} {token && ( <div className="diario-header-subrow"> <div className="diario-header-subrow-inner"> <nav className="diario-nav-tabs"> {mainViews.map((item) => ( <button key={item.id} className={`diario-nav-tab ${view === item.id ? 'active' : ''}`} onClick={() => irPara(item.id)}> {item.label} {item.badge > 0 && <span className="diario-badge">{item.badge}</span>} </button> ))} </nav> <div className="diario-nav-more" onMouseLeave={() => setAdvancedMenu(false)}> <button type="button" className={`diario-nav-more-trigger ${advancedMenu ? 'active' : ''}`} title="Mais opções" onClick={() => setAdvancedMenu((s) => !s)} > <i className="fas fa-ellipsis"></i> </button> {advancedMenu && ( <div className="diario-nav-more-menu"> {advancedViews.map((item) => ( <button key={item.id} className={`diario-nav-more-item ${view === item.id ? 'active' : ''}`} onClick={() => { irPara(item.id); setAdvancedMenu(false); }} > {item.label} </button> ))} </div> )} </div> </div> </div> )} {/* Drawer mobile do hamburguer — mainViews + advancedViews juntos, único lugar de navegação abaixo do breakpoint onde a subrow some. */} {token && mobileNavOpen && ( <div className="diario-mobile-nav-drawer"> {mainViews.map((item) => ( <button key={item.id} className={`diario-nav-btn ${view === item.id ? 'active' : ''}`} onClick={() => { irPara(item.id); setMobileNavOpen(false); }} > <span>{item.label}</span> {item.badge > 0 && <span className="diario-badge">{item.badge}</span>} </button> ))} <div className="diario-mobile-nav-sep"></div> {advancedViews.map((item) => ( <button key={item.id} className={`diario-nav-btn ${view === item.id ? 'active' : ''}`} onClick={() => { irPara(item.id); setMobileNavOpen(false); }} > {item.label} </button> ))} </div> )} <div className="diario-header-strip"></div> </header> ); if (!token) { return ( <div> {header} <div className="diario-content"> {/* Veio do link do WhatsApp: a matéria aparece antes de tudo, acima da institucional. */} <MateriaCard materia={materia} onFechar={fecharMateria} /> <div className="diario-hero"> <span className="diario-chip">Consultas, Radar e Mapa em um único lugar</span> <h1 className="diario-hero-titulo mb-2"> Diários oficiais organizados por CNPJ, nome e localização </h1> <p className="diario-hero-sub mb-3"> O mesmo motor de busca usado no WhatsApp, agora também aqui: consulte um CNPJ, um nome ou um ponto no mapa, acompanhe pelo Radar e monte seu próprio mapa com as camadas geoespaciais que importam para o seu trabalho. </p> <div className="diario-hero-cta"> <button className="btn btn-primary" onClick={() => { setAuthMode('register'); setLoginPanelOpen(true); }}> Criar conta </button> <a href="#cobertura" className="btn btn-light"> Ver cobertura e valores </a> </div> </div> {/* Valores + mapa de cobertura, integrados direto na home (2026-09-07, pedido do Anderson — antes era uma rota /sobre separada). SobrePage segue existindo como o mesmo componente usado na aba "Sobre" do dashboard logado, sem duplicar código. */} <div id="cobertura"> <SobrePage /> </div> <div> <h3 className="mb-3">Como funciona</h3> <div className="diario-feature-trio"> <div className="diario-feature-card"> <span className="diario-feature-icon"><i className="fa-brands fa-whatsapp"></i></span> <h4>Consulte</h4> <p>Envie um CNPJ, um nome ou um local pelo WhatsApp, ou faça a mesma busca aqui no site.</p> </div> <div className="diario-feature-card"> <span className="diario-feature-icon"><i className="fa-solid fa-newspaper"></i></span> <h4>Receba</h4> <p>As publicações de diários oficiais relacionadas chegam com o link para o documento original.</p> </div> <div className="diario-feature-card"> <span className="diario-feature-icon"><i className="fa-solid fa-satellite-dish"></i></span> <h4>Acompanhe</h4> <p>Ative o Radar para ser avisado automaticamente sempre que algo novo aparecer.</p> </div> </div> </div> <footer className="diario-footer"> <div className="diario-footer-col"> <strong>GEOPJ</strong> <div className="diario-note">Diários oficiais organizados por CNPJ, nome e localização.</div> </div> <div className="diario-footer-col"> <div>Produto</div> <a href="#cobertura">Cobertura e valores</a> </div> <div className="diario-footer-col"> <div>Contato</div> <a href="mailto:contato@andersonstaub.com">contato@andersonstaub.com</a> </div> </footer> </div> </div> ); } return ( <div> {header} <div className="diario-content diario-app-shell"> <main className="diario-main"> {message && <div className="alert alert-success">{message}</div>} {error && <div className="alert alert-danger">{error}</div>} <MateriaCard materia={materia} onFechar={fecharMateria} /> {view === 'compras' && ( <section> <h3 className="mb-2">Compras com fluxo simplificado</h3> {isPurchaseExempt && ( <div className="alert alert-info"> Perfil <strong>{String(usuario?.perfil || '').toLowerCase()}</strong> com isenção de compra: consultas executam sem exigir pagamento. </div> )} {planoAtivo && ( <div className="alert alert-success d-flex justify-content-between align-items-center flex-wrap gap-2"> <span> Assinatura ativa até <strong>{toDisplayDate(planoAtivo.dt_fim)}</strong> com limite de {planoAtivo.limite_monitoramentos} monitoramentos. {planoAtivo.id_assinatura && <span className="diario-note"> Cobrança recorrente no cartão, renovada automaticamente pelo Mercado Pago.</span>} </span> {planoAtivo.id_assinatura && ( <button className="btn btn-light btn-sm" onClick={() => cancelSubscription(planoAtivo.id_assinatura)} disabled={loading}> Cancelar assinatura </button> )} </div> )} <div className="diario-grid"> {produtos.map((produto) => { const ehAssinatura = produto.tipo_cobranca === 'assinatura'; return ( <div className="diario-card" key={produto.codigo}> <span className="diario-tag">{produto.tipo_cobranca}</span> <h4 className="mt-2 mb-1">{produto.nome}</h4> <div className="diario-note">{produto.descricao}</div> <PrecoComDesconto produto={produto} /> {!isPurchaseExempt ? ( <> <div className="mt-3 d-flex gap-2 flex-wrap"> {ehAssinatura ? ( <button className="btn btn-primary btn-sm" onClick={() => createSubscription(produto.codigo)} disabled={loading}> Assinar (cobrança recorrente) </button> ) : ( <> <button className="btn btn-primary btn-sm" onClick={() => createPayment(produto.codigo)} disabled={loading}>Gerar cobrança</button> <button className="btn btn-light btn-sm" onClick={() => approveMock(produto.codigo)} disabled={loading}>Aprovar mock</button> </> )} </div> {!ehAssinatura && ( <div className="diario-note mt-2">Último pagamento: {lastPaymentByProduct[produto.codigo] || '-'}</div> )} </> ) : ( <div className="diario-note mt-3">Cobrança dispensada para este perfil.</div> )} </div> ); })} </div> </section> )} {view === 'consultas' && ( <section> <div className="app-card"> <div className="d-flex justify-content-between align-items-start flex-wrap gap-2 mb-2"> <h4 className="mb-0">{modoConsulta === 'busca' ? 'Busca por CNPJ ou nome' : 'Busca por localização'}</h4> <div className="d-flex gap-2"> <button className={`btn btn-sm ${modoConsulta === 'busca' ? 'btn-primary' : 'btn-light'}`} onClick={() => setModoConsulta('busca')}> Por CNPJ / nome </button> <button className={`btn btn-sm ${modoConsulta === 'localizacao' ? 'btn-primary' : 'btn-light'}`} onClick={() => setModoConsulta('localizacao')}> Por localização </button> </div> </div> {isPurchaseExempt ? ( <div className="alert alert-success py-2">Perfil com isenção ativa. As consultas não exigem compra.</div> ) : ( <div className="alert alert-info py-2">O pagamento é reconhecido automaticamente pelo banco.</div> )} {modoConsulta === 'busca' && ( <> <div className="d-flex gap-2 flex-wrap align-items-center"> <input className="form-control form-control-sm" style={{ maxWidth: '320px' }} placeholder="CNPJ ou nome completo" value={buscaForm.termo} onChange={(e) => setBuscaForm({ termo: e.target.value })} /> <button className="btn btn-primary btn-sm" disabled={loading} onClick={() => runBuscaQuery()}>Buscar</button> </div> <div className="diario-note mt-2">A mesma busca que o número do WhatsApp faz ao receber um CNPJ ou um nome.</div> </> )} {modoConsulta === 'localizacao' && ( <> <div className="d-flex gap-2 flex-wrap align-items-center mb-2"> <input className="form-control form-control-sm" style={{ maxWidth: '300px' }} placeholder="Colar link do Google Maps" value={mapsLinkInput} onChange={(e) => setMapsLinkInput(e.target.value)} /> <button className="btn btn-light btn-sm" type="button" onClick={preencherPorLinkMaps}>Usar link</button> </div> <div className="d-flex gap-2 flex-wrap align-items-center mb-2"> <input className="form-control form-control-sm" style={{ maxWidth: '140px' }} type="number" step="0.000001" placeholder="Latitude" value={geoForm.latitude} onChange={(e) => setGeoForm({ ...geoForm, latitude: e.target.value })} /> <input className="form-control form-control-sm" style={{ maxWidth: '140px' }} type="number" step="0.000001" placeholder="Longitude" value={geoForm.longitude} onChange={(e) => setGeoForm({ ...geoForm, longitude: e.target.value })} /> {!poligonoPontos && ( <div className="diario-map-toolbar-opacity"> <span>Raio (m)</span> <input className="form-control form-control-sm" style={{ maxWidth: '90px' }} type="number" min="10" max="1000" value={raioMetros} onChange={(e) => setRaioMetros(Math.min(1000, Math.max(10, Number(e.target.value) || 10)))} /> </div> )} <button className={`btn btn-sm ${desenhandoPoligono ? 'btn-primary' : 'btn-light'}`} type="button" onClick={() => setDesenhandoPoligono((s) => !s)} > {desenhandoPoligono ? 'Clique no mapa para marcar os vértices...' : 'Desenhar área'} </button> {poligonoPontos && ( <button className="btn btn-light btn-sm" type="button" onClick={limparPoligono}>Limpar área</button> )} </div> {poligonoPontos && ( <div className={`diario-note mb-2 ${areaPoligonoKm2(poligonoPontos) > 3 ? 'diario-status-erro' : ''}`}> Área desenhada: ~{areaPoligonoKm2(poligonoPontos).toFixed(2)} km² {areaPoligonoKm2(poligonoPontos) > 3 && ' — acima do limite de 3 km², desenhe uma área menor.'} </div> )} <LocationSearchMap latitude={Number(geoForm.latitude)} longitude={Number(geoForm.longitude)} raioM={Number(raioMetros)} onCenterChange={({ latitude, longitude }) => setGeoForm((prev) => ({ ...prev, latitude, longitude }))} poligono={poligonoPontos} onPoligonoChange={(pontos) => { const area = areaPoligonoKm2(pontos); if (area > 3) { setError(`A área desenhada (${area.toFixed(2)} km²) passa do limite de 3 km². Desenhe uma área menor.`); setDesenhandoPoligono(false); return; } setPoligonoPontos(pontos); setDesenhandoPoligono(false); }} desenhando={desenhandoPoligono} /> <div className="mt-3 d-flex gap-2 flex-wrap"> <button className="btn btn-primary btn-sm" disabled={loading} onClick={runGeoQuery}>Executar consulta</button> <button className="btn btn-light btn-sm" disabled={loading} onClick={baixarPdfGeo}>Baixar PDF</button> </div> <div className="diario-note mt-2"> Mapa sem camadas ligadas, só para marcar o ponto ou a área. A consulta verifica {activeWmsSources.length}{' '} camadas WMS por trás. Limite de 1000 m de raio ou 3 km² de área, também conferido no servidor. </div> </> )} </div> {modoConsulta === 'busca' && resultadoBusca && ( resultadoBusca.tipo === 'cnpj' ? <ResultadoCnpj resultado={resultadoBusca} /> : <ResultadoNome resultado={resultadoBusca} /> )} {modoConsulta === 'localizacao' && <ResultadoGeo resultado={resultadoGeo} />} </section> )} {view === 'alertas' && ( <section> <h3 className="mb-2">Alertas IA</h3> <div className="alert alert-info"> Hoje o critério é uma lista de palavras-chave de risco (fraude, multa, embargo etc.) em citações de diário oficial. Em breve isso vai virar uma análise com IA (RAG sobre o conteúdo dos diários), trazendo resultados mais precisos. </div> <div className="app-card"> <h4>Lista de CNPJs com possíveis problemas</h4> <div className="d-flex gap-2 flex-wrap align-items-center"> <input className="form-control form-control-sm" style={{ maxWidth: '110px' }} type="number" min="1" max="50" placeholder="Limite" value={listaForm.limite} onChange={(e) => setListaForm({ ...listaForm, limite: e.target.value })} /> <button className="btn btn-primary btn-sm" disabled={loading} onClick={runProblematicQuery}>Gerar lista</button> </div> </div> <ResultadoProblematicos resultado={resultadoProblematicos} /> </section> )} {view === 'mapa' && ( <section> <h3 className="mb-2">Mapa geral</h3> {/* Relatório em PDF (2026-09-08, pedido do Anderson: botão pro topo + aceitar polígono, não só o ponto). Mesmo limite de 3 km² e mesmo componente areaPoligonoKm2 já usados em Consultas > Por localização. O ícone de desenhar área agora fica NO PRÓPRIO mapa (canto superior esquerdo, leaflet-draw) — aqui só sobra "Limpar área" quando já existe um polígono. */} <div className="app-card"> <div className="d-flex gap-2 flex-wrap align-items-center"> <button className="btn btn-primary" disabled={loading} onClick={consultarSobreposicoes}> Consultar dados </button> <button className="btn btn-light" disabled={loading} onClick={baixarPdfGeo}> Gerar relatório em PDF </button> {mapaPoligonoPontos && ( <button className="btn btn-light btn-sm" type="button" onClick={limparMapaPoligono}>Limpar área</button> )} {!mapaPoligonoPontos && ( <div className="diario-map-toolbar-opacity"> <span>Raio (m)</span> <input className="form-control form-control-sm" style={{ maxWidth: '90px' }} type="number" min="10" max="1000" value={mapaRaioMetros} onChange={(e) => setMapaRaioMetros(Math.min(1000, Math.max(10, Number(e.target.value) || 10)))} /> </div> )} </div> {mapaPoligonoPontos ? ( <div className={`diario-note mt-2 ${areaPoligonoKm2(mapaPoligonoPontos) > 3 ? 'diario-status-erro' : ''}`}> Vai usar a área desenhada: ~{areaPoligonoKm2(mapaPoligonoPontos).toFixed(2)} km² {areaPoligonoKm2(mapaPoligonoPontos) > 3 && ' — acima do limite de 3 km², desenhe uma área menor.'} </div> ) : ( <div className="diario-note mt-2"> Usa o pino atual do mapa (o mesmo ponto marcado abaixo), com raio de {mapaRaioMetros} m. Use o ícone de polígono no canto do mapa pra desenhar uma área em vez de um raio. </div> )} {gerandoPdf && ( <div className="diario-pdf-loading mt-3"> <span className="diario-spinner"></span> <span>{PDF_ETAPAS[pdfEtapaIndex]}</span> </div> )} {consultandoSobreposicoes && ( <div className="diario-pdf-loading mt-3"> <span className="diario-spinner"></span> <span>Consultando DETER, PRODES, CAR, IBAMA, ANM...</span> </div> )} </div> {/* Prévia em tela (2026-09-08, pedido do Anderson: "antes de gerar o relatório vamos exibir os dados na tela" + "exibir todos os dados do item encontrado, não só quantitativos e título") — mesma busca do PDF, sem esperar o PDF inteiro pra ver algo, e sem cortar as propriedades da feição (o PDF corta pra caber no espaço da página; aqui não tem esse limite, mostra tudo que a fonte devolveu). */} {sobreposicoesResultado && ( <div className="app-card"> <h4 className="mb-2">Dados encontrados na área</h4> {sobreposicoesResultado.length === 0 && ( <p className="diario-note mb-0">Nenhuma camada respondeu para esta área.</p> )} {sobreposicoesResultado.map((camada, i) => ( <div key={i} className="diario-overlap-camada"> <div className="d-flex align-items-center gap-2 flex-wrap mb-1"> <strong>{camada.camada}</strong> <span className="diario-tag">{camada.quantidade} registro(s)</span> </div> {camada.fonte_url && <div className="diario-note mb-2">Fonte: {camada.fonte_url}</div>} {(camada.exemplos || []).length === 0 ? ( <p className="diario-note">Nenhum registro dentro da área.</p> ) : ( camada.exemplos.map((exemplo, j) => <ExemploOverlay key={j} exemplo={exemplo} />) )} {camada.quantidade > (camada.exemplos || []).length && ( <p className="diario-note mb-0"> + {camada.quantidade - (camada.exemplos || []).length} outro(s) registro(s) não detalhado(s) aqui. </p> )} </div> ))} </div> )} <div className="diario-map-toolbar"> <input className="form-control form-control-sm" type="number" step="0.000001" placeholder="Latitude" value={geoForm.latitude} onChange={(e) => setGeoForm({ ...geoForm, latitude: e.target.value })} /> <input className="form-control form-control-sm" type="number" step="0.000001" placeholder="Longitude" value={geoForm.longitude} onChange={(e) => setGeoForm({ ...geoForm, longitude: e.target.value })} /> <div className="diario-map-toolbar-sep"></div> {POINT_LAYERS.map((def) => ( <button key={def.id} type="button" className={`diario-layer-chip ${pointLayersAtivos[def.id] ? 'is-active' : ''}`} onClick={() => setPointLayersAtivos((prev) => ({ ...prev, [def.id]: !prev[def.id] }))} > {def.curto || def.nome} </button> ))} {camadasDisponiveis.map((camada) => ( <button key={camada.chave} type="button" className={`diario-layer-chip ${mapaLayersAtivas[camada.chave] ? 'is-active' : ''}`} onClick={() => toggleMapaLayer(camada.chave)} > {camada.curto || camada.nome} </button> ))} <div className="diario-map-toolbar-sep"></div> <div className="diario-map-toolbar-opacity"> <span>Opacidade</span> <input type="range" min="0" max="1" step="0.05" value={geoForm.wms_opacity} onChange={(e) => setGeoForm({ ...geoForm, wms_opacity: Number(e.target.value) })} /> </div> </div> <GeoMap big latitude={Number(geoForm.latitude)} longitude={Number(geoForm.longitude)} wmsSources={mapaWmsSourcesAtivas} wmsOpacity={Number(geoForm.wms_opacity)} onCenterChange={({ latitude, longitude }) => setGeoForm((prev) => ({ ...prev, latitude, longitude }))} pointLayersAtivos={pointLayersAtivos} token={token} poligono={mapaPoligonoPontos} onPoligonoChange={setMapaPoligonoPontos} /> <div className="diario-note mt-2"> Arraste o mapa para mudar o centro. Empresas e Licenças atualizam sozinhas conforme você navega, mostrando até 300 pontos por vez na área visível. </div> </section> )} {view === 'radar' && ( <section> <h3 className="mb-2">Radar de CPF, CNPJ e nomes</h3> {planoAtivo ? ( <p className="diario-note">Plano ativo até {toDisplayDate(planoAtivo.dt_fim)}. Cadastre itens e acompanhe alertas nas notificações.</p> ) : ( <div className="alert alert-warning">Para usar o Radar, ative o plano mensal de monitoramento.</div> )} <div className="app-card"> <h4>Novo item no Radar</h4> <div className="diario-form-grid"> <select className="form-select" value={radarForm.tipo_alvo} onChange={(e) => setRadarForm({ ...radarForm, tipo_alvo: e.target.value })}> <option value="cnpj">CNPJ</option> <option value="cpf">CPF</option> <option value="nome">Nome</option> </select> <input className="form-control" placeholder="Documento ou nome" value={radarForm.valor_alvo} onChange={(e) => setRadarForm({ ...radarForm, valor_alvo: e.target.value })} /> <input className="form-control" placeholder="Nome de referência (opcional)" value={radarForm.nome_alvo} onChange={(e) => setRadarForm({ ...radarForm, nome_alvo: e.target.value })} /> <label className="diario-switch"> <input type="checkbox" checked={radarForm.auto_cadastro} onChange={(e) => setRadarForm({ ...radarForm, auto_cadastro: e.target.checked })} /> <span>Auto preencher dados cadastrais</span> </label> </div> <button className="btn btn-primary mt-3" disabled={loading || !planoAtivo} onClick={createRadarItem}>Salvar no Radar</button> </div> <div className="app-card"> <h4>Itens cadastrados</h4> <div className="diario-table-wrap"> <table className="diario-table"> <thead> <tr> <th>ID</th> <th>Tipo</th> <th>Alvo</th> <th>Dados cadastrais</th> <th>Status</th> <th>Ação</th> </tr> </thead> <tbody> {radarItems.map((item) => ( <tr key={item.id_radar}> <td>{item.id_radar}</td> <td>{item.tipo_alvo}</td> <td>{item.valor_alvo}</td> <td> <div className="diario-note"> {item.dados_cadastrais?.nome ? <strong>{item.dados_cadastrais.nome}</strong> : 'Sem complemento'} <br /> Origem: {item.dados_cadastrais?.origem || item.origem || '-'} </div> </td> <td><StatusBadge status={item.status} /></td> <td> <button className="btn btn-light btn-sm" onClick={() => toggleRadarItem(item.id_radar, item.status)}> {item.status === 'ativo' ? 'Pausar' : 'Ativar'} </button> </td> </tr> ))} </tbody> </table> </div> </div> </section> )} {view === 'notificacoes' && ( <section> <h3 className="mb-2">Notificações</h3> <div className="d-flex gap-2 flex-wrap mb-3"> <button className="btn btn-light" onClick={loadNotifications}>Atualizar</button> <button className="btn btn-light" onClick={markAllNotificationsAsRead}>Marcar todas como lidas</button> <button className="btn btn-primary" onClick={sendNotificationTest}>Enviar teste</button> </div> <div className="diario-notification-list"> {notifications.map((item) => ( <div className={`diario-notification ${item.lida ? 'is-read' : 'is-unread'}`} key={item.id_notificacao}> <div className="diario-notification-header"> <strong>{item.titulo}</strong> <span className="diario-note">{toDisplayDate(item.dt_envio)}</span> </div> <p className="mb-2">{item.mensagem}</p> {!item.lida && ( <button className="btn btn-sm btn-light" onClick={() => markNotificationAsRead(item.id_notificacao)}>Marcar como lida</button> )} </div> ))} </div> </section> )} {view === 'extrato' && ( <section> <h3 className="mb-3">Extrato de consultas</h3> <div className="diario-table-wrap"> <table className="diario-table"> <thead> <tr> <th>ID</th> <th>Tipo</th> <th>Pagamento</th> <th>Valor</th> <th>Status</th> <th>Data</th> </tr> </thead> <tbody> {extrato.map((item) => ( <tr key={item.id_consulta}> <td>{item.id_consulta}</td> <td>{item.tipo_consulta}</td> <td>#{item.pagamento?.id_pagamento || '-'} ({item.pagamento?.status || '-'})</td> <td>{formatMoney(item.valor_cobrado_centavos)}</td> <td><StatusBadge status={item.status} /></td> <td>{toDisplayDate(item.dt_consulta)}</td> </tr> ))} </tbody> </table> </div> </section> )} {view === 'monitoramento' && ( <section> <h3 className="mb-3">Monitoramento mensal</h3> {planoAtivo ? ( <p className="diario-note">Plano ativo até {toDisplayDate(planoAtivo.dt_fim)}. Limite atual: {planoAtivo.limite_monitoramentos} monitoramentos.</p> ) : ( <p className="diario-note">Você precisa comprar e confirmar o produto <strong>Plano mensal de monitoramento</strong> para criar monitoramentos.</p> )} <div className="app-card"> <h4>Novo monitoramento</h4> <div className="diario-form-grid"> <select className="form-select" value={monitorForm.tipo_alvo} onChange={(e) => setMonitorForm({ ...monitorForm, tipo_alvo: e.target.value })}> <option value="cnpj">CNPJ</option> <option value="nome">Nome</option> <option value="coordenada">Coordenada</option> </select> <input className="form-control" placeholder="Valor alvo" value={monitorForm.valor_alvo} onChange={(e) => setMonitorForm({ ...monitorForm, valor_alvo: e.target.value })} /> <input className="form-control" placeholder="Palavras-chave (opcional)" value={monitorForm.palavras_chave} onChange={(e) => setMonitorForm({ ...monitorForm, palavras_chave: e.target.value })} /> <input className="form-control" type="number" step="0.1" placeholder="Raio km (opcional)" value={monitorForm.raio_km} onChange={(e) => setMonitorForm({ ...monitorForm, raio_km: e.target.value })} /> </div> <button className="btn btn-primary mt-3" disabled={loading} onClick={createMonitoramento}>Salvar monitoramento</button> </div> <div className="app-card"> <h4>Itens monitorados</h4> <div className="diario-table-wrap"> <table className="diario-table"> <thead> <tr> <th>ID</th> <th>Tipo</th> <th>Alvo</th> <th>Status</th> <th>Ação</th> </tr> </thead> <tbody> {monitoramentos.map((item) => ( <tr key={item.id_monitoramento}> <td>{item.id_monitoramento}</td> <td>{item.tipo_alvo}</td> <td>{item.valor_alvo}</td> <td><StatusBadge status={item.status} /></td> <td> <button className="btn btn-light btn-sm" onClick={() => toggleMonitoramento(item.id_monitoramento, item.status)}> {item.status === 'ativo' ? 'Pausar' : 'Ativar'} </button> </td> </tr> ))} </tbody> </table> </div> </div> </section> )} {view === 'configuracoes' && ( <section> <h3 className="mb-3">Configurações</h3> <div className="app-card"> <h4>Perfil protegido</h4> <div className="diario-form-grid single"> <div><strong>Email:</strong> {usuario?.email || '-'}</div> <div><strong>CPF:</strong> {perfilPrivado?.cpf || '-'}</div> <label className="diario-note mb-0">Nome</label> <input className="form-control" value={perfilForm.nome} onChange={(e) => setPerfilForm({ ...perfilForm, nome: e.target.value })} /> <label className="diario-note mb-0">Empresa</label> <input className="form-control" placeholder="Empresa (opcional)" value={perfilForm.empresa} onChange={(e) => setPerfilForm({ ...perfilForm, empresa: e.target.value })} /> </div> <button className="btn btn-primary mt-3" disabled={loading} onClick={salvarPerfil}>Salvar perfil</button> </div> <div className="app-card"> <h4>Logo</h4> <p className="diario-note">Aparece no cabeçalho do relatório em PDF enviado pelo WhatsApp. PNG, JPG ou WEBP, até 1MB.</p> {usuario?.logo_data_uri && ( <div className="mb-2"> <img src={usuario.logo_data_uri} alt="Logo" style={{ maxWidth: '160px', maxHeight: '160px', display: 'block' }} /> </div> )} <div className="diario-form-grid mt-2" style={{ alignItems: 'center' }}> <input className="form-control" type="file" accept="image/png,image/jpeg,image/webp" disabled={loading} onChange={(e) => enviarLogo(e.target.files[0])} /> {usuario?.logo_data_uri && ( <button className="btn btn-light btn-sm" disabled={loading} onClick={removerLogo}>Remover logo</button> )} </div> </div> <div className="app-card"> <h4>Telefones</h4> <p className="diario-note">Usados para alertas e consultas por WhatsApp. Até 5 números por conta.</p> {telefones.length === 0 && <p className="diario-note">Nenhum telefone cadastrado ainda.</p>} {telefones.map((tel) => ( <div key={tel.id_telefone} className="diario-form-grid mb-2" style={{ alignItems: 'center' }}> <div>{tel.telefone_mascarado} {tel.principal && <span className="badge bg-primary ms-2">Principal</span>}</div> <button className="btn btn-light btn-sm" onClick={() => removerTelefone(tel.id_telefone)}>Remover</button> </div> ))} <div className="diario-form-grid mt-2"> <input className="form-control" placeholder="(DDD) número" value={novoTelefone} onChange={(e) => setNovoTelefone(e.target.value)} /> <button className="btn btn-primary" disabled={loading} onClick={adicionarTelefone}>Adicionar telefone</button> </div> </div> <div className="app-card"> <h4>Camadas geoespaciais personalizadas</h4> <p className="diario-note"> Fontes WFS próprias (WMS/GeoServer) usadas no relatório em PDF gerado pelo WhatsApp (quando você envia "1"). Até 10 camadas. Sem nenhuma cadastrada, o relatório usa o conjunto padrão do sistema. </p> {geoCamadas.length === 0 && <p className="diario-note">Nenhuma camada personalizada cadastrada ainda.</p>} {geoCamadas.map((cam) => ( <div key={cam.id_camada} className="diario-form-grid mb-2" style={{ alignItems: 'center' }}> <div> <strong>{cam.nome}</strong> <div className="diario-note" style={{ wordBreak: 'break-all' }}>{cam.wfs_url} · {cam.typename}</div> </div> <button className="btn btn-light btn-sm" onClick={() => removerGeoCamada(cam.id_camada)}>Remover</button> </div> ))} {geoCamadas.length < 10 && ( <div className="diario-form-grid mt-2"> <input className="form-control" placeholder="Nome (ex.: CAR Minas Gerais)" value={novaGeoCamada.nome} onChange={(e) => setNovaGeoCamada({ ...novaGeoCamada, nome: e.target.value })} /> <input className="form-control" placeholder="URL do serviço WFS" value={novaGeoCamada.wfs_url} onChange={(e) => setNovaGeoCamada({ ...novaGeoCamada, wfs_url: e.target.value })} /> <input className="form-control" placeholder="Camada (typename, ex.: sicar:sicar_imoveis_mg)" value={novaGeoCamada.typename} onChange={(e) => setNovaGeoCamada({ ...novaGeoCamada, typename: e.target.value })} /> <button className="btn btn-primary" disabled={loading} onClick={adicionarGeoCamada}>Adicionar camada</button> </div> )} </div> <div className="app-card"> <h4>Preferência de tema</h4> <p className="diario-note">Tema atual do app: <strong>{effectiveTheme === 'dark' ? 'Escuro' : 'Claro'}</strong>.</p> <select className="form-select" value={themePref} onChange={(e) => setThemePref(e.target.value)}> <option value="system">Seguir dispositivo</option> <option value="light">Claro</option> <option value="dark">Escuro</option> </select> </div> </section> )} {/* Sem cabeçalho de seção próprio: o card de ConsultaEnquadramento já se explica sozinho (mesmo componente usado dentro de SobrePage) — um título de seção aqui só duplicaria o "A sua atividade precisa de licença ambiental?" do card, um logo abaixo do outro. */} {view === 'enquadramento' && <section><ConsultaEnquadramento /></section>} {view === 'sobre' && <SobrePage />} </main> </div> </div> ); } const root = ReactDOM.createRoot(document.getElementById('diario-app')); root.render(<DiarioApp />);