Guilherme Silberfarb Costa commited on
Commit
dac5782
·
1 Parent(s): 3c854d0

correcoes de overflows e normalizacoes de tipos

Browse files
backend/app/core/elaboracao/core.py CHANGED
@@ -83,6 +83,75 @@ def detectar_abas_excel(arquivo):
83
  return [], f"Erro ao detectar abas: {str(e)}", False
84
 
85
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
86
  def carregar_arquivo(arquivo, nome_aba=None):
87
  """
88
  Carrega arquivo Excel ou CSV e retorna DataFrame.
@@ -125,6 +194,10 @@ def carregar_arquivo(arquivo, nome_aba=None):
125
  else:
126
  return None, "Formato de arquivo não suportado.", False
127
 
 
 
 
 
128
  # Reinicia índice começando em 1
129
  df = df.reset_index(drop=True)
130
  df.index = df.index + 1
@@ -708,8 +781,11 @@ def detectar_dicotomicas(df, colunas):
708
  """
709
  dicotomicas = []
710
  for col in colunas:
711
- valores = set(df[col].dropna().unique())
712
- if valores.issubset({0, 1, 0.0, 1.0}):
 
 
 
713
  dicotomicas.append(col)
714
  return dicotomicas
715
 
@@ -723,19 +799,18 @@ def detectar_codigo_alocado(df, colunas):
723
  """
724
  resultado = []
725
  for col in colunas:
726
- valores = df[col].dropna().unique()
727
- valores_set = set(valores)
 
 
728
  # Pelo menos 3 valores distintos
729
- if len(valores_set) < 3:
730
  continue
731
  # Todos devem ser inteiros
732
- try:
733
- if not all(float(v) == int(float(v)) for v in valores):
734
- continue
735
- except (ValueError, TypeError):
736
  continue
737
  # Nenhum valor zero
738
- if any(float(v) == 0 for v in valores):
739
  continue
740
  resultado.append(col)
741
  return resultado
@@ -749,22 +824,67 @@ def detectar_percentuais(df, colunas):
749
  """
750
  resultado = []
751
  for col in colunas:
752
- valores = df[col].dropna().unique()
 
 
 
753
  if len(valores) < 2:
754
  continue
755
  # Todos devem estar entre 0 e 1
756
- try:
757
- if not all(0 <= float(v) <= 1 for v in valores):
758
- continue
759
- except (ValueError, TypeError):
760
  continue
761
  # Não pode ser dicotômica pura (só {0,1})
762
- if set(valores).issubset({0, 1, 0.0, 1.0}):
763
  continue
764
  resultado.append(col)
765
  return resultado
766
 
767
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
768
  # ============================================================
769
  # VERIFICAÇÃO DE MULTICOLINEARIDADE
770
  # ============================================================
 
83
  return [], f"Erro ao detectar abas: {str(e)}", False
84
 
85
 
