diff --git a/FIX_SALVAR_LAYOUT.md b/FIX_SALVAR_LAYOUT.md new file mode 100644 index 0000000..a84064f --- /dev/null +++ b/FIX_SALVAR_LAYOUT.md @@ -0,0 +1,353 @@ +# FIX — Salvar Layout no Banco de Dados + +**Data:** 04/03/2026 +**Versão do PRD:** 1.7 +**Status:** Pendente de Implementação + +--- + +## Sumário dos Problemas + +| # | Severidade | Arquivo(s) | Descrição | +|---|-----------|------------|-----------| +| 1 | **Crítico** | `migrations.rs` | `migration_v3` sem transação — falha parcial deixa banco permanentemente quebrado | +| 2 | **Crítico** | `migrations.rs` | `schema_version` atualizada fora da transação das migrations | +| 3 | **Crítico** | `usecases/layouts.rs` | Erros SQL mapeados como `ErroLayout::JsonMalformado` — mensagem completamente enganosa | +| 4 | Moderado | `layout_repository.rs` | `delimitador` TAB salvo como byte de controle invisível no banco | +| 5 | Moderado | `usecases/layouts.rs` | `pos_numero` / `pos_serie` XLSX podem ser strings vazias — sem validação no fluxo de save da UI | +| 6 | Moderado | `layout_repository.rs` | Índices posicionais hardcoded em `row.get(N)` — quebra silenciosa se colunas do SELECT forem reordenadas | +| 7 | Menor | `usecases/layouts.rs` + `layout_repository.rs` | Renomear layout para nome já existente (UPDATE) gera `JsonMalformado` em vez de `NomeConflitante` | +| 8 | Menor | `connection.rs` | `SELECT 1` não detecta corrupção real de páginas SQLite | +| 9 | Menor | `layout_repository.rs` | UPDATE não zera campos do tipo oposto ao tipo atual do layout | + +--- + +## Problema 1 (Crítico) — `migration_v3` sem transação atômica + +### Local +`src/infrastructure/sqlite/migrations.rs` — função `migration_v3` + +### Situação Atual +```rust +fn migration_v3(conn: &Connection) -> Result<()> { + conn.execute_batch( + "ALTER TABLE layouts ADD COLUMN indice_documento_tipo INTEGER; + ALTER TABLE layouts ADD COLUMN pos_documento_tipo TEXT;", + )?; + Ok(()) +} +``` + +`execute_batch` executa os dois `ALTER TABLE` sem transação explícita. Se o processo for interrompido após o primeiro e antes do segundo, o banco fica em estado parcial: + +- `indice_documento_tipo` existe, `pos_documento_tipo` não existe. +- A versão no banco não é atualizada (o erro propaga antes). +- Na próxima execução, `migration_v3` tenta adicionar `indice_documento_tipo` novamente → SQLite retorna `"duplicate column name"` → **o app nunca mais inicializa sem intervenção manual**. + +### Correção Necessária +Envolver cada migration em uma transação explícita e atualizar `schema_version` **dentro da mesma transação**: + +```rust +fn migration_v3(conn: &Connection) -> Result<()> { + conn.execute_batch(" + BEGIN; + ALTER TABLE layouts ADD COLUMN indice_documento_tipo INTEGER; + ALTER TABLE layouts ADD COLUMN pos_documento_tipo TEXT; + UPDATE schema_version SET versao = 3; + COMMIT; + ")?; + Ok(()) +} +``` + +> **Nota:** O SQLite suporta `ALTER TABLE` dentro de transação explícita desde a versão 3.x. `execute_batch` executa múltiplos statements quando delimitados por `;` dentro do mesmo bloco `BEGIN/COMMIT`. + +--- + +## Problema 2 (Crítico) — `schema_version` atualizada fora da transação das migrations + +### Local +`src/infrastructure/sqlite/migrations.rs` — função `aplicar_migrations` + +### Situação Atual +O fluxo atual é: +1. Executa `migration_v1(conn)?` +2. Executa `migration_v2(conn)?` +3. Executa `migration_v3(conn)?` +4. **Depois** executa `INSERT OR REPLACE INTO schema_version ...` + +Se qualquer migration falhar após outras já terem sido aplicadas, a versão não é atualizada, causando re-execução problemática na próxima abertura. + +### Correção Necessária +Cada migration deve atualizar `schema_version` internamente (dentro de sua própria transação), como mostrado no Problema 1. A função `aplicar_migrations` não deve mais atualizar a versão centralmente — ela apenas chama as migrations que ainda não foram aplicadas. + +Adicionalmente, as migrations v1 e v2 devem seguir o mesmo padrão: + +```rust +fn migration_v1(conn: &Connection) -> Result<()> { + conn.execute_batch(" + BEGIN; + CREATE TABLE IF NOT EXISTS layouts ( ... ); + INSERT OR REPLACE INTO schema_version (id, versao) VALUES (1, 1); + COMMIT; + ")?; + Ok(()) +} + +fn migration_v2(conn: &Connection) -> Result<()> { + conn.execute_batch(" + BEGIN; + CREATE UNIQUE INDEX IF NOT EXISTS idx_layouts_nome ON layouts (nome); + -- renomeia duplicatas existentes se houver + UPDATE schema_version SET versao = 2; + COMMIT; + ")?; + Ok(()) +} +``` + +--- + +## Problema 3 (Crítico) — Erros SQL mapeados como `ErroLayout::JsonMalformado` + +### Local +`src/application/usecases/layouts.rs` — função `salvar_layout` e `atualizar_layout` + +### Situação Atual +```rust +layout_repository::salvar(conn, layout) + .map_err(|e| ErroLayout::JsonMalformado(e.to_string()))?; + +layout_repository::atualizar(conn, layout) + .map_err(|e| ErroLayout::JsonMalformado(e.to_string()))?; +``` + +Qualquer erro do banco de dados (violação de constraint, coluna ausente, banco travado) é apresentado ao usuário como **"JSON malformado"**, o que é completamente incorreto. Exemplos de mensagens enganosas que o usuário veria: + +- `"UNIQUE constraint failed: layouts.nome"` → exibido como "JSON malformado" +- `"no such column: pos_documento_tipo"` → exibido como "JSON malformado" +- `"attempt to write a readonly database"` → exibido como "JSON malformado" + +### Correção Necessária + +1. Adicionar variante própria em `domain/errors.rs`: +```rust +pub enum ErroLayout { + // ... variantes existentes ... + ErroBanco(String), // erros genéricos de I/O do banco + NomeConflitante, // já existe (para uso tanto em INSERT quanto em UPDATE) +} +``` + +2. No use case, inspecionar o erro antes de mapear: +```rust +layout_repository::salvar(conn, layout) + .map_err(|e| { + let msg = e.to_string(); + if msg.contains("UNIQUE constraint failed") { + ErroLayout::NomeConflitante + } else { + ErroLayout::ErroBanco(msg) + } + })?; +``` + +3. Na UI (`ui/screens/layouts.rs`), tratar `ErroBanco` com mensagem clara ao usuário: `"Erro ao salvar no banco de dados: {mensagem}"`. + +--- + +## Problema 4 (Moderado) — `delimitador` TAB salvo como byte de controle no banco + +### Local +`src/infrastructure/sqlite/layout_repository.rs` — funções `salvar` e `atualizar` (CSV) + +### Situação Atual +O `char` `'\t'` é convertido para `String` via `.to_string()`, armazenando um caractere de tabulação literal (byte `0x09`) no campo `TEXT` do banco. Embora funcional, é opaco para inspeção manual do banco e incompatível com exports/backups que não preservam bytes de controle. + +### Correção Necessária +Normalizar o delimitador para um token legível antes de salvar: + +```rust +// Ao salvar: +let delim_str = match config.delimitador { + '\t' => "tab".to_string(), + c => c.to_string(), +}; + +// Ao ler: +let delimitador = match delim_str.as_str() { + "tab" => '\t', + s => s.chars().next().unwrap_or(';'), +}; +``` + +> Isso também melhora a legibilidade dos arquivos JSON de export/import de layouts. + +--- + +## Problema 5 (Moderado) — `pos_numero` e `pos_serie` XLSX sem validação no fluxo de save da UI + +### Local +`src/application/usecases/layouts.rs` — função `salvar_layout` + +### Situação Atual +A validação atual verifica apenas que `nome` não esteja vazio. Os campos `pos_numero` e `pos_serie` de layouts XLSX são `String` obrigatórias (não `Option`), mas podem ser strings vazias `""`. O banco os aceita sem restrição, e o erro só apareceria na análise, sem indicar que o layout está incompleto. + +### Correção Necessária +Expandir a validação no use case `salvar_layout`: + +```rust +match layout { + Layout::Csv { config, .. } => { + // indice_numero e indice_serie são usize — sempre válidos se presentes + // nenhuma validação adicional necessária aqui + } + Layout::Xlsx { config, .. } => { + if config.pos_numero.trim().is_empty() { + return Err(ErroLayout::CampoObrigatorioAusente("pos_numero".to_string())); + } + if config.pos_serie.trim().is_empty() { + return Err(ErroLayout::CampoObrigatorioAusente("pos_serie".to_string())); + } + } +} +``` + +--- + +## Problema 6 (Moderado) — Índices posicionais hardcoded em `row.get(N)` + +### Local +`src/infrastructure/sqlite/layout_repository.rs` — função `listar` + +### Situação Atual +O mapeamento usa índices numéricos (`row.get(0)`, `row.get(1)`, ..., `row.get(16)`). Qualquer reordenação das colunas no `SELECT` quebra silenciosamente o mapeamento sem erro de compilação. + +### Correção Necessária +Substituir por nomes de colunas usando `row.get::<_, T>(nome_coluna)`: + +```rust +// Em vez de: +let id: i64 = row.get(0)?; +let nome: String = row.get(1)?; + +// Usar: +let id: i64 = row.get("id")?; +let nome: String = row.get("nome")?; +``` + +O `rusqlite` suporta `row.get("nome_coluna")` desde a versão 0.26. A versão usada no projeto é 0.32, portanto compatível. + +--- + +## Problema 7 (Menor) — Renomear para nome conflitante em UPDATE gera mensagem errada + +### Local +`src/application/usecases/layouts.rs` + `src/infrastructure/sqlite/layout_repository.rs` + +### Situação Atual +No fluxo de UPDATE (re-save de layout existente), não há verificação de conflito de nome antes de chamar `atualizar`. Se o usuário renomear um layout para um nome já em uso, a constraint `UNIQUE` do banco rejeita o UPDATE, o erro é mapeado como `JsonMalformado` (Problema 3 acima). + +### Correção Necessária +No use case `atualizar_layout`, verificar se o novo nome conflita com outro layout (excluindo o próprio ID): + +```rust +// Verificar conflito excluindo o próprio registro +if layout_repository::existe_nome_excluindo_id(conn, layout.nome(), layout.id())? { + return Err(ErroLayout::NomeConflitante); +} +``` + +Adicionar função no repository: +```rust +pub fn existe_nome_excluindo_id(conn: &Connection, nome: &str, id: Option) -> Result { + match id { + Some(id) => { + let count: i64 = conn.query_row( + "SELECT COUNT(*) FROM layouts WHERE nome = ?1 AND id != ?2", + params![nome, id], + |row| row.get(0), + )?; + Ok(count > 0) + } + None => layout_repository::existe_nome(conn, nome), + } +} +``` + +--- + +## Problema 8 (Menor) — `SELECT 1` não detecta corrupção real do banco + +### Local +`src/infrastructure/sqlite/connection.rs` + +### Situação Atual +```rust +match conn.execute_batch("SELECT 1;") { + Ok(_) => return Ok((conn, false)), + Err(_) => { /* trata como banco corrompido */ } +} +``` + +`SELECT 1` não acessa nenhuma página de dados do SQLite. Um banco com tabelas corrompidas, índices inválidos ou páginas com checksum errado passaria nessa verificação sem ser detectado, causando erros inesperados posteriormente. + +### Correção Necessária +```rust +match conn.execute_batch("PRAGMA quick_check;") { + Ok(_) => return Ok((conn, false)), + Err(_) => { /* banco corrompido */ } +} +``` + +`PRAGMA quick_check` verifica a integridade estrutural do banco (sem verificar cada valor de dado, como `integrity_check` faz). É mais rápido que `integrity_check` e muito mais confiável que `SELECT 1`. + +--- + +## Problema 9 (Menor) — UPDATE não zera campos do tipo oposto + +### Local +`src/infrastructure/sqlite/layout_repository.rs` — função `atualizar` + +### Situação Atual +O UPDATE de CSV não zera campos XLSX (`aba`, `pos_numero`, etc.) e o UPDATE de XLSX não zera campos CSV. Na prática não ocorre troca de tipo, mas se ocorrer (via importação JSON com mesmo nome e tipo diferente + sobrescrita), os campos do tipo anterior ficam no banco. + +### Correção Necessária +Adicionar `SET campo = NULL` explícito para os campos do tipo oposto em cada UPDATE: + +```sql +-- UPDATE CSV: zerar campos XLSX +UPDATE layouts SET + nome = ?1, delimitador = ?2, encoding = ?3, linha_cabecalho = ?4, + indice_numero = ?5, indice_serie = ?6, indice_valor = ?7, + indice_data = ?8, indice_documento_tipo = ?9, + -- zerar campos XLSX: + aba = NULL, pos_numero = NULL, pos_serie = NULL, + pos_valor = NULL, pos_data = NULL, pos_documento_tipo = NULL +WHERE id = ?10 +``` + +```sql +-- UPDATE XLSX: zerar campos CSV +UPDATE layouts SET + nome = ?1, aba = ?2, pos_numero = ?3, pos_serie = ?4, + pos_valor = ?5, pos_data = ?6, pos_documento_tipo = ?7, + -- zerar campos CSV: + delimitador = NULL, encoding = NULL, linha_cabecalho = NULL, + indice_numero = NULL, indice_serie = NULL, indice_valor = NULL, + indice_data = NULL, indice_documento_tipo = NULL +WHERE id = ?8 +``` + +--- + +## Ordem de Implementação Recomendada + +1. **Problema 1 + 2** (migrations com transação) — Risco de banco permanentemente inutilizável; implementar primeiro. +2. **Problema 3** (mapeamento de erros) — Sem isso o usuário não entende o que está errado. +3. **Problema 5** (validação de campos XLSX) — Evita layouts inválidos serem salvos. +4. **Problema 7** (conflito de nome em UPDATE) — Depende da solução do Problema 3. +5. **Problema 4** (delimitador TAB) — Melhoria de robustez; não causa crash. +6. **Problema 6** (índices posicionais) — Refactor preventivo. +7. **Problema 8** (PRAGMA quick_check) — Melhoria de confiabilidade. +8. **Problema 9** (zerar campos opostos) — Limpeza defensiva. diff --git a/src/application/usecases/executar_analise.rs b/src/application/usecases/executar_analise.rs index 4b8afb3..0e32403 100644 --- a/src/application/usecases/executar_analise.rs +++ b/src/application/usecases/executar_analise.rs @@ -2,7 +2,7 @@ use crate::domain::{ entities::{ chave_serie::ChaveSerie, nota::Nota, - resultado_analise::{ResultadoAnalise, ResultadoPreAnalise}, + resultado_analise::{IntervaloSerie, ResultadoAnalise, ResultadoPreAnalise}, }, services::{ detector_duplicidade::duplicidades_por_serie, @@ -56,12 +56,14 @@ pub fn pre_analisar(notas: &[Nota]) -> ResultadoPreAnalise { } /// Verifica se algum grupo excede o limite de faltantes. -/// Retorna lista de chaves que precisam de confirmação. -pub fn series_com_intervalo_excessivo(pre: &ResultadoPreAnalise) -> Vec<(ChaveSerie, u64)> { +/// Retorna lista de chaves com o intervalo completo para exibição ao usuário. +pub fn series_com_intervalo_excessivo( + pre: &ResultadoPreAnalise, +) -> Vec<(ChaveSerie, IntervaloSerie)> { pre.intervalos_por_serie .iter() .filter(|(_, iv)| iv.excede_limite(LIMITE_FALTANTES)) - .map(|(chave, iv)| (chave.clone(), iv.contagem_faltantes)) + .map(|(chave, iv)| (chave.clone(), iv.clone())) .collect() } diff --git a/src/application/usecases/exportar_pdf.rs b/src/application/usecases/exportar_pdf.rs index 64ffcd6..da36280 100644 --- a/src/application/usecases/exportar_pdf.rs +++ b/src/application/usecases/exportar_pdf.rs @@ -1,3 +1,4 @@ +use crate::domain::entities::nota::Nota; use crate::domain::entities::resultado_analise::ResultadoAnalise; use crate::infrastructure::pdf_generator::{MetadadosRelatorio, PdfGenerator}; use chrono::Local; @@ -9,6 +10,7 @@ use std::path::Path; pub fn exportar_pdf( gerador: &dyn PdfGenerator, resultado: &ResultadoAnalise, + notas: &[Nota], nome_arquivo: &str, nome_layout: Option<&str>, caminho_saida: &Path, @@ -19,5 +21,5 @@ pub fn exportar_pdf( gerado_em: Local::now(), }; - gerador.gerar(resultado, &meta, caminho_saida) + gerador.gerar(resultado, notas, &meta, caminho_saida) } diff --git a/src/application/usecases/importar_arquivo.rs b/src/application/usecases/importar_arquivo.rs index 1a7b847..b458fd1 100644 --- a/src/application/usecases/importar_arquivo.rs +++ b/src/application/usecases/importar_arquivo.rs @@ -44,7 +44,7 @@ pub fn importar_csv( config.indice_valor, config.indice_data, &mut avisos, - ); + )?; Ok(ResultadoImportacao { notas, avisos }) } @@ -102,13 +102,16 @@ pub fn importar_xlsx( col_valor, col_data, &mut avisos, - ); + )?; Ok(ResultadoImportacao { notas, avisos }) } /// Converte linhas brutas (Vec>) em notas fiscais, /// aplicando validações e coletando avisos. +/// +/// Retorna `Err` se algum índice obrigatório ou opcional ativo excede o número de +/// colunas da primeira linha, identificando qual campo está inválido. fn mapear_linhas_para_notas( linhas: &[Vec], idx_numero: usize, @@ -117,9 +120,38 @@ fn mapear_linhas_para_notas( idx_valor: Option, idx_data: Option, avisos: &mut ResumoAvisos, -) -> Vec { +) -> Result, ErroArquivo> { let mut notas = Vec::new(); + // Verificar se os índices existem na primeira linha disponível + if let Some(primeira_linha) = linhas.first() { + let total_colunas = primeira_linha.len(); + let obrigatorios: &[(&str, usize)] = &[("Numero", idx_numero), ("Serie", idx_serie)]; + for (nome, idx) in obrigatorios { + if *idx >= total_colunas { + return Err(ErroArquivo::ErroLeitura(format!( + "Campo '{}': índice {} não existe — o arquivo tem {} coluna(s).", + nome, idx, total_colunas + ))); + } + } + let opcionais: &[(&str, Option)] = &[ + ("Valor", idx_valor), + ("Data", idx_data), + ("Tipo de Documento", idx_documento_tipo), + ]; + for (nome, maybe_idx) in opcionais { + if let Some(idx) = maybe_idx { + if *idx >= total_colunas { + return Err(ErroArquivo::ErroLeitura(format!( + "Campo '{}': índice {} não existe — o arquivo tem {} coluna(s).", + nome, idx, total_colunas + ))); + } + } + } + } + for (i, linha) in linhas.iter().enumerate() { let linha_num = i + 1; @@ -216,7 +248,7 @@ fn mapear_linhas_para_notas( notas.push(Nota::new(numero, serie, documento_tipo, valor, data)); } - notas + Ok(notas) } /// Faz o parsing de um número, tentando extrair dígitos se necessário. diff --git a/src/infrastructure/pdf_generator.rs b/src/infrastructure/pdf_generator.rs index 080c47b..69b085d 100644 --- a/src/infrastructure/pdf_generator.rs +++ b/src/infrastructure/pdf_generator.rs @@ -1,4 +1,5 @@ use crate::domain::entities::chave_serie::ChaveSerie; +use crate::domain::entities::nota::Nota; use crate::domain::entities::resultado_analise::ResultadoAnalise; use crate::domain::services::detector_sequencia::agrupar_contiguos; use crate::domain::services::parser_monetario::formatar_valor_br; @@ -7,6 +8,7 @@ use genpdf::{ elements::{Break, Paragraph}, fonts, style, Document, SimplePageDecorator, }; +use std::collections::HashMap; use std::path::Path; // Fontes embutidas no binário em tempo de compilação. @@ -29,6 +31,7 @@ pub trait PdfGenerator { fn gerar( &self, resultado: &ResultadoAnalise, + notas: &[Nota], meta: &MetadadosRelatorio, caminho_saida: &Path, ) -> Result<(), String>; @@ -41,9 +44,20 @@ impl PdfGenerator for GenpdfGenerator { fn gerar( &self, resultado: &ResultadoAnalise, + notas: &[Nota], meta: &MetadadosRelatorio, caminho_saida: &Path, ) -> Result<(), String> { + // Construir mapa (chave_serie, numero) → data para uso na seção de duplicatas + let datas: HashMap<(ChaveSerie, u64), String> = notas + .iter() + .filter_map(|n| { + n.data.map(|d| { + let chave = ChaveSerie::new(n.serie.clone(), n.documento_tipo.clone()); + ((chave, n.numero), d.format("%d/%m/%Y").to_string()) + }) + }) + .collect(); let font_family = carregar_fonte_familia()?; let mut doc = Document::new(font_family); @@ -170,11 +184,16 @@ impl PdfGenerator for GenpdfGenerator { duplicatas.len() ))); for (numero, count) in duplicatas { + let data_str = datas + .get(&((*chave).clone(), *numero)) + .map(|d| format!(" (última: {})", d)) + .unwrap_or_default(); doc.push(Paragraph::new(format!( - " NF {} / Série {} — {} ocorrências", + " NF {} / Série {} — {} ocorrências{}", numero, chave.label(), - count + count, + data_str, ))); } } diff --git a/src/ui/app.rs b/src/ui/app.rs index 4ae9cd6..2d3701b 100644 --- a/src/ui/app.rs +++ b/src/ui/app.rs @@ -9,7 +9,7 @@ use crate::domain::{ nota::Nota, resultado_analise::{ResultadoAnalise, ResultadoPreAnalise}, }, - errors::{ErroLayout, ResumoAvisos}, + errors::{ErroArquivo, ErroLayout, ResumoAvisos}, }; use crate::infrastructure::sqlite::{connection::abrir_banco, migrations::aplicar_migrations}; use crate::ui::message::{Message, ResultadoPendente}; @@ -36,7 +36,6 @@ pub enum EstadoApp { #[derive(Debug, Clone)] pub enum EstadoModal { - Informacao { titulo: String, mensagem: String }, Aviso { titulo: String, mensagem: String }, Erro { titulo: String, mensagem: String }, Confirmacao { titulo: String, mensagem: String, acao: AcaoModal }, @@ -82,9 +81,9 @@ pub struct App { // Avisos da última importação pub avisos_importacao: Option, - // Paginação - pub pagina_faltantes: usize, - pub pagina_duplicatas: usize, + // Paginação (independente por série) + pub pagina_faltantes: std::collections::HashMap, + pub pagina_duplicatas: std::collections::HashMap, pub itens_por_pagina: usize, // Pré-visualização das primeiras linhas do arquivo @@ -95,6 +94,9 @@ pub struct App { // Drag-and-drop: indica que um arquivo está sendo arrastado sobre a janela pub arquivo_em_hover: bool, + + // Animação do spinner na tela de análise + pub spinner_frame: usize, } impl Default for App { @@ -114,12 +116,13 @@ impl Default for App { layouts_salvos: Vec::new(), modal: None, avisos_importacao: None, - pagina_faltantes: 0, - pagina_duplicatas: 0, + pagina_faltantes: std::collections::HashMap::new(), + pagina_duplicatas: std::collections::HashMap::new(), itens_por_pagina: 100, preview_arquivo: None, resultado_anterior: None, arquivo_em_hover: false, + spinner_frame: 0, } } } @@ -452,12 +455,6 @@ impl App { Task::none() } - Message::CancelarExpansao => { - self.estado = EstadoApp::ConfigurandoColunas; - self.modal = None; - Task::none() - } - Message::NovaAnalise => { self.exibir_confirmacao( "Nova Análise", @@ -473,18 +470,18 @@ impl App { } // ── Resultado ── - Message::PaginaFaltantesAlterada(p) => { - self.pagina_faltantes = p; + Message::PaginaFaltantesAlterada(chave, p) => { + self.pagina_faltantes.insert(chave, p); Task::none() } - Message::PaginaDuplicatasAlterada(p) => { - self.pagina_duplicatas = p; + Message::PaginaDuplicatasAlterada(chave, p) => { + self.pagina_duplicatas.insert(chave, p); Task::none() } Message::ItensPorPaginaAlterado(n) => { self.itens_por_pagina = n; - self.pagina_faltantes = 0; - self.pagina_duplicatas = 0; + self.pagina_faltantes.clear(); + self.pagina_duplicatas.clear(); Task::none() } Message::CopiarFaltantes(chave) => { @@ -659,6 +656,10 @@ impl App { } Message::XlsxErroAoCarregar(e) => { + self.caminho_arquivo = None; + self.nome_arquivo = String::new(); + self.preview_arquivo = None; + self.notas_importadas.clear(); self.exibir_erro(format!("Erro ao ler abas do arquivo: {}", e)); Task::none() } @@ -679,6 +680,11 @@ impl App { // ── Sem operação ── Message::Noop => Task::none(), + + Message::SpinnerTick => { + self.spinner_frame = self.spinner_frame.wrapping_add(1); + Task::none() + } } } @@ -697,10 +703,12 @@ impl App { crate::ui::screens::resultado::view(self, resultado) } EstadoApp::GerenciandoLayouts => crate::ui::screens::layouts::view(self), - EstadoApp::Analisando => { + EstadoApp::Analisando => { + const FRAMES: &[&str] = &["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"]; + let frame = FRAMES[self.spinner_frame % FRAMES.len()]; container( column![ - text("Analisando...").size(20).color(t::TEXT_SECONDARY), + text(format!("{} Analisando...", frame)).size(20).color(t::TEXT_SECONDARY), text("Aguarde enquanto o arquivo é processado.").size(14).color(t::TEXT_MUTED), ] .spacing(8) @@ -742,7 +750,7 @@ impl App { // ─── Subscription (eventos de janela) ─────────────────────────────────── pub fn subscription(&self) -> iced::Subscription { - iced::event::listen_with(|event, _status, _id| match event { + let drag_drop = iced::event::listen_with(|event, _status, _id| match event { iced::Event::Window(iced::window::Event::FileDropped(path)) => { Some(Message::ArquivoSolto(path)) } @@ -753,7 +761,15 @@ impl App { Some(Message::ArquivoHoverSaiu) } _ => None, - }) + }); + + if matches!(self.estado, EstadoApp::Analisando) { + let spinner = iced::time::every(std::time::Duration::from_millis(100)) + .map(|_| Message::SpinnerTick); + iced::Subscription::batch([drag_drop, spinner]) + } else { + drag_drop + } } // ─── Breadcrumb ────────────────────────────────────────────────────────── @@ -862,6 +878,12 @@ impl App { let layout_csv = self.layout_csv_atual.clone(); let layout_xlsx = self.layout_xlsx_atual.clone(); + // Validação de campos duplicados no mapeamento (RF02.3) + if let Some(erro) = validar_mapeamento_duplicado(&tipo, &layout_csv, &layout_xlsx) { + self.exibir_erro(erro); + return Task::none(); + } + self.estado = EstadoApp::Analisando; Task::perform( @@ -929,11 +951,13 @@ impl App { self.avisos_importacao = if avisos.tem_avisos() { Some(avisos) } else { None }; let msg = series_excessivas .iter() - .map(|(chave, count)| { + .map(|(chave, iv)| { format!( - "Série {}: intervalo de {} faltantes detectado", + "Série {}: intervalo de {} faltantes detectado (de {} a {})", chave.label(), - count + iv.contagem_faltantes, + iv.minimo, + iv.maximo, ) }) .collect::>() @@ -1072,6 +1096,7 @@ impl App { } else { Some(self.nome_layout_atual.clone()) }; + let notas = self.notas_importadas.clone(); Task::perform( async move { @@ -1094,6 +1119,7 @@ impl App { exportar_pdf( &gerador, &resultado, + ¬as, &nome_arquivo, nome_layout.as_deref(), &caminho, @@ -1215,6 +1241,78 @@ impl App { // ─── Função auxiliar síncrona para executar importação em spawn_blocking ───── +/// Verifica se algum par de campos mapeados aponta para o mesmo índice (CSV) +/// ou posição (XLSX). Retorna uma mensagem de erro descritiva se houver conflito. +fn validar_mapeamento_duplicado( + tipo: &TipoArquivo, + layout_csv: &LayoutCsv, + layout_xlsx: &LayoutXlsx, +) -> Option { + match tipo { + TipoArquivo::Csv => { + // Campos obrigatórios + opcionais ativos + let mut campos: Vec<(&str, usize)> = vec![ + ("Numero", layout_csv.indice_numero), + ("Serie", layout_csv.indice_serie), + ]; + if let Some(i) = layout_csv.indice_valor { + campos.push(("Valor", i)); + } + if let Some(i) = layout_csv.indice_data { + campos.push(("Data", i)); + } + if let Some(i) = layout_csv.indice_documento_tipo { + campos.push(("Tipo de Documento", i)); + } + // Verificar duplicatas + for i in 0..campos.len() { + for j in (i + 1)..campos.len() { + if campos[i].1 == campos[j].1 { + return Some(format!( + "Mapeamento inválido: campos '{}' e '{}' apontam para o mesmo índice ({}).", + campos[i].0, campos[j].0, campos[i].1 + )); + } + } + } + None + } + TipoArquivo::Xlsx => { + let mut campos: Vec<(&str, &str)> = vec![ + ("Numero", &layout_xlsx.pos_numero), + ("Serie", &layout_xlsx.pos_serie), + ]; + if let Some(ref p) = layout_xlsx.pos_valor { + if !p.is_empty() { + campos.push(("Valor", p)); + } + } + if let Some(ref p) = layout_xlsx.pos_data { + if !p.is_empty() { + campos.push(("Data", p)); + } + } + if let Some(ref p) = layout_xlsx.pos_documento_tipo { + if !p.is_empty() { + campos.push(("Tipo de Documento", p)); + } + } + // Verificar duplicatas (comparação case-insensitive) + for i in 0..campos.len() { + for j in (i + 1)..campos.len() { + if campos[i].1.to_uppercase() == campos[j].1.to_uppercase() { + return Some(format!( + "Mapeamento inválido: campos '{}' e '{}' apontam para a mesma posição ('{}').", + campos[i].0, campos[j].0, campos[i].1 + )); + } + } + } + None + } + } +} + fn executar_importacao_sync( caminho: PathBuf, tipo: TipoArquivo, @@ -1222,8 +1320,20 @@ fn executar_importacao_sync( layout_xlsx: LayoutXlsx, ) -> ResultadoPendente { let res_importacao = match tipo { - TipoArquivo::Csv => importar_csv(&caminho, &layout_csv).map_err(|e| e.to_string()), - TipoArquivo::Xlsx => importar_xlsx(&caminho, &layout_xlsx).map_err(|e| e.to_string()), + TipoArquivo::Csv => importar_csv(&caminho, &layout_csv).map_err(|e| match e { + ErroArquivo::TamanhoExcedido(bytes) => format!( + "Arquivo muito grande ({:.1} MB). O limite é 50 MB.", + bytes as f64 / (1024.0 * 1024.0) + ), + other => other.to_string(), + }), + TipoArquivo::Xlsx => importar_xlsx(&caminho, &layout_xlsx).map_err(|e| match e { + ErroArquivo::TamanhoExcedido(bytes) => format!( + "Arquivo muito grande ({:.1} MB). O limite é 50 MB.", + bytes as f64 / (1024.0 * 1024.0) + ), + other => other.to_string(), + }), }; match res_importacao { diff --git a/src/ui/components/modal.rs b/src/ui/components/modal.rs index f0f0792..14bf17e 100644 --- a/src/ui/components/modal.rs +++ b/src/ui/components/modal.rs @@ -28,9 +28,6 @@ pub fn view_com_modal<'a>( fn view_modal(modal: &EstadoModal) -> Element<'_, Message> { match modal { - EstadoModal::Informacao { titulo, mensagem } => { - caixa_modal(titulo, mensagem, TipoModal::Info, None, false) - } EstadoModal::Aviso { titulo, mensagem } => { caixa_modal(titulo, mensagem, TipoModal::Aviso, None, false) } diff --git a/src/ui/components/tabela_preview.rs b/src/ui/components/tabela_preview.rs index d7f7c26..6f42838 100644 --- a/src/ui/components/tabela_preview.rs +++ b/src/ui/components/tabela_preview.rs @@ -12,7 +12,7 @@ pub fn tabela_preview(linhas: &[Vec]) -> Element<'_, Message> { return text("(vazio)").size(12).color(t::TEXT_MUTED).into(); } - // Cabeçalho estilo Excel — fundo SURFACE_2 + // Cabeçalho estilo Excel — usa t::cabecalho_tabela centralizado let cabecalho = container( row((0..num_colunas) .map(|i| { @@ -30,15 +30,7 @@ pub fn tabela_preview(linhas: &[Vec]) -> Element<'_, Message> { .spacing(0), ) .width(Length::Shrink) - .style(|_theme| iced::widget::container::Style { - background: Some(t::SURFACE_2.into()), - border: Border { - color: t::BORDER, - width: 0.0, - radius: 0.0.into(), - }, - ..Default::default() - }); + .style(t::cabecalho_tabela); let linhas_view = linhas.iter().enumerate().map(|(idx, linha)| { // Linhas alternadas: SURFACE e SURFACE_2 levemente diff --git a/src/ui/message.rs b/src/ui/message.rs index 964db43..17df221 100644 --- a/src/ui/message.rs +++ b/src/ui/message.rs @@ -2,7 +2,7 @@ use crate::domain::entities::nota::Nota; use crate::domain::entities::{ chave_serie::ChaveSerie, layout::{Layout, LayoutXlsx}, - resultado_analise::{ResultadoAnalise, ResultadoPreAnalise}, + resultado_analise::{IntervaloSerie, ResultadoAnalise, ResultadoPreAnalise}, }; use crate::domain::errors::ResumoAvisos; use rusqlite::Connection; @@ -73,12 +73,11 @@ pub enum Message { ExecutarImportacao, ReanalisarArquivo, ConfirmarExpansaoFaltantes, - CancelarExpansao, NovaAnalise, // --- Resultado --- - PaginaFaltantesAlterada(usize), - PaginaDuplicatasAlterada(usize), + PaginaFaltantesAlterada(ChaveSerie, usize), + PaginaDuplicatasAlterada(ChaveSerie, usize), ItensPorPaginaAlterado(usize), CopiarFaltantes(ChaveSerie), CopiarDuplicatas(ChaveSerie), @@ -103,6 +102,9 @@ pub enum Message { // --- Sem operação (used as fallback) --- Noop, + + // --- Animação --- + SpinnerTick, } /// Resultado enviado pela task de análise em background para a UI. @@ -117,7 +119,7 @@ pub enum ResultadoPendente { /// Pré-análise concluída mas precisa de confirmação do usuário. AguardandoConfirmacao { pre: ResultadoPreAnalise, - series_excessivas: Vec<(ChaveSerie, u64)>, + series_excessivas: Vec<(ChaveSerie, IntervaloSerie)>, avisos: ResumoAvisos, notas: Vec, }, diff --git a/src/ui/screens/resultado.rs b/src/ui/screens/resultado.rs index a1f1c76..14fd0f6 100644 --- a/src/ui/screens/resultado.rs +++ b/src/ui/screens/resultado.rs @@ -82,20 +82,43 @@ pub fn view<'a>(app: &'a App, resultado: &'a ResultadoAnalise) -> Element<'a, Me let secao_duplicatas = view_duplicatas(app, resultado); let secao_totais = view_totais(resultado); - let conteudo = column![ + let mut col_principal = column![ row![text("Resultado da Análise").size(22).color(t::TEXT),], botoes_topo, stat_row, controle_pagina, - secao_faltantes, - secao_duplicatas, - secao_totais, ] .spacing(16) .padding(20) .width(Length::Fill); - container(scrollable(conteudo)) + // Badge "Sem inconsistências" quando não há faltantes nem duplicatas + if resultado.sem_inconsistencias() { + col_principal = col_principal.push( + container( + row![ + container(text("✓").size(13).color(t::SUCCESS)) + .padding([2, 8]) + .style(t::badge_sucesso), + text("Sem inconsistências — todas as séries estão completas.") + .size(14) + .color(t::SUCCESS), + ] + .spacing(8) + .align_y(Alignment::Center), + ) + .padding([10, 16]) + .width(Length::Fill) + .style(t::card), + ); + } + + col_principal = col_principal + .push(secao_faltantes) + .push(secao_duplicatas) + .push(secao_totais); + + container(scrollable(col_principal)) .width(Length::Fill) .height(Length::Fill) .style(t::fundo) @@ -132,7 +155,16 @@ fn view_faltantes<'a>(app: &'a App, resultado: &'a ResultadoAnalise) -> Element< let mut col = column![titulo_row].spacing(12); if total == 0 { - col = col.push(text("Nenhuma nota faltante.").size(14).color(t::SUCCESS)); + col = col.push( + row![ + container(text(" OK ").size(12).color(t::SUCCESS)) + .padding([2, 8]) + .style(t::badge_sucesso), + text("Nenhuma nota faltante.").size(14).color(t::SUCCESS), + ] + .spacing(8) + .align_y(Alignment::Center), + ); return container(col) .padding(16) .width(Length::Fill) @@ -145,11 +177,25 @@ fn view_faltantes<'a>(app: &'a App, resultado: &'a ResultadoAnalise) -> Element< for chave in chaves { let faltantes = &resultado.faltantes_por_serie[chave]; + + let total_notas = resultado.total_por_serie.get(chave).copied().unwrap_or(0); + if faltantes.is_empty() { + // Série completa — exibir badge verde + let linha_serie = row![ + container(text(" OK ").size(12).color(t::SUCCESS)) + .padding([2, 8]) + .style(t::badge_sucesso), + text(format!("Série {} — completa", chave.label())) + .size(14) + .color(t::SUCCESS), + ] + .spacing(8) + .align_y(Alignment::Center); + col = col.push(linha_serie); continue; } - let total_notas = resultado.total_por_serie.get(chave).copied().unwrap_or(0); let total_esperado = total_notas + faltantes.len(); let percentual = total_notas as f64 / total_esperado as f64; let percentual_f32 = percentual as f32; @@ -189,7 +235,12 @@ fn view_faltantes<'a>(app: &'a App, resultado: &'a ResultadoAnalise) -> Element< // Paginação e lista let total_paginas = (faltantes.len() + app.itens_por_pagina - 1) / app.itens_por_pagina; - let pagina = app.pagina_faltantes.min(total_paginas.saturating_sub(1)); + let pagina = app + .pagina_faltantes + .get(chave) + .copied() + .unwrap_or(0) + .min(total_paginas.saturating_sub(1)); let inicio = pagina * app.itens_por_pagina; let fim = (inicio + app.itens_por_pagina).min(faltantes.len()); @@ -210,8 +261,8 @@ fn view_faltantes<'a>(app: &'a App, resultado: &'a ResultadoAnalise) -> Element< col = col.push(crate::ui::components::paginacao::controles_paginacao( pagina, total_paginas, - Message::PaginaFaltantesAlterada(pagina.saturating_sub(1)), - Message::PaginaFaltantesAlterada(pagina + 1), + Message::PaginaFaltantesAlterada(chave.clone(), pagina.saturating_sub(1)), + Message::PaginaFaltantesAlterada(chave.clone(), pagina + 1), )); } @@ -281,7 +332,12 @@ fn view_duplicatas<'a>(app: &'a App, resultado: &'a ResultadoAnalise) -> Element col = col.push(cabecalho_serie); let total_paginas = (duplicatas.len() + app.itens_por_pagina - 1) / app.itens_por_pagina; - let pagina = app.pagina_duplicatas.min(total_paginas.saturating_sub(1)); + let pagina = app + .pagina_duplicatas + .get(chave) + .copied() + .unwrap_or(0) + .min(total_paginas.saturating_sub(1)); let inicio = pagina * app.itens_por_pagina; let fim = (inicio + app.itens_por_pagina).min(duplicatas.len()); @@ -304,8 +360,8 @@ fn view_duplicatas<'a>(app: &'a App, resultado: &'a ResultadoAnalise) -> Element col = col.push(crate::ui::components::paginacao::controles_paginacao( pagina, total_paginas, - Message::PaginaDuplicatasAlterada(pagina.saturating_sub(1)), - Message::PaginaDuplicatasAlterada(pagina + 1), + Message::PaginaDuplicatasAlterada(chave.clone(), pagina.saturating_sub(1)), + Message::PaginaDuplicatasAlterada(chave.clone(), pagina + 1), )); }