Guilherme Silberfarb Costa commited on
Commit
1b337a7
·
1 Parent(s): 5c5c634

Refine elaboracao and trabalhos tecnicos flows

Browse files
backend/app/api/elaboracao.py CHANGED
@@ -23,6 +23,14 @@ class ConfirmSheetPayload(SessionPayload):
23
  sheet_name: str
24
 
25
 
 
 
 
 
 
 
 
 
26
  class MapCoordsPayload(SessionPayload):
27
  col_lat: str
28
  col_lon: str
@@ -213,6 +221,18 @@ def confirm_sheet(payload: ConfirmSheetPayload) -> dict[str, Any]:
213
  return elaboracao_service.load_uploaded_file(session, selected_sheet=payload.sheet_name)
214
 
215
 
 
 
 
 
 
 
 
 
 
 
 
 
216
  @router.post("/map-coords")
217
  def map_coords(payload: MapCoordsPayload) -> dict[str, Any]:
218
  session = session_store.get(payload.session_id)
 
23
  sheet_name: str
24
 
25
 
26
+ class PastedTablePayload(SessionPayload):
27
+ pasted_text: str
28
+
29
+
30
+ class ApplyImportColumnsPayload(SessionPayload):
31
+ colunas: list[str] = Field(default_factory=list)
32
+
33
+
34
  class MapCoordsPayload(SessionPayload):
35
  col_lat: str
36
  col_lon: str
 
221
  return elaboracao_service.load_uploaded_file(session, selected_sheet=payload.sheet_name)
222
 
223
 
224
+ @router.post("/paste-table")
225
+ def paste_table(payload: PastedTablePayload) -> dict[str, Any]:
226
+ session = session_store.get(payload.session_id)
227
+ return elaboracao_service.load_pasted_table(session, payload.pasted_text)
228
+
229
+
230
+ @router.post("/apply-import-columns")
231
+ def apply_import_columns(payload: ApplyImportColumnsPayload) -> dict[str, Any]:
232
+ session = session_store.get(payload.session_id)
233
+ return elaboracao_service.apply_import_columns(session, payload.colunas)
234
+
235
+
236
  @router.post("/map-coords")
237
  def map_coords(payload: MapCoordsPayload) -> dict[str, Any]:
238
  session = session_store.get(payload.session_id)
backend/app/api/trabalhos_tecnicos.py CHANGED
@@ -25,8 +25,7 @@ class TrabalhoImovelEdicaoPayload(BaseModel):
25
  modelos: list[str] | None = None
26
 
27
 
28
- class TrabalhoSalvarPayload(BaseModel):
29
- trabalho_id: str
30
  nome: str | None = None
31
  tipo_codigo: str | None = None
32
  ano: int | None = None
@@ -35,6 +34,14 @@ class TrabalhoSalvarPayload(BaseModel):
35
  imoveis: list[TrabalhoImovelEdicaoPayload] | None = None
36
 
37
 
 
 
 
 
 
 
 
 
38
  class TrabalhosMapaPayload(BaseModel):
39
  trabalhos_ids: list[str] | None = None
40
  modo_exibicao: str | None = None
@@ -109,3 +116,19 @@ def salvar_trabalho(payload: TrabalhoSalvarPayload, request: Request) -> dict[st
109
  details={"trabalho_id": payload.trabalho_id},
110
  )
111
  return resposta
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
25
  modelos: list[str] | None = None
26
 
27
 
28
+ class TrabalhoPayloadBase(BaseModel):
 
29
  nome: str | None = None
30
  tipo_codigo: str | None = None
31
  ano: int | None = None
 
34
  imoveis: list[TrabalhoImovelEdicaoPayload] | None = None
35
 
36
 
37
+ class TrabalhoCadastrarPayload(TrabalhoPayloadBase):
38
+ pass
39
+
40
+
41
+ class TrabalhoSalvarPayload(TrabalhoPayloadBase):
42
+ trabalho_id: str
43
+
44
+
45
  class TrabalhosMapaPayload(BaseModel):
46
  trabalhos_ids: list[str] | None = None
47
  modo_exibicao: str | None = None
 
116
  details={"trabalho_id": payload.trabalho_id},
117
  )
118
  return resposta
119
+
120
+
121
+ @router.post("/cadastrar")
122
+ def cadastrar_trabalho(payload: TrabalhoCadastrarPayload, request: Request) -> dict[str, Any]:
123
+ user = auth_service.require_user(request)
124
+ resposta = trabalhos_tecnicos_service.cadastrar_trabalho_tecnico(payload.model_dump())
125
+ trabalho = resposta.get("trabalho") if isinstance(resposta, dict) else {}
126
+ log_event(
127
+ "trabalhos_tecnicos",
128
+ "cadastrar_trabalho",
129
+ user=user,
130
+ status="ok",
131
+ request=request,
132
+ details={"trabalho_id": trabalho.get("id") if isinstance(trabalho, dict) else None},
133
+ )
134
+ return resposta
backend/app/models/session.py CHANGED
@@ -16,6 +16,7 @@ class SessionState:
16
  uploaded_filename: str | None = None
17
  available_sheets: list[str] = field(default_factory=list)
18
 
 
19
  df_original: pd.DataFrame | None = None
20
  df_filtrado: pd.DataFrame | None = None
21
  df_geo_origem: pd.DataFrame | None = None
 
16
  uploaded_filename: str | None = None
17
  available_sheets: list[str] = field(default_factory=list)
18
 
19
+ df_import_original: pd.DataFrame | None = None
20
  df_original: pd.DataFrame | None = None
21
  df_filtrado: pd.DataFrame | None = None
22
  df_geo_origem: pd.DataFrame | None = None
backend/app/services/elaboracao_service.py CHANGED
@@ -1047,6 +1047,56 @@ def _set_dataframe_base(
1047
  }
1048
 
1049
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1050
  def save_uploaded_file(session: SessionState, filename: str, content: bytes) -> str:
1051
  safe_name = os.path.basename(filename)
1052
  destino = session.workdir / safe_name
@@ -1080,7 +1130,7 @@ def load_uploaded_file(session: SessionState, selected_sheet: str | None = None)
1080
  if not sucesso or df is None:
1081
  raise HTTPException(status_code=400, detail=msg)
1082
 
1083
- base = _set_dataframe_base(session, df, clear_models=True)
1084
  sheet_selected_result = ""
1085
  if selected_sheet is not None:
1086
  sheet_selected_result = str(selected_sheet)
@@ -1089,8 +1139,60 @@ def load_uploaded_file(session: SessionState, selected_sheet: str | None = None)
1089
  return {
1090
  "status": msg,
1091
  "requires_sheet": False,
 
1092
  "sheets": [],
1093
  "sheet_selected": sheet_selected_result,
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1094
  **base,
1095
  "contexto": _selection_context(session),
1096
  }
 
1047
  }
1048
 
1049
 
1050
+ def _build_import_preview(df: pd.DataFrame, max_rows: int = 15) -> dict[str, Any]:
1051
+ total_rows = len(df.index)
1052
+ total_columns = len(df.columns)
1053
+ sample_size = min(max_rows, total_rows)
1054
+ if sample_size > 0:
1055
+ preview_df = df.sample(n=sample_size)
1056
+ else:
1057
+ preview_df = df.head(0)
1058
+
1059
+ rows: list[dict[str, Any]] = []
1060
+ columns = [str(c) for c in df.columns]
1061
+ for tuple_row in preview_df.itertuples(index=True, name=None):
1062
+ row = {"_index": sanitize_value(tuple_row[0])}
1063
+ for col, value in zip(columns, tuple_row[1:]):
1064
+ row[col] = sanitize_value(value)
1065
+ rows.append(row)
1066
+
1067
+ return {
1068
+ "columns": columns,
1069
+ "rows": rows,
1070
+ "total_rows": total_rows,
1071
+ "total_columns": total_columns,
1072
+ "returned_rows": len(rows),
1073
+ }
1074
+
1075
+
1076
+ def _set_import_pending_base(session: SessionState, df: pd.DataFrame) -> None:
1077
+ session.df_import_original = df.copy()
1078
+ session.df_original = None
1079
+ session.df_filtrado = None
1080
+ session.df_geo_origem = df.copy()
1081
+ session.geo_falhas_df = None
1082
+ session.geo_col_cdlog = None
1083
+ session.geo_col_num = None
1084
+ session.mapa_habilitado = False
1085
+ session.reset_modelo()
1086
+ session.coluna_y = None
1087
+ session.tipo_y = None
1088
+ session.coluna_area = None
1089
+ session.colunas_x = []
1090
+ session.dicotomicas = []
1091
+ session.codigo_alocado = []
1092
+ session.percentuais = []
1093
+ session.outliers_anteriores = []
1094
+ session.iteracao = 1
1095
+ session.coluna_data_mercado = None
1096
+ session.periodo_dados_mercado_inicio = None
1097
+ session.periodo_dados_mercado_fim = None
1098
+
1099
+
1100
  def save_uploaded_file(session: SessionState, filename: str, content: bytes) -> str:
1101
  safe_name = os.path.basename(filename)
1102
  destino = session.workdir / safe_name
 
1130
  if not sucesso or df is None:
1131
  raise HTTPException(status_code=400, detail=msg)
1132
 
1133
+ _set_import_pending_base(session, df)
1134
  sheet_selected_result = ""
1135
  if selected_sheet is not None:
1136
  sheet_selected_result = str(selected_sheet)
 
1139
  return {
1140
  "status": msg,
1141
  "requires_sheet": False,
1142
+ "requires_column_selection": True,
1143
  "sheets": [],
1144
  "sheet_selected": sheet_selected_result,
1145
+ "import_preview": _build_import_preview(df),
1146
+ "contexto": _selection_context(session),
1147
+ }
1148
+
1149
+
1150
+ def load_pasted_table(session: SessionState, pasted_text: str) -> dict[str, Any]:
1151
+ texto = str(pasted_text or "").replace("\r\n", "\n").replace("\r", "\n").strip("\n")
1152
+ if not texto.strip():
1153
+ raise HTTPException(status_code=400, detail="Cole os dados copiados do Excel antes de carregar.")
1154
+
1155
+ linhas_validas = [linha for linha in texto.split("\n") if linha.strip()]
1156
+ if len(linhas_validas) < 2:
1157
+ raise HTTPException(status_code=400, detail="Cole pelo menos uma linha de cabeçalho e uma linha de dados.")
1158
+
1159
+ nome_arquivo = f"dados_colados_{datetime.now().strftime('%Y%m%d_%H%M%S')}.csv"
1160
+ save_uploaded_file(session, nome_arquivo, texto.encode("utf-8-sig"))
1161
+ resposta = load_uploaded_file(session)
1162
+ resposta["status"] = resposta.get("status") or "Dados colados carregados."
1163
+ resposta["pasted_table"] = True
1164
+ resposta["uploaded_filename"] = nome_arquivo
1165
+ return resposta
1166
+
1167
+
1168
+ def apply_import_columns(session: SessionState, colunas: list[str]) -> dict[str, Any]:
1169
+ df_origem = session.df_import_original
1170
+ if df_origem is None:
1171
+ df_origem = session.df_original
1172
+ if df_origem is None:
1173
+ raise HTTPException(status_code=400, detail="Carregue um arquivo tabular primeiro.")
1174
+
1175
+ disponiveis = {str(col): col for col in df_origem.columns}
1176
+ selecionadas: list[str] = []
1177
+ vistos: set[str] = set()
1178
+ for item in colunas or []:
1179
+ coluna = str(item or "").strip()
1180
+ if not coluna or coluna in vistos or coluna not in disponiveis:
1181
+ continue
1182
+ selecionadas.append(coluna)
1183
+ vistos.add(coluna)
1184
+
1185
+ if len(selecionadas) < 2:
1186
+ raise HTTPException(status_code=400, detail="Selecione pelo menos 2 colunas para seguir.")
1187
+
1188
+ df_selecionado = df_origem.loc[:, [disponiveis[coluna] for coluna in selecionadas]].copy()
1189
+ base = _set_dataframe_base(session, df_selecionado, clear_models=True)
1190
+ session.df_import_original = df_origem.copy()
1191
+
1192
+ return {
1193
+ "status": f"Seleção aplicada: {len(selecionadas)} colunas farão parte do modelo.",
1194
+ "requires_column_selection": False,
1195
+ "import_columns_selected": selecionadas,
1196
  **base,
1197
  "contexto": _selection_context(session),
1198
  }
