update.
This commit is contained in:
@@ -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<String>`), 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<i64>) -> Result<bool> {
|
||||
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.
|
||||
@@ -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()
|
||||
}
|
||||
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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<Vec<String>>) 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<String>],
|
||||
idx_numero: usize,
|
||||
@@ -117,9 +120,38 @@ fn mapear_linhas_para_notas(
|
||||
idx_valor: Option<usize>,
|
||||
idx_data: Option<usize>,
|
||||
avisos: &mut ResumoAvisos,
|
||||
) -> Vec<Nota> {
|
||||
) -> Result<Vec<Nota>, 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<usize>)] = &[
|
||||
("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.
|
||||
|
||||
@@ -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,
|
||||
)));
|
||||
}
|
||||
}
|
||||
|
||||
+137
-27
@@ -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<ResumoAvisos>,
|
||||
|
||||
// Paginação
|
||||
pub pagina_faltantes: usize,
|
||||
pub pagina_duplicatas: usize,
|
||||
// Paginação (independente por série)
|
||||
pub pagina_faltantes: std::collections::HashMap<crate::domain::entities::chave_serie::ChaveSerie, usize>,
|
||||
pub pagina_duplicatas: std::collections::HashMap<crate::domain::entities::chave_serie::ChaveSerie, usize>,
|
||||
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()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -698,9 +704,11 @@ impl App {
|
||||
}
|
||||
EstadoApp::GerenciandoLayouts => crate::ui::screens::layouts::view(self),
|
||||
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<Message> {
|
||||
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::<Vec<_>>()
|
||||
@@ -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<String> {
|
||||
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 {
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -12,7 +12,7 @@ pub fn tabela_preview(linhas: &[Vec<String>]) -> 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<String>]) -> 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
|
||||
|
||||
+7
-5
@@ -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<Nota>,
|
||||
},
|
||||
|
||||
+69
-13
@@ -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),
|
||||
));
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user