86
+ def _normalizar_colunas_numericas_texto(df, proporcao_minima=0.9):
87
+ """
88
+ Converte colunas textuais numericamente consistentes para dtype numérico.
89
+
90
+ Objetivo:
91
+ - preservar colunas realmente textuais;
92
+ - recuperar números em formatos comuns (pt-BR e en-US), como:
93
+ 1.234,56 | 1234.56 | 0,25 | 0.25.
94
+ """
95
+ if df is None or df.empty:
96
+ return df
97
+
98
+ df_saida = df.copy()
99
+
100
+ for col in df_saida.columns:
101
+ serie = df_saida[col]
102
+ if (
103
+ pd.api.types.is_numeric_dtype(serie)
104
+ or pd.api.types.is_bool_dtype(serie)
105
+ or pd.api.types.is_datetime64_any_dtype(serie)
106
+ ):
107
+ continue
108
+
109
+ texto = serie.astype(str).str.strip().replace({
110
+ "": np.nan,
111
+ "nan": np.nan,
112
+ "NaN": np.nan,
113
+ "None": np.nan,
114
+ "none": np.nan,
115
+ "NaT": np.nan,
116
+ "nat": np.nan,
117
+ "<NA>": np.nan,
118
+ })
119
+ texto = texto.str.replace("\u00a0", "", regex=False).str.replace(" ", "", regex=False)
120
+
121
+ preenchidos = texto.notna()
122
+ total_preenchido = int(preenchidos.sum())
123
+ if total_preenchido == 0:
124
+ continue
125
+
126
+ candidato_direto = pd.to_numeric(texto, errors="coerce")
127
+ candidato_ptbr = pd.to_numeric(
128
+ texto.str.replace(".", "", regex=False).str.replace(",", ".", regex=False),
129
+ errors="coerce",
130
+ )
131
+ candidato_enus = pd.to_numeric(
132
+ texto.str.replace(",", "", regex=False),
133
+ errors="coerce",
134
+ )
135
+
136
+ candidatos = [candidato_direto, candidato_ptbr, candidato_enus]
137
+ melhor = max(candidatos, key=lambda s: int(s[preenchidos].notna().sum()))
138
+ validos = int(melhor[preenchidos].notna().sum())
139
+ proporcao = validos / total_preenchido if total_preenchido else 0.0
140
+ if proporcao < proporcao_minima:
141
+ continue
142
+
143
+ # Evita converter identificadores com zeros à esquerda (ex.: 000123).
144
+ texto_preenchido = texto[preenchidos]
145
+ zero_esquerda = texto_preenchido.str.match(r"^0\d+$").all()
146
+ tamanho_constante = texto_preenchido.str.len().nunique() == 1
147
+ if zero_esquerda and tamanho_constante:
148
+ continue
149
+
150
+ df_saida[col] = melhor
151
+
152
+ return df_saida
153
+
154
+
155
  def carregar_arquivo(arquivo, nome_aba=None):
156
  """
157
  Carrega arquivo Excel ou CSV e retorna DataFrame.
 
194
  else:
195
  return None, "Formato de arquivo não suportado.", False
196
 
197
+ # Normaliza colunas textuais que representam números para evitar
198
+ # perda de tipagem em detecções/seleções e exportações.
199
+ df = _normalizar_colunas_numericas_texto(df)
200
+
201
  # Reinicia índice começando em 1
202
  df = df.reset_index(drop=True)
203
  df.index = df.index + 1
 
781
  """
782
  dicotomicas = []
783
  for col in colunas:
784
+ serie_num = _normalizar_serie_numerica_classificacao(df[col]).dropna()
785
+ if serie_num.empty:
786
+ continue
787
+ valores = np.unique(serie_num.to_numpy(dtype=float))
788
+ if np.all(np.isclose(valores, 0.0) | np.isclose(valores, 1.0)):
789
  dicotomicas.append(col)
790
  return dicotomicas
791
 
 
799
  """
800
  resultado = []
801
  for col in colunas:
802
+ serie_num = _normalizar_serie_numerica_classificacao(df[col]).dropna()
803
+ if serie_num.empty:
804
+ continue
805
+ valores = np.unique(serie_num.to_numpy(dtype=float))
806
  # Pelo menos 3 valores distintos
807
+ if len(valores) < 3:
808
  continue
809
  # Todos devem ser inteiros
810
+ if not np.all(np.isclose(valores, np.round(valores))):
 
 
 
811
  continue
812
  # Nenhum valor zero
813
+ if np.any(np.isclose(valores, 0.0)):
814
  continue
815
  resultado.append(col)
816
  return resultado
 
824
  """
825
  resultado = []
826
  for col in colunas:
827
+ serie_num = _normalizar_serie_numerica_classificacao(df[col]).dropna()
828
+ if serie_num.empty:
829
+ continue
830
+ valores = np.unique(serie_num.to_numpy(dtype=float))
831
  if len(valores) < 2:
832
  continue
833
  # Todos devem estar entre 0 e 1
834
+ if not np.all((valores >= -1e-9) & (valores <= 1.0 + 1e-9)):
 
 
 
835
  continue
836
  # Não pode ser dicotômica pura (só {0,1})
837
+ if np.all(np.isclose(valores, 0.0) | np.isclose(valores, 1.0)):
838
  continue
839
  resultado.append(col)
840
  return resultado
841
 
842
 
843
+ def _normalizar_serie_numerica_classificacao(serie):
844
+ """
845
+ Converte série para numérico com tolerância a texto numérico.
846
+ Suporta formatos com vírgula decimal (pt-BR) e ignora tokens vazios.
847
+ """
848
+ if pd.api.types.is_numeric_dtype(serie):
849
+ return pd.to_numeric(serie, errors='coerce')
850
+
851
+ texto = serie.astype(str).str.strip().replace({
852
+ "": np.nan,
853
+ "nan": np.nan,
854
+ "NaN": np.nan,
855
+ "None": np.nan,
856
+ "none": np.nan,
857
+ "NaT": np.nan,
858
+ "nat": np.nan,
859
+ "<NA>": np.nan,
860
+ })
861
+ texto = texto.str.replace("\u00a0", "", regex=False).str.replace(" ", "", regex=False)
862
+ texto_sem_percentual = texto.str.replace("%", "", regex=False)
863
+
864
+ # 1) Parse padrão (ex.: 0.25, 1000)
865
+ direto = pd.to_numeric(texto_sem_percentual, errors='coerce')
866
+ melhor = direto
867
+ melhor_validos = int(direto.notna().sum())
868
+
869
+ # 2) Parse pt-BR (ex.: 1.234,56 -> 1234.56 | 0,25 -> 0.25)
870
+ pt_br = pd.to_numeric(
871
+ texto_sem_percentual.str.replace('.', '', regex=False).str.replace(',', '.', regex=False),
872
+ errors='coerce',
873
+ )
874
+ validos_pt_br = int(pt_br.notna().sum())
875
+ if validos_pt_br > melhor_validos:
876
+ melhor = pt_br
877
+ melhor_validos = validos_pt_br
878
+
879
+ # 3) Parse com vírgula de milhar (ex.: 1,234.56 -> 1234.56)
880
+ en_us = pd.to_numeric(texto_sem_percentual.str.replace(',', '', regex=False), errors='coerce')
881
+ validos_en_us = int(en_us.notna().sum())
882
+ if validos_en_us > melhor_validos:
883
+ melhor = en_us
884
+
885
+ return melhor
886
+
887
+
888
  # ============================================================
889
  # VERIFICAÇÃO DE MULTICOLINEARIDADE
890
  # ============================================================
backend/app/services/elaboracao_service.py CHANGED
@@ -132,6 +132,48 @@ def _parse_serie_datas_texto_segura(serie_texto: pd.Series) -> pd.Series:
132
  return pd.to_datetime(serie_texto, errors="coerce", dayfirst=True)
133
 
134
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
135
  def list_avaliadores() -> list[dict[str, Any]]:
136
  global _AVALIADORES_CACHE
137
  if _AVALIADORES_CACHE is not None:
@@ -521,7 +563,7 @@ def _converter_coluna_para_datas(
521
  ) -> pd.Series:
522
  serie_base = serie.copy()
523
  if pd.api.types.is_object_dtype(serie_base) or pd.api.types.is_string_dtype(serie_base):
524
- serie_base = serie_base.astype(str).str.strip().replace("", np.nan)
525
 
526
  mascara_preenchida = serie_base.notna()
527
  total_preenchido = int(mascara_preenchida.sum())
@@ -547,7 +589,23 @@ def _converter_coluna_para_datas(
547
  )
548
  datas = pd.to_datetime(serie_num, unit="D", origin="1899-12-30", errors="coerce")
549
  else:
