mesa-react / frontend /src /components /PesquisaTab.jsx
Guilherme Silberfarb Costa
Enhance avaliacao map and KNN interactions
6240ef0
import React, { useEffect, useMemo, useRef, useState } from 'react'
import { createPortal } from 'react-dom'
import { api, downloadBlob } from '../api'
import { buildPesquisaLink, buildPesquisaRoutePayload, getPesquisaFilterDefaults, hasPesquisaRoutePayload } from '../deepLinks'
import DataTable from './DataTable'
import EquationFormatsPanel from './EquationFormatsPanel'
import LoadingOverlay from './LoadingOverlay'
import MapFrame from './MapFrame'
import ModeloObservacaoCard from './ModeloObservacaoCard'
import ModeloTrabalhosTecnicosPanel from './ModeloTrabalhosTecnicosPanel'
import PlotFigure from './PlotFigure'
import PesquisaAdminConfigPanel from './PesquisaAdminConfigPanel'
import SectionBlock from './SectionBlock'
import ShareLinkButton from './ShareLinkButton'
import SinglePillAutocomplete from './SinglePillAutocomplete'
import { getFaixaDataRecencyInfo } from '../modelRecency'
const EMPTY_FILTERS = {
nomeModelo: '',
tipoModelo: '',
negociacaoModelo: '',
dataMin: '',
dataMax: '',
versionamentoModelos: 'incluir_antigos',
avalFinalidade: '',
avalZona: '',
avalBairro: '',
avalArea: '',
avalRh: '',
}
const EMPTY_LOCATION_INPUTS = {
latitude: '',
longitude: '',
logradouro: '',
numero: '',
cdlog: '',
}
const CRITERIO_ESPACIAL_OPTIONS = [
{ value: 'maior_distancia', label: 'Maior distância' },
{ value: 'media_distancia', label: 'Média distância' },
{ value: 'menor_distancia', label: 'Menor distância' },
]
const RESULT_INITIAL = {
modelos: [],
sugestoes: {},
total_filtrado: 0,
total_geral: 0,
}
const PESQUISA_INNER_TABS = [
{ key: 'mapa', label: 'Mapa' },
{ key: 'trabalhos_tecnicos', label: 'Trabalhos Técnicos' },
{ key: 'dados_mercado', label: 'Dados de Mercado' },
{ key: 'metricas', label: 'Métricas' },
{ key: 'transformacoes', label: 'Transformações' },
{ key: 'resumo', label: 'Resumo' },
{ key: 'coeficientes', label: 'Coeficientes' },
{ key: 'obs_calc', label: 'Obs x Calc' },
{ key: 'graficos', label: 'Gráficos' },
]
const MODELO_ABERTO_TRABALHOS_TECNICOS_PADRAO = 'selecionados_e_outras_versoes'
const TIPO_SIGLAS = {
RECOND: 'Residencia em condominio',
RCOMD: 'Residencia em condominio',
TCOND: 'Terreno em condominio',
SALA: 'Salas comerciais',
APTO: 'Apartamentos',
APART: 'Apartamentos',
AP: 'Apartamentos',
TERRENO: 'Terrenos',
TER: 'Terrenos',
EDIF: 'Edificio',
RES: 'Residencias isoladas / casas',
CASA: 'Residencias isoladas / casas',
LOJA: 'Loja',
LCOM: 'Loja',
DEP: 'Deposito',
DEPOS: 'Deposito',
CCOM: 'Casa comercial',
}
const TIPOS_MODELO_GENERICOS = [
'Apartamentos',
'Casa comercial',
'Deposito',
'Edificio',
'Loja',
'Salas comerciais',
'Terrenos',
]
function formatRange(range) {
if (!range) return '-'
const min = range.min ?? null
const max = range.max ?? null
if (min === null && max === null) return '-'
if (min !== null && max !== null) return `${formatDateBrIfIso(min)} a ${formatDateBrIfIso(max)}`
if (min !== null) return `a partir de ${formatDateBrIfIso(min)}`
return `ate ${formatDateBrIfIso(max)}`
}
function formatCount(value) {
if (value === null || value === undefined || value === '') return '-'
if (typeof value === 'number') {
return new Intl.NumberFormat('pt-BR').format(value)
}
return String(value)
}
function formatDistanceKm(value) {
const number = Number(value)
if (!Number.isFinite(number)) return '-'
return `${number.toLocaleString('pt-BR', { minimumFractionDigits: 2, maximumFractionDigits: 2 })} km`
}
function formatAvaliandoGeoLabel(index) {
return `A${index + 1}`
}
function parseCoordinateValue(value) {
if (value === null || value === undefined) return null
const text = String(value).trim().replace(',', '.')
if (!text) return null
const parsed = Number(text)
return Number.isFinite(parsed) ? parsed : null
}
function normalizeLogradouroOption(item) {
if (typeof item === 'string') {
const text = String(item || '').trim()
return text ? { value: text, label: text } : null
}
if (!item || typeof item !== 'object') return null
const value = String(item.value ?? item.logradouro ?? '').trim()
if (!value) return null
const label = String(item.label ?? item.display_label ?? value).trim() || value
return {
value,
label,
secondary: String(item.secondary ?? '').trim(),
}
}
function buildAvaliandosGeoPayload(entries = []) {
return (entries || []).map((item, index) => ({
id: String(item?.id || `avaliando-${index + 1}`),
label: formatAvaliandoGeoLabel(index),
lat: parseCoordinateValue(item?.lat),
lon: parseCoordinateValue(item?.lon),
logradouro: item?.logradouro || null,
numero_usado: item?.numero_usado || null,
cdlog: item?.cdlog ?? null,
origem: item?.origem || null,
})).filter((item) => item.lat !== null && item.lon !== null)
}
function getResumoEspacialValor(resumo, criterio) {
if (!resumo || typeof resumo !== 'object') return null
if (criterio === 'menor_distancia') return Number(resumo.menor_distancia_km)
if (criterio === 'media_distancia') return Number(resumo.media_distancia_km)
return Number(resumo.maior_distancia_km)
}
function sortModelosBySpatialCriterion(modelos = [], criterio = 'maior_distancia') {
if (!Array.isArray(modelos)) return []
return [...modelos].sort((a, b) => {
const aValor = getResumoEspacialValor(a?.distancia_resumo, criterio)
const bValor = getResumoEspacialValor(b?.distancia_resumo, criterio)
const aSem = !Number.isFinite(aValor)
const bSem = !Number.isFinite(bValor)
if (aSem !== bSem) return aSem ? 1 : -1
if (!aSem && !bSem && aValor !== bValor) return aValor - bValor
const aNome = String(a?.nome_modelo || a?.arquivo || a?.id || '').toLowerCase()
const bNome = String(b?.nome_modelo || b?.arquivo || b?.id || '').toLowerCase()
return aNome.localeCompare(bNome, 'pt-BR')
})
}
function formatResumoEspacial(resumo) {
if (!resumo || typeof resumo !== 'object') return '-'
return `Maior ${String(resumo.maior_distancia_label || '-')} • Média ${String(resumo.media_distancia_label || '-')} • Menor ${String(resumo.menor_distancia_label || '-')}`
}
function formatResumoEspacialCobertura(resumo) {
if (!resumo || typeof resumo !== 'object') return ''
const total = Number(resumo.total_avaliandos || 0)
const comDistancia = Number(resumo.total_com_distancia || 0)
if (!total) return ''
return `${comDistancia}/${total} com distância calculada`
}
function formatDistanciasPorAvaliando(distancias = []) {
if (!Array.isArray(distancias) || !distancias.length) return '-'
return distancias.map((item, index) => {
const label = String(item?.label || formatAvaliandoGeoLabel(index)).trim() || formatAvaliandoGeoLabel(index)
const distancia = String(item?.distancia_label || '').trim() || formatDistanceKm(item?.distancia_km)
return `${label}: ${distancia}`
}).join(' • ')
}
function formatLocalizacaoOrigemLabel(localizacao) {
return localizacao?.origem === 'eixos'
? 'Eixos de logradouro'
: 'Coordenadas informadas'
}
function createLocalizacaoEntry(resolvida, id) {
return {
...resolvida,
id,
lat: parseCoordinateValue(resolvida?.lat),
lon: parseCoordinateValue(resolvida?.lon),
}
}
function formatDateBrIfIso(value) {
const text = String(value ?? '').trim()
if (!text) return '-'
const isoMatch = text.match(/^(\d{4})-(\d{2})-(\d{2})$/)
if (!isoMatch) return text
return `${isoMatch[3]}/${isoMatch[2]}/${isoMatch[1]}`
}
function normalizeTokenText(value) {
return String(value || '')
.normalize('NFD')
.replace(/[\u0300-\u036f]/g, '')
.toUpperCase()
}
function normalizeSearchText(value) {
return String(value || '')
.normalize('NFD')
.replace(/[\u0300-\u036f]/g, '')
.toLowerCase()
.trim()
}
function splitMultiTerms(value) {
const text = String(value || '').trim()
const parts = text.includes('||')
? text.split(/\s*\|\|\s*/)
: text.split(/[;|]/)
const raw = parts
.map((item) => String(item || '').trim())
.filter(Boolean)
const seen = new Set()
const unique = []
raw.forEach((item) => {
const key = normalizeSearchText(item)
if (!key || seen.has(key)) return
seen.add(key)
unique.push(item)
})
return unique
}
function joinMultiTerms(values) {
return (values || []).map((item) => String(item || '').trim()).filter(Boolean).join(' || ')
}
function sanitizeCompactList(values) {
const seen = new Set()
const unique = []
;(values || []).forEach((item) => {
const text = String(item || '').trim()
const key = normalizeSearchText(text)
if (!text || !key || seen.has(key)) return
seen.add(key)
unique.push(text)
})
return unique
}
function uppercaseListText(values) {
const items = sanitizeCompactList(values)
return items.map((item) => item.toLocaleUpperCase('pt-BR')).join(', ')
}
function inferDependentVariable(modelo) {
const equacao = String(modelo?.equacao || '').trim()
if (!equacao || !equacao.includes('=')) return ''
return equacao.split('=', 1)[0].trim()
}
function buildVariablesDisplay(modelo) {
const dependente = inferDependentVariable(modelo)
const independentes = sanitizeCompactList((modelo?.variaveis_resumo || []).map((item) => item?.variavel))
.filter((item) => normalizeSearchText(item) !== normalizeSearchText(dependente))
.filter((item) => normalizeSearchText(item) !== 'const')
const partes = []
if (dependente) {
partes.push(`DEPENDENTE: ${dependente.toLocaleUpperCase('pt-BR')}`)
}
if (independentes.length) {
partes.push(`INDEPENDENTES: ${independentes.map((item) => item.toLocaleUpperCase('pt-BR')).join(', ')}`)
}
return partes.join('\n')
}
function buildVariablesContent(text) {
const lines = String(text || '').split('\n').map((item) => item.trim()).filter(Boolean)
if (!lines.length) return null
return (
<>
{lines.map((line, index) => {
const [head, ...rest] = line.split(':')
const tail = rest.join(':').trim()
return (
<span key={`${head}-${index}`} className="pesquisa-card-popover-line">
<strong>{head}:</strong>{tail ? ` ${tail}` : ''}
</span>
)
})}
</>
)
}
function LocalizacaoResumoCard({
title,
localizacao = null,
actions = null,
className = '',
}) {
if (!localizacao) return null
const endereco = String(localizacao?.logradouro || '').trim()
const numeroUsado = String(localizacao?.numero_usado || '').trim()
const enderecoTexto = endereco ? `${endereco}${numeroUsado ? `, ${numeroUsado}` : ''}` : ''
const badges = [
enderecoTexto ? { label: 'Logradouro', value: enderecoTexto } : null,
localizacao?.cdlog ? { label: 'CDLOG', value: String(localizacao.cdlog) } : null,
{ label: 'Lat', value: Number(localizacao.lat).toFixed(6) },
{ label: 'Lon', value: Number(localizacao.lon).toFixed(6) },
{ label: 'Origem', value: formatLocalizacaoOrigemLabel(localizacao) },
].filter(Boolean)
const classes = ['pesquisa-localizacao-registered', className].filter(Boolean).join(' ')
return (
<div className={classes}>
<div className="pesquisa-localizacao-registered-head">
<div className="pesquisa-localizacao-registered-copy">
<strong>{title}</strong>
<div className="pesquisa-localizacao-registered-inline-meta">
{badges.map((item) => (
<span key={`${title}-${item.label}`} className="pesquisa-localizacao-registered-inline-item">
<strong>{item.label}:</strong> {item.value}
</span>
))}
</div>
</div>
{actions}
</div>
</div>
)
}
function CompactHoverList({
label,
buttonLabel,
previewText,
modalText,
previewContent = null,
modalContent = null,
}) {
const rootRef = useRef(null)
const closePreviewTimeoutRef = useRef(null)
const panelRef = useRef(null)
const [previewOpen, setPreviewOpen] = useState(false)
const [modalOpen, setModalOpen] = useState(false)
const [previewStyle, setPreviewStyle] = useState({})
const inlinePreviewText = String(previewText || '').trim()
const inlineModalText = String(modalText || '').trim()
function clearPreviewCloseTimeout() {
if (closePreviewTimeoutRef.current) {
window.clearTimeout(closePreviewTimeoutRef.current)
closePreviewTimeoutRef.current = null
}
}
function schedulePreviewClose() {
clearPreviewCloseTimeout()
closePreviewTimeoutRef.current = window.setTimeout(() => {
setPreviewOpen(false)
}, 80)
}
function openPreview() {
clearPreviewCloseTimeout()
setPreviewOpen(true)
}
function updatePreviewLayout() {
const bounds = rootRef.current?.getBoundingClientRect()
const panel = panelRef.current
if (!bounds || !panel || typeof window === 'undefined') return
const viewportWidth = window.innerWidth
const viewportHeight = window.innerHeight
const gap = 10
const padding = 16
const triggerCenter = bounds.left + (bounds.width / 2)
const towardCenterSide = triggerCenter >= (viewportWidth / 2) ? 'left' : 'right'
const oppositeSide = towardCenterSide === 'left' ? 'right' : 'left'
const downwardSpace = viewportHeight - bounds.bottom - gap - padding
const upwardSpace = bounds.top - gap - padding
const preferredVertical = downwardSpace >= upwardSpace ? 'down' : 'up'
const oppositeVertical = preferredVertical === 'down' ? 'up' : 'down'
const desiredWidth = Math.min(panel.scrollWidth || 560, 560)
const desiredHeight = Math.min(panel.scrollHeight || 220, 420)
function clamp(value, min, max) {
if (max < min) return min
return Math.min(Math.max(value, min), max)
}
function makeVerticalCandidate(side, vertical) {
const maxWidth = Math.max(160, viewportWidth - (padding * 2))
const width = Math.min(desiredWidth, maxWidth)
const maxHeight = Math.max(96, vertical === 'down' ? downwardSpace : upwardSpace)
const height = Math.min(desiredHeight, maxHeight)
const leftBase = side === 'left'
? bounds.right - width
: bounds.left
const left = clamp(leftBase, padding, viewportWidth - padding - width)
const topBase = vertical === 'down'
? bounds.bottom + gap
: bounds.top - gap - height
const top = clamp(topBase, padding, viewportHeight - padding - height)
return {
left,
top,
maxWidth,
maxHeight,
score: (Math.min(maxWidth, desiredWidth) / desiredWidth) + (Math.min(maxHeight, desiredHeight) / desiredHeight),
}
}
function makeSideCandidate(side) {
const maxWidth = Math.max(180, side === 'right'
? viewportWidth - bounds.right - gap - padding
: bounds.left - gap - padding)
const width = Math.min(desiredWidth, maxWidth)
const maxHeight = Math.max(120, viewportHeight - (padding * 2))
const height = Math.min(desiredHeight, maxHeight)
const leftBase = side === 'right'
? bounds.right + gap
: bounds.left - gap - width
const left = clamp(leftBase, padding, viewportWidth - padding - width)
const top = clamp(bounds.top, padding, viewportHeight - padding - height)
return {
left,
top,
maxWidth,
maxHeight,
score: (Math.min(maxWidth, desiredWidth) / desiredWidth) + (Math.min(maxHeight, desiredHeight) / desiredHeight),
}
}
const candidates = [
makeVerticalCandidate(towardCenterSide, preferredVertical),
makeVerticalCandidate(towardCenterSide, oppositeVertical),
makeSideCandidate(towardCenterSide),
makeSideCandidate(oppositeSide),
makeVerticalCandidate(oppositeSide, preferredVertical),
makeVerticalCandidate(oppositeSide, oppositeVertical),
]
const best = candidates.reduce((current, candidate) => {
if (!current || candidate.score > current.score) return candidate
return current
}, null)
if (!best) return
setPreviewStyle({
left: `${best.left}px`,
top: `${best.top}px`,
maxWidth: `${Math.max(160, best.maxWidth)}px`,
maxHeight: `${Math.max(96, best.maxHeight)}px`,
})
}
useEffect(() => {
if (!previewOpen) return undefined
const rafId = window.requestAnimationFrame(() => {
updatePreviewLayout()
})
function onWindowResize() {
updatePreviewLayout()
}
window.addEventListener('resize', onWindowResize)
return () => {
window.cancelAnimationFrame(rafId)
window.removeEventListener('resize', onWindowResize)
}
}, [previewOpen])
useEffect(() => () => {
clearPreviewCloseTimeout()
}, [])
useEffect(() => {
if (!modalOpen) return undefined
function onDocumentKeyDown(event) {
if (event.key === 'Escape') {
setModalOpen(false)
}
}
document.addEventListener('keydown', onDocumentKeyDown)
return () => {
document.removeEventListener('keydown', onDocumentKeyDown)
}
}, [modalOpen])
if (!inlineModalText) {
return (
<button type="button" className="pesquisa-card-popover-trigger is-empty" disabled>
{buttonLabel}
</button>
)
}
return (
<>
<span
ref={rootRef}
className={`pesquisa-card-popover-wrap${previewOpen ? ' is-open' : ''}`}
onMouseEnter={() => openPreview()}
onMouseLeave={() => schedulePreviewClose()}
onFocus={() => openPreview()}
onBlur={(event) => {
if (!rootRef.current?.contains(event.relatedTarget)) {
setPreviewOpen(false)
}
}}
>
<button
type="button"
className="pesquisa-card-popover-trigger"
aria-haspopup="dialog"
aria-expanded={modalOpen}
aria-label={`${label}. Clique para abrir a lista completa.`}
onClick={() => {
setPreviewOpen(false)
setModalOpen(true)
}}
>
{buttonLabel}
</button>
</span>
{previewOpen && typeof document !== 'undefined'
? createPortal(
<span
ref={panelRef}
className="pesquisa-card-popover-panel is-floating"
role="tooltip"
aria-label={label}
style={previewStyle}
onMouseEnter={() => openPreview()}
onMouseLeave={() => schedulePreviewClose()}
>
<span className="pesquisa-card-popover-preview">{previewContent || inlinePreviewText || inlineModalText}</span>
</span>,
document.body,
)
: null}
{modalOpen && typeof document !== 'undefined'
? createPortal(
<div
className="pesquisa-modal-backdrop"
role="presentation"
onClick={() => {
setModalOpen(false)
}}
>
<div
className="pesquisa-modal pesquisa-card-values-modal"
role="dialog"
aria-modal="true"
aria-label={label}
onClick={(event) => event.stopPropagation()}
>
<div className="pesquisa-modal-head">
<h4>{label}</h4>
<div className="pesquisa-card-values-modal-actions">
<button
type="button"
className="pesquisa-modal-close"
onClick={() => {
setModalOpen(false)
}}
>
Fechar
</button>
</div>
</div>
<div className="pesquisa-modal-body">
<div className="pesquisa-card-values-content">{modalContent || inlineModalText}</div>
</div>
</div>
</div>,
document.body,
)
: null}
</>
)
}
function inferTipoPorNomeModelo(...nomes) {
const tokens = Object.keys(TIPO_SIGLAS).sort((a, b) => b.length - a.length)
for (const nome of nomes) {
const source = normalizeTokenText(nome)
if (!source) continue
for (const token of tokens) {
const re = new RegExp(`(^|[^A-Z])${token}([^A-Z]|$)`)
if (re.test(source)) {
return TIPO_SIGLAS[token]
}
}
}
return ''
}
function formatTipoImovel(modelo) {
const tipoPorNome = inferTipoPorNomeModelo(modelo?.nome_modelo, modelo?.arquivo)
if (tipoPorNome) return tipoPorNome
const text = String(modelo?.tipo_imovel || '').trim()
if (!text) return '-'
const mapped = TIPO_SIGLAS[normalizeTokenText(text)]
return mapped || text
}
function buildApiFilters(filters, avaliandosGeolocalizados = []) {
const payload = {
otica: 'avaliando',
nome: filters.nomeModelo,
tipo_modelo: filters.tipoModelo,
negociacao_modelo: filters.negociacaoModelo,
aval_finalidade: filters.avalFinalidade,
aval_zona: filters.avalZona,
aval_bairro: filters.avalBairro,
data_min: filters.dataMin,
data_max: filters.dataMax,
somente_versoes_atuais: filters.versionamentoModelos !== 'incluir_antigos',
aval_area: filters.avalArea,
aval_rh: filters.avalRh,
}
const avaliandosPayload = buildAvaliandosGeoPayload(avaliandosGeolocalizados)
if (avaliandosPayload.length > 1) {
payload.avaliandos_geo_json = JSON.stringify(avaliandosPayload)
} else if (avaliandosPayload.length === 1) {
payload.aval_lat = avaliandosPayload[0].lat
payload.aval_lon = avaliandosPayload[0].lon
}
return payload
}
function toInputName(field) {
let hash = 0
for (let i = 0; i < field.length; i += 1) {
hash = (hash * 31 + field.charCodeAt(i)) % 1000000007
}
return `mesa_${Math.abs(hash)}`
}
function buildInputAutofillProps(field) {
return {
name: toInputName(field),
autoComplete: 'new-password',
autoCorrect: 'off',
autoCapitalize: 'none',
spellCheck: false,
'data-lpignore': 'true',
'data-1p-ignore': 'true',
}
}
function buildSelectAutofillProps(field) {
return {
name: toInputName(field),
autoComplete: 'new-password',
'data-lpignore': 'true',
'data-1p-ignore': 'true',
}
}
function TextFieldInput({ field, ...props }) {
return (
<input
{...props}
data-field={field}
{...buildInputAutofillProps(field)}
/>
)
}
function NumberFieldInput({ field, ...props }) {
return (
<input
{...props}
type="number"
step="any"
inputMode="decimal"
data-field={field}
{...buildInputAutofillProps(field)}
/>
)
}
function DateFieldInput({ field, ...props }) {
return (
<input
{...props}
type="date"
data-field={field}
{...buildInputAutofillProps(field)}
/>
)
}
function ChipAutocompleteInput({
field,
value,
onChange,
placeholder,
suggestions = [],
panelTitle = 'Sugestoes',
loading = false,
emptyMessage = 'Nenhuma sugestao encontrada.',
loadingMessage = 'Carregando sugestoes...',
}) {
const rootRef = useRef(null)
const [query, setQuery] = useState('')
const [open, setOpen] = useState(false)
const [activeIndex, setActiveIndex] = useState(-1)
const selectedValues = useMemo(() => splitMultiTerms(value), [value])
const selectedKeys = useMemo(() => new Set(selectedValues.map((item) => normalizeSearchText(item))), [selectedValues])
const queryNormalized = normalizeSearchText(query)
useEffect(() => {
if (!value) setQuery('')
}, [value])
const filteredSuggestions = useMemo(() => {
const unique = []
const seen = new Set()
;(suggestions || []).forEach((item) => {
const text = String(item || '').trim()
if (!text) return
const key = normalizeSearchText(text)
if (!key || seen.has(key) || selectedKeys.has(key)) return
seen.add(key)
unique.push(text)
})
if (!queryNormalized) return unique.slice(0, 120)
return unique
.filter((item) => normalizeSearchText(item).includes(queryNormalized))
.slice(0, 120)
}, [suggestions, queryNormalized, selectedKeys])
useEffect(() => {
if (!open) return undefined
function onDocumentMouseDown(event) {
if (!rootRef.current) return
if (!rootRef.current.contains(event.target)) {
setOpen(false)
}
}
document.addEventListener('mousedown', onDocumentMouseDown)
return () => document.removeEventListener('mousedown', onDocumentMouseDown)
}, [open])
useEffect(() => {
if (!open || !filteredSuggestions.length) {
setActiveIndex(-1)
return
}
setActiveIndex(-1)
}, [filteredSuggestions, open])
function emitValue(nextValue) {
onChange({
target: {
value: nextValue,
dataset: { field },
name: toInputName(field),
},
})
}
function setSelectedValues(nextSelected) {
emitValue(joinMultiTerms(nextSelected))
}
function addValue(nextValue) {
const text = String(nextValue || '').trim()
if (!text) return
const key = normalizeSearchText(text)
if (!key || selectedKeys.has(key)) {
setQuery('')
return
}
setSelectedValues([...selectedValues, text])
setQuery('')
setOpen(true)
setActiveIndex(-1)
}
function removeValue(nextValue) {
const target = String(nextValue || '').trim()
if (!target) return
const exactIndex = selectedValues.findIndex((item) => String(item || '').trim() === target)
if (exactIndex >= 0) {
const nextSelected = [...selectedValues]
nextSelected.splice(exactIndex, 1)
setSelectedValues(nextSelected)
return
}
const key = normalizeSearchText(target)
const normalizedIndex = selectedValues.findIndex((item) => normalizeSearchText(item) === key)
if (normalizedIndex < 0) return
const nextSelected = [...selectedValues]
nextSelected.splice(normalizedIndex, 1)
setSelectedValues(nextSelected)
}
function onInputChange(event) {
setQuery(event.target.value)
setOpen(true)
setActiveIndex(-1)
}
function onInputKeyDown(event) {
if (event.key === 'Escape') {
setOpen(false)
return
}
if (!filteredSuggestions.length) return
if (event.key === 'ArrowDown') {
event.preventDefault()
setOpen(true)
setActiveIndex((prev) => (prev < 0 ? 0 : (prev + 1) % filteredSuggestions.length))
return
}
if (event.key === 'ArrowUp') {
event.preventDefault()
setOpen(true)
setActiveIndex((prev) => {
if (prev < 0) return filteredSuggestions.length - 1
return (prev - 1 + filteredSuggestions.length) % filteredSuggestions.length
})
return
}
if (event.key === 'Enter' && open && activeIndex >= 0) {
event.preventDefault()
addValue(filteredSuggestions[activeIndex])
return
}
if (event.key === 'Enter') {
const next = String(query || '').trim()
if (next) {
event.preventDefault()
addValue(next)
}
return
}
if (event.key === ',' || event.key === ';' || event.key === '|') {
const next = String(query || '').trim()
if (next) {
event.preventDefault()
addValue(next)
}
return
}
if (event.key === 'Backspace' && !query && selectedValues.length) {
const nextSelected = selectedValues.slice(0, -1)
setSelectedValues(nextSelected)
}
}
return (
<div className={`chip-autocomplete${open ? ' is-open' : ''}`} ref={rootRef}>
{selectedValues.length ? (
<div className="chip-autocomplete-selected-wrap">
{selectedValues.map((item) => (
<span key={`${field}-selected-${item}`} className="chip-autocomplete-selected">
<span>{item}</span>
<button
type="button"
className="chip-autocomplete-selected-remove"
onMouseDown={(event) => {
event.preventDefault()
event.stopPropagation()
removeValue(item)
}}
onClick={(event) => {
event.preventDefault()
event.stopPropagation()
}}
aria-label={`Remover ${item}`}
>
×
</button>
</span>
))}
</div>
) : null}
<TextFieldInput
field={field}
value={query}
onChange={onInputChange}
onFocus={() => {
setOpen(true)
setActiveIndex(-1)
}}
onKeyDown={onInputKeyDown}
placeholder={placeholder}
/>
{open ? (
<div className="chip-autocomplete-panel" role="listbox">
<div className="chip-autocomplete-panel-head">{panelTitle}</div>
{filteredSuggestions.length ? (
<div className="chip-autocomplete-chip-wrap">
{filteredSuggestions.map((item, idx) => (
<button
type="button"
key={`${field}-chip-${item}-${idx}`}
className={`chip-autocomplete-chip${idx === activeIndex ? ' is-active' : ''}`}
onMouseDown={(event) => {
event.preventDefault()
event.stopPropagation()
addValue(item)
}}
>
{item}
</button>
))}
</div>
) : loading ? (
<div className="chip-autocomplete-empty">
{loadingMessage}
</div>
) : (
<div className="chip-autocomplete-empty">
{emptyMessage}
</div>
)}
</div>
) : null}
</div>
)
}
export default function PesquisaTab({
sessionId,
onUsarModeloEmAvaliacao = null,
onAbrirModeloNoRepositorio = null,
routeRequest = null,
scrollToMapaRequest = null,
onRouteChange = null,
}) {
const [searchLoading, setSearchLoading] = useState(false)
const [contextLoading, setContextLoading] = useState(false)
const [error, setError] = useState('')
const [pesquisaInicializada, setPesquisaInicializada] = useState(false)
const [sugestoesInicializadas, setSugestoesInicializadas] = useState(false)
const [mostrarAdminConfig, setMostrarAdminConfig] = useState(false)
const [logradouroOptions, setLogradouroOptions] = useState([])
const [logradouroOptionsLoading, setLogradouroOptionsLoading] = useState(false)
const [logradouroOptionsLoaded, setLogradouroOptionsLoaded] = useState(false)
const [filters, setFilters] = useState(EMPTY_FILTERS)
const [result, setResult] = useState(RESULT_INITIAL)
const [localizacaoModo, setLocalizacaoModo] = useState('endereco')
const [localizacaoInputs, setLocalizacaoInputs] = useState(EMPTY_LOCATION_INPUTS)
const [avaliandosGeolocalizados, setAvaliandosGeolocalizados] = useState([])
const [localizacaoLoading, setLocalizacaoLoading] = useState(false)
const [localizacaoError, setLocalizacaoError] = useState('')
const [localizacaoStatus, setLocalizacaoStatus] = useState('')
const [criterioEspacial, setCriterioEspacial] = useState('maior_distancia')
const [selectedIds, setSelectedIds] = useState([])
const selectAllRef = useRef(null)
const localizacaoIdCounterRef = useRef(1)
const [mapaLoading, setMapaLoading] = useState(false)
const [mapaError, setMapaError] = useState('')
const [mapaStatus, setMapaStatus] = useState('')
const [mapaHtmls, setMapaHtmls] = useState({ pontos: '', cobertura: '' })
const [mapaPayloads, setMapaPayloads] = useState({ pontos: null, cobertura: null })
const [mapaModoExibicao, setMapaModoExibicao] = useState('pontos')
const [mapaTrabalhosTecnicosModelosModo, setMapaTrabalhosTecnicosModelosModo] = useState('selecionados_e_outras_versoes')
const [mapaTrabalhosTecnicosProximidadeModo, setMapaTrabalhosTecnicosProximidadeModo] = useState('sem_proximidade')
const [mapaTrabalhosTecnicosRaio, setMapaTrabalhosTecnicosRaio] = useState(1000)
const mapaTrabalhosTecnicosConfigRef = useRef('')
const [modeloAbertoMeta, setModeloAbertoMeta] = useState(null)
const [modeloAbertoLoading, setModeloAbertoLoading] = useState(false)
const [modeloAbertoError, setModeloAbertoError] = useState('')
const [modeloAbertoActiveTab, setModeloAbertoActiveTab] = useState('mapa')
const [modeloAbertoDados, setModeloAbertoDados] = useState(null)
const [modeloAbertoEstatisticas, setModeloAbertoEstatisticas] = useState(null)
const [modeloAbertoEscalasHtml, setModeloAbertoEscalasHtml] = useState('')
const [modeloAbertoDadosTransformados, setModeloAbertoDadosTransformados] = useState(null)
const [modeloAbertoResumoHtml, setModeloAbertoResumoHtml] = useState('')
const [modeloAbertoEquacoes, setModeloAbertoEquacoes] = useState(null)
const [modeloAbertoCoeficientes, setModeloAbertoCoeficientes] = useState(null)
const [modeloAbertoObsCalc, setModeloAbertoObsCalc] = useState(null)
const [modeloAbertoMapaHtml, setModeloAbertoMapaHtml] = useState('')
const [modeloAbertoMapaPayload, setModeloAbertoMapaPayload] = useState(null)
const [modeloAbertoMapaChoices, setModeloAbertoMapaChoices] = useState(['Visualização Padrão'])
const [modeloAbertoMapaVar, setModeloAbertoMapaVar] = useState('Visualização Padrão')
const [modeloAbertoTrabalhosTecnicosModelosModo, setModeloAbertoTrabalhosTecnicosModelosModo] = useState(MODELO_ABERTO_TRABALHOS_TECNICOS_PADRAO)
const [modeloAbertoTrabalhosTecnicos, setModeloAbertoTrabalhosTecnicos] = useState([])
const [modeloAbertoPlotObsCalc, setModeloAbertoPlotObsCalc] = useState(null)
const [modeloAbertoPlotResiduos, setModeloAbertoPlotResiduos] = useState(null)
const [modeloAbertoPlotHistograma, setModeloAbertoPlotHistograma] = useState(null)
const [modeloAbertoPlotCook, setModeloAbertoPlotCook] = useState(null)
const [modeloAbertoPlotCorr, setModeloAbertoPlotCorr] = useState(null)
const [modeloAbertoLoadedTabs, setModeloAbertoLoadedTabs] = useState({})
const [modeloAbertoLoadingTabs, setModeloAbertoLoadingTabs] = useState({})
const sectionResultadosRef = useRef(null)
const sectionMapaRef = useRef(null)
const scrollMapaHandledRef = useRef('')
const routeRequestHandledRef = useRef('')
const resultRequestSeqRef = useRef(0)
const searchRequestSeqRef = useRef(0)
const contextRequestSeqRef = useRef(0)
const modeloAbertoPendingRequestsRef = useRef({})
const modeloAbertoOpenVersionRef = useRef(0)
const sugestoes = result.sugestoes || {}
const opcoesTipoModelo = useMemo(
() => {
const atual = String(filters.tipoModelo || '').trim()
if (!atual || TIPOS_MODELO_GENERICOS.includes(atual)) return TIPOS_MODELO_GENERICOS
return [...TIPOS_MODELO_GENERICOS, atual]
},
[filters.tipoModelo],
)
const modoModeloAberto = Boolean(modeloAbertoMeta)
const avaliandosGeoPayload = useMemo(
() => buildAvaliandosGeoPayload(avaliandosGeolocalizados),
[avaliandosGeolocalizados],
)
const localizacaoAtiva = avaliandosGeoPayload.length > 0
const localizacaoMultipla = avaliandosGeoPayload.length > 1
const avaliandoUnicoAtivo = avaliandosGeoPayload.length === 1
const modelosOrdenados = useMemo(
() => (localizacaoMultipla ? sortModelosBySpatialCriterion(result.modelos || [], criterioEspacial) : (result.modelos || [])),
[criterioEspacial, localizacaoMultipla, result.modelos],
)
const resultIds = useMemo(() => modelosOrdenados.map((modelo) => modelo.id), [modelosOrdenados])
const todosSelecionados = resultIds.length > 0 && resultIds.every((id) => selectedIds.includes(id))
const algunsSelecionados = resultIds.some((id) => selectedIds.includes(id))
const mapaHtmlAtual = mapaHtmls[mapaModoExibicao] || ''
const mapaPayloadAtual = mapaPayloads[mapaModoExibicao] || null
const mapaFoiGerado = Boolean(mapaHtmls.pontos || mapaHtmls.cobertura || mapaPayloads.pontos || mapaPayloads.cobertura)
const pesquisaShareAvaliando = !localizacaoMultipla ? (avaliandosGeoPayload[0] || null) : null
const pesquisaShareHref = buildPesquisaLink(filters, pesquisaShareAvaliando)
const pesquisaShareDisabled = localizacaoMultipla
const isPesquisaBusy = searchLoading
function buildPesquisaReturnIntent() {
return {
...buildPesquisaRoutePayload(filters, pesquisaShareAvaliando),
pesquisaExecutada: true,
avaliandos: avaliandosGeolocalizados.map((item, index) => ({
id: String(item?.id || `avaliando-${index + 1}`),
lat: parseCoordinateValue(item?.lat),
lon: parseCoordinateValue(item?.lon),
logradouro: String(item?.logradouro || ''),
numero_usado: String(item?.numero_usado || ''),
cdlog: item?.cdlog ?? null,
origem: String(item?.origem || 'coords'),
})).filter((item) => item.lat !== null && item.lon !== null),
}
}
function emitPesquisaRoute(nextFilters = filters, nextAvaliandos = avaliandosGeolocalizados) {
if (typeof onRouteChange !== 'function') return
const avaliandosPayload = buildAvaliandosGeoPayload(nextAvaliandos)
const avaliando = avaliandosPayload.length === 1 ? avaliandosPayload[0] : null
onRouteChange(buildPesquisaRoutePayload(nextFilters, avaliando))
}
function scrollToElementTop(el, behavior = 'smooth', offsetPx = 0) {
if (!el || typeof window === 'undefined') return
const rect = el.getBoundingClientRect()
const targetTop = Math.max(0, window.scrollY + rect.top - offsetPx)
window.scrollTo({ top: targetTop, behavior })
}
function scrollParaTopoDaPagina() {
if (typeof window === 'undefined') return
window.scrollTo({ top: 0, behavior: 'smooth' })
}
function scrollParaResultadosNoTopo() {
window.requestAnimationFrame(() => {
window.requestAnimationFrame(() => {
scrollToElementTop(sectionResultadosRef.current, 'smooth', 0)
})
})
}
function resetMapaPesquisa() {
setMapaHtmls({ pontos: '', cobertura: '' })
setMapaPayloads({ pontos: null, cobertura: null })
setMapaStatus('')
setMapaError('')
setMapaModoExibicao('pontos')
mapaTrabalhosTecnicosConfigRef.current = ''
}
function getMapaTrabalhosTecnicosRequestConfig(overrides = {}) {
const modelosModoBruto = overrides.trabalhosTecnicosModelosModo ?? mapaTrabalhosTecnicosModelosModo
const modelosModo = String(modelosModoBruto || 'selecionados') === 'selecionados_e_anteriores'
? 'selecionados_e_outras_versoes'
: String(modelosModoBruto || 'selecionados')
const proximidadeModoBruto = overrides.trabalhosTecnicosProximidadeModo ?? mapaTrabalhosTecnicosProximidadeModo
const proximidadeModo = localizacaoAtiva ? String(proximidadeModoBruto || 'sem_proximidade') : 'sem_proximidade'
const raioNumero = Number(overrides.trabalhosTecnicosRaio ?? mapaTrabalhosTecnicosRaio)
const raio = Number.isFinite(raioNumero)
? Math.max(0, Math.min(5000, Math.round(raioNumero)))
: 1000
return { modelosModo, proximidadeModo, raio }
}
function buildMapaTrabalhosTecnicosConfigKey(config) {
return JSON.stringify({
totalAvaliandos: avaliandosGeoPayload.length,
modelosModo: config.modelosModo,
proximidadeModo: config.proximidadeModo,
criterioEspacial: localizacaoMultipla ? criterioEspacial : null,
raio: config.proximidadeModo === 'proximos_ao_avaliando' ? config.raio : null,
})
}
async function carregarMapaPesquisa(ids, overrides = {}) {
const idsValidos = (ids || []).map((item) => String(item || '').trim()).filter(Boolean)
const modoExibicaoSolicitado = String(overrides.modoExibicao || mapaModoExibicao || 'pontos')
const trabalhosTecnicosConfig = getMapaTrabalhosTecnicosRequestConfig(overrides)
if (!idsValidos.length) {
setMapaHtmls({ pontos: '', cobertura: '' })
setMapaPayloads({ pontos: null, cobertura: null })
setMapaStatus('Nenhum modelo marcado para exibição no mapa.')
setMapaError('')
mapaTrabalhosTecnicosConfigRef.current = ''
return
}
setMapaLoading(true)
setMapaError('')
try {
const response = await api.pesquisarMapaModelos(
idsValidos,
localizacaoMultipla ? avaliandosGeoPayload : (avaliandosGeoPayload[0] || null),
modoExibicaoSolicitado,
criterioEspacial,
trabalhosTecnicosConfig.modelosModo,
trabalhosTecnicosConfig.proximidadeModo,
trabalhosTecnicosConfig.raio,
)
const mapaHtmlSolicitado = String(
response.mapa_html
|| (modoExibicaoSolicitado === 'cobertura' ? response.mapa_html_cobertura : response.mapa_html_pontos)
|| '',
)
const mapaPayloadSolicitado = response.mapa_payload
|| (modoExibicaoSolicitado === 'cobertura' ? response.mapa_payload_cobertura : response.mapa_payload_pontos)
|| null
setMapaHtmls({
pontos: modoExibicaoSolicitado === 'pontos' ? mapaHtmlSolicitado : '',
cobertura: modoExibicaoSolicitado === 'cobertura' ? mapaHtmlSolicitado : '',
})
setMapaPayloads({
pontos: modoExibicaoSolicitado === 'pontos' ? mapaPayloadSolicitado : null,
cobertura: modoExibicaoSolicitado === 'cobertura' ? mapaPayloadSolicitado : null,
})
setMapaStatus(response.status || '')
mapaTrabalhosTecnicosConfigRef.current = buildMapaTrabalhosTecnicosConfigKey(trabalhosTecnicosConfig)
} catch (err) {
setMapaError(err.message)
} finally {
setMapaLoading(false)
}
}
async function buscarModelos(nextFilters = filters, nextAvaliandos = avaliandosGeolocalizados, options = {}) {
const requestId = resultRequestSeqRef.current + 1
const searchRequestId = searchRequestSeqRef.current + 1
resultRequestSeqRef.current = requestId
searchRequestSeqRef.current = searchRequestId
setSearchLoading(true)
setError('')
try {
const response = await api.pesquisarModelos(buildApiFilters(nextFilters, nextAvaliandos))
if (requestId !== resultRequestSeqRef.current) return
const modelos = response.modelos || []
const idsNovos = new Set(modelos.map((item) => item.id))
setResult({
...RESULT_INITIAL,
...response,
modelos,
sugestoes: response.sugestoes || {},
})
setSelectedIds((current) => current.filter((id) => idsNovos.has(id)))
resetMapaPesquisa()
setPesquisaInicializada(true)
setSugestoesInicializadas(true)
if (options.syncRoute !== false) {
emitPesquisaRoute(nextFilters, nextAvaliandos)
}
} catch (err) {
if (requestId !== resultRequestSeqRef.current) return
setError(err.message)
} finally {
if (searchRequestId === searchRequestSeqRef.current) {
setSearchLoading(false)
}
}
}
async function carregarContextoInicial(options = {}) {
const requestId = resultRequestSeqRef.current + 1
const contextRequestId = contextRequestSeqRef.current + 1
resultRequestSeqRef.current = requestId
contextRequestSeqRef.current = contextRequestId
setContextLoading(true)
setError('')
try {
const response = await api.pesquisarModelos({ somente_contexto: true })
if (requestId !== resultRequestSeqRef.current) return
setResult({
...RESULT_INITIAL,
...response,
modelos: [],
sugestoes: response.sugestoes || {},
})
setSelectedIds([])
resetMapaPesquisa()
setPesquisaInicializada(false)
setSugestoesInicializadas(true)
if (options.syncRoute) {
emitPesquisaRoute(options.filters || getPesquisaFilterDefaults(), options.avaliandos || avaliandosGeolocalizados)
}
} catch (err) {
if (requestId !== resultRequestSeqRef.current) return
setError(err.message)
setSugestoesInicializadas(true)
} finally {
if (contextRequestId === contextRequestSeqRef.current) {
setContextLoading(false)
}
}
}
async function carregarSugestoesLogradouro() {
if (logradouroOptionsLoading || logradouroOptionsLoaded) return
setLogradouroOptionsLoading(true)
try {
const response = await api.pesquisarLogradourosEixos()
const opcoes = Array.isArray(response?.logradouros_eixos)
? response.logradouros_eixos.map(normalizeLogradouroOption).filter(Boolean)
: []
setLogradouroOptions(opcoes)
setLogradouroOptionsLoaded(true)
} catch (_err) {
setLogradouroOptions([])
} finally {
setLogradouroOptionsLoading(false)
}
}
useEffect(() => {
const requestKey = String(routeRequest?.requestKey || '').trim()
if (requestKey) return undefined
void carregarContextoInicial()
return undefined
}, [routeRequest])
useEffect(() => {
const requestKey = String(routeRequest?.requestKey || '').trim()
if (!requestKey) return
if (routeRequestHandledRef.current === requestKey) return
routeRequestHandledRef.current = requestKey
const nextFilters = {
...getPesquisaFilterDefaults(),
...(routeRequest?.filters || {}),
}
const rawAvaliandos = Array.isArray(routeRequest?.avaliandos) ? routeRequest.avaliandos : []
let nextEntries = rawAvaliandos
.map((item, index) => {
const lat = parseCoordinateValue(item?.lat)
const lon = parseCoordinateValue(item?.lon)
if (lat === null || lon === null) return null
return createLocalizacaoEntry({
lat,
lon,
origem: String(item?.origem || 'coords'),
logradouro: String(item?.logradouro || ''),
numero_usado: String(item?.numero_usado || ''),
cdlog: item?.cdlog ?? null,
}, String(item?.id || `avaliando-${localizacaoIdCounterRef.current + index}`))
})
.filter(Boolean)
const lat = parseCoordinateValue(routeRequest?.avaliando?.lat)
const lon = parseCoordinateValue(routeRequest?.avaliando?.lon)
const possuiAvaliando = lat !== null && lon !== null
if (!nextEntries.length && possuiAvaliando) {
nextEntries = [createLocalizacaoEntry({
lat,
lon,
origem: 'coords',
logradouro: '',
numero_usado: '',
cdlog: null,
}, `avaliando-${localizacaoIdCounterRef.current}`)]
localizacaoIdCounterRef.current += 1
} else if (nextEntries.length) {
localizacaoIdCounterRef.current += nextEntries.length
}
setFilters(nextFilters)
setAvaliandosGeolocalizados(nextEntries)
setLocalizacaoModo(possuiAvaliando ? 'coords' : 'endereco')
setLocalizacaoInputs(EMPTY_LOCATION_INPUTS)
setLocalizacaoError('')
setLocalizacaoStatus('')
resetMapaPesquisa()
if (routeRequest?.pesquisaExecutada || hasPesquisaRoutePayload(nextFilters, routeRequest?.avaliando) || nextEntries.length > 1) {
void buscarModelos(nextFilters, nextEntries, { syncRoute: false })
return
}
void carregarContextoInicial({ syncRoute: false, filters: nextFilters, avaliandos: nextEntries })
}, [routeRequest])
useEffect(() => {
if (!selectAllRef.current) return
selectAllRef.current.indeterminate = algunsSelecionados && !todosSelecionados
}, [algunsSelecionados, todosSelecionados])
useEffect(() => {
const requestKey = String(scrollToMapaRequest?.requestKey || '').trim()
if (!requestKey) return
if (scrollMapaHandledRef.current === requestKey) return
scrollMapaHandledRef.current = requestKey
window.requestAnimationFrame(() => {
window.requestAnimationFrame(() => {
scrollToElementTop(sectionMapaRef.current, 'smooth', 0)
})
})
}, [scrollToMapaRequest])
useEffect(() => {
if (!mapaFoiGerado || !localizacaoAtiva || mapaLoading) return undefined
const trabalhosTecnicosConfig = getMapaTrabalhosTecnicosRequestConfig()
const nextKey = buildMapaTrabalhosTecnicosConfigKey(trabalhosTecnicosConfig)
if (nextKey === mapaTrabalhosTecnicosConfigRef.current) return undefined
const timeoutId = window.setTimeout(() => {
void carregarMapaPesquisa(selectedIds)
}, 220)
return () => {
window.clearTimeout(timeoutId)
}
}, [
localizacaoAtiva,
localizacaoMultipla,
mapaFoiGerado,
mapaLoading,
mapaTrabalhosTecnicosModelosModo,
mapaTrabalhosTecnicosProximidadeModo,
mapaTrabalhosTecnicosRaio,
avaliandosGeoPayload.length,
criterioEspacial,
])
function onFieldChange(event) {
const { value, checked, type, dataset, name } = event.target
const field = dataset.field || name
if (!field) return
setFilters((prev) => ({ ...prev, [field]: type === 'checkbox' ? checked : value }))
}
function onLocalizacaoFieldChange(event) {
const { value, dataset, name } = event.target
const field = dataset.field || name
if (!field) return
atualizarCampoLocalizacao(field, value)
}
function atualizarCampoLocalizacao(field, value) {
if (!field) return
setLocalizacaoInputs((prev) => ({ ...prev, [field]: value }))
setLocalizacaoError('')
}
async function onLimparFiltros() {
const nextFilters = getPesquisaFilterDefaults()
setFilters(nextFilters)
await carregarContextoInicial({
syncRoute: true,
filters: nextFilters,
avaliandos: avaliandosGeolocalizados,
})
}
function onToggleSelecionado(modelId) {
setSelectedIds((current) => {
if (current.includes(modelId)) {
return current.filter((id) => id !== modelId)
}
return [...current, modelId]
})
}
function onToggleSelecionarTodos() {
setSelectedIds((current) => {
if (todosSelecionados) {
const idsAtuais = new Set(resultIds)
return current.filter((id) => !idsAtuais.has(id))
}
const next = new Set(current)
resultIds.forEach((id) => next.add(id))
return Array.from(next)
})
}
function onUsarEmAvaliacao(modelo) {
if (!sessionId) {
setError('Sessao indisponivel no momento. Aguarde e tente novamente.')
return
}
if (typeof onUsarModeloEmAvaliacao === 'function') {
onUsarModeloEmAvaliacao(modelo)
}
}
function limparConteudoModeloAberto() {
modeloAbertoPendingRequestsRef.current = {}
setModeloAbertoDados(null)
setModeloAbertoEstatisticas(null)
setModeloAbertoEscalasHtml('')
setModeloAbertoDadosTransformados(null)
setModeloAbertoResumoHtml('')
setModeloAbertoEquacoes(null)
setModeloAbertoCoeficientes(null)
setModeloAbertoObsCalc(null)
setModeloAbertoMapaHtml('')
setModeloAbertoMapaPayload(null)
setModeloAbertoMapaChoices(['Visualização Padrão'])
setModeloAbertoMapaVar('Visualização Padrão')
setModeloAbertoTrabalhosTecnicosModelosModo(MODELO_ABERTO_TRABALHOS_TECNICOS_PADRAO)
setModeloAbertoTrabalhosTecnicos([])
setModeloAbertoPlotObsCalc(null)
setModeloAbertoPlotResiduos(null)
setModeloAbertoPlotHistograma(null)
setModeloAbertoPlotCook(null)
setModeloAbertoPlotCorr(null)
setModeloAbertoLoadedTabs({})
setModeloAbertoLoadingTabs({})
}
function aplicarSecaoModeloAberto(secao, resp) {
const key = String(secao || '').trim()
if (key === 'dados_mercado') {
setModeloAbertoDados(resp?.dados || null)
return
}
if (key === 'metricas') {
setModeloAbertoEstatisticas(resp?.estatisticas || null)
return
}
if (key === 'transformacoes') {
setModeloAbertoEscalasHtml(resp?.escalas_html || '')
setModeloAbertoDadosTransformados(resp?.dados_transformados || null)
return
}
if (key === 'resumo') {
setModeloAbertoResumoHtml(resp?.resumo_html || '')
setModeloAbertoEquacoes(resp?.equacoes || null)
return
}
if (key === 'coeficientes') {
setModeloAbertoCoeficientes(resp?.coeficientes || null)
return
}
if (key === 'obs_calc') {
setModeloAbertoObsCalc(resp?.obs_calc || null)
return
}
if (key === 'graficos') {
setModeloAbertoPlotObsCalc(resp?.grafico_obs_calc || null)
setModeloAbertoPlotResiduos(resp?.grafico_residuos || null)
setModeloAbertoPlotHistograma(resp?.grafico_histograma || null)
setModeloAbertoPlotCook(resp?.grafico_cook || null)
setModeloAbertoPlotCorr(resp?.grafico_correlacao || null)
return
}
if (key === 'trabalhos_tecnicos') {
setModeloAbertoTrabalhosTecnicos(Array.isArray(resp?.trabalhos_tecnicos) ? resp.trabalhos_tecnicos : [])
setModeloAbertoTrabalhosTecnicosModelosModo(resp?.trabalhos_tecnicos_modelos_modo || MODELO_ABERTO_TRABALHOS_TECNICOS_PADRAO)
return
}
if (key === 'mapa') {
const nextChoices = Array.isArray(resp?.mapa_choices) && resp.mapa_choices.length
? resp.mapa_choices
: ['Visualização Padrão']
setModeloAbertoMapaHtml(resp?.mapa_html || '')
setModeloAbertoMapaPayload(resp?.mapa_payload || null)
setModeloAbertoMapaChoices(nextChoices)
setModeloAbertoMapaVar((current) => (nextChoices.includes(current) ? current : 'Visualização Padrão'))
setModeloAbertoTrabalhosTecnicos(Array.isArray(resp?.trabalhos_tecnicos) ? resp.trabalhos_tecnicos : [])
setModeloAbertoTrabalhosTecnicosModelosModo(resp?.trabalhos_tecnicos_modelos_modo || MODELO_ABERTO_TRABALHOS_TECNICOS_PADRAO)
}
}
async function garantirSecaoModeloAberto(secao, options = {}) {
const secaoNormalizada = String(secao || '').trim()
if (!sessionId || !secaoNormalizada) return
if (!options.force && modeloAbertoLoadedTabs[secaoNormalizada]) return
if (modeloAbertoPendingRequestsRef.current[secaoNormalizada]) {
await modeloAbertoPendingRequestsRef.current[secaoNormalizada]
return
}
const expectedVersion = options.expectedVersion ?? modeloAbertoOpenVersionRef.current
const trabalhosTecnicosModo = options.trabalhosTecnicosModelosModo || modeloAbertoTrabalhosTecnicosModelosModo
setModeloAbertoLoadingTabs((prev) => ({ ...prev, [secaoNormalizada]: true }))
const request = (async () => {
try {
const resp = await api.visualizacaoSection(sessionId, secaoNormalizada, trabalhosTecnicosModo)
if (modeloAbertoOpenVersionRef.current !== expectedVersion) return
aplicarSecaoModeloAberto(secaoNormalizada, resp)
setModeloAbertoLoadedTabs((prev) => ({
...prev,
[secaoNormalizada]: true,
...(secaoNormalizada === 'mapa' ? { trabalhos_tecnicos: true } : {}),
}))
} catch (err) {
if (modeloAbertoOpenVersionRef.current !== expectedVersion) return
setModeloAbertoError(err.message || 'Falha ao abrir modelo.')
} finally {
if (modeloAbertoOpenVersionRef.current !== expectedVersion) return
setModeloAbertoLoadingTabs((prev) => ({ ...prev, [secaoNormalizada]: false }))
}
})()
modeloAbertoPendingRequestsRef.current[secaoNormalizada] = request
try {
await request
} finally {
if (modeloAbertoPendingRequestsRef.current[secaoNormalizada] === request) {
delete modeloAbertoPendingRequestsRef.current[secaoNormalizada]
}
}
}
async function onAbrirModelo(modelo) {
if (typeof onAbrirModeloNoRepositorio === 'function') {
onAbrirModeloNoRepositorio({
...modelo,
returnIntent: buildPesquisaReturnIntent(),
})
return
}
if (!sessionId) {
setError('Sessao indisponivel no momento. Aguarde e tente novamente.')
return
}
modeloAbertoOpenVersionRef.current += 1
const openVersion = modeloAbertoOpenVersionRef.current
limparConteudoModeloAberto()
setModeloAbertoLoading(true)
setModeloAbertoError('')
setModeloAbertoMeta({
id: modelo.id,
nome: modelo.nome_modelo || modelo.arquivo || modelo.id,
observacao: '',
})
try {
const resp = await api.visualizacaoRepositorioCarregar(sessionId, modelo.id)
if (modeloAbertoOpenVersionRef.current !== openVersion) return
setModeloAbertoActiveTab('mapa')
setModeloAbertoMeta({
id: modelo.id,
nome: modelo.nome_modelo || modelo.arquivo || modelo.id,
observacao: String(resp?.observacao_modelo || '').trim(),
})
await garantirSecaoModeloAberto('mapa', {
force: true,
expectedVersion: openVersion,
trabalhosTecnicosModelosModo: MODELO_ABERTO_TRABALHOS_TECNICOS_PADRAO,
})
window.requestAnimationFrame(() => {
scrollParaTopoDaPagina()
})
} catch (err) {
setModeloAbertoError(err.message || 'Falha ao abrir modelo.')
} finally {
setModeloAbertoLoading(false)
}
}
function onVoltarPesquisa() {
modeloAbertoOpenVersionRef.current += 1
setModeloAbertoMeta(null)
setModeloAbertoError('')
setModeloAbertoActiveTab('mapa')
setModeloAbertoLoading(false)
limparConteudoModeloAberto()
scrollParaResultadosNoTopo()
}
async function atualizarMapaModeloAberto(
nextVar = modeloAbertoMapaVar,
nextTrabalhosTecnicosModo = modeloAbertoTrabalhosTecnicosModelosModo,
) {
if (!sessionId) return
setModeloAbertoLoadingTabs((prev) => ({ ...prev, mapa: true }))
try {
const resp = await api.updateVisualizacaoMap(sessionId, nextVar, nextTrabalhosTecnicosModo)
setModeloAbertoMapaHtml(resp?.mapa_html || '')
setModeloAbertoMapaPayload(resp?.mapa_payload || null)
setModeloAbertoTrabalhosTecnicos(Array.isArray(resp?.trabalhos_tecnicos) ? resp.trabalhos_tecnicos : [])
setModeloAbertoTrabalhosTecnicosModelosModo(resp?.trabalhos_tecnicos_modelos_modo || nextTrabalhosTecnicosModo)
setModeloAbertoLoadedTabs((prev) => ({ ...prev, mapa: true, trabalhos_tecnicos: true }))
} finally {
setModeloAbertoLoadingTabs((prev) => ({ ...prev, mapa: false }))
}
}
async function onModeloAbertoMapChange(nextVar) {
setModeloAbertoMapaVar(nextVar)
try {
await atualizarMapaModeloAberto(nextVar, modeloAbertoTrabalhosTecnicosModelosModo)
} catch (err) {
setModeloAbertoError(err.message || 'Falha ao atualizar mapa do modelo.')
}
}
async function onModeloAbertoTrabalhosTecnicosModeChange(nextMode) {
setModeloAbertoTrabalhosTecnicosModelosModo(nextMode)
try {
await atualizarMapaModeloAberto(modeloAbertoMapaVar, nextMode)
} catch (err) {
setModeloAbertoError(err.message || 'Falha ao atualizar mapa do modelo.')
}
}
async function onDownloadEquacaoModeloAberto(mode) {
if (!sessionId || !mode) return
setModeloAbertoLoading(true)
try {
const blob = await api.exportEquationViz(sessionId, mode)
const sufixo = String(mode) === 'excel_sab' ? 'estilo_sab' : 'excel'
downloadBlob(blob, `equacao_modelo_${sufixo}.xlsx`)
} catch (err) {
setModeloAbertoError(err.message || 'Falha ao exportar equação.')
} finally {
setModeloAbertoLoading(false)
}
}
function onModeloAbertoTabSelect(nextTab) {
setModeloAbertoActiveTab(nextTab)
void garantirSecaoModeloAberto(nextTab)
}
async function onGerarMapaSelecionados() {
if (!selectedIds.length) {
setMapaError('Selecione ao menos um modelo para plotar no mapa.')
return
}
await carregarMapaPesquisa(selectedIds)
}
function onMapaModoExibicaoChange(event) {
const nextModo = String(event?.target?.value || 'pontos')
setMapaModoExibicao(nextModo)
if (!mapaFoiGerado || mapaLoading || !selectedIds.length || mapaHtmls[nextModo] || mapaPayloads[nextModo]) return
void carregarMapaPesquisa(selectedIds, { modoExibicao: nextModo })
}
async function onAdminConfigSalva() {
if (pesquisaInicializada) {
await buscarModelos(filters, avaliandosGeolocalizados)
return
}
await carregarContextoInicial()
}
function onLimparFormularioLocalizacao() {
setLocalizacaoInputs(EMPTY_LOCATION_INPUTS)
setLocalizacaoModo('endereco')
setLocalizacaoError('')
setLocalizacaoStatus('')
}
async function atualizarPesquisaAposGeolocalizacao(nextEntries) {
resetMapaPesquisa()
if (pesquisaInicializada) {
await buscarModelos(filters, nextEntries)
}
}
async function onResolverLocalizacao() {
setLocalizacaoError('')
setLocalizacaoStatus('')
if (localizacaoModo === 'coords') {
const lat = Number(localizacaoInputs.latitude)
const lon = Number(localizacaoInputs.longitude)
if (!Number.isFinite(lat) || !Number.isFinite(lon)) {
setLocalizacaoError('Informe latitude e longitude válidas para localizar o avaliando.')
return
}
} else {
const logradouro = String(localizacaoInputs.logradouro || '').trim()
const cdlogTexto = String(localizacaoInputs.cdlog || '').trim()
const cdlog = cdlogTexto ? Number(cdlogTexto) : null
if (!logradouro && !(Number.isFinite(cdlog) && cdlog > 0)) {
setLocalizacaoError('Informe o logradouro ou um CDLOG válido para localizar o avaliando.')
return
}
if (cdlogTexto && !(Number.isFinite(cdlog) && cdlog > 0)) {
setLocalizacaoError('Informe um CDLOG válido para localizar o avaliando.')
return
}
const numero = Number(localizacaoInputs.numero)
if (!Number.isFinite(numero) || numero <= 0) {
setLocalizacaoError('Informe um número válido para localizar o avaliando.')
return
}
}
setLocalizacaoLoading(true)
try {
const response = await api.pesquisarLocalizacaoAvaliando({
latitude: localizacaoModo === 'coords' ? Number(localizacaoInputs.latitude) : null,
longitude: localizacaoModo === 'coords' ? Number(localizacaoInputs.longitude) : null,
logradouro: localizacaoModo === 'endereco' ? String(localizacaoInputs.logradouro || '').trim() : null,
numero: localizacaoModo === 'endereco' ? Number(localizacaoInputs.numero) : null,
cdlog: localizacaoModo === 'endereco' && String(localizacaoInputs.cdlog || '').trim()
? Number(localizacaoInputs.cdlog)
: null,
})
const resolvida = {
...response,
lat: parseCoordinateValue(response?.lat),
lon: parseCoordinateValue(response?.lon),
}
const nextEntry = createLocalizacaoEntry(
resolvida,
`avaliando-${localizacaoIdCounterRef.current}`,
)
localizacaoIdCounterRef.current += 1
const nextEntries = [...avaliandosGeolocalizados, nextEntry]
setAvaliandosGeolocalizados(nextEntries)
setLocalizacaoInputs(EMPTY_LOCATION_INPUTS)
setLocalizacaoStatus(
nextEntries.length > 1
? `${nextEntries.length} avaliandos geolocalizados com sucesso.`
: (response?.status || 'Geolocalização do avaliando registrada.')
)
await atualizarPesquisaAposGeolocalizacao(nextEntries)
} catch (err) {
setLocalizacaoError(err.message || 'Falha ao localizar o avaliando.')
} finally {
setLocalizacaoLoading(false)
}
}
async function onRemoverAvaliandoLocalizacao(id) {
const nextEntries = avaliandosGeolocalizados.filter((item) => item.id !== id)
setAvaliandosGeolocalizados(nextEntries)
setLocalizacaoError('')
setLocalizacaoStatus('')
await atualizarPesquisaAposGeolocalizacao(nextEntries)
}
if (modoModeloAberto) {
return (
<div className="tab-content">
<div className="pesquisa-opened-model-view">
<div className="pesquisa-opened-model-head">
<div className="pesquisa-opened-model-title-wrap">
<h3>{modeloAbertoMeta?.nome || 'Modelo'}</h3>
</div>
<button
type="button"
className="model-source-back-btn model-source-back-btn-danger"
onClick={onVoltarPesquisa}
disabled={modeloAbertoLoading}
>
Voltar para pesquisa
</button>
</div>
<ModeloObservacaoCard text={modeloAbertoMeta?.observacao} className="pesquisa-opened-model-observacao" />
<div className="inner-tabs" role="tablist" aria-label="Abas internas do modelo aberto na pesquisa">
{PESQUISA_INNER_TABS.map((tab) => (
<button
key={tab.key}
type="button"
className={modeloAbertoActiveTab === tab.key ? 'inner-tab-pill active' : 'inner-tab-pill'}
onClick={() => onModeloAbertoTabSelect(tab.key)}
>
{tab.label}
</button>
))}
</div>
<div className="inner-tab-panel">
{modeloAbertoActiveTab === 'mapa' ? (
!modeloAbertoLoadedTabs.mapa ? (
<div className="empty-box">Carregando mapa do modelo...</div>
) : (
<>
<div className="row compact visualizacao-mapa-controls pesquisa-mapa-controls-row">
<label className="pesquisa-field pesquisa-mapa-modo-field">
Variável no mapa
<select
{...buildSelectAutofillProps('modeloAbertoMapaVar')}
value={modeloAbertoMapaVar}
onChange={(event) => void onModeloAbertoMapChange(event.target.value)}
>
{modeloAbertoMapaChoices.map((choice) => (
<option key={`modelo-aberto-mapa-${choice}`} value={choice}>{choice}</option>
))}
</select>
</label>
<label className="pesquisa-field pesquisa-mapa-trabalhos-field">
Exibição dos trabalhos técnicos
<select
{...buildSelectAutofillProps('modeloAbertoTrabalhosTecnicosModelosModo')}
value={modeloAbertoTrabalhosTecnicosModelosModo === 'selecionados_e_anteriores'
? MODELO_ABERTO_TRABALHOS_TECNICOS_PADRAO
: modeloAbertoTrabalhosTecnicosModelosModo}
onChange={(event) => void onModeloAbertoTrabalhosTecnicosModeChange(event.target.value)}
>
<option value="selecionados">Somente deste modelo</option>
<option value="selecionados_e_outras_versoes">Incluir demais versões do modelo</option>
</select>
</label>
</div>
<MapFrame html={modeloAbertoMapaHtml} payload={modeloAbertoMapaPayload} sessionId={sessionId} />
</>
)
) : null}
{modeloAbertoActiveTab === 'trabalhos_tecnicos' ? (
modeloAbertoLoadedTabs.trabalhos_tecnicos
? <ModeloTrabalhosTecnicosPanel trabalhos={modeloAbertoTrabalhosTecnicos} />
: <div className="empty-box">Carregando trabalhos técnicos do modelo...</div>
) : null}
{modeloAbertoActiveTab === 'dados_mercado'
? (modeloAbertoLoadedTabs.dados_mercado ? <DataTable table={modeloAbertoDados} maxHeight={620} /> : <div className="empty-box">Carregando dados de mercado...</div>)
: null}
{modeloAbertoActiveTab === 'metricas'
? (modeloAbertoLoadedTabs.metricas ? <DataTable table={modeloAbertoEstatisticas} maxHeight={620} /> : <div className="empty-box">Carregando métricas do modelo...</div>)
: null}
{modeloAbertoActiveTab === 'transformacoes' ? (
modeloAbertoLoadedTabs.transformacoes ? (
<>
<div dangerouslySetInnerHTML={{ __html: modeloAbertoEscalasHtml }} />
<h4 className="visualizacao-table-title">Dados com variáveis transformadas</h4>
<DataTable table={modeloAbertoDadosTransformados} />
</>
) : (
<div className="empty-box">Carregando transformações do modelo...</div>
)
) : null}
{modeloAbertoActiveTab === 'resumo' ? (
modeloAbertoLoadedTabs.resumo ? (
<>
<div className="equation-formats-section">
<h4>Equações do Modelo</h4>
<EquationFormatsPanel
equacoes={modeloAbertoEquacoes}
onDownload={(mode) => void onDownloadEquacaoModeloAberto(mode)}
disabled={modeloAbertoLoading}
/>
</div>
<div dangerouslySetInnerHTML={{ __html: modeloAbertoResumoHtml }} />
</>
) : (
<div className="empty-box">Carregando resumo do modelo...</div>
)
) : null}
{modeloAbertoActiveTab === 'coeficientes'
? (modeloAbertoLoadedTabs.coeficientes ? <DataTable table={modeloAbertoCoeficientes} maxHeight={620} /> : <div className="empty-box">Carregando coeficientes do modelo...</div>)
: null}
{modeloAbertoActiveTab === 'obs_calc'
? (modeloAbertoLoadedTabs.obs_calc ? <DataTable table={modeloAbertoObsCalc} maxHeight={620} /> : <div className="empty-box">Carregando observados x calculados...</div>)
: null}
{modeloAbertoActiveTab === 'graficos' ? (
modeloAbertoLoadedTabs.graficos ? (
<>
<div className="plot-grid-2-fixed">
<PlotFigure figure={modeloAbertoPlotObsCalc} title="Obs x Calc" />
<PlotFigure figure={modeloAbertoPlotResiduos} title="Resíduos" />
<PlotFigure figure={modeloAbertoPlotHistograma} title="Histograma" />
<PlotFigure figure={modeloAbertoPlotCook} title="Cook" forceHideLegend />
</div>
<div className="plot-full-width">
<PlotFigure figure={modeloAbertoPlotCorr} title="Correlação" className="plot-correlation-card" />
</div>
</>
) : (
<div className="empty-box">Carregando gráficos do modelo...</div>
)
) : null}
</div>
{modeloAbertoError ? <div className="error-line inline-error">{modeloAbertoError}</div> : null}
</div>
<LoadingOverlay
show={modeloAbertoLoading || Boolean(modeloAbertoLoadingTabs[modeloAbertoActiveTab])}
label={modeloAbertoLoading ? 'Carregando modelo...' : 'Carregando seção do modelo...'}
/>
</div>
)
}
return (
<div className="tab-content">
<SectionBlock
step="1"
title="Geolocalização do Avaliando"
subtitle="Registre um endereço ou coordenadas do avaliando. O preenchimento desta seção não é obrigatório. Se você não informar localização, a pesquisa continua normal, a distância espacial dos modelos não será calculada e o avaliando não aparecerá no mapa gerado."
>
<div className="pesquisa-localizacao-section">
{avaliandosGeolocalizados.length ? (
<div className="pesquisa-localizacao-multi-grid">
{avaliandosGeolocalizados.map((item, index) => (
<LocalizacaoResumoCard
key={item.id}
title={`Avaliando ${index + 1}: Geolocalização registrada`}
localizacao={item}
className="pesquisa-localizacao-multi-card"
actions={(
<button
type="button"
className="pesquisa-localizacao-action pesquisa-localizacao-action-reset"
onClick={() => void onRemoverAvaliandoLocalizacao(item.id)}
disabled={localizacaoLoading || isPesquisaBusy}
>
Excluir avaliando
</button>
)}
/>
))}
</div>
) : null}
{localizacaoModo === 'coords' ? (
<div className="pesquisa-localizacao-grid pesquisa-localizacao-grid-coords">
<label className="pesquisa-field">
Forma de localização
<select
data-field="localizacaoModo"
{...buildSelectAutofillProps('localizacaoModo')}
value={localizacaoModo}
onChange={(event) => setLocalizacaoModo(event.target.value)}
>
<option value="endereco">Endereço</option>
<option value="coords">Coordenadas</option>
</select>
</label>
<label className="pesquisa-field">
Latitude
<NumberFieldInput field="latitude" value={localizacaoInputs.latitude} onChange={onLocalizacaoFieldChange} placeholder="-30.000000" />
</label>
<label className="pesquisa-field">
Longitude
<NumberFieldInput field="longitude" value={localizacaoInputs.longitude} onChange={onLocalizacaoFieldChange} placeholder="-51.000000" />
</label>
<div className="pesquisa-localizacao-actions-inline">
<button
type="button"
className="pesquisa-localizacao-action pesquisa-localizacao-action-ok"
onClick={() => void onResolverLocalizacao()}
disabled={localizacaoLoading}
>
{localizacaoLoading ? 'Adicionando...' : 'Adicionar'}
</button>
<button
type="button"
className="pesquisa-localizacao-action pesquisa-localizacao-action-reset"
onClick={onLimparFormularioLocalizacao}
disabled={localizacaoLoading}
>
Limpar
</button>
</div>
</div>
) : (
<div className="pesquisa-localizacao-grid pesquisa-localizacao-grid-endereco">
<label className="pesquisa-field">
Forma de localização
<select
data-field="localizacaoModo"
{...buildSelectAutofillProps('localizacaoModo')}
value={localizacaoModo}
onChange={(event) => setLocalizacaoModo(event.target.value)}
>
<option value="endereco">Endereço</option>
<option value="coords">Coordenadas</option>
</select>
</label>
<label className="pesquisa-field">
CDLOG
<NumberFieldInput field="cdlog" value={localizacaoInputs.cdlog} onChange={onLocalizacaoFieldChange} placeholder="Opcional" />
</label>
<label className="pesquisa-field pesquisa-localizacao-logradouro-field">
Logradouro
<SinglePillAutocomplete
value={localizacaoInputs.logradouro}
onChange={(nextValue) => atualizarCampoLocalizacao('logradouro', nextValue)}
options={logradouroOptions}
placeholder="Digite ou selecione um logradouro dos eixos"
panelTitle="Logradouros dos eixos"
emptyMessage="Nenhum logradouro encontrado nos eixos."
loading={logradouroOptionsLoading}
onOpenChange={(open) => {
if (open) void carregarSugestoesLogradouro()
}}
inputName={toInputName('logradouroEixosPesquisa')}
inputAutoComplete="new-password"
/>
</label>
<label className="pesquisa-field">
Número
<NumberFieldInput field="numero" value={localizacaoInputs.numero} onChange={onLocalizacaoFieldChange} placeholder="0" />
</label>
<div className="pesquisa-localizacao-actions-inline">
<button
type="button"
className="pesquisa-localizacao-action pesquisa-localizacao-action-ok"
onClick={() => void onResolverLocalizacao()}
disabled={localizacaoLoading}
>
{localizacaoLoading ? 'Adicionando...' : 'Adicionar'}
</button>
<button
type="button"
className="pesquisa-localizacao-action pesquisa-localizacao-action-reset"
onClick={onLimparFormularioLocalizacao}
disabled={localizacaoLoading}
>
Limpar
</button>
</div>
</div>
)}
{localizacaoStatus && !localizacaoAtiva ? <div className="status-line">{localizacaoStatus}</div> : null}
{localizacaoError ? <div className="error-line inline-error">{localizacaoError}</div> : null}
</div>
</SectionBlock>
<SectionBlock
step="2"
title="Filtros de Pesquisa"
subtitle="Informe os dados do avaliando para filtrar os modelos. Todos os filtros são cumulativos."
aside={(
<button
type="button"
className={`pesquisa-admin-toggle${mostrarAdminConfig ? ' active' : ''}`}
onClick={() => setMostrarAdminConfig((current) => !current)}
>
<span className="pesquisa-admin-toggle-icon" aria-hidden="true"></span>
<span>Configure as fontes dos campos</span>
</button>
)}
>
{mostrarAdminConfig ? (
<PesquisaAdminConfigPanel onSaved={() => void onAdminConfigSalva()} />
) : null}
<div className="pesquisa-fields-grid pesquisa-top-four-grid">
<label className="pesquisa-field">
Nome do modelo
<ChipAutocompleteInput
field="nomeModelo"
value={filters.nomeModelo}
onChange={onFieldChange}
placeholder="Digite e pressione Enter"
suggestions={sugestoes.nomes_modelo || []}
panelTitle="Modelos sugeridos"
loading={contextLoading && !sugestoesInicializadas}
/>
</label>
<label className="pesquisa-field">
Tipo do modelo
<select
data-field="negociacaoModelo"
{...buildSelectAutofillProps('negociacaoModelo')}
value={filters.negociacaoModelo}
onChange={onFieldChange}
>
<option value="">Indiferente</option>
<option value="aluguel">Aluguel</option>
<option value="venda">Venda</option>
</select>
</label>
<label className="pesquisa-field">
Finalidade genérica
<select
data-field="tipoModelo"
{...buildSelectAutofillProps('tipoModelo')}
value={filters.tipoModelo}
onChange={onFieldChange}
>
<option value="">Todos</option>
{opcoesTipoModelo.map((tipo) => (
<option key={`tipo-modelo-${tipo}`} value={tipo}>{tipo}</option>
))}
</select>
</label>
<label className="pesquisa-field">
Finalidades cadastrais que devem estar contidas no modelo
<ChipAutocompleteInput
field="avalFinalidade"
value={filters.avalFinalidade}
onChange={onFieldChange}
placeholder="Digite e pressione Enter"
suggestions={sugestoes.finalidades || []}
panelTitle="Finalidades sugeridas"
loading={contextLoading && !sugestoesInicializadas}
/>
</label>
</div>
<div className="pesquisa-avaliando-grid-v2">
<div className="pesquisa-area-rh-grid">
<label className="pesquisa-field">
Área do imóvel
<NumberFieldInput field="avalArea" value={filters.avalArea} onChange={onFieldChange} placeholder="0" />
</label>
<label className="pesquisa-field">
RH do imóvel
<NumberFieldInput field="avalRh" value={filters.avalRh} onChange={onFieldChange} placeholder="0" />
</label>
</div>
<div className="pesquisa-bairro-zona-grid">
<label className="pesquisa-field">
Zonas de avaliação
<ChipAutocompleteInput
field="avalZona"
value={filters.avalZona}
onChange={onFieldChange}
placeholder="Selecione uma ou mais zonas"
suggestions={sugestoes.zonas_avaliacao || []}
panelTitle="Zonas sugeridas"
loading={contextLoading && !sugestoesInicializadas}
/>
</label>
<label className="pesquisa-field pesquisa-bairro-bottom-field">
Bairros
<ChipAutocompleteInput
field="avalBairro"
value={filters.avalBairro}
onChange={onFieldChange}
placeholder="Digite e pressione Enter"
suggestions={sugestoes.bairros || []}
panelTitle="Bairros sugeridos"
loading={contextLoading && !sugestoesInicializadas}
/>
</label>
</div>
</div>
<div className="pesquisa-periodo-versoes-grid">
<div className="pesquisa-periodo-fields">
<label className="pesquisa-field">
Data inicial
<DateFieldInput field="dataMin" value={filters.dataMin} onChange={onFieldChange} />
</label>
<label className="pesquisa-field">
Data final
<DateFieldInput field="dataMax" value={filters.dataMax} onChange={onFieldChange} />
</label>
</div>
<label className="pesquisa-field pesquisa-versionamento-field">
Versionamento dos modelos
<select
data-field="versionamentoModelos"
{...buildSelectAutofillProps('versionamentoModelos')}
value={filters.versionamentoModelos}
onChange={onFieldChange}
>
<option value="incluir_antigos">Exibir todos os modelos</option>
<option value="atuais">Exibir somente versões mais atuais</option>
</select>
</label>
</div>
<div className="row pesquisa-actions pesquisa-actions-primary">
<button
type="button"
className={searchLoading ? 'is-loading' : ''}
onClick={() => void buscarModelos()}
disabled={isPesquisaBusy}
>
{searchLoading ? (
<>
<span className="repo-open-btn-spinner" aria-hidden="true" />
<span>Pesquisando...</span>
</>
) : 'Pesquisar'}
</button>
<button type="button" onClick={() => void onLimparFiltros()} disabled={isPesquisaBusy}>
Limpar filtros
</button>
</div>
{error ? <div className="error-line inline-error">{error}</div> : null}
</SectionBlock>
<div ref={sectionResultadosRef}>
<SectionBlock
step="3"
title="Resultados"
subtitle="Modelos aceitos para os parametros do avaliando informado."
>
<div className="pesquisa-results-toolbar">
<div className="pesquisa-summary-line">
<strong>{formatCount(result.total_filtrado)}</strong>{' '}
modelo(s) aceito(s) de <strong>{formatCount(result.total_geral)}</strong>.
</div>
{localizacaoMultipla ? (
<label className="pesquisa-results-criterio">
Critério espacial
<select
{...buildSelectAutofillProps('criterioEspacial')}
value={criterioEspacial}
onChange={(event) => setCriterioEspacial(event.target.value)}
>
{CRITERIO_ESPACIAL_OPTIONS.map((option) => (
<option key={`criterio-espacial-${option.value}`} value={option.value}>{option.label}</option>
))}
</select>
</label>
) : null}
<div className="pesquisa-results-toolbar-actions">
<ShareLinkButton
href={pesquisaShareHref}
label="Copiar link da pesquisa"
disabled={pesquisaShareDisabled}
title={pesquisaShareDisabled ? 'O compartilhamento da pesquisa suporta apenas um avaliando na versão atual.' : pesquisaShareHref}
/>
{resultIds.length ? (
<label className="pesquisa-select-all">
<input ref={selectAllRef} type="checkbox" checked={todosSelecionados} onChange={onToggleSelecionarTodos} />
Selecionar todos os exibidos
</label>
) : null}
</div>
</div>
{!modelosOrdenados.length ? (
<div className="empty-box">
{!pesquisaInicializada
? 'Defina os filtros desejados e clique em Pesquisar.'
: 'Nenhum modelo aceitou os parametros do avaliando informado.'}
</div>
) : (
<div className="pesquisa-card-grid">
{modelosOrdenados.map((modelo) => {
const selecionado = selectedIds.includes(modelo.id)
const faixaDataRecency = getFaixaDataRecencyInfo(modelo.faixa_data)
const finalidadesText = uppercaseListText(modelo.finalidades || [])
const bairrosText = uppercaseListText(modelo.bairros || [])
const variaveisText = buildVariablesDisplay(modelo)
const observacaoText = String(modelo.observacao_modelo || '').trim()
const resumoEspacial = modelo?.distancia_resumo || null
const coberturaEspacial = formatResumoEspacialCobertura(resumoEspacial)
const cardClassName = [
'pesquisa-card',
selecionado ? 'is-selected' : '',
].filter(Boolean).join(' ')
return (
<article key={modelo.id} className={cardClassName}>
<div className="pesquisa-card-top">
<h4 className="pesquisa-card-title">{modelo.nome_modelo || modelo.arquivo}</h4>
<div className="pesquisa-card-divider" aria-hidden="true" />
<div className="pesquisa-card-actions">
<button
type="button"
className={`btn-pesquisa-map-toggle${selecionado ? ' is-selected' : ''}`}
onClick={() => onToggleSelecionado(modelo.id)}
>
{selecionado ? 'Desmarcar' : 'Selecionar'}
</button>
<button type="button" className="btn-pesquisa-open" onClick={() => void onAbrirModelo(modelo)}>
Abrir
</button>
<button type="button" className="btn-pesquisa-eval" onClick={() => onUsarEmAvaliacao(modelo)}>
Avaliação
</button>
</div>
<div className="pesquisa-card-body">
<div className="pesquisa-card-dados-list">
{localizacaoMultipla ? (
<>
<div><strong>Resumo espacial:</strong> {formatResumoEspacial(resumoEspacial)}</div>
<div><strong>Distâncias por avaliando:</strong> {formatDistanciasPorAvaliando(modelo?.distancias_avaliandos || [])}</div>
{coberturaEspacial ? <div><strong>Cobertura espacial:</strong> {coberturaEspacial}</div> : null}
</>
) : (
<div><strong>Distância:</strong> {String(modelo.distancia_label || '').trim() || formatDistanceKm(modelo.distancia_km)}</div>
)}
<div><strong>Tipo:</strong> {formatTipoImovel(modelo)}</div>
<div><strong>Autor:</strong> {modelo.autor || '-'}</div>
<div><strong>Dados:</strong> {formatCount(modelo.total_dados)}</div>
<div><strong>Avaliandos:</strong> {formatCount(modelo.total_trabalhos)}</div>
<div><strong>Faixa area:</strong> {formatRange(modelo.faixa_area)}</div>
<div><strong>Faixa RH:</strong> {formatRange(modelo.faixa_rh)}</div>
<div>
<strong>Faixa data:</strong> {formatRange(modelo.faixa_data)}
{faixaDataRecency.label ? (
<span className={`pesquisa-card-faixa-data-badge is-aged-${faixaDataRecency.tone}`}>
{faixaDataRecency.label}
</span>
) : null}
</div>
</div>
</div>
<div className="pesquisa-card-meta-actions">
<CompactHoverList
label="Finalidades"
buttonLabel="Finalidades"
previewText={finalidadesText}
modalText={finalidadesText}
/>
<CompactHoverList
label="Bairros"
buttonLabel="Bairros"
previewText={bairrosText}
modalText={bairrosText}
/>
<CompactHoverList
label="Variáveis"
buttonLabel="Variáveis"
previewText={variaveisText}
modalText={variaveisText}
previewContent={buildVariablesContent(variaveisText)}
modalContent={buildVariablesContent(variaveisText)}
/>
<CompactHoverList
label="Observação"
buttonLabel="Obs"
previewText={observacaoText}
modalText={observacaoText}
/>
</div>
</div>
{modelo.status !== 'ok' ? <div className="inline-error pesquisa-card-error">{modelo.erro_leitura || 'Falha ao ler modelo.'}</div> : null}
</article>
)
})}
</div>
)}
</SectionBlock>
</div>
<div ref={sectionMapaRef}>
<SectionBlock
step="4"
title="Mapa"
subtitle={localizacaoAtiva
? (localizacaoMultipla
? 'Plote os modelos selecionados com dados de mercado ou cobertura dos modelos, com os avaliandos destacados.'
: 'Plote os modelos selecionados com dados de mercado ou cobertura dos modelos, com o avaliando destacado.')
: 'Plote os modelos selecionados com dados de mercado ou cobertura dos modelos.'}
>
<div className="pesquisa-summary-line">
<strong>{formatCount(selectedIds.length)}</strong> modelo(s) selecionado(s) para plotagem.
</div>
<div className="pesquisa-summary-line">
<strong>{localizacaoMultipla ? 'Avaliandos geolocalizados:' : 'Localização do avaliando:'}</strong>{' '}
{localizacaoAtiva ? (localizacaoMultipla ? formatCount(avaliandosGeoPayload.length) : 'ativa') : 'não definida'}
</div>
<div className="row pesquisa-actions pesquisa-mapa-actions">
<button type="button" onClick={() => void onGerarMapaSelecionados()} disabled={mapaLoading || !selectedIds.length}>
{mapaLoading ? 'Gerando mapa...' : 'Plotar mapa dos selecionados'}
</button>
</div>
{mapaError ? <div className="error-line inline-error">{mapaError}</div> : null}
{mapaHtmlAtual ? (
<div className="row pesquisa-actions pesquisa-mapa-actions pesquisa-mapa-controls-row">
<label className="pesquisa-field pesquisa-mapa-modo-field">
Exibição do mapa
<select
{...buildSelectAutofillProps('mapaModoExibicao')}
value={mapaModoExibicao}
onChange={onMapaModoExibicaoChange}
>
<option value="pontos">Pontos representando dados de mercado</option>
<option value="cobertura">Cobertura dos modelos</option>
</select>
</label>
<label className="pesquisa-field pesquisa-mapa-trabalhos-field">
Exibição dos trabalhos técnicos
<select
{...buildSelectAutofillProps('mapaTrabalhosTecnicosModelosModo')}
value={mapaTrabalhosTecnicosModelosModo === 'selecionados_e_anteriores'
? 'selecionados_e_outras_versoes'
: mapaTrabalhosTecnicosModelosModo}
onChange={(event) => setMapaTrabalhosTecnicosModelosModo(event.target.value)}
disabled={mapaLoading || !selectedIds.length}
>
<option value="selecionados">Somente dos modelos selecionados</option>
<option value="selecionados_e_outras_versoes">Incluir demais versões dos modelos</option>
</select>
</label>
{localizacaoAtiva ? (
<label className="pesquisa-field pesquisa-mapa-proximidade-field">
Trabalhos técnicos adicionais por raio
<select
{...buildSelectAutofillProps('mapaTrabalhosTecnicosProximidadeModo')}
value={mapaTrabalhosTecnicosProximidadeModo}
onChange={(event) => setMapaTrabalhosTecnicosProximidadeModo(event.target.value)}
disabled={mapaLoading || !selectedIds.length}
>
<option value="sem_proximidade">Não mostrar</option>
<option value="proximos_ao_avaliando">
{localizacaoMultipla ? 'Mostrar próximos de cada avaliando' : 'Mostrar próximos do avaliando'}
</option>
</select>
</label>
) : null}
{localizacaoAtiva && mapaTrabalhosTecnicosProximidadeModo === 'proximos_ao_avaliando' ? (
<label className="pesquisa-field pesquisa-mapa-raio-field">
{localizacaoMultipla ? 'Raio de cada avaliando (m)' : 'Raio do avaliando (m)'}
<div className="pesquisa-mapa-raio-control">
<input
type="range"
min="0"
max="5000"
step="100"
value={mapaTrabalhosTecnicosRaio}
onChange={(event) => setMapaTrabalhosTecnicosRaio(Number(event.target.value))}
disabled={mapaLoading || !selectedIds.length}
/>
<span className="pesquisa-mapa-raio-value">{formatCount(mapaTrabalhosTecnicosRaio)} m</span>
</div>
</label>
) : null}
</div>
) : null}
{(mapaHtmlAtual || mapaPayloadAtual)
? <MapFrame html={mapaHtmlAtual} payload={mapaPayloadAtual} sessionId={sessionId} />
: <div className="empty-box">Nenhum mapa gerado ainda.</div>}
</SectionBlock>
</div>
<LoadingOverlay show={searchLoading} label="Pesquisando modelos..." />
<LoadingOverlay show={modeloAbertoLoading} label="Carregando modelo..." />
</div>
)
}