backend/app/services/trabalhos_tecnicos_service.py CHANGED
@@ -3,7 +3,9 @@ from __future__ import annotations
3
  from datetime import datetime, timezone
4
  import json
5
  import math
 
6
  import sqlite3
 
7
  from pathlib import Path
8
  from statistics import median
9
  from threading import Lock
@@ -42,6 +44,14 @@ _MODEL_MATCH_CACHE_CATALOGO: dict[str, dict[str, str]] = {}
42
  _MODEL_MATCH_CACHE_TRABALHOS: dict[str, set[str]] = {}
43
 
44
 
 
 
 
 
 
 
 
 
45
  def _connect_database(path: Path) -> sqlite3.Connection:
46
  conn = sqlite3.connect(str(path))
47
  conn.row_factory = sqlite3.Row
@@ -745,6 +755,246 @@ def _normalizar_edicao_payload(
745
  )
746
 
747
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
748
  def _listar_avaliandos_tecnicos(chaves_modelo: list[str] | None = None) -> list[dict[str, Any]]:
749
  aliases = [str(item or "").strip() for item in (chaves_modelo or []) if str(item or "").strip()]
750
  filtrar_por_modelo = chaves_modelo is not None
@@ -779,7 +1029,7 @@ def _listar_avaliandos_tecnicos(chaves_modelo: list[str] | None = None) -> list[
779
  FROM trabalho_imoveis ti
780
  JOIN trabalhos t
781
  ON t.trabalho_id = ti.trabalho_id
782
- JOIN trabalho_imovel_modelos tim
783
  ON tim.imovel_id = ti.imovel_id
784
  ORDER BY LOWER(t.nome), ti.imovel_id, LOWER(tim.modelo_nome)
785
  """
@@ -1393,6 +1643,38 @@ def detalhar_trabalho(trabalho_id: str) -> dict[str, Any]:
1393
  )
1394
 
1395
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1396
  def salvar_edicao_trabalho(trabalho_id: str, payload: dict[str, Any] | None) -> dict[str, Any]:
1397
  chave = str(trabalho_id or "").strip()
1398
  if not chave:
@@ -1432,4 +1714,5 @@ def salvar_edicao_trabalho(trabalho_id: str, payload: dict[str, Any] | None) ->
1432
  except Exception:
1433
  pass
1434
 
 
1435
  return detalhar_trabalho(chave)
 
3
  from datetime import datetime, timezone
4
  import json
5
  import math
6
+ import re
7
  import sqlite3
8
+ import unicodedata
9
  from pathlib import Path
10
  from statistics import median
11
  from threading import Lock
 
44
  _MODEL_MATCH_CACHE_TRABALHOS: dict[str, set[str]] = {}
45
 
46
 
47
+ def _clear_model_match_cache() -> None:
48
+ global _MODEL_MATCH_CACHE_SIGNATURE, _MODEL_MATCH_CACHE_CATALOGO, _MODEL_MATCH_CACHE_TRABALHOS
49
+ with _MODEL_MATCH_CACHE_LOCK:
50
+ _MODEL_MATCH_CACHE_SIGNATURE = None
51
+ _MODEL_MATCH_CACHE_CATALOGO = {}
52
+ _MODEL_MATCH_CACHE_TRABALHOS = {}
53
+
54
+
55
  def _connect_database(path: Path) -> sqlite3.Connection:
56
  conn = sqlite3.connect(str(path))
57
  conn.row_factory = sqlite3.Row
 
755
  )
756
 
757
 
758
+ def _slug_trabalho_fragment(value: Any) -> str:
759
+ texto = str(value or "").strip()
760
+ if not texto:
761
+ return ""
762
+ sem_acentos = unicodedata.normalize("NFKD", texto).encode("ascii", "ignore").decode("ascii")
763
+ slug = re.sub(r"[^A-Za-z0-9]+", "_", sem_acentos).strip("_")
764
+ return slug.upper()
765
+
766
+
767
+ def _gerar_trabalho_id_unico(
768
+ conn: sqlite3.Connection,
769
+ nome: str,
770
+ tipo_codigo: str,
771
+ ano: int | None,
772
+ ) -> str:
773
+ partes = ["MANUAL", tipo_codigo, str(ano or ""), _slug_trabalho_fragment(nome)]
774
+ base = "_".join([parte for parte in partes if parte]).strip("_")
775
+ base = (base or "MANUAL_TRABALHO")[:96].rstrip("_") or "MANUAL_TRABALHO"
776
+
777
+ def existe(candidate: str) -> bool:
778
+ row = conn.execute(
779
+ "SELECT 1 FROM trabalhos WHERE LOWER(trabalho_id) = LOWER(?) LIMIT 1",
780
+ (candidate,),
781
+ ).fetchone()
782
+ if row is not None:
783
+ return True
784
+ _ensure_overrides_table(conn)
785
+ row = conn.execute(
786
+ "SELECT 1 FROM trabalho_edicoes WHERE LOWER(trabalho_id) = LOWER(?) LIMIT 1",
787
+ (candidate,),
788
+ ).fetchone()
789
+ return row is not None
790
+
791
+ candidate = base
792
+ suffix = 2
793
+ while existe(candidate):
794
+ suffix_text = f"_{suffix}"
795
+ candidate = f"{base[:96 - len(suffix_text)]}{suffix_text}"
796
+ suffix += 1
797
+ return candidate
798
+
799
+
800
+ def _normalizar_imoveis_cadastro(values: Any, modelos_padrao: list[str]) -> list[dict[str, Any]]:
801
+ if not isinstance(values, list):
802
+ return []
803
+
804
+ filtrados: list[dict[str, Any]] = []
805
+ for raw in values:
806
+ if not isinstance(raw, dict):
807
+ continue
808
+ tem_texto = any(
809
+ str(raw.get(key) or "").strip()
810
+ for key in ("label", "endereco", "numero")
811
+ )
812
+ tem_coord = _to_float_or_none(raw.get("coord_x")) is not None or _to_float_or_none(raw.get("coord_y")) is not None
813
+ if tem_texto or tem_coord:
814
+ filtrados.append(raw)
815
+ return _normalizar_imoveis_edicao(filtrados, modelos_padrao)
816
+
817
+
818
+ def _normalizar_cadastro_payload(
819
+ payload: dict[str, Any],
820
+ catalogo_modelos: dict[str, dict[str, str]],
821
+ ) -> dict[str, Any]:
822
+ nome = str(payload.get("nome") or "").strip()
823
+ if not nome:
824
+ raise HTTPException(status_code=400, detail="Informe o nome do trabalho tecnico.")
825
+
826
+ tipo_codigo = str(payload.get("tipo_codigo") or "").strip().upper()
827
+ if not tipo_codigo:
828
+ raise HTTPException(status_code=400, detail="Informe o tipo do trabalho tecnico.")
829
+ if tipo_codigo not in TIPO_LABELS:
830
+ raise HTTPException(status_code=400, detail="Selecione um tipo valido para o trabalho tecnico.")
831
+
832
+ ano = _to_int_or_none(payload.get("ano"))
833
+ processos_sei = _dedupe_text_list(payload.get("processos_sei"))
834
+ modelos = _dedupe_text_list(payload.get("modelos"))
835
+ imoveis = _normalizar_imoveis_cadastro(payload.get("imoveis"), modelos)
836
+ if not imoveis:
837
+ raise HTTPException(status_code=400, detail="Informe pelo menos um imovel vinculado ao trabalho tecnico.")
838
+
839
+ for imovel in imoveis:
840
+ modelos_imovel = _dedupe_text_list(imovel.get("modelos"))
841
+ imovel["modelos"] = modelos_imovel or list(modelos)
842
+
843
+ _enriquecer_modelos(modelos, catalogo_modelos)
844
+
845
+ return sanitize_value(
846
+ {
847
+ "nome": nome,
848
+ "tipo_codigo": tipo_codigo,
849
+ "ano": ano,
850
+ "processos_sei": processos_sei,
851
+ "modelos": modelos,
852
+ "imoveis": imoveis,
853
+ }
854
+ )
855
+
856
+
857
+ def _inserir_trabalho_manual(
858
+ conn: sqlite3.Connection,
859
+ trabalho_id: str,
860
+ normalized: dict[str, Any],
861
+ ) -> None:
862
+ nome = str(normalized.get("nome") or "").strip()
863
+ tipo_codigo = str(normalized.get("tipo_codigo") or "").strip().upper()
864
+ ano = _to_int_or_none(normalized.get("ano"))
865
+ processos_sei = _dedupe_text_list(normalized.get("processos_sei"))
866
+ modelos = _dedupe_text_list(normalized.get("modelos"))
867
+ imoveis = list(normalized.get("imoveis") or [])
868
+ primeiro_imovel = imoveis[0] if imoveis else {}
869
+ endereco_resumo = _resumo_enderecos(imoveis) or "Endereco nao informado"
870
+ modelo_resumo = _resumo_modelos(modelos) or "Sem modelo informado"
871
+ tem_coordenadas = any(
872
+ _coordenada_valida(item.get("coord_x")) and _coordenada_valida(item.get("coord_y"))
873
+ for item in imoveis
874
+ )
875
+
876
+ registros: list[tuple[dict[str, Any], str | None, str | None]] = []
877
+ processos_registro = processos_sei or [None]
878
+ modelos_registro = modelos or [None]
879
+ for imovel in imoveis or [{}]:
880
+ for modelo_nome in modelos_registro:
881
+ for processo in processos_registro:
882
+ registros.append((imovel, modelo_nome, processo))
883
+
884
+ conn.execute(
885
+ """
886
+ INSERT INTO trabalhos (
887
+ trabalho_id,
888
+ nome,
889
+ nome_original,
890
+ codigo_trabalho,
891
+ nome_pasta,
892
+ tipo_codigo,
893
+ tipo_label,
894
+ ano,
895
+ endereco_principal,
896
+ numero_principal,
897
+ endereco_resumo,
898
+ modelo_resumo,
899
+ tecnico_resumo,
900
+ processo_resumo,
901
+ finalidade_processo_resumo,
902
+ total_registros,
903
+ total_imoveis,
904
+ total_modelos,
905
+ tem_coordenadas
906
+ ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
907
+ """,
908
+ (
909
+ trabalho_id,
910
+ nome,
911
+ nome,
912
+ None,
913
+ None,
914
+ tipo_codigo,
915
+ _tipo_label_from_codigo(tipo_codigo),
916
+ ano,
917
+ str(primeiro_imovel.get("endereco") or "").strip() or None,
918
+ str(primeiro_imovel.get("numero") or "").strip() or None,
919
+ endereco_resumo,
920
+ modelo_resumo,
921
+ None,
922
+ _resumo_textos(processos_sei, limite_inline=2) or None,
923
+ None,
924
+ max(1, len(registros)),
925
+ len(imoveis),
926
+ len(modelos),
927
+ 1 if tem_coordenadas else 0,
928
+ ),
929
+ )
930
+
931
+ for ordem, modelo_nome in enumerate(modelos, start=1):
932
+ conn.execute(
933
+ "INSERT INTO trabalho_modelos (trabalho_id, modelo_nome, ordem) VALUES (?, ?, ?)",
934
+ (trabalho_id, modelo_nome, ordem),
935
+ )
936
+
937
+ for imovel in imoveis:
938
+ cursor = conn.execute(
939
+ """
940
+ INSERT INTO trabalho_imoveis (trabalho_id, endereco, numero, label, coord_x, coord_y)
941
+ VALUES (?, ?, ?, ?, ?, ?)
942
+ """,
943
+ (
944
+ trabalho_id,
945
+ str(imovel.get("endereco") or "").strip() or None,
946
+ str(imovel.get("numero") or "").strip() or None,
947
+ str(imovel.get("label") or "").strip() or "Imovel",
948
+ _to_float_or_none(imovel.get("coord_x")),
949
+ _to_float_or_none(imovel.get("coord_y")),
950
+ ),
951
+ )
952
+ imovel_id = int(cursor.lastrowid)
953
+ for modelo_nome in _dedupe_text_list(imovel.get("modelos")):
954
+ conn.execute(
955
+ "INSERT INTO trabalho_imovel_modelos (imovel_id, modelo_nome) VALUES (?, ?)",
956
+ (imovel_id, modelo_nome),
957
+ )
958
+
959
+ for source_row, (imovel, modelo_nome, processo) in enumerate(registros, start=1):
960
+ conn.execute(
961
+ """
962
+ INSERT INTO trabalho_registros (
963
+ trabalho_id,
964
+ source_row,
965
+ ano,
966
+ nome_original,
967
+ codigo_trabalho,
968
+ nome_pasta,
969
+ tecnico,
970
+ processo,
971
+ finalidade_processo,
972
+ modelo_nome,
973
+ endereco,
974
+ numero,
975
+ coord_x,
976
+ coord_y
977
+ ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
978
+ """,
979
+ (
980
+ trabalho_id,
981
+ source_row,
982
+ ano,
983
+ nome,
984
+ None,
985
+ None,
986
+ None,
987
+ processo,
988
+ None,
989
+ modelo_nome,
990
+ str(imovel.get("endereco") or "").strip() or None,
991
+ str(imovel.get("numero") or "").strip() or None,
992
+ _to_float_or_none(imovel.get("coord_x")),
993
+ _to_float_or_none(imovel.get("coord_y")),
994
+ ),
995
+ )
996
+
997
+
998
  def _listar_avaliandos_tecnicos(chaves_modelo: list[str] | None = None) -> list[dict[str, Any]]:
999
  aliases = [str(item or "").strip() for item in (chaves_modelo or []) if str(item or "").strip()]
1000
  filtrar_por_modelo = chaves_modelo is not None
 
1029
  FROM trabalho_imoveis ti
1030
  JOIN trabalhos t
1031
  ON t.trabalho_id = ti.trabalho_id
1032
+ LEFT JOIN trabalho_imovel_modelos tim
1033
  ON tim.imovel_id = ti.imovel_id
1034
  ORDER BY LOWER(t.nome), ti.imovel_id, LOWER(tim.modelo_nome)
1035
  """
 
1643
  )
1644
 
1645
 
1646
+ def cadastrar_trabalho_tecnico(payload: dict[str, Any] | None) -> dict[str, Any]:
1647
+ resolved = trabalhos_tecnicos_repository.resolve_database()
1648
+ if resolved.provider != "local":
1649
+ raise HTTPException(
1650
+ status_code=403,
1651
+ detail="Cadastro de trabalhos tecnicos disponivel apenas na base local neste momento",
1652
+ )
1653
+
1654
+ catalogo_modelos = _catalogo_modelos_mesa()
1655
+ conn = _connect_database(resolved.db_path)
1656
+ trabalho_id = ""
1657
+ try:
1658
+ normalized = _normalizar_cadastro_payload(payload or {}, catalogo_modelos)
1659
+ _ensure_overrides_table(conn)
1660
+ trabalho_id = _gerar_trabalho_id_unico(
1661
+ conn,
1662
+ str(normalized.get("nome") or ""),
1663
+ str(normalized.get("tipo_codigo") or ""),
1664
+ _to_int_or_none(normalized.get("ano")),
1665
+ )
1666
+ _inserir_trabalho_manual(conn, trabalho_id, normalized)
1667
+ conn.commit()
1668
+ finally:
1669
+ try:
1670
+ conn.close()
1671
+ except Exception:
1672
+ pass
1673
+
1674
+ _clear_model_match_cache()
1675
+ return detalhar_trabalho(trabalho_id)
1676
+
1677
+
1678
  def salvar_edicao_trabalho(trabalho_id: str, payload: dict[str, Any] | None) -> dict[str, Any]:
1679
  chave = str(trabalho_id or "").strip()
1680
  if not chave:
 
1714
  except Exception:
1715
  pass
1716
 
1717
+ _clear_model_match_cache()
1718
  return detalhar_trabalho(chave)
backend/run_backend.sh CHANGED
@@ -4,10 +4,53 @@ set -euo pipefail
4
  SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
5
  cd "${SCRIPT_DIR}"
6
 
7
- # Prefere o ambiente virtual local do backend quando ele existir.
8
- if [[ -f "${SCRIPT_DIR}/.venv/bin/activate" ]]; then
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
9
  # shellcheck disable=SC1091
10
- source "${SCRIPT_DIR}/.venv/bin/activate"
11
  fi
12
 
13
  uvicorn app.main:app --host 0.0.0.0 --port "${PORT:-8000}" --reload --reload-dir "${SCRIPT_DIR}"
 
4
  SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
5
  cd "${SCRIPT_DIR}"
6
 
7
+ VENV_DIR="${SCRIPT_DIR}/.venv"
8
+ VENV_PYTHON="${VENV_DIR}/bin/python"
9
+ VENV_UVICORN="${VENV_DIR}/bin/uvicorn"
10
+
11
+ find_bootstrap_python() {
12
+ local candidate
13
+ for candidate in python3.12 /Library/Frameworks/Python.framework/Versions/3.12/bin/python3.12 python3 python; do
14
+ if command -v "${candidate}" >/dev/null 2>&1; then
15
+ command -v "${candidate}"
16
+ return 0
17
+ fi
18
+ done
19
+
20
+ return 1
21
+ }
22
+
23
+ ensure_backend_venv() {
24
+ local bootstrap_python
25
+
26
+ bootstrap_python="$(find_bootstrap_python)" || {
27
+ cat >&2 <<'EOF'
28
+ [backend] Nenhum interpretador Python compativel foi encontrado.
29
+ [backend] Instale o Python 3.12 e rode ./run_backend.sh novamente.
30
+ EOF
31
+ return 1
32
+ }
33
+
34
+ if [[ ! -x "${VENV_PYTHON}" ]]; then
35
+ echo "[backend] Recriando .venv com ${bootstrap_python}..." >&2
36
+ rm -rf "${VENV_DIR}"
37
+ "${bootstrap_python}" -m venv "${VENV_DIR}"
38
+ fi
39
+
40
+ # shellcheck disable=SC1091
41
+ source "${VENV_DIR}/bin/activate"
42
+
43
+ if [[ ! -x "${VENV_UVICORN}" ]]; then
44
+ echo "[backend] Instalando dependencias do backend..." >&2
45
+ python -m pip install --retries 5 --timeout 60 -r "${SCRIPT_DIR}/requirements.txt" >&2
46
+ fi
47
+ }
48
+
49
+ ensure_backend_venv
50
+
51
+ if [[ -f "${VENV_DIR}/bin/activate" ]]; then
52
  # shellcheck disable=SC1091
53
+ source "${VENV_DIR}/bin/activate"
54
  fi
55
 
56
  uvicorn app.main:app --host 0.0.0.0 --port "${PORT:-8000}" --reload --reload-dir "${SCRIPT_DIR}"
frontend/src/api.js CHANGED
@@ -235,7 +235,9 @@ export const api = {
235
  elaboracaoRepositorioModelos: () => getJson('/api/elaboracao/repositorio-modelos'),
236
  elaboracaoRepositorioCarregar: (sessionId, modeloId) => postJson('/api/elaboracao/repositorio-carregar', { session_id: sessionId, modelo_id: modeloId }),
237
 
 
238
  confirmSheet: (sessionId, sheetName) => postJson('/api/elaboracao/confirm-sheet', { session_id: sessionId, sheet_name: sheetName }),
 
239
  mapCoords: (sessionId, colLat, colLon) => postJson('/api/elaboracao/map-coords', { session_id: sessionId, col_lat: colLat, col_lon: colLon }),
240
  geocodificar: (sessionId, colCdlog, colNum, auto200) => postJson('/api/elaboracao/geocodificar', { session_id: sessionId, col_cdlog: colCdlog, col_num: colNum, auto_200: auto200 }),
241
  geocodificarCorrecoes: (sessionId, correcoes, auto200) => postJson('/api/elaboracao/geocodificar-correcoes', { session_id: sessionId, correcoes, auto_200: auto200 }),
@@ -431,6 +433,7 @@ export const api = {
431
  avaliando_lon: avaliando?.lon ?? null,
432
  }),
433
  trabalhosTecnicosDetalhe: (trabalhoId) => postJson('/api/trabalhos-tecnicos/detalhe', { trabalho_id: trabalhoId }),
 
434
  trabalhosTecnicosSalvar: (payload = {}) => postJson('/api/trabalhos-tecnicos/salvar', payload),
435
 
436
  logsStatus: () => getJson('/api/logs/status'),
 
235
  elaboracaoRepositorioModelos: () => getJson('/api/elaboracao/repositorio-modelos'),
236
  elaboracaoRepositorioCarregar: (sessionId, modeloId) => postJson('/api/elaboracao/repositorio-carregar', { session_id: sessionId, modelo_id: modeloId }),
237
 
238
+ pasteElaboracaoTable: (sessionId, pastedText) => postJson('/api/elaboracao/paste-table', { session_id: sessionId, pasted_text: pastedText }),
239
  confirmSheet: (sessionId, sheetName) => postJson('/api/elaboracao/confirm-sheet', { session_id: sessionId, sheet_name: sheetName }),
240
+ applyImportColumns: (sessionId, colunas) => postJson('/api/elaboracao/apply-import-columns', { session_id: sessionId, colunas }),
241
  mapCoords: (sessionId, colLat, colLon) => postJson('/api/elaboracao/map-coords', { session_id: sessionId, col_lat: colLat, col_lon: colLon }),
242
  geocodificar: (sessionId, colCdlog, colNum, auto200) => postJson('/api/elaboracao/geocodificar', { session_id: sessionId, col_cdlog: colCdlog, col_num: colNum, auto_200: auto200 }),
243
  geocodificarCorrecoes: (sessionId, correcoes, auto200) => postJson('/api/elaboracao/geocodificar-correcoes', { session_id: sessionId, correcoes, auto_200: auto200 }),
 
433
  avaliando_lon: avaliando?.lon ?? null,
434
  }),