550
- datas = _parse_serie_datas_texto_segura(serie_base)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
551
 
552
  datas_validas = datas[mascara_preenchida].dropna()
553
  proporcao = len(datas_validas) / total_preenchido if total_preenchido else 0.0
@@ -566,14 +624,23 @@ def _converter_coluna_para_datas(
566
  def _sugerir_coluna_data_mercado(df: pd.DataFrame | None) -> str | None:
567
  if df is None or df.empty:
568
  return None
569
- for coluna in df.columns:
570
- nome = str(coluna)
 
 
 
 
 
 
 
 
 
571
  try:
572
  _converter_coluna_para_datas(
573
- df[coluna],
574
  nome,
575
- proporcao_minima=1.0,
576
- proporcao_excel_minima=1.0,
577
  )
578
  return nome
579
  except HTTPException:
 
132
  return pd.to_datetime(serie_texto, errors="coerce", dayfirst=True)
133
 
134
 
135
+ def _normalizar_serie_texto(serie: pd.Series) -> pd.Series:
136
+ texto = serie.astype(str).str.replace("\u00a0", " ", regex=False).str.strip()
137
+ return texto.replace(
138
+ {
139
+ "": np.nan,
140
+ "nan": np.nan,
141
+ "NaN": np.nan,
142
+ "None": np.nan,
143
+ "none": np.nan,
144
+ "NaT": np.nan,
145
+ "nat": np.nan,
146
+ "<NA>": np.nan,
147
+ }
148
+ )
149
+
150
+
151
+ def _normalizar_serie_numerica_data(serie: pd.Series) -> pd.Series:
152
+ texto = _normalizar_serie_texto(serie)
153
+ texto = texto.str.replace(" ", "", regex=False)
154
+ texto_sem_percentual = texto.str.replace("%", "", regex=False)
155
+
156
+ direto = pd.to_numeric(texto_sem_percentual, errors="coerce")
157
+ melhor = direto
158
+ melhor_validos = int(direto.notna().sum())
159
+
160
+ pt_br = pd.to_numeric(
161
+ texto_sem_percentual.str.replace(".", "", regex=False).str.replace(",", ".", regex=False),
162
+ errors="coerce",
163
+ )
164
+ validos_pt_br = int(pt_br.notna().sum())
165
+ if validos_pt_br > melhor_validos:
166
+ melhor = pt_br
167
+ melhor_validos = validos_pt_br
168
+
169
+ en_us = pd.to_numeric(texto_sem_percentual.str.replace(",", "", regex=False), errors="coerce")
170
+ validos_en_us = int(en_us.notna().sum())
171
+ if validos_en_us > melhor_validos:
172
+ melhor = en_us
173
+
174
+ return melhor
175
+
176
+
177
  def list_avaliadores() -> list[dict[str, Any]]:
178
  global _AVALIADORES_CACHE
179
  if _AVALIADORES_CACHE is not None:
 
563
  ) -> pd.Series:
564
  serie_base = serie.copy()
565
  if pd.api.types.is_object_dtype(serie_base) or pd.api.types.is_string_dtype(serie_base):
566
+ serie_base = _normalizar_serie_texto(serie_base)
567
 
568
  mascara_preenchida = serie_base.notna()
569
  total_preenchido = int(mascara_preenchida.sum())
 
589
  )
590
  datas = pd.to_datetime(serie_num, unit="D", origin="1899-12-30", errors="coerce")
591
  else:
592
+ datas_texto = _parse_serie_datas_texto_segura(serie_base)
593
+
594
+ # Também tenta serial Excel quando a coluna veio como texto numérico.
595
+ serie_num = _normalizar_serie_numerica_data(serie_base)
596
+ valores_validos = serie_num[mascara_preenchida].dropna()
597
+ if not valores_validos.empty:
598
+ proporcao_excel = float(valores_validos.between(20000, 80000).mean())
599
+ if proporcao_excel >= proporcao_excel_minima:
600
+ datas_excel = pd.to_datetime(serie_num, unit="D", origin="1899-12-30", errors="coerce")
601
+ else:
602
+ datas_excel = pd.Series(pd.NaT, index=serie_base.index, dtype="datetime64[ns]")
603
+ else:
604
+ datas_excel = pd.Series(pd.NaT, index=serie_base.index, dtype="datetime64[ns]")
605
+
606
+ validas_texto = int(datas_texto[mascara_preenchida].notna().sum())
607
+ validas_excel = int(datas_excel[mascara_preenchida].notna().sum())
608
+ datas = datas_excel if validas_excel > validas_texto else datas_texto
609
 
610
  datas_validas = datas[mascara_preenchida].dropna()
611
  proporcao = len(datas_validas) / total_preenchido if total_preenchido else 0.0
 
624
  def _sugerir_coluna_data_mercado(df: pd.DataFrame | None) -> str | None:
625
  if df is None or df.empty:
626
  return None
627
+
628
+ # Prioriza nomes de coluna com indicativo de data para reduzir falsos positivos.
629
+ candidatas = sorted(
630
+ [str(coluna) for coluna in df.columns],
631
+ key=lambda nome: (
632
+ 0 if re.search(r"(^|[^a-z0-9])(data|date|dt)($|[^a-z0-9])", nome.strip().lower()) else 1,
633
+ nome.lower(),
634
+ ),
635
+ )
636
+
637
+ for nome in candidatas:
638
  try:
639
  _converter_coluna_para_datas(
640
+ df[nome],
641
  nome,
642
+ proporcao_minima=0.6,
643
+ proporcao_excel_minima=0.6,
644
  )
645
  return nome
646
  except HTTPException:
frontend/src/components/ElaboracaoTab.jsx CHANGED
@@ -50,7 +50,7 @@ const ELABORACAO_SECOES_NAV = [
50
  { step: '10', title: 'Gráficos de Dispersão das Variáveis Independentes' },
51
  { step: '11', title: 'Transformações Sugeridas' },
52
  { step: '12', title: 'Aplicação das Transformações' },
53
- { step: '13', title: 'Visualizar Mapa dos Dados de Mercado' },
54
  { step: '14', title: 'Diagnóstico de Modelo' },
55
  { step: '15', title: 'Gráficos de Diagnóstico do Modelo' },
56
  { step: '16', title: 'Analisar Resíduos' },
@@ -1289,6 +1289,8 @@ export default function ElaboracaoTab({ sessionId }) {
1289
  ),
1290
  )
1291
  const baseCarregada = Boolean(dados)
 
 
1292
  const renderedSectionStepsSet = useMemo(() => new Set(renderedSectionSteps), [renderedSectionSteps])
1293
  const visibleSectionStepsSet = useMemo(() => new Set(visibleSectionSteps), [visibleSectionSteps])
1294
 
@@ -1448,7 +1450,7 @@ export default function ElaboracaoTab({ sessionId }) {
1448
  }
1449
  observer.disconnect()
1450
  }
1451
- }, [sectionsMountKey, baseCarregada])
1452
 
