Guilherme Silberfarb Costa commited on
Commit
d9d8daf
·
1 Parent(s): 8bd0e6d

validades dos modelos

Browse files
backend/app/services/pesquisa_service.py CHANGED
@@ -16,7 +16,7 @@ import pandas as pd
16
  from fastapi import HTTPException
17
  from joblib import load
18
 
19
- from app.core.elaboracao.core import _migrar_pacote_v1_para_v2
20
  from app.core.map_layers import add_bairros_layer, add_indice_marker, add_zoom_responsive_circle_markers
21
  from app.services import model_repository
22
  from app.services.serializers import sanitize_value
@@ -563,6 +563,7 @@ def _construir_resumo_modelo(caminho_modelo: Path) -> dict[str, Any]:
563
  "total_trabalhos": None,
564
  "endereco_referencia": None,
565
  "equacao": None,
 
566
  "r2": None,
567
  "tem_app": False,
568
  "mapa_disponivel": False,
@@ -608,6 +609,9 @@ def _construir_resumo_modelo(caminho_modelo: Path) -> dict[str, Any]:
608
  if resumo["equacao"] is None:
609
  resumo["equacao"] = _equacao_do_pacote(pacote)
610
 
 
 
 
611
  if resumo["r2"] is None:
612
  resumo["r2"] = _r2_do_pacote(pacote)
613
 
 
16
  from fastapi import HTTPException
17
  from joblib import load
18
 
19
+ from app.core.elaboracao.core import _migrar_pacote_v1_para_v2, normalizar_observacao_modelo
20
  from app.core.map_layers import add_bairros_layer, add_indice_marker, add_zoom_responsive_circle_markers
21
  from app.services import model_repository
22
  from app.services.serializers import sanitize_value
 
563
  "total_trabalhos": None,
564
  "endereco_referencia": None,
565
  "equacao": None,
566
+ "observacao_modelo": None,
567
  "r2": None,
568
  "tem_app": False,
569
  "mapa_disponivel": False,
 
609
  if resumo["equacao"] is None:
610
  resumo["equacao"] = _equacao_do_pacote(pacote)
611
 
612
+ if resumo["observacao_modelo"] is None:
613
+ resumo["observacao_modelo"] = normalizar_observacao_modelo(pacote.get("observacao_modelo"))
614
+
615
  if resumo["r2"] is None:
616
  resumo["r2"] = _r2_do_pacote(pacote)
617
 
frontend/src/components/PesquisaTab.jsx CHANGED
@@ -8,6 +8,7 @@ import MapFrame from './MapFrame'
8
  import PlotFigure from './PlotFigure'
9
  import PesquisaAdminConfigPanel from './PesquisaAdminConfigPanel'
10
  import SectionBlock from './SectionBlock'
 
11
 