435
  trabalhosTecnicosDetalhe: (trabalhoId) => postJson('/api/trabalhos-tecnicos/detalhe', { trabalho_id: trabalhoId }),
436
+ trabalhosTecnicosCadastrar: (payload = {}) => postJson('/api/trabalhos-tecnicos/cadastrar', payload),
437
  trabalhosTecnicosSalvar: (payload = {}) => postJson('/api/trabalhos-tecnicos/salvar', payload),
438
 
439
  logsStatus: () => getJson('/api/logs/status'),
frontend/src/components/ElaboracaoTab.jsx CHANGED
@@ -908,6 +908,7 @@ function buildArquivoCarregadoInfo(resp, options = {}) {
908
  const isDai = tipo === 'dai' || fileNameLower.endsWith('.dai')
909
  if (isDai) return null
910
 
 
911
  const extMatch = fileName.match(/\.([^.]+)$/)
912
  const tipoArquivo = extMatch?.[1] ? String(extMatch[1]).toUpperCase() : '-'
913
  const sheetName = String(options.sheetName || resp?.sheet_selected || '').trim()
@@ -915,8 +916,16 @@ function buildArquivoCarregadoInfo(resp, options = {}) {
915
  ? Number(resp.dados.total_rows)
916
  : Array.isArray(resp?.dados?.rows)
917
  ? resp.dados.rows.length
918
- : null
919
- const totalColunas = Array.isArray(resp?.dados?.columns) ? resp.dados.columns.length : null
 
 
 
 
 
 
 
 
920
  const totalAbas = Array.isArray(resp?.sheets) ? resp.sheets.length : 0
921
 
922
  if (!fileName && totalLinhas === null && totalColunas === null && !sheetName) return null
@@ -1026,6 +1035,12 @@ export default function ElaboracaoTab({ sessionId, authUser, quickLoadRequest =
1026
  const [requiresSheet, setRequiresSheet] = useState(false)
1027
  const [sheetOptions, setSheetOptions] = useState([])
1028
  const [selectedSheet, setSelectedSheet] = useState('')
 
 
 
 
 
 
1029
  const [elaborador, setElaborador] = useState(null)
1030
  const [modeloCarregadoInfo, setModeloCarregadoInfo] = useState(null)
1031
  const [repoModelos, setRepoModelos] = useState([])
@@ -1192,6 +1207,22 @@ export default function ElaboracaoTab({ sessionId, authUser, quickLoadRequest =
1192
 
1193
  const mapaChoices = useMemo(() => [MAPA_VARIAVEL_PADRAO, ...colunasNumericas], [colunasNumericas])
1194
  const mapaModoDisponivel = mapaVariavel !== MAPA_VARIAVEL_PADRAO
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1195
  const colunasXDisponiveis = useMemo(
1196
  () => (colunaY ? colunasNumericas.filter((coluna) => coluna !== colunaY) : []),
1197
  [colunasNumericas, colunaY],
@@ -1932,7 +1963,7 @@ export default function ElaboracaoTab({ sessionId, authUser, quickLoadRequest =
1932
  }
1933
  observer.disconnect()
1934
  }
1935
- }, [sectionsMountKey, baseCarregada, hasSelection, hasFit])
1936
 
1937
  useEffect(() => {
1938
  if (typeof window === 'undefined') return undefined
@@ -2331,6 +2362,81 @@ export default function ElaboracaoTab({ sessionId, authUser, quickLoadRequest =
2331
  }
2332
  }
2333
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
2334
  function applyBaseResponse(resp, options = {}) {
2335
  const resetXSelection = Boolean(options.resetXSelection)
2336
  const colunaYPadrao = String(resp.coluna_y_padrao || '')
@@ -2602,6 +2708,19 @@ export default function ElaboracaoTab({ sessionId, authUser, quickLoadRequest =
2602
  setImportacaoErro('')
2603
  setRequiresSheet(Boolean(resp.requires_sheet))
2604
  setSheetOptions(resp.sheets || [])
 
 
 
 
 
 
 
 
 
 
 
 
 
2605
  const nomeModeloCarregado = String(resp?.nome_modelo || '').trim()
2606
  if (Boolean(resp.requires_sheet)) {
2607
  setSelectedSheet('')
@@ -2617,6 +2736,12 @@ export default function ElaboracaoTab({ sessionId, authUser, quickLoadRequest =
2617
  ? 'dai'
2618
  : 'tabular'
2619
  setTipoFonteDados(tipoFonte)
 
 
 
 
 
 
2620
  if (tipoFonte === 'dai' && nomeModeloCarregado) {
2621
  setNomeArquivoExport(nomeModeloCarregado)
2622
  } else if (tipoFonte !== 'dai') {
@@ -2630,6 +2755,7 @@ export default function ElaboracaoTab({ sessionId, authUser, quickLoadRequest =
2630
 
2631
  setTipoFonteDados('tabular')
2632
  setNomeArquivoExport('')
 
2633
  setDados(null)
2634
  setColunasNumericas([])
2635
  setColunaY('')
@@ -2710,6 +2836,11 @@ export default function ElaboracaoTab({ sessionId, authUser, quickLoadRequest =
2710
  setSelectedSheet('')
2711
  setRequiresSheet(false)
2712
  setSheetOptions([])
 
 
 
 
 
2713
  const nomeArquivo = String(arquivoUpload?.name || '').toLowerCase()
2714
  const uploadEhDai = nomeArquivo.endsWith('.dai')
2715
  setTipoFonteDados(uploadEhDai ? 'dai' : 'tabular')
@@ -2723,6 +2854,46 @@ export default function ElaboracaoTab({ sessionId, authUser, quickLoadRequest =
2723
  })
2724
  }
2725
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
2726
  async function onCarregarModeloRepositorio(modeloIdOverride = '') {
2727
  const overrideNormalizado = (
2728
  modeloIdOverride
@@ -2749,6 +2920,11 @@ export default function ElaboracaoTab({ sessionId, authUser, quickLoadRequest =
2749
  setSelectedSheet('')
2750
  setRequiresSheet(false)
2751
  setSheetOptions([])
 
 
 
 
 
2752
  setTipoFonteDados('dai')
2753
  const resp = await api.elaboracaoRepositorioCarregar(sessionId, modeloId)
2754
  aplicarRespostaCarregamento(resp, 'dai', { source: 'repo' })
@@ -2825,27 +3001,79 @@ export default function ElaboracaoTab({ sessionId, authUser, quickLoadRequest =
2825
  setMapaResiduosExtremoAbs(MAPA_RESIDUOS_EXTREMO_ABS_DEFAULT)
2826
  setGeoAuto200(true)
2827
  const resp = await api.confirmSheet(sessionId, selectedSheet)
2828
- setTipoFonteDados('tabular')
2829
- setManualMapError('')
2830
- setGeoProcessError('')
2831
- setGeoStatusHtml('')
2832
- setGeoFalhasHtml('')
2833
- setGeoCorrecoes([])
2834
- setElaborador(resp.elaborador || null)
2835
- setObservacaoModelo(String(resp?.observacao_modelo || '').trim())
2836
- setModeloCarregadoInfo(null)
2837
- setAvaliadorSelecionado(resp.elaborador?.nome_completo || '')
2838
- setNomeArquivoExport('')
2839
- setRequiresSheet(false)
2840
- setArquivoCarregadoInfo(buildArquivoCarregadoInfo(resp, {
2841
  fileName: String(uploadedFile?.name || arquivoCarregadoInfo?.nome_arquivo || ''),
2842
  sheetName: selectedSheet,
2843
- }))
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
2844
  applyBaseResponse(resp, { resetXSelection: true })
2845
  setSectionsMountKey((prev) => prev + 1)
2846
  setModeloLoadSource('')
2847
  }, {
2848
- onError: (err) => setImportacaoErro(err?.message || 'Falha ao confirmar aba do arquivo.'),
2849
  })
2850
  }
2851
 
@@ -4188,7 +4416,7 @@ export default function ElaboracaoTab({ sessionId, authUser, quickLoadRequest =
4188
  </aside>
4189
 
4190
  <div key={`sections-${sectionsMountKey}`} className="workflow-sections-stack elaboracao-sections-stack">
4191
- <SectionBlock step="1" title="Importar Dados" subtitle="Upload de CSV, Excel ou .dai com recuperação do fluxo.">
4192
  <div className="section1-groups">
4193
  <div className="subpanel section1-group">
4194
  {!modeloLoadSource ? (
@@ -4217,6 +4445,18 @@ export default function ElaboracaoTab({ sessionId, authUser, quickLoadRequest =
4217
  >
4218
  Fazer upload de Excel ou modelo
4219
  </button>
 
 
 
 
 
 
 
 
 
 
 
 
4220
  </div>
4221
  ) : (
4222
  <div className="model-source-flow">
@@ -4307,6 +4547,44 @@ export default function ElaboracaoTab({ sessionId, authUser, quickLoadRequest =
4307
  ) : null}
4308
  </>
4309
  ) : null}
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
4310
  </div>
4311
  )}
4312
 
@@ -4353,6 +4631,115 @@ export default function ElaboracaoTab({ sessionId, authUser, quickLoadRequest =
4353
  </>
4354
  ) : null}
4355
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
4356
  {modeloCarregadoInfo ? (
4357
  <>
4358
  <h4>Informações do modelo</h4>
@@ -4433,7 +4820,7 @@ export default function ElaboracaoTab({ sessionId, authUser, quickLoadRequest =
4433
  </div>
4434
  </SectionBlock>
4435
 
4436
- {baseCarregada ? (
4437
  <>
4438
  <SectionBlock step="2" title="Resolver Coordenadas" subtitle="Mapeie lat/lon ou execute geocodificação automática.">
4439
  <div className="coords-section-groups">
 
908
  const isDai = tipo === 'dai' || fileNameLower.endsWith('.dai')
909
  if (isDai) return null
910
 
911
+ const importPreview = resp?.import_preview || null
912
  const extMatch = fileName.match(/\.([^.]+)$/)
913
  const tipoArquivo = extMatch?.[1] ? String(extMatch[1]).toUpperCase() : '-'
914
  const sheetName = String(options.sheetName || resp?.sheet_selected || '').trim()
 
916
  ? Number(resp.dados.total_rows)
917
  : Array.isArray(resp?.dados?.rows)
918
  ? resp.dados.rows.length
919
+ : Number.isFinite(Number(importPreview?.total_rows))
920
+ ? Number(importPreview.total_rows)
921
+ : null
922
+ const totalColunas = Array.isArray(resp?.dados?.columns)
923
+ ? resp.dados.columns.length
924
+ : Number.isFinite(Number(importPreview?.total_columns))
925
+ ? Number(importPreview.total_columns)
926
+ : Array.isArray(importPreview?.columns)
927
+ ? importPreview.columns.length
928
+ : null
929
  const totalAbas = Array.isArray(resp?.sheets) ? resp.sheets.length : 0
930
 
931
  if (!fileName && totalLinhas === null && totalColunas === null && !sheetName) return null
 
1035
  const [requiresSheet, setRequiresSheet] = useState(false)
1036
  const [sheetOptions, setSheetOptions] = useState([])
1037
  const [selectedSheet, setSelectedSheet] = useState('')
1038
+ const [pastedTableText, setPastedTableText] = useState('')
1039
+ const [importPreview, setImportPreview] = useState(null)
1040
+ const [importSelectedColumns, setImportSelectedColumns] = useState([])
1041
+ const [requiresImportColumnSelection, setRequiresImportColumnSelection] = useState(false)
1042
+ const [firstColumnsCount, setFirstColumnsCount] = useState('')
1043
+ const [firstColumnsChecked, setFirstColumnsChecked] = useState(false)
1044
  const [elaborador, setElaborador] = useState(null)
1045
  const [modeloCarregadoInfo, setModeloCarregadoInfo] = useState(null)
1046
  const [repoModelos, setRepoModelos] = useState([])
 
1207
 
1208
  const mapaChoices = useMemo(() => [MAPA_VARIAVEL_PADRAO, ...colunasNumericas], [colunasNumericas])
1209
  const mapaModoDisponivel = mapaVariavel !== MAPA_VARIAVEL_PADRAO
1210
+ const importPreviewColumns = useMemo(
1211
+ () => (Array.isArray(importPreview?.columns) ? importPreview.columns.map((item) => String(item)) : []),
1212
+ [importPreview],
1213
+ )
1214
+ const importPreviewRows = useMemo(
1215
+ () => (Array.isArray(importPreview?.rows) ? importPreview.rows : []),
1216
+ [importPreview],
1217
+ )
1218
+ const importSelectedColumnsSet = useMemo(
1219
+ () => new Set(importSelectedColumns),
1220
+ [importSelectedColumns],
1221
+ )
1222
+ const importSelectionReady = useMemo(
1223
+ () => importSelectedColumns.length >= 2,
1224
+ [importSelectedColumns],
1225
+ )
1226
  const colunasXDisponiveis = useMemo(
1227
  () => (colunaY ? colunasNumericas.filter((coluna) => coluna !== colunaY) : []),
1228
  [colunasNumericas, colunaY],
 
1963
  }
1964
  observer.disconnect()
1965
  }
1966
+ }, [sectionsMountKey, baseCarregada, hasSelection, hasFit, requiresImportColumnSelection])
1967
 
1968
  useEffect(() => {
1969
  if (typeof window === 'undefined') return undefined
 
2362
  }
2363
  }
2364
 
2365
+ function limparBaseCarregadaParaSelecaoImportacao() {
2366
+ setDados(null)
2367
+ clearMapaResponse()
2368
+ setMapaGerado(false)
2369
+ setMapaVariavel(MAPA_VARIAVEL_PADRAO)
2370
+ setMapaModo(MAPA_MODO_PONTOS)
2371
+ setMapaResiduosGerado(false)
2372
+ clearMapaResiduosResponse()
2373
+ setMapaResiduosModo(MAPA_MODO_PONTOS)
2374
+ setMapaResiduosExtremoAbs(MAPA_RESIDUOS_EXTREMO_ABS_DEFAULT)
2375
+ setColunasNumericas([])
2376
+ setColunaY('')
2377
+ setColunaYDraft('')
2378
+ setTipoY('')
2379
+ setTipoYDraft('')
2380
+ setColunasAreaCandidatas([])
2381
+ setColunaArea('')
2382
+ setColunasX([])
2383
+ setDicotomicas([])
2384
+ setCodigoAlocado([])
2385
+ setPercentuais([])
2386
+ setColunasDataMercado([])
2387
+ setColunaDataMercadoSugerida('')
2388
+ setColunaDataMercado('')
2389
+ setColunaDataMercadoAplicada('')
2390
+ setPeriodoDadosMercado(null)
2391
+ setPeriodoDadosMercadoPreview(null)
2392
+ setDataMercadoError('')
2393
+ setSelection(null)
2394
+ setGrauCoef(0)
2395
+ setGrauF(0)
2396
+ setFit(null)
2397
+ setSecao10InterativoFigura(null)
2398
+ setSecao10InterativoFiguraComIndices(null)
2399
+ setSecao10InterativoSelecionado('none')
2400
+ setSecao13InterativoFigura(null)
2401
+ setSecao13InterativoFiguraComIndices(null)
2402
+ setSecao13InterativoSelecionado('none')
2403
+ setSelectionAppliedSnapshot(buildSelectionSnapshot())
2404
+ setTransformacaoY('(x)')
2405
+ setTransformacoesX({})
2406
+ setTransformacaoYFixaBusca('Livre')
2407
+ setTransformacoesFixasBusca({})
2408
+ setManualTransformPreview(null)
2409
+ setManualTransformPreviewLoading(false)
2410
+ setTransformacoesAplicadas(null)
2411
+ setOrigemTransformacoes(null)
2412
+ setBuscaTransformAppliedSnapshot(buildGrauSnapshot(0, 0))
2413
+ setManualTransformAppliedSnapshot(buildTransformacoesSnapshot('(x)', {}))
2414
+ setFiltros(defaultFiltros())
2415
+ setOutliersTexto('')
2416
+ setReincluirTexto('')
2417
+ setResumoOutliers('Excluidos: 0 | A excluir: 0 | A reincluir: 0 | Total: 0')
2418
+ setOutliersHtml('')
2419
+ setOutliersAnteriores([])
2420
+ setIteracao(1)
2421
+ setOutlierFiltrosAplicadosSnapshot(buildFiltrosSnapshot(defaultFiltros()))
2422
+ setOutlierTextosAplicadosSnapshot(buildOutlierTextSnapshot('', ''))
2423
+ setCoordsInfo(null)
2424
+ setManualLat('')
2425
+ setManualLon('')
2426
+ setGeoCdlog('')
2427
+ setGeoNum('')
2428
+ setCoordsMode('menu')
2429
+ setConfirmarExclusaoCoords(false)
2430
+ setCamposAvaliacao([])
2431
+ valoresAvaliacaoRef.current = {}
2432
+ setAvaliacaoFormVersion((prev) => prev + 1)
2433
+ setAvaliacaoPendente(false)
2434
+ setConfirmarLimpezaAvaliacoes(false)
2435
+ setResultadoAvaliacaoHtml('')
2436
+ setBaseChoices([])
2437
+ setBaseValue('')
2438
+ }
2439
+
2440
  function applyBaseResponse(resp, options = {}) {
2441
  const resetXSelection = Boolean(options.resetXSelection)
2442
  const colunaYPadrao = String(resp.coluna_y_padrao || '')
 
2708
  setImportacaoErro('')
2709
  setRequiresSheet(Boolean(resp.requires_sheet))
2710
  setSheetOptions(resp.sheets || [])
2711
+ const proximaImportPreview = resp?.import_preview || null
2712
+ const proximaSelecaoColunasPendente = Boolean(resp?.requires_column_selection)
2713
+ setImportPreview(proximaImportPreview)
2714
+ setRequiresImportColumnSelection(proximaSelecaoColunasPendente)
2715
+ if (proximaImportPreview && proximaSelecaoColunasPendente) {
2716
+ setImportSelectedColumns([])
2717
+ setFirstColumnsCount('')
2718
+ setFirstColumnsChecked(false)
2719
+ } else if (!proximaImportPreview) {
2720
+ setImportSelectedColumns([])
2721
+ setFirstColumnsCount('')
2722
+ setFirstColumnsChecked(false)
2723
+ }
2724
  const nomeModeloCarregado = String(resp?.nome_modelo || '').trim()
2725
  if (Boolean(resp.requires_sheet)) {
2726
  setSelectedSheet('')
 
2736
  ? 'dai'
2737
  : 'tabular'
2738
  setTipoFonteDados(tipoFonte)
2739
+ if (proximaSelecaoColunasPendente) {
2740
+ setNomeArquivoExport('')
2741
+ setModeloLoadSource('')
2742
+ limparBaseCarregadaParaSelecaoImportacao()
2743
+ return
2744
+ }
2745
  if (tipoFonte === 'dai' && nomeModeloCarregado) {
2746
  setNomeArquivoExport(nomeModeloCarregado)
2747
  } else if (tipoFonte !== 'dai') {
 
2755
 
2756
  setTipoFonteDados('tabular')
2757
  setNomeArquivoExport('')
2758
+ setRequiresImportColumnSelection(false)
2759
  setDados(null)
2760
  setColunasNumericas([])
2761
  setColunaY('')
 
2836
  setSelectedSheet('')
2837
  setRequiresSheet(false)
2838
  setSheetOptions([])
2839
+ setImportPreview(null)
2840
+ setImportSelectedColumns([])
2841
+ setRequiresImportColumnSelection(false)
2842
+ setFirstColumnsCount('')
2843
+ setFirstColumnsChecked(false)
2844
  const nomeArquivo = String(arquivoUpload?.name || '').toLowerCase()
2845
  const uploadEhDai = nomeArquivo.endsWith('.dai')
2846
  setTipoFonteDados(uploadEhDai ? 'dai' : 'tabular')
 
2854
  })
2855
  }
2856
 
2857
+ async function onLoadPastedTable() {
2858
+ const textoColado = String(pastedTableText || '')
2859
+ if (!sessionId) return
2860
+ if (!textoColado.trim()) {
2861
+ setImportacaoErro('Cole os dados copiados do Excel antes de carregar.')
2862
+ return
2863
+ }
2864
+ setModeloLoadSource('paste')
2865
+ setImportacaoErro('')
2866
+ await withBusy(async () => {
2867
+ setMapaGerado(false)
2868
+ clearMapaResponse()
2869
+ setMapaVariavel(MAPA_VARIAVEL_PADRAO)
2870
+ setMapaModo(MAPA_MODO_PONTOS)
2871
+ setMapaResiduosGerado(false)
2872
+ clearMapaResiduosResponse()
2873
+ setMapaResiduosModo(MAPA_MODO_PONTOS)
2874
+ setMapaResiduosExtremoAbs(MAPA_RESIDUOS_EXTREMO_ABS_DEFAULT)
2875
+ setGeoAuto200(true)
2876
+ setSelectedSheet('')
2877
+ setRequiresSheet(false)
2878
+ setSheetOptions([])
2879
+ setImportPreview(null)
2880
+ setImportSelectedColumns([])
2881
+ setRequiresImportColumnSelection(false)
2882
+ setFirstColumnsCount('')
2883
+ setFirstColumnsChecked(false)
2884
+ setUploadedFile(null)
2885
+ setTipoFonteDados('tabular')
2886
+ const resp = await api.pasteElaboracaoTable(sessionId, textoColado)
2887
+ aplicarRespostaCarregamento(resp, 'tabular', {
2888
+ source: 'paste',
2889
+ fileName: String(resp?.uploaded_filename || 'dados_colados.csv'),
2890
+ sheetName: 'Dados colados',
2891
+ })
2892
+ }, {
2893
+ onError: (err) => setImportacaoErro(err?.message || 'Falha ao carregar dados colados.'),
2894
+ })
2895
+ }
2896
+
2897
  async function onCarregarModeloRepositorio(modeloIdOverride = '') {
2898
  const overrideNormalizado = (
2899
  modeloIdOverride
 
2920
  setSelectedSheet('')
2921
  setRequiresSheet(false)
2922
  setSheetOptions([])
2923
+ setImportPreview(null)
2924
+ setImportSelectedColumns([])
2925
+ setRequiresImportColumnSelection(false)
2926
+ setFirstColumnsCount('')
2927
+ setFirstColumnsChecked(false)
2928
  setTipoFonteDados('dai')
2929
  const resp = await api.elaboracaoRepositorioCarregar(sessionId, modeloId)
2930
  aplicarRespostaCarregamento(resp, 'dai', { source: 'repo' })
 
3001
  setMapaResiduosExtremoAbs(MAPA_RESIDUOS_EXTREMO_ABS_DEFAULT)
3002
  setGeoAuto200(true)
3003
  const resp = await api.confirmSheet(sessionId, selectedSheet)
3004
+ aplicarRespostaCarregamento(resp, 'tabular', {
3005
+ source: 'upload',
 
 
 
 
 
 
 
 
 
 
 
3006
  fileName: String(uploadedFile?.name || arquivoCarregadoInfo?.nome_arquivo || ''),
3007
  sheetName: selectedSheet,
3008
+ })
3009
+ }, {
3010
+ onError: (err) => setImportacaoErro(err?.message || 'Falha ao confirmar aba do arquivo.'),
3011
+ })
3012
+ }
3013
+
3014
+ function onToggleImportColumn(coluna) {
3015
+ const valor = String(coluna || '').trim()
3016
+ if (!valor) return
3017
+ setFirstColumnsChecked(false)
3018
+ setImportSelectedColumns((prev) => {
3019
+ const next = prev.includes(valor)
3020
+ ? prev.filter((item) => item !== valor)
3021
+ : importPreviewColumns.filter((col) => col === valor || prev.includes(col))
3022
+ return next
3023
+ })
3024
+ setRequiresImportColumnSelection(true)
3025
+ }
3026
+
3027
+ function getFirstImportColumnsQuantity(value = firstColumnsCount) {
3028
+ const limite = importPreviewColumns.length
3029
+ const parsed = Number.parseInt(String(value || '0'), 10)
3030
+ return Number.isFinite(parsed) ? Math.max(0, Math.min(limite, parsed)) : 0
3031
+ }
3032
+
3033
+ function onFirstColumnsCountChange(value) {
3034
+ setFirstColumnsCount(value)
3035
+ if (!firstColumnsChecked) return
3036
+ const quantidade = getFirstImportColumnsQuantity(value)
3037
+ setImportSelectedColumns(importPreviewColumns.slice(0, quantidade))
3038
+ setRequiresImportColumnSelection(true)
3039
+ setImportacaoErro('')
3040
+ }
3041
+
3042
+ function onToggleFirstImportColumns(event) {
3043
+ const checked = event.target.checked
3044
+ setFirstColumnsChecked(checked)
3045
+ const quantidade = getFirstImportColumnsQuantity()
3046
+ const primeiras = importPreviewColumns.slice(0, quantidade)
3047
+ if (checked) {
3048
+ setFirstColumnsCount(String(quantidade))
3049
+ setImportSelectedColumns(primeiras)
3050
+ } else {
3051
+ const primeirasSet = new Set(primeiras)
3052
+ setImportSelectedColumns((prev) => prev.filter((coluna) => !primeirasSet.has(coluna)))
3053
+ }
3054
+ setRequiresImportColumnSelection(true)
3055
+ setImportacaoErro('')
3056
+ }
3057
+
3058
+ function onClearImportColumns() {
3059
+ setImportSelectedColumns([])
3060
+ setFirstColumnsChecked(false)
3061
+ setRequiresImportColumnSelection(true)
3062
+ setImportacaoErro('')
3063
+ }
3064
+
3065
+ async function onApplyImportColumns() {
3066
+ if (!sessionId || importSelectedColumns.length < 2) return
3067
+ setImportacaoErro('')
3068
+ await withBusy(async () => {
3069
+ const resp = await api.applyImportColumns(sessionId, importSelectedColumns)
3070
+ setRequiresImportColumnSelection(false)
3071
+ setImportSelectedColumns(Array.isArray(resp.import_columns_selected) ? resp.import_columns_selected.map((item) => String(item)) : importSelectedColumns)
3072
  applyBaseResponse(resp, { resetXSelection: true })
3073
  setSectionsMountKey((prev) => prev + 1)
3074
  setModeloLoadSource('')
3075
  }, {
3076
+ onError: (err) => setImportacaoErro(err?.message || 'Falha ao aplicar seleção de colunas.'),
3077
  })
3078
  }
3079
 
 
4416
  </aside>
4417
 
4418
  <div key={`sections-${sectionsMountKey}`} className="workflow-sections-stack elaboracao-sections-stack">
4419
+ <SectionBlock step="1" title="Importar Dados" subtitle="Modelo, planilha ou dados colados com recuperação do fluxo.">
4420
  <div className="section1-groups">
4421
  <div className="subpanel section1-group">
4422
  {!modeloLoadSource ? (
 
4445
  >
4446
  Fazer upload de Excel ou modelo
4447
  </button>
4448
+ <button
4449
+ type="button"
4450
+ className="model-source-choice-btn model-source-choice-btn-tertiary"
4451
+ onClick={() => {
4452
+ setModeloLoadSource('paste')
4453
+ setRepoModeloDropdownOpen(false)
4454
+ setImportacaoErro('')
4455
+ }}
4456
+ disabled={loading}
4457
+ >
4458
+ Colar dados do Excel
4459
+ </button>
4460
  </div>
4461
  ) : (
4462
  <div className="model-source-flow">
 
4547
  ) : null}
4548
  </>
4549
  ) : null}
4550
+
4551
+ {modeloLoadSource === 'paste' ? (
4552
+ <div className="paste-excel-panel">
4553
+ <label className="paste-excel-field">
4554
+ Dados do Excel
4555
+ <textarea
4556
+ value={pastedTableText}
4557
+ onChange={(event) => {
4558
+ setPastedTableText(event.target.value)
4559
+ setImportacaoErro('')
4560
+ }}
4561
+ placeholder="Cole a tabela copiada do Excel"
4562
+ disabled={loading}
4563
+ rows={8}
4564
+ />
4565
+ </label>
4566
+ <div className="row compact paste-excel-actions">
4567
+ <button
4568
+ type="button"
4569
+ onClick={onLoadPastedTable}
4570
+ disabled={loading || !pastedTableText.trim()}
4571
+ >
4572
+ Carregar dados colados
4573
+ </button>
4574
+ <button
4575
+ type="button"
4576
+ className="paste-excel-clear-btn"
4577
+ onClick={() => {
4578
+ setPastedTableText('')
4579
+ setImportacaoErro('')
4580
+ }}
4581
+ disabled={loading || !pastedTableText}
4582
+ >
4583
+ Limpar
4584
+ </button>
4585
+ </div>
4586
+ </div>
4587
+ ) : null}
4588
  </div>
4589
  )}
4590
 
 
4631
  </>
4632
  ) : null}
4633
 
4634
+ {importPreview ? (
4635
+ <div className="import-preview-card">
4636
+ <div className="import-preview-head">
4637
+ <div>
4638
+ <h4>Prévia da planilha</h4>
4639
+ <div className="section1-empty-hint">
4640
+ Cabeçalhos e {Number(importPreview?.returned_rows || importPreviewRows.length).toLocaleString('pt-BR')} linhas aleatórias.
4641
+ </div>
4642
+ </div>
4643
+ <div className="import-preview-counter">
4644
+ {importSelectedColumns.length}/{importPreviewColumns.length} selecionadas
4645
+ </div>
4646
+ </div>
4647
+
4648
+ <div className="import-preview-disclaimer">
4649
+ Inclua as colunas referentes ao Bairro e à Finalidade Cadastral do imóvel para seguir as diretrizes da DAI.
4650
+ </div>
4651
+
4652
+ <div className="import-preview-tools">
4653
+ <label className="import-preview-first-columns-toggle">
4654
+ <input
4655
+ type="checkbox"
4656
+ checked={firstColumnsChecked}
4657
+ onChange={onToggleFirstImportColumns}
4658
+ disabled={loading || importPreviewColumns.length === 0}
4659
+ />
4660
+ Selecionar primeiras
4661
+ </label>
4662
+ <label className="import-preview-first-columns-field">
4663
+ <input
4664
+ type="number"
4665
+ min="0"
4666
+ max={importPreviewColumns.length}
4667
+ value={firstColumnsCount}
4668
+ onChange={(event) => onFirstColumnsCountChange(event.target.value)}
4669
+ disabled={loading || importPreviewColumns.length === 0}
4670
+ />
4671
+ colunas
4672
+ </label>
4673
+ <button
4674
+ type="button"
4675
+ className="import-preview-clear-btn"
4676
+ onClick={onClearImportColumns}
4677
+ disabled={loading || importSelectedColumns.length === 0}
4678
+ >
4679
+ Limpar selecionadas
4680
+ </button>
4681
+ </div>
4682
+
4683
+ {!importSelectionReady ? (
4684
+ <div className="section1-empty-hint">
4685
+ Selecione pelo menos 2 colunas para liberar as próximas etapas.
4686
+ </div>
4687
+ ) : null}
4688
+
4689
+ <div className="import-preview-table-wrap">
4690
+ <table className="import-preview-table">
4691
+ <thead>
4692
+ <tr>
4693
+ <th className="import-preview-index-cell">#</th>
4694
+ {importPreviewColumns.map((coluna) => {
4695
+ const selected = importSelectedColumnsSet.has(coluna)
4696
+ return (
4697
+ <th key={`import-preview-head-${coluna}`} className={selected ? 'is-selected' : ''}>
4698
+ <button
4699
+ type="button"
4700
+ className="import-preview-header-btn"
4701
+ onClick={() => onToggleImportColumn(coluna)}
4702
+ aria-pressed={selected}
4703
+ disabled={loading}
4704
+ >
4705
+ {coluna}
4706
+ </button>
4707
+ </th>
4708
+ )
4709
+ })}
4710
+ </tr>
4711
+ </thead>
4712
+ <tbody>
4713
+ {importPreviewRows.map((row, rowIndex) => (
4714
+ <tr key={`import-preview-row-${row?._index ?? rowIndex}`}>
4715
+ <td className="import-preview-index-cell">{row?._index ?? rowIndex + 1}</td>
4716
+ {importPreviewColumns.map((coluna) => {
4717
+ const selected = importSelectedColumnsSet.has(coluna)
4718
+ return (
4719
+ <td key={`import-preview-cell-${rowIndex}-${coluna}`} className={selected ? 'is-selected' : ''}>
4720
+ {String(row?.[coluna] ?? '')}
4721
+ </td>
4722
+ )
4723
+ })}
4724
+ </tr>
4725
+ ))}
4726
+ </tbody>
4727
+ </table>
4728
+ </div>
4729
+
4730
+ <div className="import-preview-apply-row">
4731
+ <button
4732
+ type="button"
4733
+ className="import-preview-apply-btn"
4734
+ onClick={onApplyImportColumns}
4735
+ disabled={loading || !importSelectionReady || !requiresImportColumnSelection}
4736
+ >
4737
+ {requiresImportColumnSelection ? 'Aplicar colunas selecionadas' : 'Seleção aplicada'}
4738
+ </button>
4739
+ </div>
4740
+ </div>
4741
+ ) : null}
4742
+
4743
  {modeloCarregadoInfo ? (
4744
  <>
4745
  <h4>Informações do modelo</h4>
 
4820
  </div>
4821
  </SectionBlock>
4822
 
4823
+ {baseCarregada && !requiresImportColumnSelection ? (
4824
  <>
4825
  <SectionBlock step="2" title="Resolver Coordenadas" subtitle="Mapeie lat/lon ou execute geocodificação automática.">
4826
  <div className="coords-section-groups">
frontend/src/components/TrabalhosTecnicosTab.jsx CHANGED
@@ -92,6 +92,28 @@ function nomeModeloExibicao(modelo) {
92
  return String(modelo?.mesa_modelo_nome || modelo?.nome || '').trim()
93
  }
94
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
95
  function buildEditState(trabalho) {
96
  const modelos = dedupeTextList(
97
  (Array.isArray(trabalho?.modelos) ? trabalho.modelos : []).map((item) => nomeModeloExibicao(item))
@@ -106,14 +128,7 @@ function buildEditState(trabalho) {
106
  coord_y: item?.coord_y ?? '',
107
  }))
108
  : [
109
- {
110
- id: `${trabalho?.id || 'trabalho'}-imovel-1`,
111
- label: '',
112
- endereco: '',
113
- numero: '',
114
- coord_x: '',
115
- coord_y: '',
116
- },
117
  ]
118
 
119
  return {
@@ -126,6 +141,23 @@ function buildEditState(trabalho) {
126
  }
127
  }
128
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
129
  function toListItemFromDetail(trabalho) {
130
  const modelos = Array.isArray(trabalho?.modelos) ? trabalho.modelos : []
131
  const modelosMesa = modelos.filter((item) => item?.disponivel_mesa)
@@ -182,6 +214,7 @@ export default function TrabalhosTecnicosTab({
182
  const [trabalhoLoading, setTrabalhoLoading] = useState(false)
183
  const [trabalhoError, setTrabalhoError] = useState('')
184
  const [editando, setEditando] = useState(false)
 
185
  const [salvando, setSalvando] = useState(false)
186
  const [edicao, setEdicao] = useState(null)
187
  const [edicaoErro, setEdicaoErro] = useState('')
@@ -420,6 +453,7 @@ export default function TrabalhosTecnicosTab({
420
  setTrabalhoError('')
421
  setEdicaoErro('')
422
  setEditando(false)
 
423
  setEdicao(null)
424
  setOrigemAbertura(String(options?.origem || 'lista').trim() || 'lista')
425
  try {
@@ -450,6 +484,7 @@ export default function TrabalhosTecnicosTab({
450
  setTrabalhoError('')
451
  setEdicaoErro('')
452
  setEditando(false)
 
453
  setEdicao(null)
454
  setModeloDraft('')
455
  if (options.syncRoute !== false && typeof onRouteChange === 'function') {
@@ -461,6 +496,10 @@ export default function TrabalhosTecnicosTab({
461
  }
462
 
463
  function onVoltarDoDetalhe() {
 
 
 
 
464
  if (origemAbertura === 'pesquisa_mapa' && typeof onVoltarAoMapaPesquisa === 'function') {
465
  onVoltarLista({ syncRoute: false })
466
  onVoltarAoMapaPesquisa()
@@ -475,6 +514,41 @@ export default function TrabalhosTecnicosTab({
475
  onVoltarLista({ subtab: activeInnerTab })
476
  }
477
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
478
  function onAbrirModeloAssociado(modelo) {
479
  if (!modelo?.disponivel_mesa || typeof onAbrirModeloNoRepositorio !== 'function') return
480
  onAbrirModeloNoRepositorio({
@@ -500,6 +574,10 @@ export default function TrabalhosTecnicosTab({
500
 
501
  function onCancelarEdicao() {
502
  if (salvando) return
 
 
 
 
503
  setEditando(false)
504
  setEdicao(null)
505
  setEdicaoErro('')
@@ -575,40 +653,41 @@ export default function TrabalhosTecnicosTab({
575
  }
576
 
577
  async function onSalvarEdicao() {
578
- if (!trabalhoAberto?.id || !edicao) return
 
579
  setSalvando(true)
580
  setEdicaoErro('')
581
  try {
582
- const payload = {
583
- trabalho_id: trabalhoAberto.id,
584
- nome: edicao.nome,
585
- tipo_codigo: edicao.tipo_codigo,
586
- ano: parseNullableInt(edicao.ano),
587
- processos_sei: splitTextareaLines(edicao.processos_sei_text),
588
- modelos: dedupeTextList(edicao.modelos),
589
- imoveis: edicao.imoveis.map((item) => ({
590
- label: item.label,
591
- endereco: item.endereco,
592
- numero: item.numero,
593
- coord_x: parseNullableNumber(item.coord_x),
594
- coord_y: parseNullableNumber(item.coord_y),
595
- })),
596
- }
597
- const resp = await api.trabalhosTecnicosSalvar(payload)
598
  const trabalhoAtualizado = resp?.trabalho || null
599
  setTrabalhoAberto(trabalhoAtualizado)
600
  setEditando(false)
 
601
  setEdicao(null)
602
  setModeloDraft('')
603
  if (trabalhoAtualizado?.id) {
604
- setTrabalhos((prev) => prev.map((item) => (
605
- String(item?.id || '') === String(trabalhoAtualizado.id)
606
- ? toListItemFromDetail(trabalhoAtualizado)
607
- : item
608
- )))
 
 
 
 
 
 
 
 
 
 
 
609
  }
610
  } catch (err) {
611
- setEdicaoErro(err.message || 'Falha ao salvar alterações do trabalho técnico.')
612
  } finally {
613
  setSalvando(false)
614
  }
@@ -686,23 +765,24 @@ export default function TrabalhosTecnicosTab({
686
  }, [activeInnerTab, trabalhoAberto])
687
 
688
  if (trabalhoAberto) {
 
689
  const modelos = Array.isArray(trabalhoAberto?.modelos) ? trabalhoAberto.modelos : []
690
  const modelosMesa = modelos.filter((item) => item?.disponivel_mesa)
691
  const imoveis = Array.isArray(trabalhoAberto?.imoveis) ? trabalhoAberto.imoveis : []
692
  const processosSei = listarProcessosSei(trabalhoAberto)
693
- const shareHref = buildTrabalhoTecnicoLink(trabalhoAberto?.id || '')
694
 
695
  return (
696
  <div className="tab-content">
697
  <div className="pesquisa-opened-model-view">
698
  <div className="pesquisa-opened-model-head">
699
  <div className="pesquisa-opened-model-title-wrap">
700
- <h3>{trabalhoAberto?.nome || 'Trabalho técnico'}</h3>
701
- <p>{trabalhoAberto?.tipo_label || 'Tipo não identificado'}</p>
702
  </div>
703
  <div className="trabalho-detail-actions">
704
- <ShareLinkButton href={shareHref} />
705
- {!editando ? (
706
  <button
707
  type="button"
708
  className="model-source-back-btn"
@@ -719,7 +799,7 @@ export default function TrabalhosTecnicosTab({
719
  onClick={() => void onSalvarEdicao()}
720
  disabled={salvando}
721
  >
722
- {salvando ? 'Salvando...' : 'Salvar alterações'}
723
  </button>
724
  <button
725
  type="button"
@@ -737,7 +817,9 @@ export default function TrabalhosTecnicosTab({
737
  onClick={onVoltarDoDetalhe}
738
  disabled={trabalhoLoading || salvando}
739
  >
740
- {origemAbertura === 'pesquisa_mapa' || origemAbertura === 'trabalhos_tecnicos_mapa'
 
 
741
  ? 'Voltar ao mapa'
742
  : 'Voltar à lista'}
743
  </button>
@@ -1022,7 +1104,9 @@ export default function TrabalhosTecnicosTab({
1022
  </div>
1023
  <LoadingOverlay
1024
  show={trabalhoLoading || salvando}
1025
- label={salvando ? 'Salvando trabalho técnico...' : 'Abrindo trabalho técnico...'}
 
 
1026
  />
1027
  </div>
1028
  )
@@ -1061,6 +1145,9 @@ export default function TrabalhosTecnicosTab({
1061
  <div><strong>Total:</strong> {trabalhos.length}</div>
1062
  </div>
1063
  <div className="repo-actions">
 
 
 
1064
  <button type="button" className="repo-refresh-btn" onClick={() => void carregarTrabalhos()} disabled={loading}>
1065
  Atualizar lista
1066
  </button>
 
92
  return String(modelo?.mesa_modelo_nome || modelo?.nome || '').trim()
93
  }
94
 
95
+ function buildEmptyImovel(idPrefix = 'novo-trabalho', index = 1) {
96
+ return {
97
+ id: `${idPrefix}-imovel-${index}`,
98
+ label: '',
99
+ endereco: '',
100
+ numero: '',
101
+ coord_x: '',
102
+ coord_y: '',
103
+ }
104
+ }
105
+
106
+ function buildCadastroState() {
107
+ return {
108
+ nome: '',
109
+ tipo_codigo: '',
110
+ ano: '',
111
+ processos_sei_text: '',
112
+ modelos: [],
113
+ imoveis: [buildEmptyImovel('novo-trabalho', 1)],
114
+ }
115
+ }
116
+
117
  function buildEditState(trabalho) {
118
  const modelos = dedupeTextList(
119
  (Array.isArray(trabalho?.modelos) ? trabalho.modelos : []).map((item) => nomeModeloExibicao(item))
 
128
  coord_y: item?.coord_y ?? '',
129
  }))
130
  : [
131
+ buildEmptyImovel(trabalho?.id || 'trabalho', 1),
 
 
 
 
 
 
 
132
  ]
133
 
134
  return {
 
141
  }
142
  }
143
 
144
+ function buildTrabalhoPayload(edicao) {
145
+ return {
146
+ nome: edicao.nome,
147
+ tipo_codigo: edicao.tipo_codigo,
148
+ ano: parseNullableInt(edicao.ano),
149
+ processos_sei: splitTextareaLines(edicao.processos_sei_text),
150
+ modelos: dedupeTextList(edicao.modelos),
151
+ imoveis: (Array.isArray(edicao.imoveis) ? edicao.imoveis : []).map((item) => ({
152
+ label: item.label,
153
+ endereco: item.endereco,
154
+ numero: item.numero,
155
+ coord_x: parseNullableNumber(item.coord_x),
156
+ coord_y: parseNullableNumber(item.coord_y),
157
+ })),
158
+ }
159
+ }
160
+
161
  function toListItemFromDetail(trabalho) {
162
  const modelos = Array.isArray(trabalho?.modelos) ? trabalho.modelos : []
163
  const modelosMesa = modelos.filter((item) => item?.disponivel_mesa)
 
214
  const [trabalhoLoading, setTrabalhoLoading] = useState(false)
215
  const [trabalhoError, setTrabalhoError] = useState('')
216
  const [editando, setEditando] = useState(false)
217
+ const [cadastrando, setCadastrando] = useState(false)
218
  const [salvando, setSalvando] = useState(false)
219
  const [edicao, setEdicao] = useState(null)
220
  const [edicaoErro, setEdicaoErro] = useState('')
 
453
  setTrabalhoError('')
454
  setEdicaoErro('')
455
  setEditando(false)
456
+ setCadastrando(false)
457
  setEdicao(null)
458
  setOrigemAbertura(String(options?.origem || 'lista').trim() || 'lista')
459
  try {
 
484
  setTrabalhoError('')
485
  setEdicaoErro('')
486
  setEditando(false)
487
+ setCadastrando(false)
488
  setEdicao(null)
489
  setModeloDraft('')
490
  if (options.syncRoute !== false && typeof onRouteChange === 'function') {
 
496
  }
497
 
498
  function onVoltarDoDetalhe() {
499
+ if (cadastrando) {
500
+ onVoltarLista({ subtab: 'repositorio' })
501
+ return
502
+ }
503
  if (origemAbertura === 'pesquisa_mapa' && typeof onVoltarAoMapaPesquisa === 'function') {
504
  onVoltarLista({ syncRoute: false })
505
  onVoltarAoMapaPesquisa()
 
514
  onVoltarLista({ subtab: activeInnerTab })
515
  }
516
 
517
+ function onIniciarCadastro() {
518
+ const draft = buildCadastroState()
519
+ setActiveInnerTab('repositorio')
520
+ setTrabalhoAberto({
521
+ id: '',
522
+ nome: '',
523
+ tipo_codigo: '',
524
+ tipo_label: 'Novo trabalho técnico',
525
+ ano: '',
526
+ endereco_resumo: '',
527
+ modelo_resumo: '',
528
+ total_registros_planilha: 0,
529
+ total_imoveis: 0,
530
+ total_modelos: 0,
531
+ tem_coordenadas: false,
532
+ processos_sei: [],
533
+ modelos: [],
534
+ imoveis: [],
535
+ })
536
+ setEdicao(draft)
537
+ setModeloDraft('')
538
+ setEdicaoErro('')
539
+ setTrabalhoError('')
540
+ setOrigemAbertura('lista')
541
+ setCadastrando(true)
542
+ setEditando(true)
543
+ void carregarSugestoesModelosMesa()
544
+ if (typeof onRouteChange === 'function') {
545
+ onRouteChange({
546
+ tab: 'trabalhos',
547
+ subtab: 'repositorio',
548
+ })
549
+ }
550
+ }
551
+
552
  function onAbrirModeloAssociado(modelo) {
553
  if (!modelo?.disponivel_mesa || typeof onAbrirModeloNoRepositorio !== 'function') return
554
  onAbrirModeloNoRepositorio({
 
574
 
575
  function onCancelarEdicao() {
576
  if (salvando) return
577
+ if (cadastrando) {
578
+ onVoltarLista({ subtab: 'repositorio' })
579
+ return
580
+ }
581
  setEditando(false)
582
  setEdicao(null)
583
  setEdicaoErro('')
 
653
  }
654
 
655
  async function onSalvarEdicao() {
656
+ if (!edicao) return
657
+ if (!cadastrando && !trabalhoAberto?.id) return
658
  setSalvando(true)
659
  setEdicaoErro('')
660
  try {
661
+ const payload = buildTrabalhoPayload(edicao)
662
+ const resp = cadastrando
663
+ ? await api.trabalhosTecnicosCadastrar(payload)
664
+ : await api.trabalhosTecnicosSalvar({ trabalho_id: trabalhoAberto.id, ...payload })
 
 
 
 
 
 
 
 
 
 
 
 
665
  const trabalhoAtualizado = resp?.trabalho || null
666
  setTrabalhoAberto(trabalhoAtualizado)
667
  setEditando(false)
668
+ setCadastrando(false)
669
  setEdicao(null)
670
  setModeloDraft('')
671
  if (trabalhoAtualizado?.id) {
672
+ const itemAtualizado = toListItemFromDetail(trabalhoAtualizado)
673
+ setTrabalhos((prev) => {
674
+ const existe = prev.some((item) => String(item?.id || '') === String(trabalhoAtualizado.id))
675
+ if (!existe) return [itemAtualizado, ...prev]
676
+ return prev.map((item) => (
677
+ String(item?.id || '') === String(trabalhoAtualizado.id)
678
+ ? itemAtualizado
679
+ : item
680
+ ))
681
+ })
682
+ if (typeof onRouteChange === 'function') {
683
+ onRouteChange({
684
+ tab: 'trabalhos',
685
+ trabalhoId: String(trabalhoAtualizado.id),
686
+ })
687
+ }
688
  }
689
  } catch (err) {
690
+ setEdicaoErro(err.message || (cadastrando ? 'Falha ao cadastrar trabalho técnico.' : 'Falha ao salvar alterações do trabalho técnico.'))
691
  } finally {
692
  setSalvando(false)
693
  }
 
765
  }, [activeInnerTab, trabalhoAberto])
766
 
767
  if (trabalhoAberto) {
768
+ const isCadastro = Boolean(cadastrando)
769
  const modelos = Array.isArray(trabalhoAberto?.modelos) ? trabalhoAberto.modelos : []
770
  const modelosMesa = modelos.filter((item) => item?.disponivel_mesa)
771
  const imoveis = Array.isArray(trabalhoAberto?.imoveis) ? trabalhoAberto.imoveis : []
772
  const processosSei = listarProcessosSei(trabalhoAberto)
773
+ const shareHref = !isCadastro && trabalhoAberto?.id ? buildTrabalhoTecnicoLink(trabalhoAberto.id) : ''
774
 
775
  return (
776
  <div className="tab-content">
777
  <div className="pesquisa-opened-model-view">
778
  <div className="pesquisa-opened-model-head">
779
  <div className="pesquisa-opened-model-title-wrap">
780
+ <h3>{isCadastro ? (edicao?.nome || 'Novo trabalho técnico') : (trabalhoAberto?.nome || 'Trabalho técnico')}</h3>
781
+ <p>{isCadastro ? 'Cadastro manual' : (trabalhoAberto?.tipo_label || 'Tipo não identificado')}</p>
782
  </div>
783
  <div className="trabalho-detail-actions">
784
+ {shareHref ? <ShareLinkButton href={shareHref} /> : null}
785
+ {!editando && !isCadastro ? (
786
  <button
787
  type="button"
788
  className="model-source-back-btn"
 
799
  onClick={() => void onSalvarEdicao()}
800
  disabled={salvando}
801
  >
802
+ {salvando ? 'Salvando...' : (isCadastro ? 'Cadastrar trabalho' : 'Salvar alterações')}
803
  </button>
804
  <button
805
  type="button"
 
817
  onClick={onVoltarDoDetalhe}
818
  disabled={trabalhoLoading || salvando}
819
  >
820
+ {isCadastro
821
+ ? 'Voltar à lista'
822
+ : origemAbertura === 'pesquisa_mapa' || origemAbertura === 'trabalhos_tecnicos_mapa'
823
  ? 'Voltar ao mapa'
824
  : 'Voltar à lista'}
825
  </button>
 
1104
  </div>
1105
  <LoadingOverlay
1106
  show={trabalhoLoading || salvando}
1107
+ label={salvando
1108
+ ? (cadastrando ? 'Cadastrando trabalho técnico...' : 'Salvando trabalho técnico...')
1109
+ : 'Abrindo trabalho técnico...'}
1110
  />
1111
  </div>
1112
  )
 
1145
  <div><strong>Total:</strong> {trabalhos.length}</div>
1146
  </div>
1147
  <div className="repo-actions">
1148
+ <button type="button" className="repo-refresh-btn trabalhos-create-btn" onClick={onIniciarCadastro} disabled={loading || trabalhoLoading}>
1149
+ Cadastrar trabalho
1150
+ </button>
1151
  <button type="button" className="repo-refresh-btn" onClick={() => void carregarTrabalhos()} disabled={loading}>
1152
  Atualizar lista
1153
  </button>
frontend/src/styles.css CHANGED
@@ -460,6 +460,14 @@ textarea {
460
  color: #ffffff;
461
  }
462
 
 
 
 
 
 
 
 
 
463
  .repo-upload-row,
464
  .repo-delete-row {
465
  display: flex;
@@ -4641,6 +4649,15 @@ button.model-source-choice-btn-secondary {
4641
  color: #35506a;
4642
  }
4643
 
 
 
 
 
 
 
 
 
 
4644
  .model-source-flow {
4645
  display: grid;
4646
  gap: 12px;
@@ -4731,6 +4748,46 @@ button.btn-upload-select {
4731
  word-break: break-word;
4732
  }
4733
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
4734
  .upload-file-info-card {
4735
  margin-top: 0;
4736
  border: 1px solid #d1deea;
@@ -4770,6 +4827,228 @@ button.btn-upload-select {
4770
  word-break: break-word;
4771
  }
4772
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
4773
  .import-feedback-line {
4774
  margin-top: 10px;
4775
  }
 
460
  color: #ffffff;
461
  }
462
 
463
+ .trabalhos-create-btn {
464
+ --btn-bg-start: #2f8f5b;
465
+ --btn-bg-end: #247a4b;
466
+ --btn-border: #1f6b42;
467
+ --btn-shadow-soft: rgba(47, 143, 91, 0.2);
468
+ --btn-shadow-strong: rgba(47, 143, 91, 0.28);
469
+ }
470
+
471
  .repo-upload-row,
472
  .repo-delete-row {
473
  display: flex;
 
4649
  color: #35506a;
4650
  }
4651
 
4652
+ button.model-source-choice-btn-tertiary {
4653
+ --btn-bg-start: #fff6e8;
4654
+ --btn-bg-end: #ffe6bf;
4655
+ --btn-border: #e2a94d;
4656
+ --btn-shadow-soft: rgba(226, 169, 77, 0.16);
4657
+ --btn-shadow-strong: rgba(226, 169, 77, 0.24);
4658
+ color: #704b12;
4659
+ }
4660
+
4661
  .model-source-flow {
4662
  display: grid;
4663
  gap: 12px;
 
4748
  word-break: break-word;
4749
  }
4750
 
4751
+ .paste-excel-panel {
4752
+ display: grid;
4753
+ gap: 10px;
4754
+ border: 1px solid #d1deea;
4755
+ border-radius: 8px;
4756
+ background: #fbfdff;
4757
+ padding: 11px 12px;
4758
+ }
4759
+
4760
+ .paste-excel-field {
4761
+ display: grid;
4762
+ gap: 7px;
4763
+ color: #33485d;
4764
+ font-size: 0.9rem;
4765
+ font-weight: 800;
4766
+ }
4767
+
4768
+ .paste-excel-field textarea {
4769
+ min-height: 168px;
4770
+ resize: vertical;
4771
+ font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", monospace;
4772
+ font-size: 0.84rem;
4773
+ font-weight: 500;
4774
+ line-height: 1.45;
4775
+ white-space: pre;
4776
+ }
4777
+
4778
+ .paste-excel-actions {
4779
+ justify-content: flex-start;
4780
+ }
4781
+
4782
+ button.paste-excel-clear-btn {
4783
+ --btn-bg-start: #f5f8fb;
4784
+ --btn-bg-end: #edf2f7;
4785
+ --btn-border: #c9d7e4;
4786
+ --btn-shadow-soft: rgba(53, 74, 95, 0.08);
4787
+ --btn-shadow-strong: rgba(53, 74, 95, 0.12);
4788
+ color: #40576f;
4789
+ }
4790
+
4791
  .upload-file-info-card {
4792
  margin-top: 0;
4793
  border: 1px solid #d1deea;
 
4827
  word-break: break-word;
4828
  }
4829
 
4830
+ .import-preview-card {
4831
+ display: grid;
4832
+ gap: 12px;
4833
+ border: 1px solid #cbd9e7;
4834
+ border-radius: 8px;
4835
+ background: #fbfdff;
4836
+ padding: 12px;
4837
+ }
4838
+
4839
+ .import-preview-head {
4840
+ display: flex;
4841
+ justify-content: space-between;
4842
+ align-items: flex-start;
4843
+ gap: 12px;
4844
+ }
4845
+
4846
+ .import-preview-head h4 {
4847
+ margin: 0 0 4px;
4848
+ }
4849
+
4850
+ .import-preview-counter {
4851
+ flex: 0 0 auto;
4852
+ border: 1px solid #a9c9e7;
4853
+ border-radius: 999px;
4854
+ background: #eef7ff;
4855
+ color: #245f91;
4856
+ font-size: 0.78rem;
4857
+ font-weight: 800;
4858
+ padding: 5px 9px;
4859
+ }
4860
+
4861
+ .import-preview-disclaimer {
4862
+ border-left: 3px solid #e28a29;
4863
+ background: #fff8ed;
4864
+ color: #624622;
4865
+ font-size: 0.86rem;
4866
+ font-weight: 700;
4867
+ line-height: 1.4;
4868
+ padding: 8px 10px;
4869
+ }
4870
+
4871
+ .import-preview-tools {
4872
+ display: flex;
4873
+ flex-wrap: wrap;
4874
+ align-items: center;
4875
+ gap: 8px;
4876
+ }
4877
+
4878
+ .import-preview-first-columns-field {
4879
+ display: inline-flex;
4880
+ align-items: center;
4881
+ gap: 7px;
4882
+ color: #33485d;
4883
+ font-size: 0.86rem;
4884
+ font-weight: 700;
4885
+ }
4886
+
4887
+ .import-preview-first-columns-toggle {
4888
+ display: inline-flex;
4889
+ align-items: center;
4890
+ gap: 7px;
4891
+ min-height: 34px;
4892
+ border: 1px solid #c8d8e8;
4893
+ border-radius: 8px;
4894
+ background: #f6f9fc;
4895
+ color: #33485d;
4896
+ font-size: 0.86rem;
4897
+ font-weight: 800;
4898
+ padding: 6px 9px;
4899
+ }
4900
+
4901
+ .import-preview-first-columns-toggle input {
4902
+ width: auto;
4903
+ margin: 0;
4904
+ }
4905
+
4906
+ .import-preview-first-columns-field input {
4907
+ width: 78px;
4908
+ }
4909
+
4910
+ button.import-preview-apply-btn {
4911
+ --btn-bg-start: #ff8c00;
4912
+ --btn-bg-end: #e67900;
4913
+ --btn-border: #cf6f00;
4914
+ --btn-shadow-soft: rgba(255, 140, 0, 0.2);
4915
+ --btn-shadow-strong: rgba(255, 140, 0, 0.28);
4916
+ }
4917
+
4918
+ button.import-preview-clear-btn {
4919
+ --btn-bg-start: #f5f8fb;
4920
+ --btn-bg-end: #edf2f7;
4921
+ --btn-border: #c9d7e4;
4922
+ --btn-shadow-soft: rgba(53, 74, 95, 0.08);
4923
+ --btn-shadow-strong: rgba(53, 74, 95, 0.12);
4924
+ color: #40576f;
4925
+ }
4926
+
4927
+ .import-preview-apply-row {
4928
+ display: flex;
4929
+ justify-content: flex-start;
4930
+ padding-top: 2px;
4931
+ }
4932
+
4933
+ .import-preview-table-wrap {
4934
+ overflow: auto;
4935
+ max-height: 360px;
4936
+ border: 1px solid #d4e0eb;
4937
+ border-radius: 8px;
4938
+ background: #ffffff;
4939
+ }
4940
+
4941
+ .import-preview-table {
4942
+ width: max-content;
4943
+ min-width: 100%;
4944
+ border-collapse: separate;
4945
+ border-spacing: 0;
4946
+ font-size: 0.82rem;
4947
+ }
4948
+
4949
+ .import-preview-table th,
4950
+ .import-preview-table td {
4951
+ max-width: 220px;
4952
+ min-width: 120px;
4953
+ border-right: 1px solid #e0e8f0;
4954
+ border-bottom: 1px solid #e8eef4;
4955
+ padding: 0;
4956
+ text-align: left;
4957
+ vertical-align: top;
4958
+ }
4959
+
4960
+ .import-preview-table td {
4961
+ color: #2f4358;
4962
+ padding: 7px 9px;
4963
+ white-space: nowrap;
4964
+ overflow: hidden;
4965
+ text-overflow: ellipsis;
4966
+ }
4967
+
4968
+ .import-preview-table th {
4969
+ position: sticky;
4970
+ top: 0;
4971
+ z-index: 1;
4972
+ background: #eef3f8;
4973
+ padding: 6px;
4974
+ }
4975
+
4976
+ .import-preview-table th.is-selected {
4977
+ background: #d9edff;
4978
+ box-shadow: inset 0 -3px 0 #2f80cf;
4979
+ }
4980
+
4981
+ .import-preview-table td.is-selected {
4982
+ background: #f0f8ff;
4983
+ }
4984
+
4985
+ .import-preview-table tr:hover td {
4986
+ background: #f8fbfe;
4987
+ }
4988
+
4989
+ .import-preview-table tr:hover td.is-selected {
4990
+ background: #e6f3ff;
4991
+ }
4992
+
4993
+ .import-preview-header-btn {
4994
+ display: flex;
4995
+ align-items: center;
4996
+ gap: 8px;
4997
+ width: 100%;
4998
+ min-height: 42px;
4999
+ border: 1px solid #9fbfdd;
5000
+ border-radius: 7px;
5001
+ background: linear-gradient(180deg, #ffffff 0%, #edf6ff 100%);
5002
+ box-shadow: 0 2px 5px rgba(49, 86, 122, 0.12);
5003
+ color: #24506f;
5004
+ font: inherit;
5005
+ font-weight: 800;
5006
+ line-height: 1.25;
5007
+ text-align: left;
5008
+ white-space: normal;
5009
+ overflow-wrap: anywhere;
5010
+ padding: 8px 10px;
5011
+ }
5012
+
5013
+ .import-preview-header-btn::before {
5014
+ content: "";
5015
+ flex: 0 0 14px;
5016
+ width: 14px;
5017
+ height: 14px;
5018
+ border: 2px solid #6d9dcc;
5019
+ border-radius: 4px;
5020
+ background: #ffffff;
5021
+ box-shadow: inset 0 0 0 2px #ffffff;
5022
+ }
5023
+
5024
+ .import-preview-header-btn:hover,
5025
+ .import-preview-header-btn:focus-visible {
5026
+ border-color: #2f80cf;
5027
+ background: linear-gradient(180deg, #f7fbff 0%, #dceeff 100%);
5028
+ box-shadow: 0 4px 10px rgba(47, 128, 207, 0.18);
5029
+ transform: none;
5030
+ }
5031
+
5032
+ .import-preview-table th.is-selected .import-preview-header-btn {
5033
+ border-color: #2f80cf;
5034
+ background: linear-gradient(180deg, #e6f4ff 0%, #cde8ff 100%);
5035
+ color: #155e99;
5036
+ }
5037
+
5038
+ .import-preview-table th.is-selected .import-preview-header-btn::before {
5039
+ border-color: #2f80cf;
5040
+ background: #2f80cf;
5041
+ }
5042
+
5043
+ .import-preview-index-cell {
5044
+ min-width: 58px !important;
5045
+ max-width: 72px !important;
5046
+ width: 64px;
5047
+ color: #66788a;
5048
+ font-weight: 800;
5049
+ background: #f7f9fb;
5050
+ }
5051
+
5052
  .import-feedback-line {
5053
  margin-top: 10px;
5054
  }