1453
  useEffect(() => {
1454
  if (typeof window === 'undefined') return undefined
@@ -1874,7 +1876,6 @@ export default function ElaboracaoTab({ sessionId }) {
1874
  setManualTransformAppliedSnapshot(buildTransformacoesSnapshot('(x)', {}))
1875
  setOutliersAnteriores([])
1876
  setIteracao(1)
1877
- setColunaDataMercadoSugerida('')
1878
  setColunaDataMercado('')
1879
  setColunaDataMercadoAplicada('')
1880
  setPeriodoDadosMercado(null)
@@ -3047,6 +3048,24 @@ export default function ElaboracaoTab({ sessionId }) {
3047
  downloadBlob(blob, `${sanitizeFileName(fileNameBase, 'tabela')}.csv`)
3048
  }
3049
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
3050
  async function onDownloadTablesCsvBatch(items) {
3051
  const validItems = (items || []).filter((item) => item?.table)
3052
  if (validItems.length === 0) {
@@ -3303,7 +3322,10 @@ export default function ElaboracaoTab({ sessionId }) {
3303
 
3304
  const offsetTopo = 96
3305
  const alvo = Math.max(0, window.scrollY + secao.getBoundingClientRect().top - offsetTopo)
3306
- window.scrollTo({ top: alvo, behavior: 'smooth' })
 
 
 
3307
  }
3308
 
3309
  return (
@@ -3916,8 +3938,10 @@ export default function ElaboracaoTab({ sessionId }) {
3916
  <button
3917
  type="button"
3918
  className="btn-download-subtle"
3919
- onClick={() => onDownloadTableCsv(dados, 'secao4_dados_mercado')}
3920
- disabled={loading || downloadingAssets || !dados}
 
 
3921
  >
3922
  Fazer download
3923
  </button>
@@ -4558,7 +4582,7 @@ export default function ElaboracaoTab({ sessionId }) {
4558
 
4559
  {fit ? (
4560
  <>
4561
- <SectionBlock step="13" title="Visualizar Mapa dos Dados de Mercado" subtitle="Escolha livre dos eixos para análise gráfica do modelo.">
4562
  {secao13ModoPng ? (
4563
  <div className="section-disclaimer-warning">
4564
  Modo PNG automático para mais de {secao13PngPayload?.limiar || fit?.grafico_dispersao_modelo_limiar_png || 1500} pontos. Ao final da seção, podem ser gerados individualmente os gráficos interativos.
 
50
  { step: '10', title: 'Gráficos de Dispersão das Variáveis Independentes' },
51
  { step: '11', title: 'Transformações Sugeridas' },
52
  { step: '12', title: 'Aplicação das Transformações' },
53
+ { step: '13', title: 'Gráficos de Dispersão com Transformações e Resíduos' },
54
  { step: '14', title: 'Diagnóstico de Modelo' },
55
  { step: '15', title: 'Gráficos de Diagnóstico do Modelo' },
56
  { step: '16', title: 'Analisar Resíduos' },
 
1289
  ),
1290
  )
1291
  const baseCarregada = Boolean(dados)
1292
+ const hasSelection = Boolean(selection)
1293
+ const hasFit = Boolean(fit)
1294
  const renderedSectionStepsSet = useMemo(() => new Set(renderedSectionSteps), [renderedSectionSteps])
1295
  const visibleSectionStepsSet = useMemo(() => new Set(visibleSectionSteps), [visibleSectionSteps])
1296
 
 
1450
  }
1451
  observer.disconnect()
1452
  }
1453
+ }, [sectionsMountKey, baseCarregada, hasSelection, hasFit])
1454
 
1455
  useEffect(() => {
1456
  if (typeof window === 'undefined') return undefined
 
1876
  setManualTransformAppliedSnapshot(buildTransformacoesSnapshot('(x)', {}))
1877
  setOutliersAnteriores([])
1878
  setIteracao(1)
 
1879
  setColunaDataMercado('')
1880
  setColunaDataMercadoAplicada('')
1881
  setPeriodoDadosMercado(null)
 
3048
  downloadBlob(blob, `${sanitizeFileName(fileNameBase, 'tabela')}.csv`)
3049
  }
3050
 
3051
+ async function onDownloadBaseOriginalCsv() {
3052
+ if (!sessionId) {
3053
+ onDownloadTableCsv(dados, 'secao4_dados_mercado')
3054
+ return
3055
+ }
3056
+
3057
+ setDownloadingAssets(true)
3058
+ setError('')
3059
+ try {
3060
+ const blob = await api.exportBase(sessionId, false)
3061
+ downloadBlob(blob, 'base_original.csv')
3062
+ } catch (err) {
3063
+ setError(err.message || 'Falha ao baixar base original.')
3064
+ } finally {
3065
+ setDownloadingAssets(false)
3066
+ }
3067
+ }
3068
+
3069
  async function onDownloadTablesCsvBatch(items) {
3070
  const validItems = (items || []).filter((item) => item?.table)
3071
  if (validItems.length === 0) {
 
3322
 
3323
  const offsetTopo = 96
3324
  const alvo = Math.max(0, window.scrollY + secao.getBoundingClientRect().top - offsetTopo)
3325
+ const deslocamento = Math.abs(alvo - window.scrollY)
3326
+ if (deslocamento <= 2) return
3327
+ const behavior = deslocamento < 520 ? 'auto' : 'smooth'
3328
+ window.scrollTo({ top: alvo, behavior })
3329
  }
3330
 
3331
  return (
 
3938
  <button
3939
  type="button"
3940
  className="btn-download-subtle"
3941
+ onClick={() => {
3942
+ void onDownloadBaseOriginalCsv()
3943
+ }}
3944
+ disabled={loading || downloadingAssets || (!sessionId && !dados)}
3945
  >
3946
  Fazer download
3947
  </button>
 
4582
 
4583
  {fit ? (
4584
  <>
4585
+ <SectionBlock step="13" title="Gráficos de Dispersão com Transformações e Resíduos" subtitle="Escolha livre dos eixos para análise gráfica do modelo.">
4586
  {secao13ModoPng ? (
4587
  <div className="section-disclaimer-warning">
4588
  Modo PNG automático para mais de {secao13PngPayload?.limiar || fit?.grafico_dispersao_modelo_limiar_png || 1500} pontos. Ao final da seção, podem ser gerados individualmente os gráficos interativos.
frontend/src/components/RepositorioTab.jsx CHANGED
@@ -258,7 +258,7 @@ export default function RepositorioTab({ authUser, sessionId }) {
258
  <h3>{modeloAbertoMeta?.nome || 'Modelo'}</h3>
259
  <p>Visualização do modelo do repositório</p>
260
  </div>
261
- <button type="button" className="model-source-back-btn" onClick={onVoltarRepositorio} disabled={modeloAbertoLoading}>
262
  Voltar ao repositório
263
  </button>
264
  </div>
 
258
  <h3>{modeloAbertoMeta?.nome || 'Modelo'}</h3>
259
  <p>Visualização do modelo do repositório</p>
260
  </div>
261
+ <button type="button" className="model-source-back-btn model-source-back-btn-danger" onClick={onVoltarRepositorio} disabled={modeloAbertoLoading}>
262
  Voltar ao repositório
263
  </button>
264
  </div>
frontend/src/styles.css CHANGED
@@ -1871,9 +1871,25 @@ button.pesquisa-coluna-remove:hover {
1871
  padding: 14px;
1872
  display: grid;
1873
  gap: 12px;
 
1874
  box-shadow: var(--shadow-sm);
1875
  }
1876
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1877
  .pesquisa-opened-model-head {
1878
  display: flex;
1879
  justify-content: space-between;
 
1871
  padding: 14px;
1872
  display: grid;
1873
  gap: 12px;
1874
+ min-width: 0;
1875
  box-shadow: var(--shadow-sm);
1876
  }
1877
 
1878
+ .pesquisa-opened-model-view .inner-tab-panel {
1879
+ min-width: 0;
1880
+ }
1881
+
1882
+ .pesquisa-opened-model-view .table-wrapper {
1883
+ width: 100%;
1884
+ min-width: 0;
1885
+ overflow-x: auto;
1886
+ }
1887
+
1888
+ .pesquisa-opened-model-view .table-wrapper table {
1889
+ width: max-content;
1890
+ min-width: 100%;
1891
+ }
1892
+
1893
  .pesquisa-opened-model-head {
1894
  display: flex;
1895
  justify-content: space-between;