12
  const EMPTY_FILTERS = {
13
  nomeModelo: '',
@@ -192,16 +193,33 @@ function CompactHoverList({
192
  modalContent = null,
193
  }) {
194
  const rootRef = useRef(null)
 
195
  const panelRef = useRef(null)
196
  const [previewOpen, setPreviewOpen] = useState(false)
197
  const [modalOpen, setModalOpen] = useState(false)
198
- const [previewSide, setPreviewSide] = useState('left')
199
- const [previewVertical, setPreviewVertical] = useState('down')
200
- const [previewMaxHeight, setPreviewMaxHeight] = useState(160)
201
- const [previewMaxWidth, setPreviewMaxWidth] = useState(560)
202
  const inlinePreviewText = String(previewText || '').trim()
203
  const inlineModalText = String(modalText || '').trim()
204
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
205
  function updatePreviewLayout() {
206
  const bounds = rootRef.current?.getBoundingClientRect()
207
  const panel = panelRef.current
@@ -209,45 +227,89 @@ function CompactHoverList({
209
 
210
  const viewportWidth = window.innerWidth
211
  const viewportHeight = window.innerHeight
212
- const gap = 8
213
  const padding = 16
214
  const triggerCenter = bounds.left + (bounds.width / 2)
215
- const viewportCenter = viewportWidth / 2
216
- const triggerMiddleY = bounds.top + (bounds.height / 2)
217
- const viewportMiddleY = viewportHeight / 2
218
-
219
- const preferredSide = triggerCenter >= viewportCenter ? 'left' : 'right'
220
- const preferredVertical = triggerMiddleY >= viewportMiddleY ? 'up' : 'down'
221
-
222
- const availableLeft = Math.max(120, bounds.right - padding)
223
- const availableRight = Math.max(120, viewportWidth - bounds.left - padding)
224
- const availableAbove = Math.max(96, bounds.top - gap - padding)
225
- const availableBelow = Math.max(96, viewportHeight - bounds.bottom - gap - padding)
226
 
227
  const desiredWidth = Math.min(panel.scrollWidth || 560, 560)
228
- const desiredHeight = Math.min(panel.scrollHeight || 160, 320)
229
 
230
- let nextSide = preferredSide
231
- if (preferredSide === 'left' && desiredWidth > availableLeft && availableRight > availableLeft) {
232
- nextSide = 'right'
233
- } else if (preferredSide === 'right' && desiredWidth > availableRight && availableLeft > availableRight) {
234
- nextSide = 'left'
235
  }
236
 
237
- let nextVertical = preferredVertical
238
- if (preferredVertical === 'up' && desiredHeight > availableAbove && availableBelow > availableAbove) {
239
- nextVertical = 'down'
240
- } else if (preferredVertical === 'down' && desiredHeight > availableBelow && availableAbove > availableBelow) {
241
- nextVertical = 'up'
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
242
  }
243
 
244
- const nextMaxWidth = nextSide === 'left' ? availableLeft : availableRight
245
- const nextMaxHeight = nextVertical === 'up' ? availableAbove : availableBelow
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
246
 
247
- setPreviewSide(nextSide)
248
- setPreviewVertical(nextVertical)
249
- setPreviewMaxWidth(nextMaxWidth)
250
- setPreviewMaxHeight(nextMaxHeight)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
251
  }
252
 
253
  useEffect(() => {
@@ -268,6 +330,10 @@ function CompactHoverList({
268
  }
269
  }, [previewOpen])
270
 
 
 
 
 
271
  useEffect(() => {
272
  if (!modalOpen) return undefined
273
 
@@ -296,15 +362,9 @@ function CompactHoverList({
296
  <span
297
  ref={rootRef}
298
  className={`pesquisa-card-popover-wrap${previewOpen ? ' is-open' : ''}`}
299
- data-preview-side={previewSide}
300
- data-preview-vertical={previewVertical}
301
- onMouseEnter={() => {
302
- setPreviewOpen(true)
303
- }}
304
- onMouseLeave={() => setPreviewOpen(false)}
305
- onFocus={() => {
306
- setPreviewOpen(true)
307
- }}
308
  onBlur={(event) => {
309
  if (!rootRef.current?.contains(event.relatedTarget)) {
310
  setPreviewOpen(false)
@@ -324,19 +384,23 @@ function CompactHoverList({
324
  >
325
  {buttonLabel}
326
  </button>
327
- <span
328
- ref={panelRef}
329
- className="pesquisa-card-popover-panel"
330
- role="tooltip"
331
- aria-label={label}
332
- style={{
333
- maxWidth: `${Math.max(120, previewMaxWidth)}px`,
334
- maxHeight: `${Math.max(96, previewMaxHeight)}px`,
335
- }}
336
- >
337
- <span className="pesquisa-card-popover-preview">{previewContent || inlinePreviewText || inlineModalText}</span>
338
- </span>
339
  </span>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
340
  {modalOpen && typeof document !== 'undefined'
341
  ? createPortal(
342
  <div
@@ -1274,13 +1338,21 @@ export default function PesquisaTab({ sessionId, onUsarModeloEmAvaliacao = null
1274
  <div className="pesquisa-card-grid">
1275
  {result.modelos.map((modelo) => {
1276
  const selecionado = selectedIds.includes(modelo.id)
 
1277
  const finalidadesText = uppercaseListText(modelo.finalidades || [])
1278
  const bairrosText = uppercaseListText(modelo.bairros || [])
1279
  const variaveisText = buildVariablesDisplay(modelo)
 
 
 
 
 
 
1280
  return (
1281
- <article key={modelo.id} className={`pesquisa-card${selecionado ? ' is-selected' : ''}`}>
1282
  <div className="pesquisa-card-top">
1283
  <h4 className="pesquisa-card-title">{modelo.nome_modelo || modelo.arquivo}</h4>
 
1284
  <div className="pesquisa-card-actions">
1285
  <button
1286
  type="button"
@@ -1303,7 +1375,14 @@ export default function PesquisaTab({ sessionId, onUsarModeloEmAvaliacao = null
1303
  <div><strong>Dados:</strong> {formatCount(modelo.total_dados)}</div>
1304
  <div><strong>Faixa area:</strong> {formatRange(modelo.faixa_area)}</div>
1305
  <div><strong>Faixa RH:</strong> {formatRange(modelo.faixa_rh)}</div>
1306
- <div><strong>Faixa data:</strong> {formatRange(modelo.faixa_data)}</div>
 
 
 
 
 
 
 
1307
  </div>
1308
  </div>
1309
  <div className="pesquisa-card-meta-actions">
@@ -1327,6 +1406,12 @@ export default function PesquisaTab({ sessionId, onUsarModeloEmAvaliacao = null
1327
  previewContent={buildVariablesContent(variaveisText)}
1328
  modalContent={buildVariablesContent(variaveisText)}
1329
  />
 
 
 
 
 
 
1330
  </div>
1331
  </div>
1332
 
 
8
  import PlotFigure from './PlotFigure'
9
  import PesquisaAdminConfigPanel from './PesquisaAdminConfigPanel'
10
  import SectionBlock from './SectionBlock'
11
+ import { getFaixaDataRecencyInfo } from '../modelRecency'
12
 
13
  const EMPTY_FILTERS = {
14
  nomeModelo: '',
 
193
  modalContent = null,
194
  }) {
195
  const rootRef = useRef(null)
196
+ const closePreviewTimeoutRef = useRef(null)
197
  const panelRef = useRef(null)
198
  const [previewOpen, setPreviewOpen] = useState(false)
199
  const [modalOpen, setModalOpen] = useState(false)
200
+ const [previewStyle, setPreviewStyle] = useState({})
 
 
 
201
  const inlinePreviewText = String(previewText || '').trim()
202
  const inlineModalText = String(modalText || '').trim()
203
 
204
+ function clearPreviewCloseTimeout() {
205
+ if (closePreviewTimeoutRef.current) {
206
+ window.clearTimeout(closePreviewTimeoutRef.current)
207
+ closePreviewTimeoutRef.current = null
208
+ }
209
+ }
210
+
211
+ function schedulePreviewClose() {
212
+ clearPreviewCloseTimeout()
213
+ closePreviewTimeoutRef.current = window.setTimeout(() => {
214
+ setPreviewOpen(false)
215
+ }, 80)
216
+ }
217
+
218
+ function openPreview() {
219
+ clearPreviewCloseTimeout()
220
+ setPreviewOpen(true)
221
+ }
222
+
223
  function updatePreviewLayout() {
224
  const bounds = rootRef.current?.getBoundingClientRect()
225
  const panel = panelRef.current
 
227
 
228
  const viewportWidth = window.innerWidth
229
  const viewportHeight = window.innerHeight
230
+ const gap = 10
231
  const padding = 16
232
  const triggerCenter = bounds.left + (bounds.width / 2)
233
+ const towardCenterSide = triggerCenter >= (viewportWidth / 2) ? 'left' : 'right'
234
+ const oppositeSide = towardCenterSide === 'left' ? 'right' : 'left'
235
+ const downwardSpace = viewportHeight - bounds.bottom - gap - padding
236
+ const upwardSpace = bounds.top - gap - padding
237
+ const preferredVertical = downwardSpace >= upwardSpace ? 'down' : 'up'
238
+ const oppositeVertical = preferredVertical === 'down' ? 'up' : 'down'
 
 
 
 
 
239
 
240
  const desiredWidth = Math.min(panel.scrollWidth || 560, 560)
241
+ const desiredHeight = Math.min(panel.scrollHeight || 220, 420)
242
 
243
+ function clamp(value, min, max) {
244
+ if (max < min) return min
245
+ return Math.min(Math.max(value, min), max)
 
 
246
  }
247
 
248
+ function makeVerticalCandidate(side, vertical) {
249
+ const maxWidth = Math.max(160, viewportWidth - (padding * 2))
250
+ const width = Math.min(desiredWidth, maxWidth)
251
+ const maxHeight = Math.max(96, vertical === 'down' ? downwardSpace : upwardSpace)
252
+ const height = Math.min(desiredHeight, maxHeight)
253
+ const leftBase = side === 'left'
254
+ ? bounds.right - width
255
+ : bounds.left
256
+ const left = clamp(leftBase, padding, viewportWidth - padding - width)
257
+ const topBase = vertical === 'down'
258
+ ? bounds.bottom + gap
259
+ : bounds.top - gap - height
260
+ const top = clamp(topBase, padding, viewportHeight - padding - height)
261
+ return {
262
+ left,
263
+ top,
264
+ maxWidth,
265
+ maxHeight,
266
+ score: (Math.min(maxWidth, desiredWidth) / desiredWidth) + (Math.min(maxHeight, desiredHeight) / desiredHeight),
267
+ }
268
  }
269
 
270
+ function makeSideCandidate(side) {
271
+ const maxWidth = Math.max(180, side === 'right'
272
+ ? viewportWidth - bounds.right - gap - padding
273
+ : bounds.left - gap - padding)
274
+ const width = Math.min(desiredWidth, maxWidth)
275
+ const maxHeight = Math.max(120, viewportHeight - (padding * 2))
276
+ const height = Math.min(desiredHeight, maxHeight)
277
+ const leftBase = side === 'right'
278
+ ? bounds.right + gap
279
+ : bounds.left - gap - width
280
+ const left = clamp(leftBase, padding, viewportWidth - padding - width)
281
+ const top = clamp(bounds.top, padding, viewportHeight - padding - height)
282
+ return {
283
+ left,
284
+ top,
285
+ maxWidth,
286
+ maxHeight,
287
+ score: (Math.min(maxWidth, desiredWidth) / desiredWidth) + (Math.min(maxHeight, desiredHeight) / desiredHeight),
288
+ }
289
+ }
290
 
291
+ const candidates = [
292
+ makeVerticalCandidate(towardCenterSide, preferredVertical),
293
+ makeVerticalCandidate(towardCenterSide, oppositeVertical),
294
+ makeSideCandidate(towardCenterSide),
295
+ makeSideCandidate(oppositeSide),
296
+ makeVerticalCandidate(oppositeSide, preferredVertical),
297
+ makeVerticalCandidate(oppositeSide, oppositeVertical),
298
+ ]
299
+
300
+ const best = candidates.reduce((current, candidate) => {
301
+ if (!current || candidate.score > current.score) return candidate
302
+ return current
303
+ }, null)
304
+
305
+ if (!best) return
306
+
307
+ setPreviewStyle({
308
+ left: `${best.left}px`,
309
+ top: `${best.top}px`,
310
+ maxWidth: `${Math.max(160, best.maxWidth)}px`,
311
+ maxHeight: `${Math.max(96, best.maxHeight)}px`,
312
+ })
313
  }
314
 
315
  useEffect(() => {
 
330
  }
331
  }, [previewOpen])
332
 
333
+ useEffect(() => () => {
334
+ clearPreviewCloseTimeout()
335
+ }, [])
336
+
337
  useEffect(() => {
338
  if (!modalOpen) return undefined
339
 
 
362
  <span
363
  ref={rootRef}
364
  className={`pesquisa-card-popover-wrap${previewOpen ? ' is-open' : ''}`}
365
+ onMouseEnter={() => openPreview()}
366
+ onMouseLeave={() => schedulePreviewClose()}
367
+ onFocus={() => openPreview()}
 
 
 
 
 
 
368
  onBlur={(event) => {
369
  if (!rootRef.current?.contains(event.relatedTarget)) {
370
  setPreviewOpen(false)
 
384
  >
385
  {buttonLabel}
386
  </button>
 
 
 
 
 
 
 
 
 
 
 
 
387
  </span>
388
+ {previewOpen && typeof document !== 'undefined'
389
+ ? createPortal(
390
+ <span
391
+ ref={panelRef}
392
+ className="pesquisa-card-popover-panel is-floating"
393
+ role="tooltip"
394
+ aria-label={label}
395
+ style={previewStyle}
396
+ onMouseEnter={() => openPreview()}
397
+ onMouseLeave={() => schedulePreviewClose()}
398
+ >
399
+ <span className="pesquisa-card-popover-preview">{previewContent || inlinePreviewText || inlineModalText}</span>
400
+ </span>,
401
+ document.body,
402
+ )
403
+ : null}
404
  {modalOpen && typeof document !== 'undefined'
405
  ? createPortal(
406
  <div
 
1338
  <div className="pesquisa-card-grid">
1339
  {result.modelos.map((modelo) => {
1340
  const selecionado = selectedIds.includes(modelo.id)
1341
+ const faixaDataRecency = getFaixaDataRecencyInfo(modelo.faixa_data)
1342
  const finalidadesText = uppercaseListText(modelo.finalidades || [])
1343
  const bairrosText = uppercaseListText(modelo.bairros || [])
1344
  const variaveisText = buildVariablesDisplay(modelo)
1345
+ const observacaoText = String(modelo.observacao_modelo || '').trim()
1346
+ const cardClassName = [
1347
+ 'pesquisa-card',
1348
+ selecionado ? 'is-selected' : '',
1349
+ ].filter(Boolean).join(' ')
1350
+
1351
  return (
1352
+ <article key={modelo.id} className={cardClassName}>
1353
  <div className="pesquisa-card-top">
1354
  <h4 className="pesquisa-card-title">{modelo.nome_modelo || modelo.arquivo}</h4>
1355
+ <div className="pesquisa-card-divider" aria-hidden="true" />
1356
  <div className="pesquisa-card-actions">
1357
  <button
1358
  type="button"
 
1375
  <div><strong>Dados:</strong> {formatCount(modelo.total_dados)}</div>
1376
  <div><strong>Faixa area:</strong> {formatRange(modelo.faixa_area)}</div>
1377
  <div><strong>Faixa RH:</strong> {formatRange(modelo.faixa_rh)}</div>
1378
+ <div>
1379
+ <strong>Faixa data:</strong> {formatRange(modelo.faixa_data)}
1380
+ {faixaDataRecency.label ? (
1381
+ <span className={`pesquisa-card-faixa-data-badge is-aged-${faixaDataRecency.tone}`}>
1382
+ {faixaDataRecency.label}
1383
+ </span>
1384
+ ) : null}
1385
+ </div>
1386
  </div>
1387
  </div>
1388
  <div className="pesquisa-card-meta-actions">
 
1406
  previewContent={buildVariablesContent(variaveisText)}
1407
  modalContent={buildVariablesContent(variaveisText)}
1408
  />
1409
+ <CompactHoverList
1410
+ label="Observação"
1411
+ buttonLabel="Obs"
1412
+ previewText={observacaoText}
1413
+ modalText={observacaoText}
1414
+ />
1415
  </div>
1416
  </div>
1417
 
frontend/src/components/RepositorioTab.jsx CHANGED
@@ -5,6 +5,7 @@ import EquationFormatsPanel from './EquationFormatsPanel'
5
  import LoadingOverlay from './LoadingOverlay'
6
  import MapFrame from './MapFrame'
7
  import PlotFigure from './PlotFigure'
 
8
 
9
  const REPO_INNER_TABS = [
10
  { key: 'mapa', label: 'Mapa' },
@@ -395,8 +396,6 @@ export default function RepositorioTab({ authUser, sessionId }) {
395
  <th>Autor</th>
396
  <th>Período</th>
397
  <th>Dados</th>
398
- <th>APP</th>
399
- <th>Status</th>
400
  <th className="repo-col-open">Abrir</th>
401
  {isAdmin ? <th className="repo-col-delete">Excluir</th> : null}
402
  </tr>
@@ -405,16 +404,24 @@ export default function RepositorioTab({ authUser, sessionId }) {
405
  {modelos.map((item) => {
406
  const key = String(item.id)
407
  const emConfirmacao = confirmDeleteId === key
 
408
  return (
409
  <tr key={key}>
410
  <td>{item.nome_modelo || item.arquivo || key}</td>
411
  <td>{item.tipo_imovel || '-'}</td>
412
  <td>{item.finalidade || '-'}</td>
413
  <td>{item.autor || '-'}</td>
414
- <td>{item.periodo_dados?.label || '-'}</td>
 
 
 
 
 
 
 
 
 
415
  <td>{item.total_dados ?? '-'}</td>
416
- <td>{item.tem_app ? 'Sim' : 'Não'}</td>
417
- <td>{item.status || '-'}</td>
418
  <td className="repo-col-open">
419
  <button
420
  type="button"
@@ -466,7 +473,7 @@ export default function RepositorioTab({ authUser, sessionId }) {
466
  })}
467
  {!modelos.length ? (
468
  <tr>
469
- <td colSpan={isAdmin ? 10 : 9}>
470
  {loading ? 'Carregando modelos...' : 'Nenhum modelo encontrado no repositório.'}
471
  </td>
472
  </tr>
 
5
  import LoadingOverlay from './LoadingOverlay'
6
  import MapFrame from './MapFrame'
7
  import PlotFigure from './PlotFigure'
8
+ import { getFaixaDataRecencyInfo } from '../modelRecency'
9
 
10
  const REPO_INNER_TABS = [
11
  { key: 'mapa', label: 'Mapa' },
 
396
  <th>Autor</th>
397
  <th>Período</th>
398
  <th>Dados</th>
 
 
399
  <th className="repo-col-open">Abrir</th>
400
  {isAdmin ? <th className="repo-col-delete">Excluir</th> : null}
401
  </tr>
 
404
  {modelos.map((item) => {
405
  const key = String(item.id)
406
  const emConfirmacao = confirmDeleteId === key
407
+ const periodoRecency = getFaixaDataRecencyInfo(item.periodo_dados)
408
  return (
409
  <tr key={key}>
410
  <td>{item.nome_modelo || item.arquivo || key}</td>
411
  <td>{item.tipo_imovel || '-'}</td>
412
  <td>{item.finalidade || '-'}</td>
413
  <td>{item.autor || '-'}</td>
414
+ <td>
415
+ <span className="repo-periodo-wrap">
416
+ <span>{item.periodo_dados?.label || '-'}</span>
417
+ {periodoRecency.label ? (
418
+ <span className={`pesquisa-card-faixa-data-badge is-aged-${periodoRecency.tone}`}>
419
+ {periodoRecency.label}
420
+ </span>
421
+ ) : null}
422
+ </span>
423
+ </td>
424
  <td>{item.total_dados ?? '-'}</td>
 
 
425
  <td className="repo-col-open">
426
  <button
427
  type="button"
 
473
  })}
474
  {!modelos.length ? (
475
  <tr>
476
+ <td colSpan={isAdmin ? 8 : 7}>
477
  {loading ? 'Carregando modelos...' : 'Nenhum modelo encontrado no repositório.'}
478
  </td>
479
  </tr>
frontend/src/modelRecency.js ADDED
@@ -0,0 +1,55 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ function parseIsoDateOnly(value) {
2
+ const text = String(value ?? '').trim()
3
+ const isoMatch = text.match(/^(\d{4})-(\d{2})-(\d{2})$/)
4
+ if (!isoMatch) return null
5
+
6
+ const year = Number(isoMatch[1])
7
+ const month = Number(isoMatch[2])
8
+ const day = Number(isoMatch[3])
9
+ const date = new Date(Date.UTC(year, month - 1, day))
10
+
11
+ if (
12
+ Number.isNaN(date.getTime())
13
+ || date.getUTCFullYear() !== year
14
+ || date.getUTCMonth() !== month - 1
15
+ || date.getUTCDate() !== day
16
+ ) {
17
+ return null
18
+ }
19
+
20
+ return date
21
+ }
22
+
23
+ function subtractMonthsUtc(date, months) {
24
+ const year = date.getUTCFullYear()
25
+ const month = date.getUTCMonth()
26
+ const day = date.getUTCDate()
27
+ const base = new Date(Date.UTC(year, month - months, 1))
28
+ const lastDayOfTargetMonth = new Date(
29
+ Date.UTC(base.getUTCFullYear(), base.getUTCMonth() + 1, 0),
30
+ ).getUTCDate()
31
+
32
+ base.setUTCDate(Math.min(day, lastDayOfTargetMonth))
33
+ return base
34
+ }
35
+
36
+ export function getFaixaDataRecencyInfo(periodo) {
37
+ const dataFinal = parseIsoDateOnly(periodo?.max ?? periodo?.data_final)
38
+ if (!dataFinal) return { tone: '', label: '' }
39
+
40
+ const now = new Date()
41
+ const hojeUtc = new Date(Date.UTC(now.getFullYear(), now.getMonth(), now.getDate()))
42
+ if (dataFinal > hojeUtc) return { tone: '', label: '' }
43
+
44
+ if (dataFinal <= subtractMonthsUtc(hojeUtc, 24)) {
45
+ return { tone: '2y', label: '>= 2 anos' }
46
+ }
47
+ if (dataFinal <= subtractMonthsUtc(hojeUtc, 12)) {
48
+ return { tone: '1y', label: '>= 1 ano' }
49
+ }
50
+ if (dataFinal <= subtractMonthsUtc(hojeUtc, 6)) {
51
+ return { tone: '6m', label: '>= 6 meses' }
52
+ }
53
+
54
+ return { tone: '', label: '' }
55
+ }
frontend/src/styles.css CHANGED
@@ -493,6 +493,13 @@ textarea {
493
  color: #48627a;
494
  }
495
 
 
 
 
 
 
 
 
496
  .repo-col-open,
497
  .repo-col-delete {
498
  width: 68px;
@@ -1929,9 +1936,14 @@ button.pesquisa-coluna-remove:hover {
1929
  }
1930
 
1931
  .pesquisa-card {
1932
- border: 1px solid #dbe7f2;
 
 
 
 
 
1933
  border-radius: 14px;
1934
- background: linear-gradient(180deg, #ffffff 0%, #fcfdff 100%);
1935
  padding: 12px;
1936
  display: flex;
1937
  flex-direction: column;
@@ -1951,17 +1963,16 @@ button.pesquisa-coluna-remove:hover {
1951
  .pesquisa-card:focus-within {
1952
  z-index: 3;
1953
  transform: translateY(-1px);
1954
- border-color: #c8dced;
1955
  box-shadow:
1956
  0 10px 22px rgba(26, 43, 61, 0.08),
1957
  inset 0 0 0 1px rgba(255, 255, 255, 0.82);
1958
  }
1959
 
1960
  .pesquisa-card.is-selected {
1961
- border-color: #ffbe77;
1962
  box-shadow:
1963
  0 10px 24px rgba(255, 163, 63, 0.17),
1964
- 0 0 0 1px rgba(255, 163, 63, 0.28);
1965
  }
1966
 
1967
  .pesquisa-card-top {
@@ -1981,6 +1992,12 @@ button.pesquisa-coluna-remove:hover {
1981
  overflow-wrap: anywhere;
1982
  }
1983
 
 
 
 
 
 
 
1984
  .pesquisa-card-actions {
1985
  display: grid;
1986
  grid-template-columns: repeat(3, minmax(0, 1fr));
@@ -1990,7 +2007,7 @@ button.pesquisa-coluna-remove:hover {
1990
 
1991
  .pesquisa-card-meta-actions {
1992
  display: grid;
1993
- grid-template-columns: repeat(3, minmax(0, 1fr));
1994
  gap: 8px;
1995
  min-width: 0;
1996
  margin-top: auto;
@@ -2058,7 +2075,7 @@ button.pesquisa-coluna-remove:hover {
2058
  gap: 9px;
2059
  min-width: 0;
2060
  flex: 1 1 auto;
2061
- border-top: 1px solid #e7eef5;
2062
  padding-top: 9px;
2063
  }
2064
 
@@ -2088,6 +2105,40 @@ button.pesquisa-coluna-remove:hover {
2088
  line-height: 1.34;
2089
  }
2090
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
2091
  .pesquisa-card-popover-row {
2092
  display: flex;
2093
  align-items: flex-start;
@@ -2151,12 +2202,11 @@ button.pesquisa-coluna-remove:hover {
2151
  }
2152
 
2153
  .pesquisa-card-popover-panel {
2154
- position: absolute;
2155
- top: calc(100% + 8px);
2156
- right: 0;
2157
  display: block;
2158
  width: min(560px, calc(100vw - 56px));
2159
  max-height: 160px;
 
2160
  padding: 10px 12px;
2161
  border: 1px solid #000;
2162
  border-radius: 10px;
@@ -2166,39 +2216,15 @@ button.pesquisa-coluna-remove:hover {
2166
  opacity: 0;
2167
  visibility: hidden;
2168
  transform: translateY(6px);
2169
- pointer-events: none;
2170
  transition: opacity 0.16s ease, transform 0.16s ease, visibility 0.16s ease;
 
2171
  }
2172
 
2173
- .pesquisa-card-popover-wrap[data-preview-vertical='up'] .pesquisa-card-popover-panel {
2174
- top: auto;
2175
- bottom: calc(100% + 8px);
2176
- transform: translateY(-6px);
2177
- }
2178
-
2179
- .pesquisa-card-popover-wrap[data-preview-vertical='down'] .pesquisa-card-popover-panel {
2180
- top: calc(100% + 8px);
2181
- bottom: auto;
2182
- transform: translateY(6px);
2183
- }
2184
-
2185
- .pesquisa-card-popover-wrap[data-preview-side='right'] .pesquisa-card-popover-panel {
2186
- left: 0;
2187
- right: auto;
2188
- }
2189
-
2190
- .pesquisa-card-popover-wrap[data-preview-side='left'] .pesquisa-card-popover-panel {
2191
- right: 0;
2192
- left: auto;
2193
- }
2194
-
2195
- .pesquisa-card-popover-wrap:hover .pesquisa-card-popover-panel,
2196
- .pesquisa-card-popover-wrap:focus-within .pesquisa-card-popover-panel,
2197
- .pesquisa-card-popover-wrap.is-open .pesquisa-card-popover-panel {
2198
  opacity: 1;
2199
  visibility: visible;
2200
  transform: translateY(0);
2201
- pointer-events: auto;
2202
  }
2203
 
2204
  .pesquisa-card-popover-preview {
 
493
  color: #48627a;
494
  }
495
 
496
+ .repo-periodo-wrap {
497
+ display: inline-flex;
498
+ align-items: center;
499
+ flex-wrap: wrap;
500
+ gap: 6px;
501
+ }
502
+
503
  .repo-col-open,
504
  .repo-col-delete {
505
  width: 68px;
 
1936
  }
1937
 
1938
  .pesquisa-card {
1939
+ --pesquisa-card-bg-start: #ffffff;
1940
+ --pesquisa-card-bg-end: #fcfdff;
1941
+ --pesquisa-card-border: #dbe7f2;
1942
+ --pesquisa-card-border-hover: #c8dced;
1943
+ --pesquisa-card-divider: #e7eef5;
1944
+ border: 1px solid var(--pesquisa-card-border);
1945
  border-radius: 14px;
1946
+ background: linear-gradient(180deg, var(--pesquisa-card-bg-start) 0%, var(--pesquisa-card-bg-end) 100%);
1947
  padding: 12px;
1948
  display: flex;
1949
  flex-direction: column;
 
1963
  .pesquisa-card:focus-within {
1964
  z-index: 3;
1965
  transform: translateY(-1px);
1966
+ border-color: var(--pesquisa-card-border-hover);
1967
  box-shadow:
1968
  0 10px 22px rgba(26, 43, 61, 0.08),
1969
  inset 0 0 0 1px rgba(255, 255, 255, 0.82);
1970
  }
1971
 
1972
  .pesquisa-card.is-selected {
 
1973
  box-shadow:
1974
  0 10px 24px rgba(255, 163, 63, 0.17),
1975
+ 0 0 0 2px rgba(255, 163, 63, 0.18);
1976
  }
1977
 
1978
  .pesquisa-card-top {
 
1992
  overflow-wrap: anywhere;
1993
  }
1994
 
1995
+ .pesquisa-card-divider {
1996
+ width: 100%;
1997
+ height: 1px;
1998
+ background: var(--pesquisa-card-divider);
1999
+ }
2000
+
2001
  .pesquisa-card-actions {
2002
  display: grid;
2003
  grid-template-columns: repeat(3, minmax(0, 1fr));
 
2007
 
2008
  .pesquisa-card-meta-actions {
2009
  display: grid;
2010
+ grid-template-columns: repeat(4, minmax(0, 1fr));
2011
  gap: 8px;
2012
  min-width: 0;
2013
  margin-top: auto;
 
2075
  gap: 9px;
2076
  min-width: 0;
2077
  flex: 1 1 auto;
2078
+ border-top: 1px solid var(--pesquisa-card-divider);
2079
  padding-top: 9px;
2080
  }
2081
 
 
2105
  line-height: 1.34;
2106
  }
2107
 
2108
+ .pesquisa-card-faixa-data-badge {
2109
+ display: inline-flex;
2110
+ align-items: center;
2111
+ justify-content: center;
2112
+ margin-left: 8px;
2113
+ padding: 2px 8px;
2114
+ border-radius: 999px;
2115
+ border: 1px solid transparent;
2116
+ font-size: 0.72rem;
2117
+ font-weight: 800;
2118
+ line-height: 1.2;
2119
+ letter-spacing: 0.01em;
2120
+ vertical-align: middle;
2121
+ white-space: nowrap;
2122
+ }
2123
+
2124
+ .pesquisa-card-faixa-data-badge.is-aged-6m {
2125
+ background: #fff0b8;
2126
+ border-color: #d9bd72;
2127
+ color: #705800;
2128
+ }
2129
+
2130
+ .pesquisa-card-faixa-data-badge.is-aged-1y {
2131
+ background: #ffd7a8;
2132
+ border-color: #d5a16a;
2133
+ color: #754100;
2134
+ }
2135
+
2136
+ .pesquisa-card-faixa-data-badge.is-aged-2y {
2137
+ background: #f4c0c0;
2138
+ border-color: #d69292;
2139
+ color: #7a2f2f;
2140
+ }
2141
+
2142
  .pesquisa-card-popover-row {
2143
  display: flex;
2144
  align-items: flex-start;
 
2202
  }
2203
 
2204
  .pesquisa-card-popover-panel {
2205
+ position: fixed;
 
 
2206
  display: block;
2207
  width: min(560px, calc(100vw - 56px));
2208
  max-height: 160px;
2209
+ overflow: auto;
2210
  padding: 10px 12px;
2211
  border: 1px solid #000;
2212
  border-radius: 10px;
 
2216
  opacity: 0;
2217
  visibility: hidden;
2218
  transform: translateY(6px);
2219
+ pointer-events: auto;
2220
  transition: opacity 0.16s ease, transform 0.16s ease, visibility 0.16s ease;
2221
+ z-index: 1600;
2222
  }
2223
 
2224
+ .pesquisa-card-popover-panel.is-floating {
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
2225
  opacity: 1;
2226
  visibility: visible;
2227
  transform: translateY(0);
 
2228
  }
2229
 
2230
  .pesquisa-card-popover-preview {