Spaces:
Running
Running
| 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> | |
| ) | |
| } | |