Compare commits
11
Commits
master
..
3af14ab957
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3af14ab957 | ||
|
|
06eca335db | ||
|
|
6a5d941a00 | ||
|
|
5745a78a5a | ||
|
|
1f4ebb4686 | ||
|
|
007f6c818e | ||
|
|
d06110240f | ||
|
|
ad09c53a5d | ||
|
|
8d00cfc4d7 | ||
|
|
2d1d29ce3d | ||
|
|
12b8501ffb |
@@ -1 +1,2 @@
|
||||
/target
|
||||
nul
|
||||
|
||||
Generated
+1236
-694
File diff suppressed because it is too large
Load Diff
+5
-2
@@ -4,8 +4,8 @@ version = "0.1.0"
|
||||
edition = "2024"
|
||||
|
||||
[dependencies]
|
||||
eframe = "0.31"
|
||||
egui = "0.31"
|
||||
iced = { version = "0.13", features = ["tokio", "image"] }
|
||||
tokio = { version = "1", features = ["full"] }
|
||||
rusqlite = { version = "0.32", features = ["bundled"] }
|
||||
csv = "1.3"
|
||||
calamine = "0.26"
|
||||
@@ -20,3 +20,6 @@ dirs = "5"
|
||||
thiserror = "2"
|
||||
regex = "1"
|
||||
rfd = "0.15"
|
||||
|
||||
[build-dependencies]
|
||||
winres = "0.1"
|
||||
|
||||
-113
@@ -1,113 +0,0 @@
|
||||
# Sugestões de Melhoria — Fluxo da Interface
|
||||
|
||||
**Data:** 02/03/2026
|
||||
|
||||
---
|
||||
|
||||
## 1. Indicador de progresso das etapas
|
||||
|
||||
O fluxo possui 3–4 passos bem definidos (`Importando → ConfigurandoColunas → ExibindoResultado`), mas não há nenhum indicador visual de onde o usuário está.
|
||||
|
||||
**Sugestão:** Adicionar um breadcrumb simples no topo de todas as telas:
|
||||
|
||||
```
|
||||
① Arquivo ② Colunas ③ Resultado
|
||||
```
|
||||
|
||||
O passo atual ficaria destacado. Isso orienta o usuário sobre o que falta sem exigir nenhuma regra de negócio adicional.
|
||||
|
||||
---
|
||||
|
||||
## 2. Unificar importação e configuração de colunas em um único painel
|
||||
|
||||
Atualmente o caminho é:
|
||||
1. Selecionar arquivo → clicar **"▶ Configurar Colunas"**
|
||||
2. Configurar colunas → clicar **"▶ Importar e Analisar"**
|
||||
|
||||
São 3 ações separadas para chegar à análise.
|
||||
|
||||
**Sugestão:** Mover as configurações CSV/XLSX para a mesma tela de importação como uma seção expansível ("Configurações avançadas"), deixando o botão principal como **"▶ Importar e Analisar"** direto.
|
||||
|
||||
---
|
||||
|
||||
## 3. Pré-visualização das primeiras linhas do arquivo
|
||||
|
||||
Após selecionar o arquivo, o usuário precisa alternar entre a aplicação e a planilha para descobrir quais índices correspondem a cada campo.
|
||||
|
||||
**Sugestão:** Exibir as primeiras 3–5 linhas do arquivo em uma tabela simples logo após a seleção, para que o usuário identifique visualmente o índice de cada coluna sem sair do app.
|
||||
|
||||
---
|
||||
|
||||
## 4. Confirmação ao clicar em "Nova Análise"
|
||||
|
||||
O botão **"< Nova Análise"** em `resultado.rs` executa `app.notas_importadas.clear()` imediatamente, sem nenhum modal de confirmação. Um clique acidental descarta o resultado atual sem aviso.
|
||||
|
||||
**Sugestão:** Exibir modal de confirmação com a mensagem:
|
||||
> "Deseja iniciar uma nova análise? O resultado atual será descartado."
|
||||
|
||||
Botões: **Confirmar** | **Cancelar**
|
||||
|
||||
---
|
||||
|
||||
## 5. Reorganizar seções do resultado
|
||||
|
||||
A ordem atual das seções em `resultado.rs` é:
|
||||
|
||||
```
|
||||
Totais → Faltantes → Duplicatas
|
||||
```
|
||||
|
||||
O objetivo principal do software é detectar faltantes e duplicatas; os totais são informação complementar.
|
||||
|
||||
**Sugestão:** Inverter para:
|
||||
|
||||
```
|
||||
Faltantes → Duplicatas → Totais
|
||||
```
|
||||
|
||||
Isso coloca a informação mais relevante no topo da tela.
|
||||
|
||||
---
|
||||
|
||||
## 6. Seletor de layout também na tela de configuração de colunas
|
||||
|
||||
O dropdown de layouts está disponível apenas em `import.rs`. O usuário frequentemente percebe que precisa de um layout diferente **depois** de visitar a tela de configuração e ver os campos.
|
||||
|
||||
**Sugestão:** Duplicar o seletor de layout no topo de `configuracao_colunas.rs`, evitando que o usuário volte à tela anterior só para trocar o layout.
|
||||
|
||||
---
|
||||
|
||||
## 7. Botão "Salvar como layout..." na tela de configuração
|
||||
|
||||
Para salvar um layout atualmente o usuário precisa navegar para `GerenciandoLayouts`. Esse desvio quebra o fluxo principal.
|
||||
|
||||
**Sugestão:** Adicionar um botão **"💾 Salvar como layout..."** diretamente em `configuracao_colunas.rs` que abre um modal simples pedindo apenas o nome do layout. Internamente, chama o mesmo use case `salvar_layout`.
|
||||
|
||||
---
|
||||
|
||||
## 8. Feedback visual durante a análise
|
||||
|
||||
A transição `ConfigurandoColunas → ExibindoResultado` pode demorar com arquivos grandes (até 100k registros, conforme RNF03). Atualmente o app não exibe nenhum sinal enquanto processa, parecendo travado.
|
||||
|
||||
**Sugestão:** Adicionar o estado `Analisando` no `EstadoApp` (já previsto no IMPLEMENTACAO.md mas não implementado) e exibir uma mensagem simples tipo:
|
||||
|
||||
```
|
||||
⏳ Analisando... aguarde.
|
||||
```
|
||||
|
||||
Mesmo sem progresso percentual, já elimina a percepção de travamento.
|
||||
|
||||
---
|
||||
|
||||
## Resumo de Impacto
|
||||
|
||||
| # | Sugestão | Impacto UX | Esforço estimado |
|
||||
|---|----------|------------|-----------------|
|
||||
| 1 | Breadcrumb de etapas | Médio | Baixo |
|
||||
| 2 | Unificar importação + configuração | Alto | Médio |
|
||||
| 3 | Pré-visualização do arquivo | Alto | Médio |
|
||||
| 4 | Confirmação em "Nova Análise" | Baixo | Baixo |
|
||||
| 5 | Reordenar seções do resultado | Médio | Baixo |
|
||||
| 6 | Seletor de layout em configuração | Médio | Baixo |
|
||||
| 7 | Salvar layout na tela de configuração | Médio | Baixo |
|
||||
| 8 | Feedback durante análise | Alto | Baixo |
|
||||
@@ -0,0 +1,7 @@
|
||||
fn main() {
|
||||
if std::env::var("CARGO_CFG_TARGET_OS").unwrap() == "windows" {
|
||||
let mut res = winres::WindowsResource::new();
|
||||
res.set_icon("icon.ico");
|
||||
res.compile().unwrap();
|
||||
}
|
||||
}
|
||||
@@ -26,17 +26,13 @@ Se não for muito custoso para implementar, pode ser uma boa ideia adicionar uma
|
||||
|
||||
---
|
||||
|
||||
### F-02 — Validação de Tamanho de Arquivo (PRD RF01.3) (Implementar)
|
||||
### F-02 — Validação de Tamanho de Arquivo (PRD RF01.3) (Implementado)
|
||||
|
||||
**Problema:** O PRD especifica limite de 50 MB, mas não há validação implementada. Arquivos muito grandes podem travar a UI durante a leitura.
|
||||
**Status:** Já implementado. Ambos `csv_reader.rs` e `xlsx_reader.rs` verificam o tamanho do arquivo
|
||||
antes de qualquer leitura via constante `LIMITE_BYTES = 50 MB`, retornando `ErroArquivo::TamanhoExcedido`
|
||||
se excedido — exibido como modal de erro pela UI.
|
||||
|
||||
**Solução:** Verificar `fs::metadata(caminho)?.len()` antes de iniciar importação. Exibir modal de erro se exceder o limite.
|
||||
|
||||
**Escopo técnico:**
|
||||
- Adicionar checagem em `importar_arquivo.rs` antes de chamar CSV/XLSX reader
|
||||
- Constante `LIMITE_ARQUIVO_BYTES: u64 = 50 * 1024 * 1024`
|
||||
|
||||
**Esforço estimado:** Baixo (30min)
|
||||
**Esforço estimado:** Baixo (30min) — *concluído*
|
||||
**Impacto:** Médio — evita travamentos inesperados
|
||||
|
||||
---
|
||||
@@ -84,7 +80,7 @@ Verificar se já existe no codigo, pois na tela de configuração do Layout ele
|
||||
|
||||
---
|
||||
|
||||
### F-05 — Recarregar Arquivo Sem Reconfigurar (Sim bem necessario)
|
||||
### F-05 — Recarregar Arquivo Sem Reconfigurar (Implementado)
|
||||
|
||||
**Problema:** Quando o usuário corrige o arquivo fonte e quer re-verificar, precisa navegar todo o fluxo novamente (selecionar arquivo → configurar colunas → analisar).
|
||||
|
||||
@@ -154,8 +150,8 @@ Verificar se já existe no codigo, pois na tela de configuração do Layout ele
|
||||
|
||||
## Resumo
|
||||
|
||||
| ID | Feature | Esforço | Impacto | Prioridade |
|
||||
|---|---|---|---|---|
|
||||
| ID | Feature | Esforço | Impacto | Prioridade | Status |
|
||||
|---|---|---|---|---|---|
|
||||
| F-01 | Exportar resultado em CSV | Médio | Alto | Alta |
|
||||
| F-02 | Validação de tamanho de arquivo | Baixo | Médio | Alta |
|
||||
| F-03 | Preview de colunas antes da análise | Alto | Alto | Média |
|
||||
@@ -163,4 +159,4 @@ Verificar se já existe no codigo, pois na tela de configuração do Layout ele
|
||||
| F-06 | Recarregar arquivo sem reconfigurar | Baixo | Médio | Média |
|
||||
| F-07 | Auto-detecção de delimitador CSV | Médio | Baixo-Médio | Média |
|
||||
| F-08 | Auto-detecção de encoding CSV | Médio | Baixo | Baixa |
|
||||
| F-09 | Agrupamento de faltantes no PDF | Baixo | Baixo-Médio | Baixa |
|
||||
| F-09 | Agrupamento de faltantes no PDF | Baixo | Baixo-Médio | Baixa | ✅ Implementado |
|
||||
@@ -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.
|
||||
@@ -0,0 +1,205 @@
|
||||
# Application — AGENTS.md
|
||||
|
||||
Camada de aplicação do projeto `comparador-notas`. Orquestra os serviços de domínio
|
||||
e a infraestrutura para expor casos de uso coesos à camada de UI (Tauri/frontend).
|
||||
Não contém regras de negócio próprias; delega ao `domain` e ao `infrastructure`.
|
||||
|
||||
---
|
||||
|
||||
## Estrutura dos arquivos
|
||||
|
||||
```
|
||||
src/application/
|
||||
├── mod.rs
|
||||
└── usecases/
|
||||
├── mod.rs
|
||||
├── executar_analise.rs # Análise de sequência e duplicatas
|
||||
├── exportar_pdf.rs # Exportação de relatório PDF
|
||||
├── importar_arquivo.rs # Importação de CSV e XLSX → Vec<Nota>
|
||||
└── layouts.rs # CRUD e import/export JSON de layouts
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## usecases/mod.rs
|
||||
|
||||
Re-exporta os quatro módulos de casos de uso:
|
||||
|
||||
```rust
|
||||
pub mod executar_analise;
|
||||
pub mod exportar_pdf;
|
||||
pub mod importar_arquivo;
|
||||
pub mod layouts;
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## executar_analise.rs
|
||||
|
||||
Orquestra a análise em **duas etapas** para lidar com grandes intervalos de
|
||||
faltantes sem travar a UI (regra RF04).
|
||||
|
||||
### Etapa 1 — `pre_analisar(notas) -> ResultadoPreAnalise`
|
||||
|
||||
1. Agrupa as notas por `ChaveSerie` (`serie` + `documento_tipo`).
|
||||
2. Para cada grupo:
|
||||
- Acumula soma total e por série.
|
||||
- Chama `calcular_intervalo` (sem materializar faltantes).
|
||||
3. Detecta duplicidades via `duplicidades_por_serie`.
|
||||
4. Retorna `ResultadoPreAnalise` com intervalos, duplicadas e somas.
|
||||
|
||||
### Verificação intermediária — `series_com_intervalo_excessivo(pre) -> Vec<(ChaveSerie, IntervaloSerie)>`
|
||||
|
||||
Filtra as séries cujo `contagem_faltantes > LIMITE_FALTANTES` (10.000).
|
||||
O caller (UI) deve exibir confirmação ao usuário se a lista não for vazia.
|
||||
|
||||
### Etapa 2 — `expandir_analise(pre, notas) -> ResultadoAnalise`
|
||||
|
||||
Materializa a lista completa de faltantes via `detectar_faltantes` e combina
|
||||
com os dados já calculados na pré-análise (duplicadas, somas, totais).
|
||||
|
||||
### Fluxo de uso
|
||||
|
||||
```
|
||||
pre_analisar(notas)
|
||||
└─ ResultadoPreAnalise
|
||||
|
||||
series_com_intervalo_excessivo(&pre)
|
||||
├─ [] → chamar expandir_analise diretamente
|
||||
└─ [...] → exibir diálogo de confirmação na UI
|
||||
└─ confirmado → expandir_analise(pre, notas)
|
||||
|
||||
expandir_analise(pre, notas)
|
||||
└─ ResultadoAnalise (com faltantes materializados)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## exportar_pdf.rs
|
||||
|
||||
### `exportar_pdf(gerador, resultado, notas, nome_arquivo, nome_layout, caminho_saida) -> Result<(), String>`
|
||||
|
||||
Caso de uso simples que:
|
||||
|
||||
1. Constrói `MetadadosRelatorio` com `nome_arquivo`, `nome_layout` e timestamp
|
||||
`Local::now()`.
|
||||
2. Delega a geração para `gerador.gerar(...)` via a trait abstrata `PdfGenerator`.
|
||||
|
||||
A dependência em `&dyn PdfGenerator` (e não em `GenpdfGenerator` diretamente)
|
||||
mantém o use case desacoplado da implementação concreta e facilita testes.
|
||||
|
||||
---
|
||||
|
||||
## importar_arquivo.rs
|
||||
|
||||
Converte arquivos brutos (CSV ou XLSX) em `Vec<Nota>` prontas para análise,
|
||||
acumulando avisos não-fatais em `ResumoAvisos`.
|
||||
|
||||
### Tipos de saída
|
||||
|
||||
```rust
|
||||
pub struct ResultadoImportacao {
|
||||
pub notas: Vec<Nota>,
|
||||
pub avisos: ResumoAvisos,
|
||||
}
|
||||
|
||||
pub struct InfoXlsx {
|
||||
pub abas: Vec<String>,
|
||||
}
|
||||
```
|
||||
|
||||
### `listar_abas_xlsx(caminho) -> Result<InfoXlsx, ErroArquivo>`
|
||||
|
||||
Delega para `xlsx_reader::listar_abas`. Retorna `InfoXlsx` com os nomes das
|
||||
abas para que a UI permita ao usuário selecionar a aba correta.
|
||||
|
||||
### `importar_csv(caminho, config: &LayoutCsv) -> Result<ResultadoImportacao, ErroArquivo>`
|
||||
|
||||
1. Chama `csv_reader::ler_csv` com os parâmetros do layout.
|
||||
2. Passa as linhas brutas para `mapear_linhas_para_notas`.
|
||||
|
||||
### `importar_xlsx(caminho, config: &LayoutXlsx) -> Result<ResultadoImportacao, ErroArquivo>`
|
||||
|
||||
1. Converte `pos_numero` e `pos_serie` (LetraLinha) para coordenadas.
|
||||
2. Determina `linha_inicio` como o mínimo entre as linhas das duas posições.
|
||||
3. Chama `xlsx_reader::ler_xlsx`.
|
||||
4. Converte posições de todos os campos mapeados para índices de coluna (base 0).
|
||||
5. Passa as linhas brutas para `mapear_linhas_para_notas`.
|
||||
|
||||
### `mapear_linhas_para_notas(...)` (privada)
|
||||
|
||||
Função central de mapeamento. Para cada linha:
|
||||
|
||||
1. **Validação de índices** (na primeira linha disponível):
|
||||
- Campos obrigatórios (`Numero`, `Serie`): retorna `Err` se o índice não existe.
|
||||
- Campos opcionais (`Valor`, `Data`, `Tipo de Documento`): retorna `Err` se
|
||||
configurado com índice fora dos limites.
|
||||
2. **Numero**: tenta `parse::<u64>` direto; se falhar, extrai apenas dígitos.
|
||||
Rejeita zero. Incrementa `avisos.numeros_invalidos` e pula a linha se inválido.
|
||||
3. **Serie**: valida via `domain::entities::serie::validar_serie`. Pula linha se inválida.
|
||||
4. **Valor** (opcional): faz `parse_valor`; se inválido, registra em
|
||||
`avisos.valores_invalidos` e usa `None` (não descarta a linha).
|
||||
5. **Data** (opcional): tenta `dd/mm/aaaa`, `aaaa-mm-dd` e `dd-mm-aaaa`. `None`
|
||||
se nenhum formato casar (não gera aviso).
|
||||
6. **Tipo de Documento** (opcional): qualquer string não vazia.
|
||||
|
||||
### `parse_numero(s) -> Result<u64, String>` (privada)
|
||||
|
||||
- Tenta `s.parse::<u64>()` diretamente.
|
||||
- Se falhar, extrai apenas dígitos ASCII e tenta novamente.
|
||||
- Rejeita zero em ambos os casos.
|
||||
|
||||
### `parse_data(s) -> Option<NaiveDate>` (privada)
|
||||
|
||||
Tenta os formatos `%d/%m/%Y`, `%Y-%m-%d` e `%d-%m-%Y` nessa ordem.
|
||||
|
||||
---
|
||||
|
||||
## layouts.rs
|
||||
|
||||
CRUD de layouts sobre o banco SQLite e import/export em JSON.
|
||||
|
||||
### `salvar_layout(conn, layout) -> Result<i64, ErroLayout>`
|
||||
|
||||
- Valida que `nome` não está vazio.
|
||||
- Se `layout.id()` é `Some` → chama `layout_repository::atualizar` e retorna o id.
|
||||
- Se `None` → verifica conflito de nome via `existe_nome`; se existir, retorna
|
||||
`ErroLayout::NomeConflitante`; caso contrário, insere e retorna o novo id.
|
||||
|
||||
### `listar_layouts(conn) -> Result<Vec<Layout>, String>`
|
||||
|
||||
Delega para `layout_repository::listar`. Retorna todos os layouts ordenados
|
||||
por nome.
|
||||
|
||||
### `excluir_layout(conn, id) -> Result<(), String>`
|
||||
|
||||
Delega para `layout_repository::excluir`.
|
||||
|
||||
### `exportar_layout_json(layout) -> Result<(String, String), String>`
|
||||
|
||||
1. Converte `Layout` para `LayoutJson` via `From<&Layout>`.
|
||||
2. Serializa com `serde_json::to_string_pretty`.
|
||||
3. Retorna `(conteúdo_json, nome_arquivo_sugerido)` onde o nome é `"{nome}.json"`.
|
||||
|
||||
### `importar_layout_json(conn, json, sobrescrever_se_existir, novo_nome) -> Result<i64, ErroLayout>`
|
||||
|
||||
1. Desserializa `json` para `LayoutJson`.
|
||||
2. Converte para `Layout` via `TryFrom` (valida campos obrigatórios).
|
||||
3. Aplica `novo_nome` se fornecido (mutação direta no enum).
|
||||
4. Verifica conflito de nome:
|
||||
- Se existe e `sobrescrever_se_existir == true`: busca o id existente,
|
||||
injeta no layout e chama `atualizar`.
|
||||
- Se existe e `false`: retorna `ErroLayout::NomeConflitante`.
|
||||
5. Se não existe: chama `layout_repository::salvar`.
|
||||
|
||||
---
|
||||
|
||||
## Dependencias de outros módulos
|
||||
|
||||
```
|
||||
application::usecases
|
||||
├─ domain::entities::{chave_serie, nota, resultado_analise, layout, serie}
|
||||
├─ domain::services::{detector_duplicidade, detector_sequencia, parser_monetario}
|
||||
├─ domain::errors::{ErroArquivo, ErroLayout, ResumoAvisos}
|
||||
└─ infrastructure::{csv_reader, xlsx_reader, pdf_generator, sqlite::layout_repository}
|
||||
```
|
||||
@@ -2,11 +2,11 @@ use crate::domain::{
|
||||
entities::{
|
||||
chave_serie::ChaveSerie,
|
||||
nota::Nota,
|
||||
resultado_analise::{ResultadoAnalise, ResultadoPreAnalise},
|
||||
resultado_analise::{IntervaloSerie, ResultadoAnalise, ResultadoPreAnalise},
|
||||
},
|
||||
services::{
|
||||
detector_duplicidade::duplicidades_por_serie,
|
||||
detector_sequencia::{LIMITE_FALTANTES, calcular_intervalo, detectar_faltantes},
|
||||
detector_sequencia::{calcular_intervalo, detectar_faltantes, LIMITE_FALTANTES},
|
||||
},
|
||||
};
|
||||
use rust_decimal::Decimal;
|
||||
@@ -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()
|
||||
}
|
||||
|
||||
@@ -107,7 +109,13 @@ mod tests {
|
||||
}
|
||||
|
||||
fn nota_com_tipo(numero: u64, serie: &str, tipo: &str) -> Nota {
|
||||
Nota::new(numero, serie.to_string(), Some(tipo.to_string()), None, None)
|
||||
Nota::new(
|
||||
numero,
|
||||
serie.to_string(),
|
||||
Some(tipo.to_string()),
|
||||
None,
|
||||
None,
|
||||
)
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -0,0 +1,304 @@
|
||||
# Domain — AGENTS.md
|
||||
|
||||
Camada de domínio do projeto `comparador-notas`. Contém as entidades, serviços
|
||||
de negócio e erros tipados. Não possui dependências de infraestrutura — todo
|
||||
acesso a banco, arquivos ou UI é responsabilidade das camadas superiores.
|
||||
|
||||
---
|
||||
|
||||
## Estrutura dos arquivos
|
||||
|
||||
```
|
||||
src/domain/
|
||||
├── mod.rs
|
||||
├── errors.rs # Enums de erro tipados
|
||||
├── entities/
|
||||
│ ├── mod.rs
|
||||
│ ├── chave_serie.rs # Chave composta (serie + documento_tipo)
|
||||
│ ├── layout.rs # Entidades de configuração de layout CSV/XLSX
|
||||
│ ├── nota.rs # Entidade Nota Fiscal
|
||||
│ ├── resultado_analise.rs # Resultados de análise (pré e completo)
|
||||
│ └── serie.rs # Validação de série
|
||||
└── services/
|
||||
├── mod.rs
|
||||
├── detector_duplicidade.rs # Detecção de notas duplicadas
|
||||
├── detector_sequencia.rs # Detecção de notas faltantes na sequência
|
||||
└── parser_monetario.rs # Parsing e formatação de valores monetários
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## errors.rs
|
||||
|
||||
Erros tipados com a crate `thiserror`. Cada domínio tem seu próprio enum.
|
||||
|
||||
### `ErroSerie`
|
||||
|
||||
| Variante | Mensagem |
|
||||
|---|---|
|
||||
| `Invalida(String)` | Série inválida: deve conter de 1 a 3 dígitos numéricos |
|
||||
| `Vazia` | Série vazia |
|
||||
|
||||
### `ErroValor`
|
||||
|
||||
| Variante | Mensagem |
|
||||
|---|---|
|
||||
| `Negativo(String)` | Valor negativo não é permitido |
|
||||
| `NaoNumerico(String)` | Valor não numérico |
|
||||
|
||||
### `ErroLayout`
|
||||
|
||||
| Variante | Mensagem |
|
||||
|---|---|
|
||||
| `CampoObrigatorioAusente(String)` | Campo obrigatório ausente |
|
||||
| `JsonMalformado(String)` | JSON malformado |
|
||||
| `NomeConflitante(String)` | Layout com esse nome já existe |
|
||||
|
||||
### `ErroArquivo`
|
||||
|
||||
| Variante | Mensagem |
|
||||
|---|---|
|
||||
| `TamanhoExcedido(u64)` | Arquivo > 50 MB |
|
||||
| `Corrompido(String)` | Arquivo corrompido ou ilegível |
|
||||
| `ErroLeitura(String)` | Erro genérico de leitura |
|
||||
|
||||
### `ResumoAvisos`
|
||||
|
||||
Estrutura de avisos não-fatais acumulados durante a importação de um arquivo.
|
||||
|
||||
```rust
|
||||
pub struct ResumoAvisos {
|
||||
pub linhas_malformadas: usize,
|
||||
pub numeros_invalidos: usize,
|
||||
pub series_invalidas: usize,
|
||||
pub valores_invalidos: usize,
|
||||
pub detalhes: Vec<String>, // mensagens individuais por linha
|
||||
}
|
||||
```
|
||||
|
||||
- `tem_avisos()` → `true` se qualquer contador > 0
|
||||
- `linhas_para_exibir()` → `Vec<String>` com resumo para exibição em modal
|
||||
|
||||
---
|
||||
|
||||
## entities/
|
||||
|
||||
### `chave_serie.rs` — `ChaveSerie`
|
||||
|
||||
Chave composta que identifica um grupo de notas fiscais. Combina série com
|
||||
tipo de documento (`NFE`, `NFCE`, etc.).
|
||||
|
||||
```rust
|
||||
pub struct ChaveSerie {
|
||||
pub serie: String,
|
||||
pub documento_tipo: Option<String>,
|
||||
}
|
||||
```
|
||||
|
||||
- Deriva `Hash`, `Eq`, `Ord` — usada como chave em `HashMap` e para ordenação.
|
||||
- `new(serie, documento_tipo)` — construtor.
|
||||
- `label()` — formata para exibição:
|
||||
- `Some(tipo)` → `"001 / NFE"`
|
||||
- `None` → `"001"`
|
||||
|
||||
### `nota.rs` — `Nota`
|
||||
|
||||
Entidade central. `(numero, serie, documento_tipo)` é o identificador único.
|
||||
|
||||
```rust
|
||||
pub struct Nota {
|
||||
pub numero: u64,
|
||||
pub serie: String,
|
||||
pub documento_tipo: Option<String>, // None quando não mapeado
|
||||
pub valor: Option<Decimal>,
|
||||
pub data: Option<NaiveDate>, // usado no PDF, não em regras
|
||||
}
|
||||
```
|
||||
|
||||
### `serie.rs` — `validar_serie`
|
||||
|
||||
```rust
|
||||
pub fn validar_serie(s: &str) -> Result<String, ErroSerie>
|
||||
```
|
||||
|
||||
Valida via regex `^[0-9]{1,3}$` (1 a 3 dígitos numéricos). Faz trim antes
|
||||
de validar. Usa `OnceLock` para compilar o regex uma única vez.
|
||||
|
||||
### `resultado_analise.rs`
|
||||
|
||||
Dois tipos de resultado que modelam o fluxo de análise em duas etapas:
|
||||
|
||||
#### `ResultadoPreAnalise`
|
||||
|
||||
Resultado intermediário — calculado sem expandir a lista completa de faltantes.
|
||||
Usado para verificar se algum intervalo excede 10.000 registros (RF04) antes
|
||||
de pedir confirmação ao usuário.
|
||||
|
||||
```rust
|
||||
pub struct ResultadoPreAnalise {
|
||||
pub intervalos_por_serie: HashMap<ChaveSerie, IntervaloSerie>,
|
||||
pub duplicadas_por_serie: HashMap<ChaveSerie, Vec<(u64, usize)>>,
|
||||
pub soma_total: Decimal,
|
||||
pub soma_por_serie: HashMap<ChaveSerie, Decimal>,
|
||||
pub total_por_serie: HashMap<ChaveSerie, usize>,
|
||||
}
|
||||
```
|
||||
|
||||
#### `IntervaloSerie`
|
||||
|
||||
```rust
|
||||
pub struct IntervaloSerie {
|
||||
pub minimo: u64,
|
||||
pub maximo: u64,
|
||||
pub contagem_faltantes: u64,
|
||||
}
|
||||
```
|
||||
|
||||
- `excede_limite(limite)` → `contagem_faltantes > limite`
|
||||
|
||||
#### `ResultadoAnalise`
|
||||
|
||||
Resultado completo com a lista materializada de faltantes.
|
||||
|
||||
```rust
|
||||
pub struct ResultadoAnalise {
|
||||
pub faltantes_por_serie: HashMap<ChaveSerie, Vec<u64>>, // ordenados crescentemente
|
||||
pub duplicadas_por_serie: HashMap<ChaveSerie, Vec<(u64, usize)>>,
|
||||
pub soma_total: Decimal,
|
||||
pub soma_por_serie: HashMap<ChaveSerie, Decimal>,
|
||||
pub total_por_serie: HashMap<ChaveSerie, usize>,
|
||||
}
|
||||
```
|
||||
|
||||
Métodos auxiliares:
|
||||
- `sem_inconsistencias()` → `true` se não há faltantes nem duplicatas
|
||||
- `total_faltantes()` → soma do tamanho de todas as listas de faltantes
|
||||
- `total_duplicatas()` → soma do número de grupos de duplicatas
|
||||
|
||||
### `layout.rs`
|
||||
|
||||
Entidades de configuração de layout de arquivo.
|
||||
|
||||
#### `TipoArquivo`
|
||||
|
||||
```rust
|
||||
pub enum TipoArquivo { Csv, Xlsx }
|
||||
```
|
||||
|
||||
#### `LayoutCsv`
|
||||
|
||||
| Campo | Tipo | Descrição |
|
||||
|---|---|---|
|
||||
| `delimitador` | `char` | `','`, `';'` ou `'\t'` |
|
||||
| `encoding` | `String` | `"utf-8"` ou `"windows-1252"` |
|
||||
| `linha_cabecalho` | `usize` | Linha do cabeçalho (base 1). `0` = sem cabeçalho |
|
||||
| `indice_numero` | `usize` | Índice da coluna Numero (base 0) |
|
||||
| `indice_serie` | `usize` | Índice da coluna Serie (base 0) |
|
||||
| `indice_valor` | `Option<usize>` | Índice da coluna Valor (base 0), opcional |
|
||||
| `indice_data` | `Option<usize>` | Índice da coluna Data (base 0), opcional |
|
||||
| `indice_documento_tipo` | `Option<usize>` | Índice da coluna Tipo Documento (base 0), opcional |
|
||||
|
||||
#### `LayoutXlsx`
|
||||
|
||||
| Campo | Tipo | Descrição |
|
||||
|---|---|---|
|
||||
| `aba` | `String` | Nome da aba a processar |
|
||||
| `pos_numero` | `String` | Posição LetraLinha (ex: `"D3"`) |
|
||||
| `pos_serie` | `String` | Posição LetraLinha (ex: `"B3"`) |
|
||||
| `pos_valor` | `Option<String>` | Posição LetraLinha, opcional |
|
||||
| `pos_data` | `Option<String>` | Posição LetraLinha, opcional |
|
||||
| `pos_documento_tipo` | `Option<String>` | Posição LetraLinha, opcional |
|
||||
|
||||
#### `Layout` (enum)
|
||||
|
||||
```rust
|
||||
pub enum Layout {
|
||||
Csv { id: Option<i64>, nome: String, config: LayoutCsv },
|
||||
Xlsx { id: Option<i64>, nome: String, config: LayoutXlsx },
|
||||
}
|
||||
```
|
||||
|
||||
Métodos: `id()`, `nome()`, `tipo()`.
|
||||
|
||||
#### `LayoutJson` (serialização)
|
||||
|
||||
Representação `serde` com tag `"tipo"` para importação/exportação em JSON.
|
||||
Implementa `TryFrom<LayoutJson> for Layout` (valida campos obrigatórios) e
|
||||
`From<&Layout> for LayoutJson`.
|
||||
|
||||
---
|
||||
|
||||
## services/
|
||||
|
||||
### `detector_duplicidade.rs`
|
||||
|
||||
#### `detectar_duplicidades(notas) -> HashMap<(u64, String, Option<String>), usize>`
|
||||
|
||||
Conta ocorrências de cada `(numero, serie, documento_tipo)`. Retém apenas
|
||||
grupos com mais de uma ocorrência.
|
||||
|
||||
#### `duplicidades_por_serie(notas) -> HashMap<ChaveSerie, Vec<(u64, usize)>>`
|
||||
|
||||
Agrupa o resultado de `detectar_duplicidades` por `ChaveSerie`. Cada vetor
|
||||
é ordenado por `numero` crescente.
|
||||
|
||||
Regras:
|
||||
- O mesmo número em séries diferentes **não** é duplicata.
|
||||
- O mesmo número com `documento_tipo` diferente **não** é duplicata.
|
||||
- O mesmo número com mesma série e mesmo `documento_tipo` **é** duplicata.
|
||||
|
||||
---
|
||||
|
||||
### `detector_sequencia.rs`
|
||||
|
||||
#### Constante
|
||||
|
||||
```rust
|
||||
pub const LIMITE_FALTANTES: u64 = 10_000;
|
||||
```
|
||||
|
||||
#### `calcular_intervalo(notas) -> Option<IntervaloSerie>`
|
||||
|
||||
Calcula `minimo`, `maximo` e `contagem_faltantes` de forma incremental
|
||||
(usando `windows(2)`) **sem materializar** a lista de faltantes. Deduplica
|
||||
números antes do cálculo para não contar duplicatas como faltantes.
|
||||
|
||||
#### `detectar_faltantes(notas) -> Vec<u64>`
|
||||
|
||||
Materializa a lista completa de faltantes em ordem crescente. Deve ser
|
||||
chamado apenas após confirmação do usuário quando `calcular_intervalo`
|
||||
indica que o intervalo excede `LIMITE_FALTANTES`.
|
||||
|
||||
#### `agrupar_contiguos(faltantes) -> Vec<(u64, u64)>`
|
||||
|
||||
Recebe uma lista **já ordenada** de números faltantes e retorna intervalos
|
||||
contíguos como pares `(inicio, fim)`. Números isolados têm `inicio == fim`.
|
||||
|
||||
Exemplo: `[1, 2, 3, 5, 8, 9]` → `[(1, 3), (5, 5), (8, 9)]`
|
||||
|
||||
---
|
||||
|
||||
### `parser_monetario.rs`
|
||||
|
||||
#### `parse_valor(input) -> Result<Decimal, ErroValor>`
|
||||
|
||||
Faz o parsing de uma string monetária suportando formatos brasileiro e americano.
|
||||
Rejeita valores negativos.
|
||||
|
||||
Algoritmo (RF06):
|
||||
|
||||
| Condição | Regra |
|
||||
|---|---|
|
||||
| Contém ponto **e** vírgula | Último separador é o decimal |
|
||||
| Apenas ponto, 3 dígitos após | Separador de milhar (`1.234` → `1234`) |
|
||||
| Apenas ponto, outros casos | Decimal (`1000.00`) |
|
||||
| Apenas vírgula, 3 dígitos após | Separador de milhar (`1,234` → `1234`) |
|
||||
| Apenas vírgula, outros casos | Decimal (`1000,00` → `1000.00`) |
|
||||
| Sem separador | Número inteiro |
|
||||
|
||||
Aceita prefixo `R$` (case-insensitive).
|
||||
|
||||
#### `formatar_valor_br(valor) -> String`
|
||||
|
||||
Formata `Decimal` para exibição brasileira com 2 casas decimais e pontos de
|
||||
milhar. Ex: `1234567.89` → `"1.234.567,89"`.
|
||||
@@ -1,5 +1,5 @@
|
||||
pub mod chave_serie;
|
||||
pub mod layout;
|
||||
pub mod nota;
|
||||
pub mod resultado_analise;
|
||||
pub mod serie;
|
||||
pub mod chave_serie;
|
||||
|
||||
@@ -50,7 +50,13 @@ mod tests {
|
||||
}
|
||||
|
||||
fn nota_com_tipo(numero: u64, serie: &str, tipo: &str) -> Nota {
|
||||
Nota::new(numero, serie.to_string(), Some(tipo.to_string()), None, None)
|
||||
Nota::new(
|
||||
numero,
|
||||
serie.to_string(),
|
||||
Some(tipo.to_string()),
|
||||
None,
|
||||
None,
|
||||
)
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -84,16 +90,25 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn mesmo_numero_serie_tipos_diferentes_nao_e_duplicata() {
|
||||
let notas = vec![nota_com_tipo(1, "001", "NFE"), nota_com_tipo(1, "001", "NFCE")];
|
||||
let notas = vec![
|
||||
nota_com_tipo(1, "001", "NFE"),
|
||||
nota_com_tipo(1, "001", "NFCE"),
|
||||
];
|
||||
let dup = detectar_duplicidades(¬as);
|
||||
assert!(dup.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn mesmo_numero_serie_tipo_igual_e_duplicata() {
|
||||
let notas = vec![nota_com_tipo(1, "001", "NFE"), nota_com_tipo(1, "001", "NFE")];
|
||||
let notas = vec![
|
||||
nota_com_tipo(1, "001", "NFE"),
|
||||
nota_com_tipo(1, "001", "NFE"),
|
||||
];
|
||||
let dup = detectar_duplicidades(¬as);
|
||||
assert_eq!(dup.get(&(1, "001".to_string(), Some("NFE".to_string()))), Some(&2));
|
||||
assert_eq!(
|
||||
dup.get(&(1, "001".to_string(), Some("NFE".to_string()))),
|
||||
Some(&2)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -0,0 +1,207 @@
|
||||
# Infrastructure — AGENTS.md
|
||||
|
||||
Camada de infraestrutura do projeto `comparador-notas`. Responsável por toda I/O
|
||||
concreta: leitura de arquivos (CSV e XLSX), geração de PDF e persistência SQLite.
|
||||
Não contém regras de negócio; depende do `domain` para tipos e erros.
|
||||
|
||||
---
|
||||
|
||||
## Estrutura dos arquivos
|
||||
|
||||
```
|
||||
src/infrastructure/
|
||||
├── mod.rs # Re-exporta os submódulos públicos
|
||||
├── csv_reader.rs # Leitura e preview de arquivos CSV
|
||||
├── xlsx_reader.rs # Leitura e preview de arquivos XLSX/XLS
|
||||
├── pdf_generator.rs # Trait abstrata + implementação concreta de geração de PDF
|
||||
└── sqlite/ # Submódulo de persistência (ver sqlite/AGENTS.md)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## mod.rs
|
||||
|
||||
Re-exporta os quatro submódulos:
|
||||
|
||||
```rust
|
||||
pub mod csv_reader;
|
||||
pub mod pdf_generator;
|
||||
pub mod sqlite;
|
||||
pub mod xlsx_reader;
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## csv_reader.rs
|
||||
|
||||
Leitura de arquivos CSV com suporte a múltiplos encodings e delimitadores.
|
||||
|
||||
### Constante
|
||||
|
||||
```rust
|
||||
const LIMITE_BYTES: u64 = 50 * 1024 * 1024; // 50 MB
|
||||
```
|
||||
|
||||
### `ResultadoCsv`
|
||||
|
||||
```rust
|
||||
pub struct ResultadoCsv {
|
||||
pub linhas: Vec<Vec<String>>, // dados sem o cabeçalho
|
||||
pub avisos: ResumoAvisos,
|
||||
}
|
||||
```
|
||||
|
||||
### `ler_csv(caminho, delimitador, encoding, linha_cabecalho) -> Result<ResultadoCsv, ErroArquivo>`
|
||||
|
||||
Fluxo:
|
||||
|
||||
1. Verifica tamanho do arquivo — retorna `ErroArquivo::TamanhoExcedido` se > 50 MB.
|
||||
2. Lê os bytes brutos com `std::fs::read`.
|
||||
3. Decodifica o conteúdo:
|
||||
- `"windows-1252"`, `"latin-1"`, `"iso-8859-1"` → `encoding_rs::WINDOWS_1252`
|
||||
- qualquer outro → `String::from_utf8` (UTF-8)
|
||||
4. Constrói um `csv::ReaderBuilder` com `flexible(true)` e `has_headers(false)`.
|
||||
5. Itera sobre todos os registros:
|
||||
- Pula linhas até e incluindo `linha_cabecalho` (quando > 0).
|
||||
- Ignora linhas completamente em branco.
|
||||
- Registra erros de parse em `avisos.linhas_malformadas`.
|
||||
6. Retorna `ResultadoCsv` com as linhas de dados e os avisos.
|
||||
|
||||
### `preview_csv(caminho, delimitador, encoding, n) -> Result<Vec<Vec<String>>, ErroArquivo>`
|
||||
|
||||
Retorna as primeiras `n` linhas brutas (sem pular cabeçalho). Usado exclusivamente
|
||||
para pré-visualização na UI. Não verifica tamanho do arquivo.
|
||||
|
||||
---
|
||||
|
||||
## xlsx_reader.rs
|
||||
|
||||
Leitura de arquivos XLSX (e XLS por magic bytes) com suporte a coordenadas
|
||||
no formato `LetraLinha` (ex: `"B3"`).
|
||||
|
||||
### Constante
|
||||
|
||||
```rust
|
||||
const LIMITE_BYTES: u64 = 50 * 1024 * 1024; // 50 MB
|
||||
```
|
||||
|
||||
### Tipos auxiliares
|
||||
|
||||
```rust
|
||||
pub struct Coordenada {
|
||||
pub coluna: u32, // base 0
|
||||
pub linha: u32, // base 1
|
||||
}
|
||||
```
|
||||
|
||||
### `listar_abas(caminho) -> Result<Vec<String>, ErroArquivo>`
|
||||
|
||||
Abre o workbook via `calamine::open_workbook_auto` (detecção por magic bytes)
|
||||
e retorna os nomes das abas.
|
||||
|
||||
### `ler_xlsx(caminho, nome_aba, linha_inicio) -> Result<ResultadoXlsx, ErroArquivo>`
|
||||
|
||||
1. Verifica tamanho (50 MB).
|
||||
2. Abre workbook e seleciona a aba pelo nome.
|
||||
3. Itera sobre as linhas a partir de `linha_inicio - 1` (base 0 internamente).
|
||||
4. Converte cada célula para `String` via `celula_para_string` (ver abaixo).
|
||||
5. Ignora linhas completamente em branco.
|
||||
6. Retorna `ResultadoXlsx { linhas, avisos }`.
|
||||
|
||||
### `preview_xlsx(caminho, nome_aba) -> Result<Vec<Vec<String>>, ErroArquivo>`
|
||||
|
||||
Retorna as primeiras 5 linhas brutas da aba, a partir da linha 1. Usado para
|
||||
pré-visualização na UI.
|
||||
|
||||
### `parsear_letra_linha(s) -> Option<Coordenada>`
|
||||
|
||||
Converte notação Excel (`"B3"`) para `Coordenada { coluna: 1, linha: 3 }`:
|
||||
|
||||
- Normaliza para maiúsculas e faz trim.
|
||||
- Divide entre letras e dígitos.
|
||||
- Converte letras para índice de coluna base 0:
|
||||
`A=0, B=1, ..., Z=25, AA=26, ...`
|
||||
- Retorna `None` para notações inválidas (vazio, só números, zero, etc.).
|
||||
|
||||
### `celula_para_string(cell) -> String` (privada)
|
||||
|
||||
| Tipo calamine | Conversão |
|
||||
|---|---|
|
||||
| `Empty` | `""` |
|
||||
| `String(s)` | `s.clone()` |
|
||||
| `Float(f)` | sem `.0` quando `f.fract() == 0.0` |
|
||||
| `Int(i)` | `i.to_string()` |
|
||||
| `Bool(b)` | `b.to_string()` |
|
||||
| `DateTime` / `DateTimeIso` / `DurationIso` | representação string |
|
||||
| `Error(_)` | `""` |
|
||||
|
||||
---
|
||||
|
||||
## pdf_generator.rs
|
||||
|
||||
Geração de relatórios PDF com fontes embutidas no binário.
|
||||
|
||||
### Fontes embutidas
|
||||
|
||||
```rust
|
||||
const FONT_REGULAR: &[u8] = include_bytes!("../../assets/fonts/LiberationSans-Regular.ttf");
|
||||
const FONT_BOLD: &[u8] = include_bytes!("../../assets/fonts/LiberationSans-Bold.ttf");
|
||||
```
|
||||
|
||||
Liberation Sans (~402 KB/variante) é usada em vez de Arial do sistema (~993 KB),
|
||||
eliminando dependência externa e reduzindo o tamanho dos PDFs.
|
||||
|
||||
### `PdfGenerator` (trait pública)
|
||||
|
||||
```rust
|
||||
pub trait PdfGenerator {
|
||||
fn gerar(
|
||||
&self,
|
||||
resultado: &ResultadoAnalise,
|
||||
notas: &[Nota],
|
||||
meta: &MetadadosRelatorio,
|
||||
caminho_saida: &Path,
|
||||
) -> Result<(), String>;
|
||||
}
|
||||
```
|
||||
|
||||
Permite que o use case `exportar_pdf` dependa da abstração, não da crate `genpdf`.
|
||||
|
||||
### `GenpdfGenerator` (implementação concreta)
|
||||
|
||||
Implementa `PdfGenerator` usando a crate `genpdf`. Estrutura do PDF gerado:
|
||||
|
||||
1. **Título** — "Relatório de Análise de Notas Fiscais" (bold, 16pt)
|
||||
2. **Metadados** — nome do arquivo, layout (se presente) e data/hora de geração
|
||||
3. **Totais** — soma total e por série (`ChaveSerie.label()`)
|
||||
4. **Notas Faltantes por Série** — lista agrupada em intervalos contíguos
|
||||
(ex: `100–104 (5 notas)` em vez de `100, 101, 102, 103, 104`)
|
||||
5. **Duplicatas por Série** — grupo por número com contagem e data da última ocorrência
|
||||
|
||||
### `MetadadosRelatorio`
|
||||
|
||||
```rust
|
||||
pub struct MetadadosRelatorio {
|
||||
pub nome_arquivo: String,
|
||||
pub nome_layout: Option<String>,
|
||||
pub gerado_em: DateTime<Local>,
|
||||
}
|
||||
```
|
||||
|
||||
### `carregar_fonte_familia()` (privada)
|
||||
|
||||
Constrói `fonts::FontFamily` com os 4 slots exigidos por `genpdf`. Como o
|
||||
relatório não usa itálico, `italic` e `bold_italic` reutilizam os dados de
|
||||
`regular` e `bold` respectivamente.
|
||||
|
||||
---
|
||||
|
||||
## Dependencias externas relevantes
|
||||
|
||||
| Crate | Uso |
|
||||
|-------|-----|
|
||||
| `csv` | Parser de arquivos CSV com suporte a delimitadores e modo flexível |
|
||||
| `encoding_rs` | Decodificação Windows-1252 / Latin-1 |
|
||||
| `calamine` | Leitura de XLSX/XLS com detecção automática por magic bytes |
|
||||
| `genpdf` | Geração de PDF com layout de parágrafos e decorador de página |
|
||||
| `chrono` | `DateTime<Local>` para timestamp do relatório |
|
||||
@@ -26,15 +26,14 @@ pub fn ler_csv(
|
||||
linha_cabecalho: usize,
|
||||
) -> Result<ResultadoCsv, ErroArquivo> {
|
||||
// Verificar tamanho
|
||||
let metadata = std::fs::metadata(caminho)
|
||||
.map_err(|e| ErroArquivo::ErroLeitura(e.to_string()))?;
|
||||
let metadata =
|
||||
std::fs::metadata(caminho).map_err(|e| ErroArquivo::ErroLeitura(e.to_string()))?;
|
||||
if metadata.len() > LIMITE_BYTES {
|
||||
return Err(ErroArquivo::TamanhoExcedido(metadata.len()));
|
||||
}
|
||||
|
||||
// Ler conteúdo bruto
|
||||
let bytes = std::fs::read(caminho)
|
||||
.map_err(|e| ErroArquivo::ErroLeitura(e.to_string()))?;
|
||||
let bytes = std::fs::read(caminho).map_err(|e| ErroArquivo::ErroLeitura(e.to_string()))?;
|
||||
|
||||
// Decodificar encoding
|
||||
let conteudo = match encoding.to_lowercase().as_str() {
|
||||
@@ -42,10 +41,8 @@ pub fn ler_csv(
|
||||
let (decoded, _, _) = WINDOWS_1252.decode(&bytes);
|
||||
decoded.into_owned()
|
||||
}
|
||||
_ => {
|
||||
String::from_utf8(bytes)
|
||||
.map_err(|e| ErroArquivo::ErroLeitura(format!("Encoding inválido: {}", e)))?
|
||||
}
|
||||
_ => String::from_utf8(bytes)
|
||||
.map_err(|e| ErroArquivo::ErroLeitura(format!("Encoding inválido: {}", e)))?,
|
||||
};
|
||||
|
||||
let mut avisos = ResumoAvisos::default();
|
||||
@@ -97,8 +94,7 @@ pub fn preview_csv(
|
||||
encoding: &str,
|
||||
n: usize,
|
||||
) -> Result<Vec<Vec<String>>, ErroArquivo> {
|
||||
let bytes = std::fs::read(caminho)
|
||||
.map_err(|e| ErroArquivo::ErroLeitura(e.to_string()))?;
|
||||
let bytes = std::fs::read(caminho).map_err(|e| ErroArquivo::ErroLeitura(e.to_string()))?;
|
||||
|
||||
let conteudo = match encoding.to_lowercase().as_str() {
|
||||
"windows-1252" | "latin-1" | "iso-8859-1" => {
|
||||
|
||||
@@ -1,20 +1,21 @@
|
||||
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;
|
||||
use chrono::{DateTime, Local};
|
||||
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.
|
||||
// Liberation Sans (~402 KB cada) substitui Arial do sistema (~993 KB cada),
|
||||
// eliminando dependência de fonte externa e reduzindo o tamanho dos PDFs gerados.
|
||||
const FONT_REGULAR: &[u8] =
|
||||
include_bytes!("../../assets/fonts/LiberationSans-Regular.ttf");
|
||||
const FONT_BOLD: &[u8] =
|
||||
include_bytes!("../../assets/fonts/LiberationSans-Bold.ttf");
|
||||
const FONT_REGULAR: &[u8] = include_bytes!("../../assets/fonts/LiberationSans-Regular.ttf");
|
||||
const FONT_BOLD: &[u8] = include_bytes!("../../assets/fonts/LiberationSans-Bold.ttf");
|
||||
|
||||
/// Metadados do relatório.
|
||||
pub struct MetadadosRelatorio {
|
||||
@@ -30,6 +31,7 @@ pub trait PdfGenerator {
|
||||
fn gerar(
|
||||
&self,
|
||||
resultado: &ResultadoAnalise,
|
||||
notas: &[Nota],
|
||||
meta: &MetadadosRelatorio,
|
||||
caminho_saida: &Path,
|
||||
) -> Result<(), String>;
|
||||
@@ -42,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);
|
||||
@@ -56,12 +69,10 @@ impl PdfGenerator for GenpdfGenerator {
|
||||
doc.set_page_decorator(decorator);
|
||||
|
||||
// Título
|
||||
doc.push(
|
||||
Paragraph::new("").styled_string(
|
||||
doc.push(Paragraph::new("").styled_string(
|
||||
"Relatório de Análise de Notas Fiscais",
|
||||
style::Style::new().bold().with_font_size(16),
|
||||
),
|
||||
);
|
||||
));
|
||||
doc.push(Break::new(1));
|
||||
|
||||
// Metadados
|
||||
@@ -81,7 +92,8 @@ impl PdfGenerator for GenpdfGenerator {
|
||||
|
||||
// Totais
|
||||
doc.push(
|
||||
Paragraph::new("").styled_string("Totais", style::Style::new().bold().with_font_size(14)),
|
||||
Paragraph::new("")
|
||||
.styled_string("Totais", style::Style::new().bold().with_font_size(14)),
|
||||
);
|
||||
doc.push(Paragraph::new(format!(
|
||||
"Total Geral: R$ {}",
|
||||
@@ -102,9 +114,10 @@ impl PdfGenerator for GenpdfGenerator {
|
||||
doc.push(Break::new(1));
|
||||
|
||||
// Notas Faltantes
|
||||
doc.push(
|
||||
Paragraph::new("").styled_string("Notas Faltantes por Série", style::Style::new().bold().with_font_size(14)),
|
||||
);
|
||||
doc.push(Paragraph::new("").styled_string(
|
||||
"Notas Faltantes por Série",
|
||||
style::Style::new().bold().with_font_size(14),
|
||||
));
|
||||
|
||||
// Use faltantes keys for this section (may differ from soma keys if no values)
|
||||
let mut chaves_faltantes: Vec<&ChaveSerie> = resultado.faltantes_por_serie.keys().collect();
|
||||
@@ -114,7 +127,10 @@ impl PdfGenerator for GenpdfGenerator {
|
||||
let faltantes = match resultado.faltantes_por_serie.get(*chave) {
|
||||
Some(f) if !f.is_empty() => f,
|
||||
_ => {
|
||||
doc.push(Paragraph::new(format!(" Série {}: nenhuma faltante", chave.label())));
|
||||
doc.push(Paragraph::new(format!(
|
||||
" Série {}: nenhuma faltante",
|
||||
chave.label()
|
||||
)));
|
||||
continue;
|
||||
}
|
||||
};
|
||||
@@ -124,16 +140,28 @@ impl PdfGenerator for GenpdfGenerator {
|
||||
chave.label(),
|
||||
faltantes.len()
|
||||
)));
|
||||
let numeros: Vec<String> = faltantes.iter().map(|n| n.to_string()).collect();
|
||||
doc.push(Paragraph::new(format!(" {}", numeros.join(", "))));
|
||||
// Agrupar faltantes contíguos em intervalos (ex: 100–104 em vez de 100, 101, 102, 103, 104)
|
||||
let grupos = agrupar_contiguos(faltantes);
|
||||
let descricao: Vec<String> = grupos
|
||||
.iter()
|
||||
.map(|(ini, fim)| {
|
||||
if ini == fim {
|
||||
ini.to_string()
|
||||
} else {
|
||||
format!("{ini}–{fim} ({} notas)", fim - ini + 1)
|
||||
}
|
||||
})
|
||||
.collect();
|
||||
doc.push(Paragraph::new(format!(" {}", descricao.join(", "))));
|
||||
}
|
||||
|
||||
doc.push(Break::new(1));
|
||||
|
||||
// Duplicatas
|
||||
doc.push(
|
||||
Paragraph::new("").styled_string("Duplicatas por Série", style::Style::new().bold().with_font_size(14)),
|
||||
);
|
||||
doc.push(Paragraph::new("").styled_string(
|
||||
"Duplicatas por Série",
|
||||
style::Style::new().bold().with_font_size(14),
|
||||
));
|
||||
|
||||
let mut chaves_dup: Vec<&ChaveSerie> = resultado.duplicadas_por_serie.keys().collect();
|
||||
chaves_dup.sort();
|
||||
@@ -142,7 +170,10 @@ impl PdfGenerator for GenpdfGenerator {
|
||||
let duplicatas = match resultado.duplicadas_por_serie.get(*chave) {
|
||||
Some(d) if !d.is_empty() => d,
|
||||
_ => {
|
||||
doc.push(Paragraph::new(format!(" Série {}: nenhuma duplicata", chave.label())));
|
||||
doc.push(Paragraph::new(format!(
|
||||
" Série {}: nenhuma duplicata",
|
||||
chave.label()
|
||||
)));
|
||||
continue;
|
||||
}
|
||||
};
|
||||
@@ -153,9 +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",
|
||||
numero, chave.label(), count
|
||||
" NF {} / Série {} — {} ocorrências{}",
|
||||
numero,
|
||||
chave.label(),
|
||||
count,
|
||||
data_str,
|
||||
)));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,202 @@
|
||||
# SQLite Infrastructure — AGENTS.md
|
||||
|
||||
Visão geral da camada de persistência SQLite do projeto `comparador-notas`.
|
||||
|
||||
---
|
||||
|
||||
## Estrutura dos arquivos
|
||||
|
||||
```
|
||||
src/infrastructure/sqlite/
|
||||
├── mod.rs # Re-exporta os módulos públicos
|
||||
├── connection.rs # Abertura e validação da conexão
|
||||
├── migrations.rs # Controle de versão do schema
|
||||
└── layout_repository.rs # CRUD da entidade Layout
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## mod.rs
|
||||
|
||||
Ponto de entrada do módulo. Apenas re-exporta os três submódulos:
|
||||
|
||||
```rust
|
||||
pub mod connection;
|
||||
pub mod layout_repository;
|
||||
pub mod migrations;
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## connection.rs
|
||||
|
||||
Responsável por localizar, abrir e validar o arquivo SQLite.
|
||||
|
||||
### Caminho do banco
|
||||
|
||||
`caminho_banco()` resolve o diretório de configuração do sistema operacional via
|
||||
`dirs::config_dir()` e retorna:
|
||||
|
||||
```
|
||||
<config_dir>/comparador-notas/config.db
|
||||
```
|
||||
|
||||
Exemplos por SO:
|
||||
- **Linux**: `~/.config/comparador-notas/config.db`
|
||||
- **macOS**: `~/Library/Application Support/comparador-notas/config.db`
|
||||
- **Windows**: `%APPDATA%\comparador-notas\config.db`
|
||||
|
||||
### Abertura da conexão — `abrir_banco()`
|
||||
|
||||
Delega para `abrir_banco_no_caminho()` com o caminho padrão. Retorna
|
||||
`Result<(Connection, bool), String>`, onde o `bool` indica se o banco foi
|
||||
**recriado** (era corrompido).
|
||||
|
||||
### Lógica de recuperação de corrupção — `abrir_banco_no_caminho(path)`
|
||||
|
||||
1. Cria o diretório pai caso não exista (`create_dir_all`).
|
||||
2. Se o arquivo já existe, tenta abri-lo com `Connection::open`.
|
||||
3. Executa `SELECT 1;` como teste de sanidade.
|
||||
- Sucesso → retorna a conexão com flag `false` (não recriado).
|
||||
- Falha (corrupção ou erro de abertura) → renomeia o arquivo para
|
||||
`config.db.bak` e segue para a criação de um banco novo.
|
||||
4. Cria um banco vazio e retorna com flag `true` (banco foi recriado).
|
||||
|
||||
---
|
||||
|
||||
## migrations.rs
|
||||
|
||||
Controla a evolução incremental do schema via uma tabela interna de versão.
|
||||
|
||||
### Tabela de controle
|
||||
|
||||
```sql
|
||||
CREATE TABLE IF NOT EXISTS schema_version (
|
||||
versao INTEGER NOT NULL
|
||||
);
|
||||
```
|
||||
|
||||
Armazena apenas uma linha com a versão atual do schema.
|
||||
|
||||
### `aplicar_migrations(conn)`
|
||||
|
||||
Fluxo:
|
||||
|
||||
1. Garante que `schema_version` existe.
|
||||
2. Lê a versão atual (padrão `0` caso a tabela esteja vazia).
|
||||
3. Executa sequencialmente as migrations pendentes:
|
||||
- `versao_atual < 1` → `migration_v1`
|
||||
- `versao_atual < 2` → `migration_v2`
|
||||
- `versao_atual < 3` → `migration_v3`
|
||||
4. Persiste a nova versão (`INSERT` se era `0`, `UPDATE` caso contrário).
|
||||
|
||||
### Histórico de migrations
|
||||
|
||||
| Versão | Descrição |
|
||||
|--------|-----------|
|
||||
| **v1** | Cria a tabela `layouts` com campos para CSV e XLSX. |
|
||||
| **v2** | Renomeia layouts com nomes duplicados (sufixo `(id)`) e cria índice único `idx_layouts_nome` em `layouts.nome`. |
|
||||
| **v3** | Adiciona colunas `indice_documento_tipo` (INTEGER) e `pos_documento_tipo` (TEXT) na tabela `layouts`. |
|
||||
|
||||
### Schema final da tabela `layouts`
|
||||
|
||||
```sql
|
||||
CREATE TABLE layouts (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
nome TEXT NOT NULL,
|
||||
tipo TEXT NOT NULL CHECK(tipo IN ('csv', 'xlsx')),
|
||||
|
||||
-- Campos CSV
|
||||
delimitador TEXT,
|
||||
encoding TEXT,
|
||||
linha_cabecalho INTEGER,
|
||||
indice_numero INTEGER,
|
||||
indice_serie INTEGER,
|
||||
indice_valor INTEGER,
|
||||
indice_data INTEGER,
|
||||
indice_documento_tipo INTEGER, -- adicionado em v3
|
||||
|
||||
-- Campos XLSX
|
||||
aba TEXT,
|
||||
pos_numero TEXT,
|
||||
pos_serie TEXT,
|
||||
pos_valor TEXT,
|
||||
pos_data TEXT,
|
||||
pos_documento_tipo TEXT -- adicionado em v3
|
||||
);
|
||||
|
||||
CREATE UNIQUE INDEX idx_layouts_nome ON layouts (nome); -- adicionado em v2
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## layout_repository.rs
|
||||
|
||||
Implementa as operações CRUD sobre a entidade `Layout` (enum com variantes
|
||||
`Layout::Csv` e `Layout::Xlsx`).
|
||||
|
||||
### Funções públicas
|
||||
|
||||
#### `salvar(conn, layout) -> Result<i64>`
|
||||
|
||||
Insere um novo layout e retorna o `rowid` gerado.
|
||||
|
||||
- `Layout::Csv` → preenche colunas CSV; colunas XLSX ficam `NULL`.
|
||||
- `Layout::Xlsx` → preenche colunas XLSX; colunas CSV ficam `NULL`.
|
||||
|
||||
#### `atualizar(conn, layout) -> Result<()>`
|
||||
|
||||
Atualiza um layout existente pelo `id` embutido na variante. Retorna erro se
|
||||
`id` for `None`.
|
||||
|
||||
- `Layout::Csv` → atualiza apenas as colunas CSV.
|
||||
- `Layout::Xlsx` → atualiza apenas as colunas XLSX.
|
||||
|
||||
#### `listar(conn) -> Result<Vec<Layout>>`
|
||||
|
||||
Seleciona todos os layouts ordenados por `nome ASC`. Para cada linha:
|
||||
|
||||
- `tipo == "csv"` → constrói `Layout::Csv` mapeando as colunas de índice.
|
||||
- `tipo == "xlsx"` → constrói `Layout::Xlsx` mapeando as colunas de posição.
|
||||
|
||||
Campos opcionais (`Option<usize>`) são lidos como `Option<i64>` e convertidos.
|
||||
|
||||
#### `excluir(conn, id) -> Result<()>`
|
||||
|
||||
Remove o registro com o `id` informado via `DELETE`.
|
||||
|
||||
#### `existe_nome(conn, nome) -> Result<bool>`
|
||||
|
||||
Conta registros com o nome fornecido; retorna `true` se `COUNT(*) > 0`.
|
||||
Usado para validar unicidade antes de salvar.
|
||||
|
||||
---
|
||||
|
||||
## Fluxo de inicialização
|
||||
|
||||
```
|
||||
abrir_banco()
|
||||
└─> abrir_banco_no_caminho(caminho)
|
||||
├─ cria diretório se necessário
|
||||
├─ testa banco existente (SELECT 1)
|
||||
│ ├─ OK → retorna (conn, false)
|
||||
│ └─ ERR → renomeia para .bak, cria banco novo → (conn, true)
|
||||
└─ banco novo → retorna (conn, true)
|
||||
|
||||
aplicar_migrations(conn)
|
||||
├─ cria schema_version se necessário
|
||||
├─ lê versão atual
|
||||
├─ executa migrations pendentes (v1 → v2 → v3)
|
||||
└─ grava versão final
|
||||
```
|
||||
|
||||
Após esse fluxo, a conexão está pronta para uso pelo `layout_repository`.
|
||||
|
||||
---
|
||||
|
||||
## Dependencias externas relevantes
|
||||
|
||||
| Crate | Uso |
|
||||
|-------|-----|
|
||||
| `rusqlite` | Driver SQLite embutido (sem servidor externo) |
|
||||
| `dirs` | Resolve `config_dir()` conforme o SO |
|
||||
@@ -125,15 +125,9 @@ pub fn listar(conn: &Connection) -> Result<Vec<Layout>> {
|
||||
linha_cabecalho: row.get::<_, i64>(5)? as usize,
|
||||
indice_numero: row.get::<_, i64>(6)? as usize,
|
||||
indice_serie: row.get::<_, i64>(7)? as usize,
|
||||
indice_valor: row
|
||||
.get::<_, Option<i64>>(8)?
|
||||
.map(|v| v as usize),
|
||||
indice_data: row
|
||||
.get::<_, Option<i64>>(9)?
|
||||
.map(|v| v as usize),
|
||||
indice_documento_tipo: row
|
||||
.get::<_, Option<i64>>(15)?
|
||||
.map(|v| v as usize),
|
||||
indice_valor: row.get::<_, Option<i64>>(8)?.map(|v| v as usize),
|
||||
indice_data: row.get::<_, Option<i64>>(9)?.map(|v| v as usize),
|
||||
indice_documento_tipo: row.get::<_, Option<i64>>(15)?.map(|v| v as usize),
|
||||
},
|
||||
})
|
||||
} else {
|
||||
|
||||
@@ -14,11 +14,9 @@ pub fn aplicar_migrations(conn: &Connection) -> Result<()> {
|
||||
)?;
|
||||
|
||||
let versao_atual: i64 = conn
|
||||
.query_row(
|
||||
"SELECT versao FROM schema_version LIMIT 1;",
|
||||
[],
|
||||
|row| row.get(0),
|
||||
)
|
||||
.query_row("SELECT versao FROM schema_version LIMIT 1;", [], |row| {
|
||||
row.get(0)
|
||||
})
|
||||
.unwrap_or(0);
|
||||
|
||||
if versao_atual < 1 {
|
||||
@@ -32,16 +30,20 @@ pub fn aplicar_migrations(conn: &Connection) -> Result<()> {
|
||||
}
|
||||
|
||||
if versao_atual == 0 {
|
||||
conn.execute("INSERT INTO schema_version (versao) VALUES (?1);", [VERSAO_SCHEMA_ATUAL])?;
|
||||
conn.execute(
|
||||
"INSERT INTO schema_version (versao) VALUES (?1);",
|
||||
[VERSAO_SCHEMA_ATUAL],
|
||||
)?;
|
||||
} else if versao_atual < VERSAO_SCHEMA_ATUAL {
|
||||
conn.execute("UPDATE schema_version SET versao = ?1;", [VERSAO_SCHEMA_ATUAL])?;
|
||||
conn.execute(
|
||||
"UPDATE schema_version SET versao = ?1;",
|
||||
[VERSAO_SCHEMA_ATUAL],
|
||||
)?;
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Migration v2: adicionar índice único em layouts.nome.
|
||||
|
||||
/// Migration v3: adicionar colunas de tipo de documento nos layouts.
|
||||
fn migration_v3(conn: &Connection) -> Result<()> {
|
||||
conn.execute_batch(
|
||||
@@ -51,6 +53,8 @@ fn migration_v3(conn: &Connection) -> Result<()> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Migration v2: adicionar índice único em layouts.nome.
|
||||
///
|
||||
/// Renomeia duplicatas (sufixo com id) antes de criar o índice para não falhar
|
||||
/// em bancos que já possuem nomes repetidos.
|
||||
fn migration_v2(conn: &Connection) -> Result<()> {
|
||||
|
||||
@@ -24,8 +24,8 @@ pub struct ResultadoXlsx {
|
||||
pub fn listar_abas(caminho: &Path) -> Result<Vec<String>, ErroArquivo> {
|
||||
verificar_tamanho(caminho)?;
|
||||
|
||||
let workbook = open_workbook_auto(caminho)
|
||||
.map_err(|e| ErroArquivo::Corrompido(e.to_string()))?;
|
||||
let workbook =
|
||||
open_workbook_auto(caminho).map_err(|e| ErroArquivo::Corrompido(e.to_string()))?;
|
||||
|
||||
Ok(workbook.sheet_names().to_vec())
|
||||
}
|
||||
@@ -45,8 +45,8 @@ pub fn ler_xlsx(
|
||||
) -> Result<ResultadoXlsx, ErroArquivo> {
|
||||
verificar_tamanho(caminho)?;
|
||||
|
||||
let mut workbook = open_workbook_auto(caminho)
|
||||
.map_err(|e| ErroArquivo::Corrompido(e.to_string()))?;
|
||||
let mut workbook =
|
||||
open_workbook_auto(caminho).map_err(|e| ErroArquivo::Corrompido(e.to_string()))?;
|
||||
|
||||
let range: calamine::Range<calamine::Data> = workbook
|
||||
.worksheet_range(nome_aba)
|
||||
@@ -62,29 +62,7 @@ pub fn ler_xlsx(
|
||||
continue;
|
||||
}
|
||||
|
||||
let campos: Vec<String> = row
|
||||
.iter()
|
||||
.map(|cell| {
|
||||
use calamine::Data;
|
||||
match cell {
|
||||
Data::Empty => String::new(),
|
||||
Data::String(s) => s.clone(),
|
||||
Data::Float(f) => {
|
||||
if f.fract() == 0.0 {
|
||||
format!("{}", *f as i64)
|
||||
} else {
|
||||
format!("{}", f)
|
||||
}
|
||||
}
|
||||
Data::Int(i) => i.to_string(),
|
||||
Data::Bool(b) => b.to_string(),
|
||||
Data::DateTime(dt) => dt.to_string(),
|
||||
Data::DateTimeIso(s) => s.clone(),
|
||||
Data::DurationIso(s) => s.clone(),
|
||||
Data::Error(_) => String::new(),
|
||||
}
|
||||
})
|
||||
.collect();
|
||||
let campos: Vec<String> = row.iter().map(celula_para_string).collect();
|
||||
|
||||
// Ignorar linhas completamente em branco
|
||||
if campos.iter().all(|s: &String| s.trim().is_empty()) {
|
||||
@@ -99,14 +77,11 @@ pub fn ler_xlsx(
|
||||
|
||||
/// Retorna as primeiras 5 linhas de uma aba XLSX, a partir da linha 1.
|
||||
/// Usado exclusivamente para pré-visualização na UI.
|
||||
pub fn preview_xlsx(
|
||||
caminho: &Path,
|
||||
nome_aba: &str,
|
||||
) -> Result<Vec<Vec<String>>, ErroArquivo> {
|
||||
pub fn preview_xlsx(caminho: &Path, nome_aba: &str) -> Result<Vec<Vec<String>>, ErroArquivo> {
|
||||
verificar_tamanho(caminho)?;
|
||||
|
||||
let mut workbook = open_workbook_auto(caminho)
|
||||
.map_err(|e| ErroArquivo::Corrompido(e.to_string()))?;
|
||||
let mut workbook =
|
||||
open_workbook_auto(caminho).map_err(|e| ErroArquivo::Corrompido(e.to_string()))?;
|
||||
|
||||
let range: calamine::Range<calamine::Data> = workbook
|
||||
.worksheet_range(nome_aba)
|
||||
@@ -116,28 +91,7 @@ pub fn preview_xlsx(
|
||||
.rows()
|
||||
.take(5)
|
||||
.map(|row| {
|
||||
row.iter()
|
||||
.map(|cell| {
|
||||
use calamine::Data;
|
||||
match cell {
|
||||
Data::Empty => String::new(),
|
||||
Data::String(s) => s.clone(),
|
||||
Data::Float(f) => {
|
||||
if f.fract() == 0.0 {
|
||||
format!("{}", *f as i64)
|
||||
} else {
|
||||
format!("{}", f)
|
||||
}
|
||||
}
|
||||
Data::Int(i) => i.to_string(),
|
||||
Data::Bool(b) => b.to_string(),
|
||||
Data::DateTime(dt) => dt.to_string(),
|
||||
Data::DateTimeIso(s) => s.clone(),
|
||||
Data::DurationIso(s) => s.clone(),
|
||||
Data::Error(_) => String::new(),
|
||||
}
|
||||
})
|
||||
.collect()
|
||||
row.iter().map(celula_para_string).collect()
|
||||
})
|
||||
.collect();
|
||||
|
||||
@@ -181,9 +135,33 @@ pub fn parsear_letra_linha(s: &str) -> Option<Coordenada> {
|
||||
Some(Coordenada { coluna, linha })
|
||||
}
|
||||
|
||||
/// Converte uma célula calamine em String, usando representação legível.
|
||||
/// Inteiros sem parte decimal são formatados sem ".0"; erros e células vazias
|
||||
/// retornam string vazia.
|
||||
fn celula_para_string(cell: &calamine::Data) -> String {
|
||||
use calamine::Data;
|
||||
match cell {
|
||||
Data::Empty => String::new(),
|
||||
Data::String(s) => s.clone(),
|
||||
Data::Float(f) => {
|
||||
if f.fract() == 0.0 {
|
||||
format!("{}", *f as i64)
|
||||
} else {
|
||||
format!("{}", f)
|
||||
}
|
||||
}
|
||||
Data::Int(i) => i.to_string(),
|
||||
Data::Bool(b) => b.to_string(),
|
||||
Data::DateTime(dt) => dt.to_string(),
|
||||
Data::DateTimeIso(s) => s.clone(),
|
||||
Data::DurationIso(s) => s.clone(),
|
||||
Data::Error(_) => String::new(),
|
||||
}
|
||||
}
|
||||
|
||||
fn verificar_tamanho(caminho: &Path) -> Result<(), ErroArquivo> {
|
||||
let metadata = std::fs::metadata(caminho)
|
||||
.map_err(|e| ErroArquivo::ErroLeitura(e.to_string()))?;
|
||||
let metadata =
|
||||
std::fs::metadata(caminho).map_err(|e| ErroArquivo::ErroLeitura(e.to_string()))?;
|
||||
if metadata.len() > LIMITE_BYTES {
|
||||
return Err(ErroArquivo::TamanhoExcedido(metadata.len()));
|
||||
}
|
||||
|
||||
+11
-17
@@ -1,3 +1,5 @@
|
||||
#![cfg_attr(not(debug_assertions), windows_subsystem = "windows")]
|
||||
|
||||
mod application;
|
||||
mod domain;
|
||||
mod infrastructure;
|
||||
@@ -5,22 +7,14 @@ mod ui;
|
||||
|
||||
use ui::app::App;
|
||||
|
||||
fn main() -> eframe::Result {
|
||||
let native_options = eframe::NativeOptions {
|
||||
viewport: egui::ViewportBuilder::default()
|
||||
.with_title("Comparador de Notas")
|
||||
.with_inner_size([1024.0, 768.0])
|
||||
.with_min_inner_size([800.0, 600.0]),
|
||||
fn main() -> iced::Result {
|
||||
iced::application("Comparador de Notas", App::update, App::view)
|
||||
.subscription(App::subscription)
|
||||
.theme(|_app| ui::theme::tema_dark())
|
||||
.window(iced::window::Settings {
|
||||
size: iced::Size::new(1024.0, 768.0),
|
||||
min_size: Some(iced::Size::new(800.0, 600.0)),
|
||||
..Default::default()
|
||||
};
|
||||
|
||||
eframe::run_native(
|
||||
"Comparador de Notas",
|
||||
native_options,
|
||||
Box::new(|_cc| {
|
||||
let mut app = App::default();
|
||||
app.inicializar();
|
||||
Ok(Box::new(app))
|
||||
}),
|
||||
)
|
||||
})
|
||||
.run_with(App::new)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,324 @@
|
||||
# AGENTS.md — Guia de desenvolvimento da UI
|
||||
|
||||
Este documento descreve como a interface do **Comparador de Notas** é estruturada,
|
||||
quais padrões devem ser seguidos e o que não fazer. Leia antes de criar ou modificar
|
||||
qualquer arquivo em `src/ui/`.
|
||||
|
||||
---
|
||||
|
||||
## 1. Arquitetura da UI
|
||||
|
||||
O projeto usa **iced 0.13** com arquitetura Elm (Model / Update / View).
|
||||
|
||||
```
|
||||
src/ui/
|
||||
├── app.rs Estado global (App), update, view raiz, breadcrumb
|
||||
├── message.rs Enum Message — todos os eventos da UI
|
||||
├── mod.rs Re-exporta submódulos
|
||||
├── theme.rs Paleta de cores, estilos de widgets (sem lógica)
|
||||
├── components/
|
||||
│ ├── modal.rs Overlay de modal bloqueante
|
||||
│ ├── paginacao.rs Controles de paginação (◀ / ▶)
|
||||
│ └── tabela_preview.rs Tabela de pré-visualização do arquivo importado
|
||||
└── screens/
|
||||
├── import.rs Tela 1 — seleção de arquivo e layout
|
||||
├── selecionar_aba.rs Tela 1.5 — seleção de aba XLSX
|
||||
├── configuracao_colunas.rs Tela 2 — mapeamento de colunas
|
||||
├── resultado.rs Tela 3 — resultado da análise
|
||||
└── layouts.rs Tela lateral — gerenciamento de layouts
|
||||
```
|
||||
|
||||
### Responsabilidades por camada
|
||||
|
||||
| Camada | Responsabilidade |
|
||||
|--------|-----------------|
|
||||
| `app.rs` | Estado global, `update()`, `view()` raiz, roteamento entre telas, tarefas assíncronas |
|
||||
| `screens/` | Renderização de cada tela: coleta inputs, monta widgets, emite `Message` |
|
||||
| `components/` | Widgets reutilizáveis sem estado próprio (recebem dados por parâmetro) |
|
||||
| `theme.rs` | Apenas estilos visuais. Sem lógica de negócio. Sem `Message`. |
|
||||
| `message.rs` | Todos os eventos possíveis da UI. Nenhuma lógica aqui. |
|
||||
|
||||
**Regra:** nenhuma lógica de domínio (parsing, validação de sequência, cálculos) pode
|
||||
estar em `src/ui/`. A UI apenas chama use cases de `src/application/`.
|
||||
|
||||
---
|
||||
|
||||
## 2. Tema e paleta de cores
|
||||
|
||||
Todas as cores estão em `theme.rs` como constantes `Color`. **Nunca use valores RGB
|
||||
literais fora de `theme.rs`.**
|
||||
|
||||
### Paleta
|
||||
|
||||
| Constante | Hex | Uso |
|
||||
|-----------|-----|-----|
|
||||
| `BG` | `#0F172A` | Fundo geral da janela |
|
||||
| `SURFACE` | `#1E293B` | Cards primários |
|
||||
| `SURFACE_2` | `#334155` | Cards secundários, cabeçalho de tabela |
|
||||
| `BORDER` | `#334155` | Bordas de inputs e cards |
|
||||
| `TEXT` | `#F1F5F9` | Texto principal |
|
||||
| `TEXT_SECONDARY` | `#94A3B8` | Labels, placeholders |
|
||||
| `TEXT_MUTED` | `#64748B` | Texto desabilitado |
|
||||
| `PRIMARY` | `#3B82F6` | Botões primários, step ativo |
|
||||
| `PRIMARY_HOVER` | `#2563EB` | Hover em botões primários |
|
||||
| `SUCCESS` | `#22C55E` | Badge OK, barra ≥ 90% |
|
||||
| `WARNING` | `#F59E0B` | Badge faltante, barra 60–89% |
|
||||
| `DANGER` | `#EF4444` | Badge duplicada, barra < 60% |
|
||||
| `TRACK_BG` | `#111827` | Trilha da progress bar |
|
||||
|
||||
### Helpers de estilo disponíveis em `theme.rs`
|
||||
|
||||
**Containers:**
|
||||
- `t::fundo` — fundo geral da janela
|
||||
- `t::card` — card principal (SURFACE + borda + radius 8)
|
||||
- `t::card_secondary` — card secundário (SURFACE_2 + borda + radius 6)
|
||||
- `t::cabecalho_tabela` — cabeçalho de tabela sem borda
|
||||
- `t::badge_sucesso` / `t::badge_aviso` / `t::badge_perigo` — badges coloridos
|
||||
- `t::area_erro` — área de validação com fundo vermelho sutil
|
||||
- `t::stat_card` — card de estatística (igual a `t::card`)
|
||||
- `t::separador` — linha divisória fina
|
||||
- `t::breadcrumb_bg` — fundo da barra de breadcrumb
|
||||
|
||||
**Botões:**
|
||||
- `t::btn_primary` — azul sólido, ação principal
|
||||
- `t::btn_secondary` — SURFACE_2, ação secundária
|
||||
- `t::btn_ghost` — transparente com borda, ação terciária
|
||||
- `t::btn_danger` — vermelho semitransparente, exclusão
|
||||
- `t::btn_aba_ativa` / `t::btn_aba_inativa` — seleção de aba XLSX
|
||||
- `t::btn_pagina_ativo` / `t::btn_pagina_inativo` — paginação
|
||||
|
||||
**Inputs:**
|
||||
- `t::input_dark` — text_input com fundo BG, borda BORDER, focus PRIMARY
|
||||
|
||||
**Progress bar:**
|
||||
- `t::progress_bar_por_percentual(f32)` — retorna closure com cor por threshold:
|
||||
- ≥ 0.90 → SUCCESS, 0.60–0.89 → WARNING, < 0.60 → DANGER
|
||||
|
||||
---
|
||||
|
||||
## 3. Regras de layout e responsividade
|
||||
|
||||
### Princípio geral
|
||||
|
||||
A janela tem tamanho mínimo de **800×600**. Todo layout deve funcionar bem nessa
|
||||
dimensão e escalar corretamente ao aumentar.
|
||||
|
||||
### O que usar
|
||||
|
||||
| Situação | Valor correto |
|
||||
|----------|--------------|
|
||||
| Widget que deve preencher o espaço disponível | `Length::Fill` |
|
||||
| Label ao lado de input em linha | `Length::FillPortion(3)` (label) + input com tamanho fixo pequeno ou `Fill` |
|
||||
| Input numérico curto (índice, posição) | `Length::Fixed(90.0)` ou `Length::Fixed(110.0)` |
|
||||
| Input de texto longo (nome, aba) | `Length::Fill` |
|
||||
| Pick list de opções | `Length::Fill` |
|
||||
| Modal/card centralizado com largura máxima | `.max_width(N)` + `Length::Fill` |
|
||||
| Botões em linha que podem quebrar | `.wrap()` no `row![]` |
|
||||
| Elemento que deve ter tamanho mínimo sem crescer | `Length::Shrink` |
|
||||
| Células de tabela com scroll horizontal | `Length::Fixed(100.0)` mínimo |
|
||||
|
||||
### O que **não** fazer
|
||||
|
||||
- **Não use `Length::Fixed` em labels de formulário.** Labels devem usar
|
||||
`FillPortion` para se adaptar ao espaço disponível.
|
||||
- **Não use `Length::Fixed` em pick lists ou text inputs de texto livre.**
|
||||
Use `Fill` para que se adaptem à largura do container pai.
|
||||
- **Não coloque valores maiores que `max_width` em modais.** Use `.max_width(N)`
|
||||
em vez de `Fixed(N)` para que o modal encolha em janelas menores.
|
||||
- **Não deixe telas sem `scrollable`.** Toda tela com conteúdo vertical deve ser
|
||||
envolvida em `scrollable()` para evitar clipping em janelas pequenas.
|
||||
- **Não use `row![]` com muitos itens sem `.wrap()`.** Botões de ação e grupos
|
||||
de controles devem usar `.wrap()` para quebrar linha quando não couberem.
|
||||
|
||||
### Padrão de campo de formulário
|
||||
|
||||
```rust
|
||||
// Linha de campo: label proporcional + input
|
||||
fn campo_row<'a>(label: &'a str, input: Element<'a, Message>) -> Element<'a, Message> {
|
||||
row![
|
||||
text(label)
|
||||
.size(13)
|
||||
.color(t::TEXT_SECONDARY)
|
||||
.width(Length::FillPortion(3)), // proporcional, não fixo
|
||||
input, // input define seu próprio tamanho
|
||||
]
|
||||
.spacing(10)
|
||||
.align_y(Alignment::Center)
|
||||
.into()
|
||||
}
|
||||
```
|
||||
|
||||
### Padrão de campo opcional com checkbox
|
||||
|
||||
```rust
|
||||
// Quando ativo: checkbox (FillPortion) + input
|
||||
// Quando inativo: só o checkbox
|
||||
if ativo {
|
||||
row![
|
||||
cb.width(Length::FillPortion(3)),
|
||||
text_input("...", &val)
|
||||
.width(Length::Fixed(90.0)), // input numérico curto
|
||||
]
|
||||
.spacing(10)
|
||||
.align_y(Alignment::Center)
|
||||
.into()
|
||||
} else {
|
||||
row![cb].into()
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 4. Estrutura de uma tela (screen)
|
||||
|
||||
Toda tela segue o mesmo padrão de função pública `view`:
|
||||
|
||||
```rust
|
||||
pub fn view(app: &App) -> Element<'_, Message> {
|
||||
// 1. Montar seções/componentes individuais
|
||||
let secao_x = ...;
|
||||
let secao_y = ...;
|
||||
|
||||
// 2. Combinar em coluna principal
|
||||
let conteudo = column![secao_x, secao_y]
|
||||
.spacing(14)
|
||||
.padding([20, 24])
|
||||
.width(Length::Fill);
|
||||
|
||||
// 3. Envolver em scrollable + container de fundo
|
||||
container(scrollable(conteudo))
|
||||
.style(t::fundo)
|
||||
.width(Length::Fill)
|
||||
.height(Length::Fill)
|
||||
.into()
|
||||
}
|
||||
```
|
||||
|
||||
**Toda tela deve ter `scrollable` e `container` com `t::fundo` na raiz.**
|
||||
|
||||
---
|
||||
|
||||
## 5. Componentes reutilizáveis
|
||||
|
||||
### `modal::view_com_modal(conteudo, modal)`
|
||||
|
||||
Envolve qualquer `Element` com um overlay de modal bloqueante.
|
||||
Chamado em `app.rs` quando `self.modal.is_some()`.
|
||||
|
||||
- Modal tem `max_width(420)` + `Length::Fill` para ser responsivo.
|
||||
- Tipos disponíveis: `Informacao`, `Aviso`, `Erro`, `Confirmacao`, `InputTexto`.
|
||||
- Disparar modal: usar os helpers em `app.rs` (`exibir_erro`, `exibir_aviso`, `exibir_confirmacao`).
|
||||
|
||||
### `tabela_preview::tabela_preview(linhas)`
|
||||
|
||||
Renderiza as primeiras N linhas do arquivo com cabeçalho estilo Excel (A, B, C...).
|
||||
|
||||
- Células com `Fixed(100.0)` — tamanho fixo mínimo com scroll horizontal.
|
||||
- A altura da área de scroll está fixada em `Fixed(160.0)` — intencional.
|
||||
- Scroll horizontal via `scrollable::Direction::Horizontal`.
|
||||
|
||||
### `paginacao::controles_paginacao(pagina, total, msg_anterior, msg_proxima)`
|
||||
|
||||
Row de botões ◀ / "Página X / Y" / ▶.
|
||||
Emite as mensagens passadas como parâmetro.
|
||||
|
||||
---
|
||||
|
||||
## 6. Roteamento entre telas
|
||||
|
||||
O roteamento é feito pelo enum `EstadoApp` em `app.rs`:
|
||||
|
||||
| Estado | Tela renderizada |
|
||||
|--------|-----------------|
|
||||
| `Importando` | `screens/import.rs` |
|
||||
| `SelecionandoAba` | `screens/selecionar_aba.rs` |
|
||||
| `ConfigurandoColunas` | `screens/configuracao_colunas.rs` |
|
||||
| `ConfirmandoIntervalo` | `screens/configuracao_colunas.rs` (mesmo view) |
|
||||
| `ExibindoResultado(r)` | `screens/resultado.rs` |
|
||||
| `GerenciandoLayouts` | `screens/layouts.rs` |
|
||||
| `Analisando` | Spinner inline em `app.rs` |
|
||||
|
||||
Transições são sempre via `Message` → `update()`. **Nunca altere `self.estado`
|
||||
diretamente de dentro de uma tela.**
|
||||
|
||||
O breadcrumb é renderizado automaticamente por `app.rs` para todos os estados
|
||||
exceto `GerenciandoLayouts` e `Analisando`.
|
||||
|
||||
---
|
||||
|
||||
## 7. Adicionando uma nova tela
|
||||
|
||||
1. Crie `src/ui/screens/minha_tela.rs` com função `pub fn view(app: &App) -> Element<'_, Message>`.
|
||||
2. Adicione `pub mod minha_tela;` em `src/ui/screens/mod.rs`.
|
||||
3. Adicione a variante correspondente em `EstadoApp` (`app.rs`).
|
||||
4. Adicione o arm no `match &self.estado` em `app.view()` (`app.rs`).
|
||||
5. Adicione as mensagens necessárias em `message.rs`.
|
||||
6. Trate as mensagens no `update()` de `app.rs`.
|
||||
|
||||
---
|
||||
|
||||
## 8. Adicionando um novo componente
|
||||
|
||||
1. Crie `src/ui/components/meu_componente.rs`.
|
||||
2. Adicione `pub mod meu_componente;` em `src/ui/components/mod.rs`.
|
||||
3. O componente deve ser uma função pura: recebe dados por parâmetro, retorna `Element<'_, Message>`.
|
||||
4. Sem estado interno, sem `self`, sem acesso ao banco.
|
||||
|
||||
---
|
||||
|
||||
## 9. Adicionando novos estilos ao tema
|
||||
|
||||
- Sempre adicione em `theme.rs`.
|
||||
- Siga o padrão dos helpers existentes: função que recebe `&Theme` e retorna o `Style` do widget.
|
||||
- Para cores com alpha: use `Color { a: 0.N, ..CONSTANTE }` em vez de valores RGB manuais.
|
||||
- Nomeie helpers de container como `nome_do_contexto`, botões como `btn_nome`, inputs como `input_nome`.
|
||||
|
||||
---
|
||||
|
||||
## 10. Mensagens e estado assíncrono
|
||||
|
||||
- Operações bloqueantes (I/O, análise) são sempre executadas em `Task::perform` com
|
||||
`tokio::task::spawn_blocking`.
|
||||
- O resultado retorna para `update()` via `Message`.
|
||||
- O estado `EstadoApp::Analisando` é usado enquanto a operação está em background.
|
||||
- `ResultadoPendente` é o tipo intermediário entre a thread de análise e a UI.
|
||||
|
||||
**Não bloquear a thread principal da UI.** Qualquer operação lenta deve usar `Task`.
|
||||
|
||||
---
|
||||
|
||||
## 11. Drag-and-drop de arquivos
|
||||
|
||||
O iced 0.13 expõe eventos de janela para drag-and-drop via `iced::window::Event`.
|
||||
O projeto os captura através de `App::subscription()` registrado em `main.rs`.
|
||||
|
||||
### Eventos capturados
|
||||
|
||||
| Evento iced | Mensagem emitida | Efeito |
|
||||
|---|---|---|
|
||||
| `window::Event::FileDropped(path)` | `Message::ArquivoSolto(path)` | Processa o arquivo como se fosse selecionado via botão |
|
||||
| `window::Event::FileHovered(_)` | `Message::ArquivoEmHover` | Liga `App.arquivo_em_hover = true` |
|
||||
| `window::Event::FilesHoveredLeft` | `Message::ArquivoHoverSaiu` | Liga `App.arquivo_em_hover = false` |
|
||||
|
||||
### Subscription
|
||||
|
||||
`App::subscription()` em `app.rs` usa `iced::event::listen_with` para filtrar apenas
|
||||
os três eventos acima. Está registrado em `main.rs` via `.subscription(App::subscription)`.
|
||||
|
||||
### Estado de hover
|
||||
|
||||
O campo `App.arquivo_em_hover: bool` é `true` enquanto um arquivo está sendo arrastado
|
||||
sobre a janela. A tela `screens/import.rs` usa esse campo para alterar visualmente a
|
||||
`drop_zone`: borda mais brilhante (`a: 0.9`) e mais espessa (`width: 2.0`) durante o hover.
|
||||
|
||||
### Fluxo de processamento
|
||||
|
||||
`Message::ArquivoSolto` chama `self.processar_arquivo_selecionado(caminho)` — o mesmo
|
||||
método chamado pelo botão de seleção de arquivo. O comportamento é idêntico: validação
|
||||
de extensão, leitura de abas (XLSX) ou preview (CSV), e transição de estado.
|
||||
|
||||
### Como estender
|
||||
|
||||
Para adicionar suporte a drag-and-drop em outras telas (ex: importar layout JSON por
|
||||
drag), basta verificar `App.estado` dentro do arm `Message::ArquivoSolto` antes de
|
||||
chamar `processar_arquivo_selecionado`, e desviar conforme necessário.
|
||||
+1131
-465
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,3 @@
|
||||
pub mod modal;
|
||||
pub mod paginacao;
|
||||
pub mod tabela_preview;
|
||||
@@ -0,0 +1,143 @@
|
||||
use crate::ui::app::EstadoModal;
|
||||
use crate::ui::message::Message;
|
||||
use crate::ui::theme as t;
|
||||
use iced::widget::{button, column, container, mouse_area, row, stack, text, text_input};
|
||||
use iced::{Alignment, Color, Element, Length};
|
||||
|
||||
/// Envolve o conteúdo principal com uma camada de modal por cima.
|
||||
/// O overlay escuro bloqueia cliques no conteúdo de baixo.
|
||||
pub fn view_com_modal<'a>(
|
||||
conteudo: Element<'a, Message>,
|
||||
modal: &'a EstadoModal,
|
||||
) -> Element<'a, Message> {
|
||||
let overlay = mouse_area(
|
||||
container(view_modal(modal))
|
||||
.width(Length::Fill)
|
||||
.height(Length::Fill)
|
||||
.style(|_theme| container::Style {
|
||||
background: Some(Color::from_rgba(0.0, 0.0, 0.0, 0.6).into()),
|
||||
..Default::default()
|
||||
})
|
||||
.center_x(Length::Fill)
|
||||
.center_y(Length::Fill),
|
||||
)
|
||||
.on_press(Message::ModalCancelado);
|
||||
|
||||
stack![conteudo, overlay].into()
|
||||
}
|
||||
|
||||
fn view_modal(modal: &EstadoModal) -> Element<'_, Message> {
|
||||
match modal {
|
||||
EstadoModal::Aviso { titulo, mensagem } => {
|
||||
caixa_modal(titulo, mensagem, TipoModal::Aviso, None, false)
|
||||
}
|
||||
EstadoModal::Erro { titulo, mensagem } => {
|
||||
caixa_modal(titulo, mensagem, TipoModal::Erro, None, false)
|
||||
}
|
||||
EstadoModal::Confirmacao {
|
||||
titulo, mensagem, ..
|
||||
} => caixa_modal(titulo, mensagem, TipoModal::Confirmacao, None, true),
|
||||
EstadoModal::InputTexto {
|
||||
titulo,
|
||||
mensagem,
|
||||
texto,
|
||||
..
|
||||
} => caixa_modal(
|
||||
titulo,
|
||||
mensagem,
|
||||
TipoModal::Info,
|
||||
Some(texto.as_str()),
|
||||
true,
|
||||
),
|
||||
}
|
||||
}
|
||||
|
||||
enum TipoModal {
|
||||
Info,
|
||||
Aviso,
|
||||
Erro,
|
||||
Confirmacao,
|
||||
}
|
||||
|
||||
fn caixa_modal<'a>(
|
||||
titulo: &'a str,
|
||||
mensagem: &'a str,
|
||||
tipo: TipoModal,
|
||||
input: Option<&'a str>,
|
||||
com_confirmar: bool,
|
||||
) -> Element<'a, Message> {
|
||||
// Cor e label de ícone por tipo
|
||||
let (cor_titulo, icone) = match tipo {
|
||||
TipoModal::Info => (t::PRIMARY, "i"),
|
||||
TipoModal::Aviso => (t::WARNING, "!"),
|
||||
TipoModal::Erro => (t::DANGER, "x"),
|
||||
TipoModal::Confirmacao => (t::WARNING, "?"),
|
||||
};
|
||||
|
||||
let badge_icone = container(text(icone).size(13).color(cor_titulo))
|
||||
.padding([2, 8])
|
||||
.style(move |_theme| iced::widget::container::Style {
|
||||
background: Some(
|
||||
Color {
|
||||
a: 0.15,
|
||||
..cor_titulo
|
||||
}
|
||||
.into(),
|
||||
),
|
||||
border: iced::Border {
|
||||
color: Color {
|
||||
a: 0.3,
|
||||
..cor_titulo
|
||||
},
|
||||
width: 1.0,
|
||||
radius: 4.0.into(),
|
||||
},
|
||||
..Default::default()
|
||||
});
|
||||
|
||||
let titulo_row = row![badge_icone, text(titulo).size(17).color(t::TEXT),]
|
||||
.spacing(8)
|
||||
.align_y(Alignment::Center);
|
||||
|
||||
let mut col =
|
||||
column![titulo_row, text(mensagem).size(14).color(t::TEXT_SECONDARY),].spacing(12);
|
||||
|
||||
if let Some(valor) = input {
|
||||
col = col.push(
|
||||
text_input("Nome...", valor)
|
||||
.on_input(Message::ModalTextoAlterado)
|
||||
.padding(8)
|
||||
.style(t::input_dark),
|
||||
);
|
||||
}
|
||||
|
||||
// Separador
|
||||
col = col.push(
|
||||
container(iced::widget::horizontal_rule(1))
|
||||
.width(Length::Fill)
|
||||
.padding([4, 0]),
|
||||
);
|
||||
|
||||
// Botões
|
||||
let btn_fechar = button("Fechar")
|
||||
.on_press(Message::ModalCancelado)
|
||||
.style(t::btn_ghost);
|
||||
|
||||
let mut botoes = row![btn_fechar].spacing(8);
|
||||
|
||||
if com_confirmar {
|
||||
let btn_confirmar = button("Confirmar")
|
||||
.on_press(Message::ModalConfirmado)
|
||||
.style(t::btn_primary);
|
||||
botoes = botoes.push(btn_confirmar);
|
||||
}
|
||||
|
||||
col = col.push(botoes);
|
||||
|
||||
container(col.align_x(Alignment::Start))
|
||||
.max_width(420)
|
||||
.width(Length::Fill)
|
||||
.padding(24)
|
||||
.style(t::card)
|
||||
.into()
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
use crate::ui::message::Message;
|
||||
use crate::ui::theme as t;
|
||||
use iced::widget::{button, row, text};
|
||||
use iced::{Alignment, Element};
|
||||
|
||||
/// Renderiza controles de paginação reutilizáveis.
|
||||
pub fn controles_paginacao(
|
||||
pagina_atual: usize,
|
||||
total_paginas: usize,
|
||||
msg_anterior: Message,
|
||||
msg_proximo: Message,
|
||||
) -> Element<'static, Message> {
|
||||
let btn_anterior = button("<")
|
||||
.on_press_maybe((pagina_atual > 0).then_some(msg_anterior))
|
||||
.style(t::btn_ghost);
|
||||
|
||||
let btn_proximo = button(">")
|
||||
.on_press_maybe((pagina_atual + 1 < total_paginas).then_some(msg_proximo))
|
||||
.style(t::btn_ghost);
|
||||
|
||||
row![
|
||||
btn_anterior,
|
||||
text(format!("Página {} / {}", pagina_atual + 1, total_paginas))
|
||||
.size(13)
|
||||
.color(t::TEXT_SECONDARY),
|
||||
btn_proximo,
|
||||
]
|
||||
.spacing(8)
|
||||
.align_y(Alignment::Center)
|
||||
.into()
|
||||
}
|
||||
@@ -0,0 +1,97 @@
|
||||
use crate::ui::message::Message;
|
||||
use crate::ui::screens::indice_para_letra;
|
||||
use crate::ui::theme as t;
|
||||
use iced::widget::{column, container, row, scrollable, text};
|
||||
use iced::{Border, Color, Element, Font, Length};
|
||||
|
||||
/// Renderiza uma tabela de pré-visualização das primeiras linhas do arquivo.
|
||||
/// Mostra uma linha de cabeçalho com letras estilo Excel (A, B, C, ...) seguida pelos dados.
|
||||
pub fn tabela_preview(linhas: &[Vec<String>]) -> Element<'_, Message> {
|
||||
let num_colunas = linhas.iter().map(|l| l.len()).max().unwrap_or(0);
|
||||
if num_colunas == 0 {
|
||||
return text("(vazio)").size(12).color(t::TEXT_MUTED).into();
|
||||
}
|
||||
|
||||
// Cabeçalho estilo Excel — usa t::cabecalho_tabela centralizado
|
||||
let cabecalho = container(
|
||||
row((0..num_colunas)
|
||||
.map(|i| {
|
||||
container(
|
||||
text(format!("{} ({})", indice_para_letra(i), i))
|
||||
.font(Font::MONOSPACE)
|
||||
.size(12)
|
||||
.color(t::TEXT_SECONDARY),
|
||||
)
|
||||
.width(Length::Fixed(100.0))
|
||||
.padding([4, 6])
|
||||
.into()
|
||||
})
|
||||
.collect::<Vec<_>>())
|
||||
.spacing(0),
|
||||
)
|
||||
.width(Length::Shrink)
|
||||
.style(t::cabecalho_tabela);
|
||||
|
||||
let linhas_view = linhas.iter().enumerate().map(|(idx, linha)| {
|
||||
// Linhas alternadas: SURFACE e SURFACE_2 levemente
|
||||
let bg = if idx % 2 == 0 {
|
||||
t::SURFACE
|
||||
} else {
|
||||
Color {
|
||||
r: t::SURFACE.r + 0.01,
|
||||
g: t::SURFACE.g + 0.01,
|
||||
b: t::SURFACE.b + 0.015,
|
||||
a: 1.0,
|
||||
}
|
||||
};
|
||||
|
||||
container(
|
||||
row((0..num_colunas)
|
||||
.map(|col| {
|
||||
let celula = linha.get(col).map(|s| s.as_str()).unwrap_or("");
|
||||
let truncado: String = if celula.chars().count() > 30 {
|
||||
format!("{}...", celula.chars().take(30).collect::<String>())
|
||||
} else {
|
||||
celula.to_string()
|
||||
};
|
||||
container(text(truncado).font(Font::MONOSPACE).size(11).color(t::TEXT))
|
||||
.width(Length::Fixed(100.0))
|
||||
.padding([3, 6])
|
||||
.into()
|
||||
})
|
||||
.collect::<Vec<_>>())
|
||||
.spacing(0),
|
||||
)
|
||||
.width(Length::Shrink)
|
||||
.style(move |_theme| iced::widget::container::Style {
|
||||
background: Some(bg.into()),
|
||||
..Default::default()
|
||||
})
|
||||
.into()
|
||||
});
|
||||
|
||||
let todas_linhas = column(
|
||||
std::iter::once(cabecalho.into())
|
||||
.chain(linhas_view)
|
||||
.collect::<Vec<_>>(),
|
||||
)
|
||||
.spacing(0);
|
||||
|
||||
container(
|
||||
scrollable(todas_linhas)
|
||||
.direction(scrollable::Direction::Horizontal(
|
||||
scrollable::Scrollbar::default(),
|
||||
))
|
||||
.height(Length::Fixed(160.0)),
|
||||
)
|
||||
.style(|_theme| iced::widget::container::Style {
|
||||
background: Some(t::SURFACE.into()),
|
||||
border: Border {
|
||||
color: t::BORDER,
|
||||
width: 1.0,
|
||||
radius: 6.0.into(),
|
||||
},
|
||||
..Default::default()
|
||||
})
|
||||
.into()
|
||||
}
|
||||
@@ -0,0 +1,130 @@
|
||||
use crate::domain::entities::nota::Nota;
|
||||
use crate::domain::entities::{
|
||||
chave_serie::ChaveSerie,
|
||||
layout::{Layout, LayoutXlsx},
|
||||
resultado_analise::{IntervaloSerie, ResultadoAnalise, ResultadoPreAnalise},
|
||||
};
|
||||
use crate::domain::errors::ResumoAvisos;
|
||||
use rusqlite::Connection;
|
||||
use std::path::PathBuf;
|
||||
use std::sync::{Arc, Mutex};
|
||||
|
||||
/// Todos os eventos/interações da UI.
|
||||
#[derive(Debug, Clone)]
|
||||
pub enum Message {
|
||||
// --- Inicialização ---
|
||||
BancoInicializado(Result<(Arc<Mutex<Connection>>, bool, Vec<Layout>), String>),
|
||||
LayoutsRecarregados(Vec<Layout>),
|
||||
|
||||
// --- Navegação ---
|
||||
IrParaImportacao,
|
||||
IrParaConfiguracaoColunas,
|
||||
IrParaLayouts,
|
||||
Voltar,
|
||||
|
||||
// --- Arquivo ---
|
||||
SelecionarArquivo,
|
||||
ArquivoSelecionado(PathBuf),
|
||||
/// Arquivo arrastado e solto sobre a janela (drag-and-drop).
|
||||
ArquivoSolto(PathBuf),
|
||||
/// Arquivo está sendo arrastado sobre a janela (hover).
|
||||
ArquivoEmHover,
|
||||
/// Arquivo arrastado saiu da janela sem ser solto.
|
||||
ArquivoHoverSaiu,
|
||||
AbaSelecionada(String),
|
||||
|
||||
// --- XLSX: abas carregadas em background ---
|
||||
AbaxlsxCarregadas {
|
||||
caminho: PathBuf,
|
||||
abas: Vec<String>,
|
||||
layout_xlsx: LayoutXlsx,
|
||||
nome_layout: String,
|
||||
},
|
||||
XlsxErroAoCarregar(String),
|
||||
|
||||
// --- Background tasks ---
|
||||
AnaliseCompleta(ResultadoPendente),
|
||||
|
||||
// --- Configuração CSV ---
|
||||
DelimitadorAlterado(char),
|
||||
EncodingAlterado(String),
|
||||
LinhaCabecalhoAlterada(usize),
|
||||
IndiceNumeroAlterado(usize),
|
||||
IndiceSerieAlterado(usize),
|
||||
IndiceValorToggle(bool),
|
||||
IndiceValorAlterado(usize),
|
||||
IndiceDataToggle(bool),
|
||||
IndiceDataAlterado(usize),
|
||||
IndiceDocTipoToggle(bool),
|
||||
IndiceDocTipoAlterado(usize),
|
||||
|
||||
// --- Configuração XLSX ---
|
||||
AbaXlsxAlterada(String),
|
||||
PosNumeroAlterada(String),
|
||||
PosSerieAlterada(String),
|
||||
PosValorToggle(bool),
|
||||
PosValorAlterada(String),
|
||||
PosDataToggle(bool),
|
||||
PosDataAlterada(String),
|
||||
PosDocTipoToggle(bool),
|
||||
PosDocTipoAlterada(String),
|
||||
|
||||
// --- Análise ---
|
||||
ExecutarImportacao,
|
||||
ReanalisarArquivo,
|
||||
ConfirmarExpansaoFaltantes,
|
||||
NovaAnalise,
|
||||
|
||||
// --- Resultado ---
|
||||
PaginaFaltantesAlterada(ChaveSerie, usize),
|
||||
PaginaDuplicatasAlterada(ChaveSerie, usize),
|
||||
ItensPorPaginaAlterado(usize),
|
||||
CopiarFaltantes(ChaveSerie),
|
||||
CopiarDuplicatas(ChaveSerie),
|
||||
ExportarPdf,
|
||||
PdfExportado(Result<PathBuf, String>),
|
||||
|
||||
// --- Layouts ---
|
||||
LayoutSelecionado(i64),
|
||||
SalvarLayout,
|
||||
NomeLayoutAlterado(String),
|
||||
ExcluirLayout(i64),
|
||||
ExclusaoConfirmada(i64),
|
||||
ExportarLayoutJson(i64),
|
||||
ImportarLayoutJson,
|
||||
LayoutJsonImportado(String),
|
||||
SobrescreverLayout(Layout),
|
||||
|
||||
// --- Modal ---
|
||||
ModalTextoAlterado(String),
|
||||
ModalConfirmado,
|
||||
ModalCancelado,
|
||||
|
||||
// --- Sem operação (used as fallback) ---
|
||||
Noop,
|
||||
|
||||
// --- Animação ---
|
||||
SpinnerTick,
|
||||
}
|
||||
|
||||
/// Resultado enviado pela task de análise em background para a UI.
|
||||
#[derive(Debug, Clone)]
|
||||
pub enum ResultadoPendente {
|
||||
/// Análise concluída com sucesso.
|
||||
Concluido {
|
||||
resultado: ResultadoAnalise,
|
||||
avisos: Option<ResumoAvisos>,
|
||||
notas: Option<Vec<Nota>>,
|
||||
},
|
||||
/// Pré-análise concluída mas precisa de confirmação do usuário.
|
||||
AguardandoConfirmacao {
|
||||
pre: ResultadoPreAnalise,
|
||||
series_excessivas: Vec<(ChaveSerie, IntervaloSerie)>,
|
||||
avisos: ResumoAvisos,
|
||||
notas: Vec<Nota>,
|
||||
},
|
||||
/// Arquivo importado não continha notas válidas.
|
||||
Vazio,
|
||||
/// Erro durante importação ou análise.
|
||||
Erro(String),
|
||||
}
|
||||
@@ -1,2 +1,5 @@
|
||||
pub mod app;
|
||||
pub mod components;
|
||||
pub mod message;
|
||||
pub mod screens;
|
||||
pub mod theme;
|
||||
|
||||
@@ -1,345 +1,506 @@
|
||||
use crate::application::usecases::importar_arquivo::{importar_csv, importar_xlsx};
|
||||
use crate::application::usecases::executar_analise::{
|
||||
expandir_analise, pre_analisar, series_com_intervalo_excessivo,
|
||||
use crate::domain::entities::layout::TipoArquivo;
|
||||
use crate::ui::app::App;
|
||||
use crate::ui::message::Message;
|
||||
use crate::ui::theme as t;
|
||||
use iced::widget::{
|
||||
button, checkbox, column, container, pick_list, row, scrollable, text, text_input, Column,
|
||||
};
|
||||
use crate::domain::entities::layout::{Layout, TipoArquivo};
|
||||
use crate::ui::app::{App, EstadoApp, ResultadoPendente};
|
||||
use egui::{Context, Ui};
|
||||
use iced::{Alignment, Element, Length};
|
||||
|
||||
/// Renderiza a tela de configuração de colunas.
|
||||
pub fn renderizar(ui: &mut Ui, ctx: &Context, app: &mut App) {
|
||||
ui.heading("Configuração de Colunas");
|
||||
ui.add_space(8.0);
|
||||
const OPCOES_DELIMITADOR: &[(&str, char)] = &[
|
||||
("Vírgula (,)", ','),
|
||||
("Ponto e vírgula (;)", ';'),
|
||||
("Tabulação (Tab)", '\t'),
|
||||
];
|
||||
|
||||
if let Some(caminho) = &app.caminho_arquivo.clone() {
|
||||
ui.label(format!("Arquivo: {}", caminho.display()));
|
||||
}
|
||||
const OPCOES_ENCODING: &[&str] = &["utf-8", "windows-1252"];
|
||||
|
||||
ui.add_space(8.0);
|
||||
/// Tela de configuração de colunas.
|
||||
pub fn view(app: &App) -> Element<'_, Message> {
|
||||
// ── Cabeçalho ─────────────────────────────────────────────────────────────
|
||||
let header = column![
|
||||
text("Configuração de Colunas").size(20).color(t::TEXT),
|
||||
if let Some(caminho) = &app.caminho_arquivo {
|
||||
row![
|
||||
text("Arquivo:").size(12).color(t::TEXT_MUTED),
|
||||
text(
|
||||
caminho
|
||||
.file_name()
|
||||
.map(|n| n.to_string_lossy().to_string())
|
||||
.unwrap_or_default()
|
||||
)
|
||||
.size(12)
|
||||
.color(t::TEXT_SECONDARY),
|
||||
]
|
||||
.spacing(4)
|
||||
.align_y(Alignment::Center)
|
||||
} else {
|
||||
row![text("").size(12)]
|
||||
},
|
||||
]
|
||||
.spacing(4);
|
||||
|
||||
// Seletor de layout
|
||||
// ── Seletor de layout ──────────────────────────────────────────────────────
|
||||
let tipo_atual = app.tipo_arquivo_atual.clone();
|
||||
let opcoes_layout: Vec<(i64, String)> = app
|
||||
let opcoes_layout: Vec<String> = app
|
||||
.layouts_salvos
|
||||
.iter()
|
||||
.filter(|l| l.tipo() == tipo_atual)
|
||||
.filter_map(|l| l.id().map(|id| (id, l.nome().to_string())))
|
||||
.map(|l| l.nome().to_string())
|
||||
.collect();
|
||||
let nome_layout_atual = app.nome_layout_atual.clone();
|
||||
|
||||
ui.horizontal(|ui| {
|
||||
ui.label("Layout:");
|
||||
egui::ComboBox::from_id_salt("combo_layouts_config")
|
||||
.selected_text(if nome_layout_atual.is_empty() {
|
||||
"— Selecionar layout —"
|
||||
let nome_layout_sel: Option<String> = if app.nome_layout_atual.is_empty() {
|
||||
None
|
||||
} else {
|
||||
&nome_layout_atual
|
||||
Some(app.nome_layout_atual.clone())
|
||||
};
|
||||
|
||||
let secao_layout = container(
|
||||
row![
|
||||
text("Layout:")
|
||||
.size(13)
|
||||
.color(t::TEXT_SECONDARY)
|
||||
.width(Length::Shrink),
|
||||
pick_list(opcoes_layout, nome_layout_sel, {
|
||||
let layouts = app.layouts_salvos.clone();
|
||||
move |nome_selecionado: String| {
|
||||
if let Some(id) = layouts
|
||||
.iter()
|
||||
.find(|l| l.nome() == nome_selecionado)
|
||||
.and_then(|l| l.id())
|
||||
{
|
||||
Message::LayoutSelecionado(id)
|
||||
} else {
|
||||
Message::NomeLayoutAlterado(nome_selecionado)
|
||||
}
|
||||
}
|
||||
})
|
||||
.show_ui(ui, |ui| {
|
||||
for (id, nome) in &opcoes_layout {
|
||||
if ui
|
||||
.selectable_label(nome_layout_atual == *nome, nome.as_str())
|
||||
.clicked()
|
||||
{
|
||||
app.nome_layout_atual = nome.clone();
|
||||
if let Some(layout) =
|
||||
app.layouts_salvos.iter().find(|l| l.id() == Some(*id))
|
||||
{
|
||||
let layout = layout.clone();
|
||||
match &layout {
|
||||
Layout::Csv { config, .. } => {
|
||||
app.layout_csv_atual = config.clone();
|
||||
}
|
||||
Layout::Xlsx { config, .. } => {
|
||||
app.layout_xlsx_atual = config.clone();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
.placeholder("— Selecionar layout —")
|
||||
.style(input_style_pick)
|
||||
.width(Length::Fill),
|
||||
]
|
||||
.spacing(8)
|
||||
.align_y(Alignment::Center),
|
||||
)
|
||||
.style(t::card_secondary)
|
||||
.padding([10, 14])
|
||||
.width(Length::Fill);
|
||||
|
||||
ui.add_space(12.0);
|
||||
// ── Configuração específica ───────────────────────────────────────────────
|
||||
let config_section = match app.tipo_arquivo_atual {
|
||||
TipoArquivo::Csv => view_csv(app),
|
||||
TipoArquivo::Xlsx => view_xlsx(app),
|
||||
};
|
||||
|
||||
match app.tipo_arquivo_atual.clone() {
|
||||
TipoArquivo::Csv => renderizar_csv(ui, app),
|
||||
TipoArquivo::Xlsx => renderizar_xlsx(ui, app),
|
||||
}
|
||||
// ── Pré-visualização ──────────────────────────────────────────────────────
|
||||
let preview_section: Element<Message> = if let Some(linhas) = &app.preview_arquivo {
|
||||
container(
|
||||
column![
|
||||
text("Pré-visualização (5 primeiras linhas)")
|
||||
.size(13)
|
||||
.color(t::TEXT_SECONDARY),
|
||||
crate::ui::components::tabela_preview::tabela_preview(linhas),
|
||||
]
|
||||
.spacing(8),
|
||||
)
|
||||
.style(t::card)
|
||||
.padding(14)
|
||||
.width(Length::Fill)
|
||||
.into()
|
||||
} else {
|
||||
text("").size(1).into()
|
||||
};
|
||||
|
||||
// Pré-visualização do arquivo
|
||||
if let Some(preview) = &app.preview_arquivo.clone() {
|
||||
ui.add_space(8.0);
|
||||
ui.separator();
|
||||
ui.add_space(4.0);
|
||||
crate::ui::screens::renderizar_tabela_preview(ui, preview);
|
||||
}
|
||||
|
||||
ui.add_space(16.0);
|
||||
ui.separator();
|
||||
ui.add_space(8.0);
|
||||
|
||||
// Validação e botões de ação
|
||||
// ── Validação ─────────────────────────────────────────────────────────────
|
||||
let (valido, erros) = validar_config(app);
|
||||
|
||||
if !erros.is_empty() {
|
||||
for erro in &erros {
|
||||
ui.colored_label(egui::Color32::RED, format!("⚠ {}", erro));
|
||||
}
|
||||
ui.add_space(8.0);
|
||||
}
|
||||
let erros_section: Element<Message> = if erros.is_empty() {
|
||||
text("").size(1).into()
|
||||
} else {
|
||||
container(
|
||||
Column::with_children(
|
||||
erros
|
||||
.iter()
|
||||
.map(|e| text(format!("(!) {}", e)).size(13).color(t::DANGER).into())
|
||||
.collect::<Vec<_>>(),
|
||||
)
|
||||
.spacing(4),
|
||||
)
|
||||
.style(t::area_erro)
|
||||
.padding([10, 14])
|
||||
.width(Length::Fill)
|
||||
.into()
|
||||
};
|
||||
|
||||
ui.horizontal(|ui| {
|
||||
if ui.button("< Voltar").clicked() {
|
||||
app.estado = EstadoApp::Importando;
|
||||
}
|
||||
// ── Botões ────────────────────────────────────────────────────────────────
|
||||
let tem_arquivo = app.caminho_arquivo.is_some();
|
||||
let tem_notas = !app.notas_importadas.is_empty();
|
||||
|
||||
ui.add_enabled_ui(valido && app.caminho_arquivo.is_some(), |ui| {
|
||||
if ui.button("▶ Importar e Analisar").clicked() {
|
||||
executar_importacao(app, ctx);
|
||||
}
|
||||
});
|
||||
let botoes = container(
|
||||
row![
|
||||
button(text("Voltar").size(13))
|
||||
.on_press(Message::Voltar)
|
||||
.style(t::btn_ghost)
|
||||
.padding([9, 14]),
|
||||
button(text("Importar e Analisar").size(13))
|
||||
.on_press_maybe((valido && tem_arquivo).then_some(Message::ExecutarImportacao))
|
||||
.style(t::btn_primary)
|
||||
.padding([9, 14]),
|
||||
button(text("Reanalisar").size(13))
|
||||
.on_press_maybe((valido && tem_notas).then_some(Message::ReanalisarArquivo))
|
||||
.style(t::btn_secondary)
|
||||
.padding([9, 14]),
|
||||
button(text("Salvar layout").size(13))
|
||||
.on_press(Message::SalvarLayout)
|
||||
.style(t::btn_ghost)
|
||||
.padding([9, 14]),
|
||||
]
|
||||
.spacing(8)
|
||||
.align_y(Alignment::Center),
|
||||
)
|
||||
.style(t::card_secondary)
|
||||
.padding([12, 16])
|
||||
.width(Length::Fill);
|
||||
|
||||
if valido && !app.notas_importadas.is_empty() {
|
||||
if ui
|
||||
.button("🔄 Reanalisar")
|
||||
.on_hover_text("Reanalisa as notas já importadas sem reimportar o arquivo")
|
||||
.clicked()
|
||||
{
|
||||
app.executar_analise();
|
||||
}
|
||||
}
|
||||
// ── Layout geral ──────────────────────────────────────────────────────────
|
||||
let content = column![
|
||||
header,
|
||||
secao_layout,
|
||||
config_section,
|
||||
preview_section,
|
||||
erros_section,
|
||||
botoes,
|
||||
]
|
||||
.spacing(14)
|
||||
.padding([20, 24])
|
||||
.width(Length::Fill);
|
||||
|
||||
if ui.button("💾 Salvar como layout...").clicked() {
|
||||
app.exibir_modal_salvar_layout();
|
||||
}
|
||||
});
|
||||
container(scrollable(content))
|
||||
.style(t::fundo)
|
||||
.width(Length::Fill)
|
||||
.height(Length::Fill)
|
||||
.into()
|
||||
}
|
||||
|
||||
fn renderizar_csv(ui: &mut Ui, app: &mut App) {
|
||||
ui.group(|ui| {
|
||||
ui.label("Configurações CSV");
|
||||
ui.add_space(4.0);
|
||||
// ─── Estilo do pick_list ──────────────────────────────────────────────────────
|
||||
|
||||
// Delimitador
|
||||
ui.horizontal(|ui| {
|
||||
ui.label("Delimitador:");
|
||||
let delim_str = match app.layout_csv_atual.delimitador {
|
||||
fn input_style_pick(
|
||||
theme: &iced::Theme,
|
||||
status: iced::widget::pick_list::Status,
|
||||
) -> iced::widget::pick_list::Style {
|
||||
let base = iced::widget::pick_list::Style {
|
||||
text_color: t::TEXT,
|
||||
placeholder_color: t::TEXT_MUTED,
|
||||
handle_color: t::TEXT_SECONDARY,
|
||||
background: t::BG.into(),
|
||||
border: iced::Border {
|
||||
color: t::BORDER,
|
||||
width: 1.0,
|
||||
radius: 6.0.into(),
|
||||
},
|
||||
};
|
||||
match status {
|
||||
iced::widget::pick_list::Status::Opened | iced::widget::pick_list::Status::Hovered => {
|
||||
iced::widget::pick_list::Style {
|
||||
border: iced::Border {
|
||||
color: t::PRIMARY,
|
||||
..base.border
|
||||
},
|
||||
..base
|
||||
}
|
||||
}
|
||||
_ => {
|
||||
let _ = theme;
|
||||
base
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Configuração CSV ────────────────────────────────────────────────────────
|
||||
|
||||
fn view_csv(app: &App) -> Element<'_, Message> {
|
||||
let c = &app.layout_csv_atual;
|
||||
|
||||
let delim_str = match c.delimitador {
|
||||
',' => "Vírgula (,)",
|
||||
';' => "Ponto e vírgula (;)",
|
||||
'\t' => "Tabulação (Tab)",
|
||||
_ => "Outro",
|
||||
};
|
||||
let mut delim_mudou = false;
|
||||
egui::ComboBox::from_id_salt("combo_delimitador")
|
||||
.selected_text(delim_str)
|
||||
.show_ui(ui, |ui| {
|
||||
if ui
|
||||
.selectable_label(app.layout_csv_atual.delimitador == ',', "Vírgula (,)")
|
||||
.clicked()
|
||||
{
|
||||
app.layout_csv_atual.delimitador = ',';
|
||||
delim_mudou = true;
|
||||
}
|
||||
if ui
|
||||
.selectable_label(
|
||||
app.layout_csv_atual.delimitador == ';',
|
||||
"Ponto e vírgula (;)",
|
||||
.to_string();
|
||||
|
||||
let opcoes_delim: Vec<String> = OPCOES_DELIMITADOR
|
||||
.iter()
|
||||
.map(|(s, _)| s.to_string())
|
||||
.collect();
|
||||
let opcoes_enc: Vec<String> = OPCOES_ENCODING.iter().map(|s| s.to_string()).collect();
|
||||
|
||||
let linha_cabecalho_str = c.linha_cabecalho.to_string();
|
||||
let indice_numero_str = c.indice_numero.to_string();
|
||||
let indice_serie_str = c.indice_serie.to_string();
|
||||
|
||||
let inner = column![
|
||||
secao_titulo("Importação CSV"),
|
||||
campo_row(
|
||||
"Delimitador",
|
||||
pick_list(opcoes_delim, Some(delim_str), |selecionado| {
|
||||
let c = OPCOES_DELIMITADOR
|
||||
.iter()
|
||||
.find(|(s, _)| *s == selecionado)
|
||||
.map(|(_, c)| *c)
|
||||
.unwrap_or(',');
|
||||
Message::DelimitadorAlterado(c)
|
||||
})
|
||||
.style(input_style_pick)
|
||||
.width(Length::Fill)
|
||||
.into(),
|
||||
),
|
||||
campo_row(
|
||||
"Encoding",
|
||||
pick_list(
|
||||
opcoes_enc,
|
||||
Some(c.encoding.clone()),
|
||||
Message::EncodingAlterado
|
||||
)
|
||||
.clicked()
|
||||
{
|
||||
app.layout_csv_atual.delimitador = ';';
|
||||
delim_mudou = true;
|
||||
}
|
||||
if ui
|
||||
.selectable_label(
|
||||
app.layout_csv_atual.delimitador == '\t',
|
||||
"Tabulação (Tab)",
|
||||
)
|
||||
.clicked()
|
||||
{
|
||||
app.layout_csv_atual.delimitador = '\t';
|
||||
delim_mudou = true;
|
||||
}
|
||||
});
|
||||
if delim_mudou {
|
||||
if let Some(caminho) = &app.caminho_arquivo.clone() {
|
||||
app.preview_arquivo = crate::infrastructure::csv_reader::preview_csv(
|
||||
caminho,
|
||||
app.layout_csv_atual.delimitador as u8,
|
||||
&app.layout_csv_atual.encoding.clone(),
|
||||
5,
|
||||
).ok();
|
||||
}
|
||||
}
|
||||
});
|
||||
.style(input_style_pick)
|
||||
.width(Length::Fill)
|
||||
.into(),
|
||||
),
|
||||
campo_row(
|
||||
"Linha cabeçalho",
|
||||
text_input("0", &linha_cabecalho_str)
|
||||
.on_input(|s| {
|
||||
s.parse::<usize>()
|
||||
.map(Message::LinhaCabecalhoAlterada)
|
||||
.unwrap_or(Message::Noop)
|
||||
})
|
||||
.style(t::input_dark)
|
||||
.width(Length::Fixed(90.0))
|
||||
.into(),
|
||||
),
|
||||
secao_subtitulo("Mapeamento de colunas (índice base 0)"),
|
||||
campo_row(
|
||||
"Número (obrigatório)",
|
||||
text_input("0", &indice_numero_str)
|
||||
.on_input(|s| {
|
||||
s.parse::<usize>()
|
||||
.map(Message::IndiceNumeroAlterado)
|
||||
.unwrap_or(Message::Noop)
|
||||
})
|
||||
.style(t::input_dark)
|
||||
.width(Length::Fixed(90.0))
|
||||
.into(),
|
||||
),
|
||||
campo_row(
|
||||
"Série (obrigatório)",
|
||||
text_input("0", &indice_serie_str)
|
||||
.on_input(|s| {
|
||||
s.parse::<usize>()
|
||||
.map(Message::IndiceSerieAlterado)
|
||||
.unwrap_or(Message::Noop)
|
||||
})
|
||||
.style(t::input_dark)
|
||||
.width(Length::Fixed(90.0))
|
||||
.into(),
|
||||
),
|
||||
campo_indice_opcional_csv(
|
||||
"Valor (opcional)",
|
||||
c.indice_valor,
|
||||
Message::IndiceValorToggle,
|
||||
Message::IndiceValorAlterado,
|
||||
),
|
||||
campo_indice_opcional_csv(
|
||||
"Data (opcional)",
|
||||
c.indice_data,
|
||||
Message::IndiceDataToggle,
|
||||
Message::IndiceDataAlterado,
|
||||
),
|
||||
campo_indice_opcional_csv(
|
||||
"Tipo Documento (opcional)",
|
||||
c.indice_documento_tipo,
|
||||
Message::IndiceDocTipoToggle,
|
||||
Message::IndiceDocTipoAlterado,
|
||||
),
|
||||
]
|
||||
.spacing(10);
|
||||
|
||||
// Encoding
|
||||
ui.horizontal(|ui| {
|
||||
ui.label("Encoding:");
|
||||
egui::ComboBox::from_id_salt("combo_encoding")
|
||||
.selected_text(&app.layout_csv_atual.encoding)
|
||||
.show_ui(ui, |ui| {
|
||||
if ui
|
||||
.selectable_label(app.layout_csv_atual.encoding == "utf-8", "UTF-8")
|
||||
.clicked()
|
||||
{
|
||||
app.layout_csv_atual.encoding = "utf-8".to_string();
|
||||
}
|
||||
if ui
|
||||
.selectable_label(
|
||||
app.layout_csv_atual.encoding == "windows-1252",
|
||||
"Windows-1252 (Latin-1)",
|
||||
)
|
||||
.clicked()
|
||||
{
|
||||
app.layout_csv_atual.encoding = "windows-1252".to_string();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// Linha cabeçalho
|
||||
ui.horizontal(|ui| {
|
||||
ui.label("Linha do cabeçalho (0 = sem cabeçalho):");
|
||||
let mut val = app.layout_csv_atual.linha_cabecalho;
|
||||
ui.add(egui::DragValue::new(&mut val).range(0..=100));
|
||||
app.layout_csv_atual.linha_cabecalho = val;
|
||||
});
|
||||
});
|
||||
|
||||
ui.add_space(8.0);
|
||||
ui.group(|ui| {
|
||||
ui.label("Mapeamento de Colunas (índice base 0)");
|
||||
ui.add_space(4.0);
|
||||
|
||||
campo_indice(
|
||||
ui,
|
||||
"Número (obrigatório):",
|
||||
&mut app.layout_csv_atual.indice_numero,
|
||||
);
|
||||
campo_indice(
|
||||
ui,
|
||||
"Série (obrigatório):",
|
||||
&mut app.layout_csv_atual.indice_serie,
|
||||
);
|
||||
|
||||
campo_indice_opcional(
|
||||
ui,
|
||||
"Valor (opcional):",
|
||||
&mut app.layout_csv_atual.indice_valor,
|
||||
);
|
||||
campo_indice_opcional(
|
||||
ui,
|
||||
"Data (opcional):",
|
||||
&mut app.layout_csv_atual.indice_data,
|
||||
);
|
||||
campo_indice_opcional(
|
||||
ui,
|
||||
"Tipo Documento (opcional):",
|
||||
&mut app.layout_csv_atual.indice_documento_tipo,
|
||||
);
|
||||
});
|
||||
container(inner)
|
||||
.style(t::card)
|
||||
.padding([14, 18])
|
||||
.width(Length::Fill)
|
||||
.into()
|
||||
}
|
||||
|
||||
fn renderizar_xlsx(ui: &mut Ui, app: &mut App) {
|
||||
ui.group(|ui| {
|
||||
ui.label("Configurações XLSX");
|
||||
ui.add_space(4.0);
|
||||
// ─── Configuração XLSX ────────────────────────────────────────────────────────
|
||||
|
||||
ui.horizontal(|ui| {
|
||||
ui.label("Aba:");
|
||||
if app.abas_xlsx.is_empty() {
|
||||
ui.text_edit_singleline(&mut app.layout_xlsx_atual.aba);
|
||||
fn view_xlsx(app: &App) -> Element<'_, Message> {
|
||||
let c = &app.layout_xlsx_atual;
|
||||
|
||||
let secao_aba: Element<Message> = if app.abas_xlsx.is_empty() {
|
||||
campo_row(
|
||||
"Aba",
|
||||
text_input("Nome da aba", &c.aba)
|
||||
.on_input(Message::AbaXlsxAlterada)
|
||||
.style(t::input_dark)
|
||||
.width(Length::Fill)
|
||||
.into(),
|
||||
)
|
||||
} else {
|
||||
let aba_atual = app.layout_xlsx_atual.aba.clone();
|
||||
egui::ComboBox::from_id_salt("combo_aba")
|
||||
.selected_text(&aba_atual)
|
||||
.show_ui(ui, |ui| {
|
||||
for aba in &app.abas_xlsx.clone() {
|
||||
if ui.selectable_label(aba_atual == *aba, aba).clicked() {
|
||||
app.layout_xlsx_atual.aba = aba.clone();
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
ui.add_space(8.0);
|
||||
ui.group(|ui| {
|
||||
ui.label("Mapeamento de Colunas (formato LetraLinha, ex: B3)");
|
||||
ui.add_space(4.0);
|
||||
let aba_sel = if c.aba.is_empty() {
|
||||
None
|
||||
} else {
|
||||
Some(c.aba.clone())
|
||||
};
|
||||
campo_row(
|
||||
"Aba",
|
||||
pick_list(app.abas_xlsx.clone(), aba_sel, Message::AbaXlsxAlterada)
|
||||
.style(input_style_pick)
|
||||
.width(Length::Fill)
|
||||
.into(),
|
||||
)
|
||||
};
|
||||
|
||||
let inner = column![
|
||||
secao_titulo("Importação XLSX"),
|
||||
secao_aba,
|
||||
secao_subtitulo("Mapeamento de colunas (formato LetraLinha, ex: B3)"),
|
||||
campo_letra_linha(
|
||||
ui,
|
||||
"Número (obrigatório):",
|
||||
&mut app.layout_xlsx_atual.pos_numero,
|
||||
);
|
||||
"Número (obrigatório)",
|
||||
&c.pos_numero,
|
||||
Message::PosNumeroAlterada
|
||||
),
|
||||
campo_letra_linha(
|
||||
ui,
|
||||
"Série (obrigatório):",
|
||||
&mut app.layout_xlsx_atual.pos_serie,
|
||||
);
|
||||
"Série (obrigatório)",
|
||||
&c.pos_serie,
|
||||
Message::PosSerieAlterada
|
||||
),
|
||||
campo_letra_linha_opcional(
|
||||
ui,
|
||||
"Valor (opcional):",
|
||||
&mut app.layout_xlsx_atual.pos_valor,
|
||||
);
|
||||
campo_letra_linha_opcional(ui, "Data (opcional):", &mut app.layout_xlsx_atual.pos_data);
|
||||
"Valor (opcional)",
|
||||
c.pos_valor.as_deref(),
|
||||
Message::PosValorToggle,
|
||||
Message::PosValorAlterada,
|
||||
),
|
||||
campo_letra_linha_opcional(
|
||||
ui,
|
||||
"Tipo Documento (opcional):",
|
||||
&mut app.layout_xlsx_atual.pos_documento_tipo,
|
||||
);
|
||||
});
|
||||
"Data (opcional)",
|
||||
c.pos_data.as_deref(),
|
||||
Message::PosDataToggle,
|
||||
Message::PosDataAlterada,
|
||||
),
|
||||
campo_letra_linha_opcional(
|
||||
"Tipo Documento (opcional)",
|
||||
c.pos_documento_tipo.as_deref(),
|
||||
Message::PosDocTipoToggle,
|
||||
Message::PosDocTipoAlterada,
|
||||
),
|
||||
]
|
||||
.spacing(10);
|
||||
|
||||
container(inner)
|
||||
.style(t::card)
|
||||
.padding([14, 18])
|
||||
.width(Length::Fill)
|
||||
.into()
|
||||
}
|
||||
|
||||
fn campo_indice(ui: &mut Ui, label: &str, valor: &mut usize) {
|
||||
ui.horizontal(|ui| {
|
||||
ui.label(label);
|
||||
ui.add(egui::DragValue::new(valor).range(0..=999usize));
|
||||
});
|
||||
// ─── Helpers visuais ─────────────────────────────────────────────────────────
|
||||
|
||||
fn secao_titulo(label: &str) -> Element<'_, Message> {
|
||||
text(label).size(14).color(t::TEXT).into()
|
||||
}
|
||||
|
||||
fn campo_indice_opcional(ui: &mut Ui, label: &str, valor: &mut Option<usize>) {
|
||||
ui.horizontal(|ui| {
|
||||
let mut ativo = valor.is_some();
|
||||
if ui.checkbox(&mut ativo, label).changed() {
|
||||
*valor = if ativo { Some(0) } else { None };
|
||||
fn secao_subtitulo(label: &str) -> Element<'_, Message> {
|
||||
text(label).size(12).color(t::TEXT_SECONDARY).into()
|
||||
}
|
||||
|
||||
fn campo_row<'a>(label: &'a str, input: Element<'a, Message>) -> Element<'a, Message> {
|
||||
row![
|
||||
text(label)
|
||||
.size(13)
|
||||
.color(t::TEXT_SECONDARY)
|
||||
.width(Length::FillPortion(3)),
|
||||
input,
|
||||
]
|
||||
.spacing(10)
|
||||
.align_y(Alignment::Center)
|
||||
.into()
|
||||
}
|
||||
|
||||
fn campo_indice_opcional_csv(
|
||||
label: &str,
|
||||
valor: Option<usize>,
|
||||
msg_toggle: impl Fn(bool) -> Message + 'static,
|
||||
msg_valor: impl Fn(usize) -> Message + 'static,
|
||||
) -> Element<'static, Message> {
|
||||
let ativo = valor.is_some();
|
||||
let val_str = valor.map(|v| v.to_string()).unwrap_or_default();
|
||||
|
||||
let cb = checkbox(label, ativo).on_toggle(msg_toggle).text_size(13);
|
||||
|
||||
if ativo {
|
||||
row![
|
||||
cb.width(Length::FillPortion(3)),
|
||||
text_input("0", &val_str)
|
||||
.on_input(move |s| { s.parse::<usize>().map(&msg_valor).unwrap_or(Message::Noop) })
|
||||
.style(t::input_dark)
|
||||
.width(Length::Fixed(90.0)),
|
||||
]
|
||||
.spacing(10)
|
||||
.align_y(Alignment::Center)
|
||||
.into()
|
||||
} else {
|
||||
row![cb].into()
|
||||
}
|
||||
if let Some(v) = valor {
|
||||
ui.add(egui::DragValue::new(v).range(0..=999usize));
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
fn campo_letra_linha(ui: &mut Ui, label: &str, valor: &mut String) {
|
||||
ui.horizontal(|ui| {
|
||||
ui.label(label);
|
||||
ui.text_edit_singleline(valor);
|
||||
});
|
||||
fn campo_letra_linha<'a>(
|
||||
label: &'a str,
|
||||
valor: &'a str,
|
||||
msg: impl Fn(String) -> Message + 'a,
|
||||
) -> Element<'a, Message> {
|
||||
campo_row(
|
||||
label,
|
||||
text_input("ex: B3", valor)
|
||||
.on_input(msg)
|
||||
.style(t::input_dark)
|
||||
.width(Length::Fixed(110.0))
|
||||
.into(),
|
||||
)
|
||||
}
|
||||
|
||||
fn campo_letra_linha_opcional(ui: &mut Ui, label: &str, valor: &mut Option<String>) {
|
||||
ui.horizontal(|ui| {
|
||||
let mut ativo = valor.is_some();
|
||||
if ui.checkbox(&mut ativo, label).changed() {
|
||||
*valor = if ativo { Some(String::new()) } else { None };
|
||||
fn campo_letra_linha_opcional<'a>(
|
||||
label: &'a str,
|
||||
valor: Option<&'a str>,
|
||||
msg_toggle: impl Fn(bool) -> Message + 'a,
|
||||
msg_valor: impl Fn(String) -> Message + 'a,
|
||||
) -> Element<'a, Message> {
|
||||
let ativo = valor.is_some();
|
||||
let val_str = valor.unwrap_or("").to_string();
|
||||
|
||||
let cb = checkbox(label, ativo).on_toggle(msg_toggle).text_size(13);
|
||||
|
||||
if ativo {
|
||||
row![
|
||||
cb.width(Length::FillPortion(3)),
|
||||
text_input("ex: B3", &val_str)
|
||||
.on_input(msg_valor)
|
||||
.style(t::input_dark)
|
||||
.width(Length::Fixed(110.0)),
|
||||
]
|
||||
.spacing(10)
|
||||
.align_y(Alignment::Center)
|
||||
.into()
|
||||
} else {
|
||||
row![cb].into()
|
||||
}
|
||||
if let Some(v) = valor {
|
||||
ui.text_edit_singleline(v);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/// Valida a configuração atual. Retorna (é_válido, lista_de_erros).
|
||||
// ─── Validação ────────────────────────────────────────────────────────────────
|
||||
|
||||
fn validar_config(app: &App) -> (bool, Vec<String>) {
|
||||
let mut erros = Vec::new();
|
||||
|
||||
match &app.tipo_arquivo_atual {
|
||||
TipoArquivo::Csv => {
|
||||
let c = &app.layout_csv_atual;
|
||||
// Verificar índices duplicados
|
||||
let mut indices: Vec<(String, usize)> = vec![
|
||||
("Numero".to_string(), c.indice_numero),
|
||||
("Serie".to_string(), c.indice_serie),
|
||||
@@ -393,62 +554,3 @@ fn verificar_duplicados(indices: &[(String, usize)], erros: &mut Vec<String>) {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn executar_importacao(app: &mut App, ctx: &egui::Context) {
|
||||
let caminho = match &app.caminho_arquivo {
|
||||
Some(p) => p.clone(),
|
||||
None => return,
|
||||
};
|
||||
|
||||
let tipo = app.tipo_arquivo_atual.clone();
|
||||
let layout_csv = app.layout_csv_atual.clone();
|
||||
let layout_xlsx = app.layout_xlsx_atual.clone();
|
||||
|
||||
let (tx, rx) = std::sync::mpsc::channel();
|
||||
app.resultado_pendente = Some(rx);
|
||||
app.estado = EstadoApp::Analisando;
|
||||
ctx.request_repaint();
|
||||
|
||||
std::thread::spawn(move || {
|
||||
// 1. Importar arquivo
|
||||
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()),
|
||||
};
|
||||
|
||||
let res = match res_importacao {
|
||||
Err(e) => ResultadoPendente::Erro(e),
|
||||
Ok(importado) => {
|
||||
if importado.notas.is_empty() {
|
||||
ResultadoPendente::Vazio
|
||||
} else {
|
||||
let avisos = importado.avisos.clone();
|
||||
let notas = importado.notas;
|
||||
|
||||
// 2. Pré-análise
|
||||
let pre = pre_analisar(¬as);
|
||||
let excessivos = series_com_intervalo_excessivo(&pre);
|
||||
|
||||
if !excessivos.is_empty() {
|
||||
ResultadoPendente::AguardandoConfirmacao {
|
||||
pre,
|
||||
series_excessivas: excessivos,
|
||||
avisos,
|
||||
notas,
|
||||
}
|
||||
} else {
|
||||
// 3. Expandir faltantes
|
||||
let resultado = expandir_analise(pre, ¬as);
|
||||
ResultadoPendente::Concluido {
|
||||
resultado,
|
||||
avisos: Some(avisos),
|
||||
notas: Some(notas),
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
let _ = tx.send(res);
|
||||
});
|
||||
}
|
||||
|
||||
+205
-303
@@ -1,328 +1,230 @@
|
||||
use crate::application::usecases::executar_analise::{
|
||||
expandir_analise, pre_analisar, series_com_intervalo_excessivo,
|
||||
};
|
||||
use crate::application::usecases::importar_arquivo::{importar_xlsx, listar_abas_xlsx};
|
||||
use crate::domain::entities::layout::{Layout, TipoArquivo};
|
||||
use crate::ui::app::{App, EstadoApp, ResultadoPendente};
|
||||
use egui::{Context, Ui};
|
||||
use std::path::PathBuf;
|
||||
use crate::ui::app::App;
|
||||
use crate::ui::message::Message;
|
||||
use crate::ui::theme as t;
|
||||
use iced::widget::{button, column, container, pick_list, row, scrollable, text};
|
||||
use iced::{Alignment, Element, Length};
|
||||
|
||||
/// Renderiza a tela de importação de arquivos.
|
||||
pub fn renderizar(ui: &mut Ui, ctx: &Context, app: &mut App) {
|
||||
ui.heading("Comparador de Notas — Importar Arquivo");
|
||||
ui.add_space(16.0);
|
||||
|
||||
// --- Seleção de arquivo ---
|
||||
ui.group(|ui| {
|
||||
ui.label("Arquivo:");
|
||||
ui.horizontal(|ui| {
|
||||
let nome = if app.nome_arquivo.is_empty() {
|
||||
"Nenhum arquivo selecionado".to_string()
|
||||
/// Tela de importação de arquivos.
|
||||
pub fn view(app: &App) -> Element<'_, Message> {
|
||||
// ── Área de seleção de arquivo ────────────────────────────────────────────
|
||||
let (nome_arquivo, tem_arquivo) = if app.nome_arquivo.is_empty() {
|
||||
("Nenhum arquivo selecionado".to_string(), false)
|
||||
} else {
|
||||
app.nome_arquivo.clone()
|
||||
(app.nome_arquivo.clone(), true)
|
||||
};
|
||||
ui.label(nome);
|
||||
|
||||
if ui.button("📂 Selecionar arquivo...").clicked() {
|
||||
if let Some(caminho) = rfd::FileDialog::new()
|
||||
.add_filter("Planilhas", &["csv", "xlsx", "xls"])
|
||||
.pick_file()
|
||||
{
|
||||
on_arquivo_selecionado(app, ctx, caminho);
|
||||
let em_hover = app.arquivo_em_hover;
|
||||
|
||||
let icone_arquivo: Element<Message> = container(
|
||||
text(if tem_arquivo { "CSV / XLSX" } else { "Arquivo" })
|
||||
.size(12)
|
||||
.color(if tem_arquivo {
|
||||
t::PRIMARY
|
||||
} else {
|
||||
t::TEXT_MUTED
|
||||
}),
|
||||
)
|
||||
.padding([6, 12])
|
||||
.style(move |_theme| iced::widget::container::Style {
|
||||
background: Some(
|
||||
iced::Color {
|
||||
a: 0.1,
|
||||
..if tem_arquivo {
|
||||
t::PRIMARY
|
||||
} else {
|
||||
t::TEXT_MUTED
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
.into(),
|
||||
),
|
||||
border: iced::Border {
|
||||
color: iced::Color {
|
||||
a: 0.3,
|
||||
..if tem_arquivo {
|
||||
t::PRIMARY
|
||||
} else {
|
||||
t::TEXT_MUTED
|
||||
}
|
||||
},
|
||||
width: 1.0,
|
||||
radius: 6.0.into(),
|
||||
},
|
||||
..Default::default()
|
||||
})
|
||||
.into();
|
||||
|
||||
ui.add_space(8.0);
|
||||
let texto_arquivo: Element<Message> = if tem_arquivo {
|
||||
let nome_str = nome_arquivo.clone();
|
||||
column![
|
||||
text(nome_str).size(14).color(t::TEXT),
|
||||
text("Arquivo pronto para configuração")
|
||||
.size(12)
|
||||
.color(t::TEXT_SECONDARY),
|
||||
]
|
||||
.spacing(4)
|
||||
.align_x(Alignment::Center)
|
||||
.into()
|
||||
} else {
|
||||
column![
|
||||
text("Selecione ou arraste um arquivo CSV ou XLSX")
|
||||
.size(14)
|
||||
.color(t::TEXT_SECONDARY),
|
||||
text("Suportado: .csv, .xlsx, .xls")
|
||||
.size(12)
|
||||
.color(t::TEXT_MUTED),
|
||||
]
|
||||
.spacing(4)
|
||||
.align_x(Alignment::Center)
|
||||
.into()
|
||||
};
|
||||
|
||||
// Coletar infos dos layouts antecipadamente para evitar borrow duplo
|
||||
let drop_zone = container(
|
||||
column![icone_arquivo, texto_arquivo]
|
||||
.spacing(12)
|
||||
.align_x(Alignment::Center)
|
||||
.width(Length::Fill),
|
||||
)
|
||||
.style(move |_theme| iced::widget::container::Style {
|
||||
background: Some(
|
||||
iced::Color {
|
||||
a: if em_hover { 0.12 } else { 0.05 },
|
||||
..t::PRIMARY
|
||||
}
|
||||
.into(),
|
||||
),
|
||||
border: iced::Border {
|
||||
color: if em_hover {
|
||||
iced::Color {
|
||||
a: 0.9,
|
||||
..t::PRIMARY
|
||||
}
|
||||
} else if tem_arquivo {
|
||||
iced::Color {
|
||||
a: 0.5,
|
||||
..t::PRIMARY
|
||||
}
|
||||
} else {
|
||||
iced::Color {
|
||||
a: 0.3,
|
||||
..t::BORDER
|
||||
}
|
||||
},
|
||||
width: if em_hover { 2.0 } else { 1.5 },
|
||||
radius: 8.0.into(),
|
||||
},
|
||||
..Default::default()
|
||||
})
|
||||
.padding([28, 20])
|
||||
.width(Length::Fill);
|
||||
|
||||
let btn_selecionar = button(
|
||||
row![text("Selecionar arquivo...").size(14)]
|
||||
.align_y(Alignment::Center)
|
||||
.spacing(6),
|
||||
)
|
||||
.on_press(Message::SelecionarArquivo)
|
||||
.style(t::btn_primary)
|
||||
.width(Length::Fill)
|
||||
.padding([10, 16]);
|
||||
|
||||
// ── Seção de layout ───────────────────────────────────────────────────────
|
||||
let tipo_atual = app.tipo_arquivo_atual.clone();
|
||||
let opcoes_layout: Vec<(i64, String)> = app
|
||||
let opcoes_layout: Vec<String> = app
|
||||
.layouts_salvos
|
||||
.iter()
|
||||
.filter(|l| l.tipo() == tipo_atual)
|
||||
.filter_map(|l| l.id().map(|id| (id, l.nome().to_string())))
|
||||
.map(|l| l.nome().to_string())
|
||||
.collect();
|
||||
let nome_layout_atual = app.nome_layout_atual.clone();
|
||||
|
||||
ui.horizontal(|ui| {
|
||||
ui.label("Layout:");
|
||||
egui::ComboBox::from_id_salt("combo_layouts_import")
|
||||
.selected_text(if nome_layout_atual.is_empty() {
|
||||
"— Selecionar layout —"
|
||||
let nome_layout_sel: Option<String> = if app.nome_layout_atual.is_empty() {
|
||||
None
|
||||
} else {
|
||||
&nome_layout_atual
|
||||
})
|
||||
.show_ui(ui, |ui| {
|
||||
for (id, nome) in &opcoes_layout {
|
||||
if ui
|
||||
.selectable_label(nome_layout_atual == *nome, nome.as_str())
|
||||
.clicked()
|
||||
{
|
||||
app.nome_layout_atual = nome.clone();
|
||||
if let Some(layout) =
|
||||
app.layouts_salvos.iter().find(|l| l.id() == Some(*id))
|
||||
{
|
||||
let layout = layout.clone();
|
||||
aplicar_layout(app, &layout);
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
if ui.button("⚙ Gerenciar Layouts").clicked() {
|
||||
app.estado = EstadoApp::GerenciandoLayouts;
|
||||
}
|
||||
});
|
||||
|
||||
ui.add_space(16.0);
|
||||
|
||||
if app.caminho_arquivo.is_some() {
|
||||
if ui.button("▶ Configurar Colunas").clicked() {
|
||||
app.estado = EstadoApp::ConfigurandoColunas;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Renderiza a tela de seleção de aba (XLSX).
|
||||
pub fn renderizar_selecao_aba(ui: &mut Ui, ctx: &Context, app: &mut App) {
|
||||
ui.heading("Selecionar Aba da Planilha");
|
||||
ui.add_space(16.0);
|
||||
|
||||
let (abas, caminho) = match &app.estado {
|
||||
EstadoApp::SelecionandoAba { abas, caminho } => (abas.clone(), caminho.clone()),
|
||||
_ => return,
|
||||
Some(app.nome_layout_atual.clone())
|
||||
};
|
||||
|
||||
ui.label(format!("Arquivo: {}", caminho.display()));
|
||||
ui.add_space(8.0);
|
||||
|
||||
// --- Seleção de preset ---
|
||||
let opcoes_layout: Vec<(i64, String)> = app
|
||||
.layouts_salvos
|
||||
let secao_layout = row![
|
||||
text("Layout salvo:")
|
||||
.size(13)
|
||||
.color(t::TEXT_SECONDARY)
|
||||
.width(Length::Shrink),
|
||||
pick_list(opcoes_layout, nome_layout_sel, {
|
||||
let layouts = app.layouts_salvos.clone();
|
||||
move |nome_selecionado: String| {
|
||||
if let Some(id) = layouts
|
||||
.iter()
|
||||
.filter(|l| l.tipo() == TipoArquivo::Xlsx)
|
||||
.filter_map(|l| l.id().map(|id| (id, l.nome().to_string())))
|
||||
.collect();
|
||||
let nome_layout_atual = app.nome_layout_atual.clone();
|
||||
|
||||
if !opcoes_layout.is_empty() {
|
||||
ui.horizontal(|ui| {
|
||||
ui.label("Layout:");
|
||||
egui::ComboBox::from_id_salt("combo_layouts_aba")
|
||||
.selected_text(if nome_layout_atual.is_empty() {
|
||||
"— Selecionar layout —"
|
||||
.find(|l| l.nome() == nome_selecionado)
|
||||
.and_then(|l| l.id())
|
||||
{
|
||||
Message::LayoutSelecionado(id)
|
||||
} else {
|
||||
&nome_layout_atual
|
||||
Message::NomeLayoutAlterado(nome_selecionado)
|
||||
}
|
||||
}
|
||||
})
|
||||
.show_ui(ui, |ui| {
|
||||
for (id, nome) in &opcoes_layout {
|
||||
if ui
|
||||
.selectable_label(nome_layout_atual == *nome, nome.as_str())
|
||||
.clicked()
|
||||
{
|
||||
app.nome_layout_atual = nome.clone();
|
||||
if let Some(layout) =
|
||||
app.layouts_salvos.iter().find(|l| l.id() == Some(*id))
|
||||
{
|
||||
let layout = layout.clone();
|
||||
aplicar_layout(app, &layout);
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
ui.add_space(8.0);
|
||||
}
|
||||
.placeholder("— Selecionar layout —")
|
||||
.width(Length::Fill),
|
||||
button(text("Gerenciar").size(13))
|
||||
.on_press(Message::IrParaLayouts)
|
||||
.style(t::btn_ghost)
|
||||
.padding([8, 12]),
|
||||
]
|
||||
.spacing(8)
|
||||
.align_y(Alignment::Center);
|
||||
|
||||
ui.label("Selecione a aba a processar:");
|
||||
|
||||
let aba_atual = app.layout_xlsx_atual.aba.clone();
|
||||
for aba in &abas {
|
||||
if ui.selectable_label(aba_atual == *aba, aba).clicked() {
|
||||
app.layout_xlsx_atual.aba = aba.clone();
|
||||
// Gerar pré-visualização da aba selecionada
|
||||
app.preview_arquivo =
|
||||
crate::infrastructure::xlsx_reader::preview_xlsx(&caminho, aba).ok();
|
||||
}
|
||||
}
|
||||
|
||||
// Pré-visualização da aba selecionada
|
||||
if !app.layout_xlsx_atual.aba.is_empty() {
|
||||
if let Some(preview) = &app.preview_arquivo {
|
||||
ui.add_space(8.0);
|
||||
crate::ui::screens::renderizar_tabela_preview(ui, preview);
|
||||
}
|
||||
}
|
||||
|
||||
ui.add_space(12.0);
|
||||
|
||||
if !app.layout_xlsx_atual.aba.is_empty() {
|
||||
ui.horizontal(|ui| {
|
||||
// Se há preset selecionado, oferecer processamento direto
|
||||
let tem_preset = !app.nome_layout_atual.is_empty();
|
||||
if tem_preset {
|
||||
let caminho_clone = caminho.clone();
|
||||
if ui.button("▶ Processar").clicked() {
|
||||
app.nome_arquivo = caminho_clone
|
||||
.file_name()
|
||||
.map(|n| n.to_string_lossy().to_string())
|
||||
.unwrap_or_default();
|
||||
app.caminho_arquivo = Some(caminho_clone);
|
||||
disparar_analise(app, ctx);
|
||||
}
|
||||
}
|
||||
|
||||
if ui.button("⚙ Configurar Colunas").clicked() {
|
||||
app.nome_arquivo = caminho
|
||||
.file_name()
|
||||
.map(|n| n.to_string_lossy().to_string())
|
||||
.unwrap_or_default();
|
||||
app.caminho_arquivo = Some(caminho);
|
||||
app.estado = EstadoApp::ConfigurandoColunas;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
if ui.button("< Voltar").clicked() {
|
||||
app.estado = EstadoApp::Importando;
|
||||
}
|
||||
}
|
||||
|
||||
fn on_arquivo_selecionado(app: &mut App, ctx: &Context, caminho: PathBuf) {
|
||||
let extensao = caminho
|
||||
.extension()
|
||||
.and_then(|e| e.to_str())
|
||||
.unwrap_or("")
|
||||
.to_lowercase();
|
||||
|
||||
app.nome_arquivo = caminho
|
||||
.file_name()
|
||||
.map(|n| n.to_string_lossy().to_string())
|
||||
.unwrap_or_default();
|
||||
|
||||
match extensao.as_str() {
|
||||
"csv" => {
|
||||
app.tipo_arquivo_atual = TipoArquivo::Csv;
|
||||
app.caminho_arquivo = Some(caminho.clone());
|
||||
app.notas_importadas.clear();
|
||||
// Gerar pré-visualização com o delimitador atual
|
||||
app.preview_arquivo = crate::infrastructure::csv_reader::preview_csv(
|
||||
&caminho,
|
||||
app.layout_csv_atual.delimitador as u8,
|
||||
&app.layout_csv_atual.encoding.clone(),
|
||||
5,
|
||||
)
|
||||
.ok();
|
||||
}
|
||||
"xlsx" | "xls" => {
|
||||
app.tipo_arquivo_atual = TipoArquivo::Xlsx;
|
||||
match listar_abas_xlsx(&caminho) {
|
||||
Ok(info) => {
|
||||
app.notas_importadas.clear();
|
||||
|
||||
// Verificar se há preset XLSX ativo com aba compatível
|
||||
let preset_aba = if !app.nome_layout_atual.is_empty() {
|
||||
let aba = app.layout_xlsx_atual.aba.clone();
|
||||
if !aba.is_empty() && info.abas.contains(&aba) {
|
||||
Some(aba)
|
||||
// ── Botão avançar ─────────────────────────────────────────────────────────
|
||||
let botao_avancar: Element<Message> = if tem_arquivo {
|
||||
button(row![text("Configurar Colunas").size(14)].align_y(Alignment::Center))
|
||||
.on_press(Message::IrParaConfiguracaoColunas)
|
||||
.style(t::btn_primary)
|
||||
.width(Length::Fill)
|
||||
.padding([11, 16])
|
||||
.into()
|
||||
} else {
|
||||
None
|
||||
}
|
||||
} else {
|
||||
None
|
||||
container(text("").size(1))
|
||||
.height(Length::Fixed(42.0))
|
||||
.into()
|
||||
};
|
||||
|
||||
if let Some(aba) = preset_aba {
|
||||
// Fluxo rápido: aba do preset existe → disparar análise direto
|
||||
app.layout_xlsx_atual.aba = aba.clone();
|
||||
app.caminho_arquivo = Some(caminho.clone());
|
||||
app.abas_xlsx = info.abas;
|
||||
app.preview_arquivo =
|
||||
crate::infrastructure::xlsx_reader::preview_xlsx(&caminho, &aba).ok();
|
||||
disparar_analise(app, ctx);
|
||||
} else {
|
||||
// Fluxo normal: exibir tela de seleção de aba
|
||||
app.abas_xlsx = info.abas.clone();
|
||||
app.estado = EstadoApp::SelecionandoAba {
|
||||
abas: info.abas,
|
||||
caminho: caminho.clone(),
|
||||
};
|
||||
}
|
||||
}
|
||||
Err(e) => {
|
||||
app.exibir_erro(format!("Erro ao ler abas do arquivo: {}", e));
|
||||
}
|
||||
}
|
||||
}
|
||||
_ => {
|
||||
app.exibir_erro("Formato de arquivo não suportado. Use CSV, XLSX ou XLS.");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Dispara a análise assíncrona com o layout XLSX atual.
|
||||
/// Usado tanto no fluxo rápido (preset com aba compatível) quanto no botão "Processar" da tela de aba.
|
||||
fn disparar_analise(app: &mut App, ctx: &Context) {
|
||||
let caminho = match &app.caminho_arquivo {
|
||||
Some(p) => p.clone(),
|
||||
None => return,
|
||||
};
|
||||
|
||||
let layout_xlsx = app.layout_xlsx_atual.clone();
|
||||
let (tx, rx) = std::sync::mpsc::channel();
|
||||
app.resultado_pendente = Some(rx);
|
||||
app.estado = EstadoApp::Analisando;
|
||||
ctx.request_repaint();
|
||||
|
||||
std::thread::spawn(move || {
|
||||
let res_importacao = importar_xlsx(&caminho, &layout_xlsx).map_err(|e| e.to_string());
|
||||
|
||||
let res = match res_importacao {
|
||||
Err(e) => ResultadoPendente::Erro(e),
|
||||
Ok(importado) => {
|
||||
if importado.notas.is_empty() {
|
||||
ResultadoPendente::Vazio
|
||||
} else {
|
||||
let avisos = importado.avisos.clone();
|
||||
let notas = importado.notas;
|
||||
|
||||
let pre = pre_analisar(¬as);
|
||||
let excessivos = series_com_intervalo_excessivo(&pre);
|
||||
|
||||
if !excessivos.is_empty() {
|
||||
ResultadoPendente::AguardandoConfirmacao {
|
||||
pre,
|
||||
series_excessivas: excessivos,
|
||||
avisos,
|
||||
notas,
|
||||
}
|
||||
} else {
|
||||
let resultado = expandir_analise(pre, ¬as);
|
||||
ResultadoPendente::Concluido {
|
||||
resultado,
|
||||
avisos: Some(avisos),
|
||||
notas: Some(notas),
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
let _ = tx.send(res);
|
||||
});
|
||||
}
|
||||
|
||||
fn aplicar_layout(app: &mut App, layout: &Layout) {
|
||||
match layout {
|
||||
Layout::Csv { config, .. } => {
|
||||
app.layout_csv_atual = config.clone();
|
||||
app.tipo_arquivo_atual = TipoArquivo::Csv;
|
||||
}
|
||||
Layout::Xlsx { config, .. } => {
|
||||
app.layout_xlsx_atual = config.clone();
|
||||
app.tipo_arquivo_atual = TipoArquivo::Xlsx;
|
||||
}
|
||||
}
|
||||
// ── Separador visual ──────────────────────────────────────────────────────
|
||||
let separador = container(text(""))
|
||||
.height(Length::Fixed(1.0))
|
||||
.width(Length::Fill)
|
||||
.style(t::separador);
|
||||
|
||||
// ── Card central ──────────────────────────────────────────────────────────
|
||||
let card_inner = column![
|
||||
drop_zone,
|
||||
btn_selecionar,
|
||||
separador,
|
||||
secao_layout,
|
||||
botao_avancar,
|
||||
]
|
||||
.spacing(14)
|
||||
.padding(24)
|
||||
.width(Length::Fill);
|
||||
|
||||
let card = container(card_inner)
|
||||
.style(t::card)
|
||||
.max_width(560)
|
||||
.width(Length::Fill);
|
||||
|
||||
// ── Layout geral ──────────────────────────────────────────────────────────
|
||||
container(scrollable(
|
||||
column![
|
||||
text("Importar Arquivo").size(20).color(t::TEXT),
|
||||
text("Selecione e configure sua planilha para análise")
|
||||
.size(13)
|
||||
.color(t::TEXT_SECONDARY),
|
||||
card,
|
||||
]
|
||||
.spacing(16)
|
||||
.align_x(Alignment::Center)
|
||||
.padding([32, 20])
|
||||
.width(Length::Fill),
|
||||
))
|
||||
.style(t::fundo)
|
||||
.width(Length::Fill)
|
||||
.height(Length::Fill)
|
||||
.center_x(Length::Fill)
|
||||
.into()
|
||||
}
|
||||
|
||||
+81
-251
@@ -1,267 +1,97 @@
|
||||
use crate::application::usecases::layouts::{
|
||||
exportar_layout_json, importar_layout_json, salvar_layout,
|
||||
};
|
||||
use crate::domain::entities::layout::{Layout, LayoutJson, TipoArquivo};
|
||||
use crate::domain::errors::ErroLayout;
|
||||
use crate::ui::app::{AcaoModal, App, EstadoApp};
|
||||
use egui::{Context, Ui};
|
||||
use crate::domain::entities::layout::{Layout, TipoArquivo};
|
||||
use crate::ui::app::App;
|
||||
use crate::ui::message::Message;
|
||||
use crate::ui::theme as t;
|
||||
use iced::widget::{button, column, container, horizontal_space, row, scrollable, text};
|
||||
use iced::{Alignment, Element, Length};
|
||||
|
||||
/// Renderiza a tela de gerenciamento de layouts.
|
||||
pub fn renderizar(ui: &mut Ui, ctx: &Context, app: &mut App) {
|
||||
ui.heading("Gerenciar Layouts");
|
||||
ui.add_space(8.0);
|
||||
/// Tela de gerenciamento de layouts.
|
||||
pub fn view(app: &App) -> Element<'_, Message> {
|
||||
let cabecalho = row![
|
||||
button("Voltar")
|
||||
.on_press(Message::IrParaImportacao)
|
||||
.style(t::btn_secondary),
|
||||
horizontal_space(),
|
||||
button("Importar JSON")
|
||||
.on_press(Message::ImportarLayoutJson)
|
||||
.style(t::btn_ghost),
|
||||
]
|
||||
.spacing(8)
|
||||
.align_y(Alignment::Center)
|
||||
.width(Length::Fill);
|
||||
|
||||
ui.horizontal(|ui| {
|
||||
if ui.button("< Voltar").clicked() {
|
||||
app.estado = EstadoApp::Importando;
|
||||
}
|
||||
});
|
||||
let titulo = text("Gerenciar Layouts").size(22).color(t::TEXT);
|
||||
|
||||
ui.add_space(12.0);
|
||||
ui.separator();
|
||||
let secao_csv = view_secao_layouts("Layouts CSV", &app.layouts_salvos, TipoArquivo::Csv);
|
||||
let secao_xlsx = view_secao_layouts("Layouts XLSX", &app.layouts_salvos, TipoArquivo::Xlsx);
|
||||
|
||||
// Salvar layout atual
|
||||
ui.add_space(8.0);
|
||||
ui.group(|ui| {
|
||||
ui.label("Salvar Layout Atual");
|
||||
ui.horizontal(|ui| {
|
||||
ui.label("Nome:");
|
||||
ui.text_edit_singleline(&mut app.nome_layout_atual);
|
||||
if ui.button("💾 Salvar").clicked() {
|
||||
salvar_layout_atual(app);
|
||||
}
|
||||
});
|
||||
});
|
||||
let content = column![titulo, cabecalho, secao_csv, secao_xlsx,]
|
||||
.spacing(16)
|
||||
.padding(20)
|
||||
.width(Length::Fill);
|
||||
|
||||
ui.add_space(12.0);
|
||||
ui.separator();
|
||||
ui.add_space(8.0);
|
||||
|
||||
// Layouts CSV
|
||||
let layouts_csv: Vec<_> = app
|
||||
.layouts_salvos
|
||||
.iter()
|
||||
.filter(|l| l.tipo() == TipoArquivo::Csv)
|
||||
.cloned()
|
||||
.collect();
|
||||
|
||||
let layouts_xlsx: Vec<_> = app
|
||||
.layouts_salvos
|
||||
.iter()
|
||||
.filter(|l| l.tipo() == TipoArquivo::Xlsx)
|
||||
.cloned()
|
||||
.collect();
|
||||
|
||||
egui::ScrollArea::vertical().show(ui, |ui| {
|
||||
renderizar_secao_layouts(ui, ctx, app, "Layouts CSV", &layouts_csv);
|
||||
ui.add_space(12.0);
|
||||
renderizar_secao_layouts(ui, ctx, app, "Layouts XLSX", &layouts_xlsx);
|
||||
|
||||
ui.add_space(16.0);
|
||||
ui.separator();
|
||||
ui.add_space(8.0);
|
||||
|
||||
// Importar de JSON
|
||||
ui.horizontal(|ui| {
|
||||
ui.label("Importar layout de arquivo JSON:");
|
||||
if ui.button("📥 Importar JSON").clicked() {
|
||||
if let Some(caminho) = rfd::FileDialog::new()
|
||||
.add_filter("JSON", &["json"])
|
||||
.pick_file()
|
||||
{
|
||||
match std::fs::read_to_string(&caminho) {
|
||||
Ok(conteudo) => importar_json(app, &conteudo),
|
||||
Err(e) => {
|
||||
app.exibir_erro(format!("Erro ao ler arquivo JSON: {}", e));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
container(scrollable(content))
|
||||
.width(Length::Fill)
|
||||
.height(Length::Fill)
|
||||
.style(t::fundo)
|
||||
.into()
|
||||
}
|
||||
|
||||
fn renderizar_secao_layouts(
|
||||
ui: &mut Ui,
|
||||
_ctx: &Context,
|
||||
app: &mut App,
|
||||
titulo: &str,
|
||||
layouts: &[Layout],
|
||||
) {
|
||||
ui.label(egui::RichText::new(titulo).strong());
|
||||
ui.add_space(4.0);
|
||||
fn view_secao_layouts<'a>(
|
||||
titulo: &'a str,
|
||||
layouts: &'a [Layout],
|
||||
tipo: TipoArquivo,
|
||||
) -> Element<'a, Message> {
|
||||
let titulo_widget = text(titulo).size(16).color(t::TEXT_SECONDARY);
|
||||
|
||||
if layouts.is_empty() {
|
||||
ui.label("(nenhum layout salvo)");
|
||||
return;
|
||||
let filtrados: Vec<&Layout> = layouts.iter().filter(|l| l.tipo() == tipo).collect();
|
||||
|
||||
let mut col = column![titulo_widget].spacing(4);
|
||||
|
||||
if filtrados.is_empty() {
|
||||
col = col.push(text("(nenhum layout salvo)").size(13).color(t::TEXT_MUTED));
|
||||
return container(col)
|
||||
.padding([12, 16])
|
||||
.width(Length::Fill)
|
||||
.style(t::card)
|
||||
.into();
|
||||
}
|
||||
|
||||
for layout in layouts {
|
||||
ui.horizontal(|ui| {
|
||||
ui.label(layout.nome());
|
||||
ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
|
||||
// Excluir
|
||||
for layout in filtrados {
|
||||
if let Some(id) = layout.id() {
|
||||
if ui.button("🗑 Excluir").clicked() {
|
||||
app.exibir_confirmacao(
|
||||
"Confirmar exclusão",
|
||||
format!("Deseja excluir o layout '{}'?", layout.nome()),
|
||||
AcaoModal::ConfirmarExclusaoLayout(id),
|
||||
);
|
||||
}
|
||||
let linha = container(
|
||||
row![
|
||||
text(layout.nome())
|
||||
.size(14)
|
||||
.color(t::TEXT)
|
||||
.width(Length::Fill),
|
||||
horizontal_space(),
|
||||
button("Carregar")
|
||||
.on_press(Message::LayoutSelecionado(id))
|
||||
.style(t::btn_primary),
|
||||
button("Exportar JSON")
|
||||
.on_press(Message::ExportarLayoutJson(id))
|
||||
.style(t::btn_ghost),
|
||||
button("Excluir")
|
||||
.on_press(Message::ExcluirLayout(id))
|
||||
.style(t::btn_danger),
|
||||
]
|
||||
.spacing(8)
|
||||
.align_y(Alignment::Center)
|
||||
.padding([10, 0]),
|
||||
)
|
||||
.width(Length::Fill);
|
||||
|
||||
// Exportar
|
||||
if ui.button("📤 Exportar JSON").clicked() {
|
||||
match exportar_layout_json(layout) {
|
||||
Ok((conteudo, nome_sugerido)) => {
|
||||
if let Some(caminho) = rfd::FileDialog::new()
|
||||
.set_file_name(&nome_sugerido)
|
||||
.add_filter("JSON", &["json"])
|
||||
.save_file()
|
||||
{
|
||||
if let Err(e) = std::fs::write(&caminho, &conteudo) {
|
||||
app.exibir_erro(format!("Erro ao salvar JSON: {}", e));
|
||||
}
|
||||
}
|
||||
}
|
||||
Err(e) => {
|
||||
app.exibir_erro(format!("Erro ao exportar layout: {}", e));
|
||||
}
|
||||
col = col.push(linha);
|
||||
|
||||
// Separador entre linhas
|
||||
col = col.push(container(iced::widget::horizontal_rule(1)).width(Length::Fill));
|
||||
}
|
||||
}
|
||||
|
||||
// Carregar
|
||||
if ui.button("📂 Carregar").clicked() {
|
||||
aplicar_layout(app, layout);
|
||||
app.nome_layout_atual = layout.nome().to_string();
|
||||
app.estado = EstadoApp::ConfigurandoColunas;
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
fn salvar_layout_atual(app: &mut App) {
|
||||
if app.nome_layout_atual.trim().is_empty() {
|
||||
app.exibir_aviso("Nome inválido", "Informe um nome para o layout.");
|
||||
return;
|
||||
}
|
||||
|
||||
let layout = match app.tipo_arquivo_atual.clone() {
|
||||
TipoArquivo::Csv => Layout::Csv {
|
||||
id: None,
|
||||
nome: app.nome_layout_atual.trim().to_string(),
|
||||
config: app.layout_csv_atual.clone(),
|
||||
},
|
||||
TipoArquivo::Xlsx => Layout::Xlsx {
|
||||
id: None,
|
||||
nome: app.nome_layout_atual.trim().to_string(),
|
||||
config: app.layout_xlsx_atual.clone(),
|
||||
},
|
||||
};
|
||||
|
||||
if let Some(conn) = &app.conn {
|
||||
match salvar_layout(conn, &layout) {
|
||||
Ok(_) => {
|
||||
app.recarregar_layouts();
|
||||
app.exibir_aviso("Sucesso", "Layout salvo com sucesso.");
|
||||
}
|
||||
Err(ErroLayout::NomeConflitante(nome)) => {
|
||||
let id_existente = app
|
||||
.layouts_salvos
|
||||
.iter()
|
||||
.find(|l| l.nome() == nome)
|
||||
.and_then(|l| l.id());
|
||||
if let Some(id) = id_existente {
|
||||
let layout_com_id = match app.tipo_arquivo_atual.clone() {
|
||||
TipoArquivo::Csv => Layout::Csv {
|
||||
id: Some(id),
|
||||
nome: nome.clone(),
|
||||
config: app.layout_csv_atual.clone(),
|
||||
},
|
||||
TipoArquivo::Xlsx => Layout::Xlsx {
|
||||
id: Some(id),
|
||||
nome: nome.clone(),
|
||||
config: app.layout_xlsx_atual.clone(),
|
||||
},
|
||||
};
|
||||
app.exibir_confirmacao(
|
||||
"Conflito de nome",
|
||||
format!(
|
||||
"Já existe um layout com o nome '{}'. Deseja sobrescrever?",
|
||||
nome
|
||||
),
|
||||
AcaoModal::SobrescreverLayout(layout_com_id),
|
||||
);
|
||||
}
|
||||
}
|
||||
Err(e) => {
|
||||
app.exibir_erro(format!("Erro ao salvar layout: {}", e));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn importar_json(app: &mut App, conteudo: &str) {
|
||||
if let Some(conn) = &app.conn {
|
||||
match importar_layout_json(conn, conteudo, false, None) {
|
||||
Ok(_) => {
|
||||
app.recarregar_layouts();
|
||||
app.exibir_aviso("Sucesso", "Layout importado com sucesso.");
|
||||
}
|
||||
Err(ErroLayout::NomeConflitante(nome)) => {
|
||||
// Recriar o layout parseado para passá-lo no modal de confirmação.
|
||||
// O JSON já foi validado pela chamada acima, então o parse aqui não falha.
|
||||
let parsed = serde_json::from_str::<LayoutJson>(conteudo)
|
||||
.ok()
|
||||
.and_then(|json_repr| Layout::try_from(json_repr).ok());
|
||||
|
||||
let id_existente = app
|
||||
.layouts_salvos
|
||||
.iter()
|
||||
.find(|l| l.nome() == nome)
|
||||
.and_then(|l| l.id());
|
||||
|
||||
match (parsed, id_existente) {
|
||||
(Some(mut layout), Some(id)) => {
|
||||
match &mut layout {
|
||||
Layout::Csv { id: i, .. } => *i = Some(id),
|
||||
Layout::Xlsx { id: i, .. } => *i = Some(id),
|
||||
}
|
||||
app.exibir_confirmacao(
|
||||
"Conflito de nome",
|
||||
format!(
|
||||
"Já existe um layout com o nome '{}'. Deseja sobrescrever?",
|
||||
nome
|
||||
),
|
||||
AcaoModal::SobrescreverLayout(layout),
|
||||
);
|
||||
}
|
||||
_ => {
|
||||
app.exibir_erro(format!(
|
||||
"Conflito de nome: layout '{}' já existe.",
|
||||
nome
|
||||
));
|
||||
}
|
||||
}
|
||||
}
|
||||
Err(e) => {
|
||||
app.exibir_erro(format!("Erro ao importar layout: {}", e));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn aplicar_layout(app: &mut App, layout: &Layout) {
|
||||
match layout {
|
||||
Layout::Csv { config, .. } => {
|
||||
app.layout_csv_atual = config.clone();
|
||||
app.tipo_arquivo_atual = TipoArquivo::Csv;
|
||||
}
|
||||
Layout::Xlsx { config, .. } => {
|
||||
app.layout_xlsx_atual = config.clone();
|
||||
app.tipo_arquivo_atual = TipoArquivo::Xlsx;
|
||||
}
|
||||
}
|
||||
container(col)
|
||||
.padding([12, 16])
|
||||
.width(Length::Fill)
|
||||
.style(t::card)
|
||||
.into()
|
||||
}
|
||||
|
||||
+2
-56
@@ -2,9 +2,10 @@ pub mod configuracao_colunas;
|
||||
pub mod import;
|
||||
pub mod layouts;
|
||||
pub mod resultado;
|
||||
pub mod selecionar_aba;
|
||||
|
||||
/// Converte um índice de coluna base-0 para a notação de letras do Excel (A, B, ..., Z, AA, ...).
|
||||
fn indice_para_letra(mut idx: usize) -> String {
|
||||
pub fn indice_para_letra(mut idx: usize) -> String {
|
||||
let mut resultado = String::new();
|
||||
loop {
|
||||
resultado.insert(0, (b'A' + (idx % 26) as u8) as char);
|
||||
@@ -15,58 +16,3 @@ fn indice_para_letra(mut idx: usize) -> String {
|
||||
}
|
||||
resultado
|
||||
}
|
||||
|
||||
/// Renderiza uma tabela simples de pré-visualização do arquivo.
|
||||
/// Exibe uma linha de cabeçalho com letras no estilo Excel (A, B, C, ...)
|
||||
/// seguida pelas linhas de dados.
|
||||
pub fn renderizar_tabela_preview(ui: &mut egui::Ui, linhas: &[Vec<String>]) {
|
||||
let num_colunas = linhas.iter().map(|l| l.len()).max().unwrap_or(0);
|
||||
if num_colunas == 0 {
|
||||
return;
|
||||
}
|
||||
|
||||
ui.label(
|
||||
egui::RichText::new(format!(
|
||||
"Pré-visualização ({} linha(s))",
|
||||
linhas.len()
|
||||
))
|
||||
.small()
|
||||
.weak(),
|
||||
);
|
||||
ui.add_space(2.0);
|
||||
|
||||
egui::ScrollArea::horizontal()
|
||||
.id_salt("scroll_preview")
|
||||
.max_height(160.0)
|
||||
.show(ui, |ui| {
|
||||
egui::Grid::new("tabela_preview")
|
||||
.striped(true)
|
||||
.spacing([8.0, 2.0])
|
||||
.show(ui, |ui| {
|
||||
// Linha de cabeçalho: letras A, B, C, ... com índice base-0 entre parênteses
|
||||
for i in 0..num_colunas {
|
||||
ui.label(
|
||||
egui::RichText::new(format!("{} ({})", indice_para_letra(i), i))
|
||||
.strong()
|
||||
.monospace(),
|
||||
);
|
||||
}
|
||||
ui.end_row();
|
||||
|
||||
// Linhas de dados
|
||||
for linha in linhas {
|
||||
for col in 0..num_colunas {
|
||||
let celula = linha.get(col).map(|s| s.as_str()).unwrap_or("");
|
||||
let texto = if celula.chars().count() > 30 {
|
||||
let truncado: String = celula.chars().take(30).collect();
|
||||
format!("{}...", truncado)
|
||||
} else {
|
||||
celula.to_string()
|
||||
};
|
||||
ui.label(egui::RichText::new(texto).monospace().small());
|
||||
}
|
||||
ui.end_row();
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
+371
-243
@@ -1,127 +1,175 @@
|
||||
use crate::application::usecases::exportar_pdf::exportar_pdf;
|
||||
use crate::domain::{
|
||||
entities::{chave_serie::ChaveSerie, resultado_analise::ResultadoAnalise},
|
||||
services::{detector_sequencia::agrupar_contiguos, parser_monetario::formatar_valor_br},
|
||||
};
|
||||
use crate::infrastructure::pdf_generator::GenpdfGenerator;
|
||||
use crate::ui::app::{AcaoModal, App, EstadoApp};
|
||||
use egui::{Context, Ui};
|
||||
use crate::ui::app::App;
|
||||
use crate::ui::message::Message;
|
||||
use crate::ui::theme as t;
|
||||
use iced::widget::{button, column, container, progress_bar, row, scrollable, text};
|
||||
use iced::{Alignment, Element, Length};
|
||||
|
||||
const OPCOES_PAGINA: &[usize] = &[50, 100, 200, 1000];
|
||||
|
||||
/// Renderiza a tela de resultados.
|
||||
pub fn renderizar(ui: &mut Ui, ctx: &Context, app: &mut App) {
|
||||
// Extrair resultado do estado (sem mover)
|
||||
let resultado = match &app.estado {
|
||||
EstadoApp::ExibindoResultado(r) => r.clone(),
|
||||
_ => return,
|
||||
};
|
||||
|
||||
ui.heading("Resultado da Análise");
|
||||
ui.add_space(8.0);
|
||||
|
||||
ui.horizontal(|ui| {
|
||||
if ui.button("< Nova Análise").clicked() {
|
||||
app.exibir_confirmacao(
|
||||
"Nova Análise",
|
||||
"Deseja iniciar uma nova análise? O resultado atual será descartado.",
|
||||
AcaoModal::ConfirmarNovaAnalise,
|
||||
);
|
||||
}
|
||||
|
||||
if ui.button("⚙ Reconfigurar Colunas").clicked() {
|
||||
app.estado = EstadoApp::ConfigurandoColunas;
|
||||
return;
|
||||
}
|
||||
|
||||
let pode_reanalisar = app.caminho_arquivo.is_some();
|
||||
if ui
|
||||
.add_enabled(pode_reanalisar, egui::Button::new("🔄 Reanalisar Arquivo"))
|
||||
.on_hover_text("Reimporta o arquivo do disco com o layout atual e reanalisa")
|
||||
.clicked()
|
||||
{
|
||||
app.reimportar_e_analisar(ctx);
|
||||
}
|
||||
|
||||
if ui.button("📄 Exportar PDF").clicked() {
|
||||
exportar_para_pdf(app, &resultado);
|
||||
}
|
||||
});
|
||||
|
||||
ui.add_space(8.0);
|
||||
|
||||
// Controle de itens por página
|
||||
ui.horizontal(|ui| {
|
||||
ui.label("Itens por página:");
|
||||
for &opcao in OPCOES_PAGINA {
|
||||
if ui
|
||||
.selectable_label(app.itens_por_pagina == opcao, opcao.to_string())
|
||||
.clicked()
|
||||
{
|
||||
app.itens_por_pagina = opcao;
|
||||
app.pagina_faltantes = 0;
|
||||
app.pagina_duplicatas = 0;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
ui.add_space(12.0);
|
||||
ui.separator();
|
||||
|
||||
egui::ScrollArea::vertical().show(ui, |ui| {
|
||||
// Faltantes
|
||||
renderizar_faltantes(ui, app, &resultado);
|
||||
|
||||
ui.add_space(12.0);
|
||||
ui.separator();
|
||||
|
||||
// Duplicatas
|
||||
renderizar_duplicatas(ui, app, &resultado);
|
||||
|
||||
ui.add_space(12.0);
|
||||
ui.separator();
|
||||
|
||||
// Totais
|
||||
renderizar_totais(ui, &resultado);
|
||||
});
|
||||
}
|
||||
|
||||
fn renderizar_totais(ui: &mut Ui, resultado: &ResultadoAnalise) {
|
||||
ui.label(egui::RichText::new("Totais").heading().strong());
|
||||
ui.add_space(4.0);
|
||||
|
||||
ui.label(format!(
|
||||
"Total Geral: R$ {}",
|
||||
formatar_valor_br(&resultado.soma_total)
|
||||
));
|
||||
|
||||
let mut chaves: Vec<&ChaveSerie> = resultado.soma_por_serie.keys().collect();
|
||||
chaves.sort();
|
||||
|
||||
for chave in chaves {
|
||||
let soma = &resultado.soma_por_serie[chave];
|
||||
let total_notas = resultado.total_por_serie.get(chave).copied().unwrap_or(0);
|
||||
ui.label(format!(
|
||||
" Série {}: {} nota(s) — R$ {}",
|
||||
chave.label(),
|
||||
total_notas,
|
||||
formatar_valor_br(soma)
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
fn renderizar_faltantes(ui: &mut Ui, app: &mut App, resultado: &ResultadoAnalise) {
|
||||
/// Tela de resultados da análise.
|
||||
pub fn view<'a>(app: &'a App, resultado: &'a ResultadoAnalise) -> Element<'a, Message> {
|
||||
// ── Stat cards no topo ────────────────────────────────────────────────────
|
||||
let total_faltantes = resultado.total_faltantes();
|
||||
ui.label(
|
||||
egui::RichText::new(format!("Notas Faltantes ({} total)", total_faltantes))
|
||||
.heading()
|
||||
.strong(),
|
||||
);
|
||||
ui.add_space(4.0);
|
||||
let total_duplicatas = resultado.total_duplicatas();
|
||||
let total_notas: usize = resultado.total_por_serie.values().sum();
|
||||
let valor_total_str = format!("R$ {}", formatar_valor_br(&resultado.soma_total));
|
||||
|
||||
if total_faltantes == 0 {
|
||||
ui.label("✔ Nenhuma nota faltante.");
|
||||
return;
|
||||
let stat_faltantes = stat_card_widget("Faltantes", total_faltantes.to_string(), t::WARNING);
|
||||
let stat_duplicatas = stat_card_widget("Duplicatas", total_duplicatas.to_string(), t::DANGER);
|
||||
let stat_total = stat_card_widget("Total de Notas", total_notas.to_string(), t::PRIMARY);
|
||||
let stat_valor = stat_card_widget("Valor Total", valor_total_str, t::SUCCESS);
|
||||
|
||||
let stat_row = row![stat_faltantes, stat_duplicatas, stat_total, stat_valor]
|
||||
.spacing(12)
|
||||
.width(Length::Fill);
|
||||
|
||||
// ── Botões de ação ────────────────────────────────────────────────────────
|
||||
let botoes_topo = row![
|
||||
button("Nova Análise")
|
||||
.on_press(Message::NovaAnalise)
|
||||
.style(t::btn_secondary)
|
||||
.width(Length::Shrink),
|
||||
button("Reconfigurar")
|
||||
.on_press(Message::IrParaConfiguracaoColunas)
|
||||
.style(t::btn_ghost)
|
||||
.width(Length::Shrink),
|
||||
button("Reanalisar")
|
||||
.on_press_maybe(
|
||||
app.caminho_arquivo
|
||||
.as_ref()
|
||||
.map(|_| Message::ReanalisarArquivo)
|
||||
)
|
||||
.style(t::btn_ghost)
|
||||
.width(Length::Shrink),
|
||||
button("Exportar PDF")
|
||||
.on_press(Message::ExportarPdf)
|
||||
.style(t::btn_primary)
|
||||
.width(Length::Shrink),
|
||||
]
|
||||
.spacing(8)
|
||||
.wrap();
|
||||
|
||||
// ── Controle de itens por página ──────────────────────────────────────────
|
||||
let opcoes_por_pagina = row(OPCOES_PAGINA
|
||||
.iter()
|
||||
.map(|&n| {
|
||||
let ativo = n == app.itens_por_pagina;
|
||||
button(text(n.to_string()).size(13))
|
||||
.on_press(Message::ItensPorPaginaAlterado(n))
|
||||
.style(if ativo {
|
||||
t::btn_pagina_ativo
|
||||
} else {
|
||||
t::btn_pagina_inativo
|
||||
})
|
||||
.into()
|
||||
})
|
||||
.collect::<Vec<_>>())
|
||||
.spacing(4);
|
||||
|
||||
let controle_pagina = row![
|
||||
text("Itens por página:").size(13).color(t::TEXT_SECONDARY),
|
||||
opcoes_por_pagina,
|
||||
]
|
||||
.spacing(8)
|
||||
.align_y(Alignment::Center);
|
||||
|
||||
// ── Seções principais ─────────────────────────────────────────────────────
|
||||
let secao_faltantes = view_faltantes(app, resultado);
|
||||
let secao_duplicatas = view_duplicatas(app, resultado);
|
||||
let secao_totais = view_totais(resultado);
|
||||
|
||||
let mut col_principal = column![
|
||||
row![text("Resultado da Análise").size(22).color(t::TEXT),],
|
||||
botoes_topo,
|
||||
stat_row,
|
||||
controle_pagina,
|
||||
]
|
||||
.spacing(16)
|
||||
.padding(20)
|
||||
.width(Length::Fill);
|
||||
|
||||
// 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)
|
||||
.into()
|
||||
}
|
||||
|
||||
/// Cria um stat card com label, valor e cor de destaque.
|
||||
fn stat_card_widget<'a>(label: &'a str, valor: String, cor: iced::Color) -> Element<'a, Message> {
|
||||
let conteudo = column![
|
||||
text(label).size(12).color(t::TEXT_SECONDARY),
|
||||
text(valor).size(24).color(cor),
|
||||
]
|
||||
.spacing(4);
|
||||
|
||||
container(conteudo)
|
||||
.padding([14, 18])
|
||||
.width(Length::Fill)
|
||||
.style(t::stat_card)
|
||||
.into()
|
||||
}
|
||||
|
||||
fn view_faltantes<'a>(app: &'a App, resultado: &'a ResultadoAnalise) -> Element<'a, Message> {
|
||||
let total = resultado.total_faltantes();
|
||||
|
||||
let titulo_row = row![
|
||||
text("Notas Faltantes").size(18).color(t::TEXT),
|
||||
container(text(format!(" {} ", total)).size(12).color(t::WARNING))
|
||||
.padding([2, 8])
|
||||
.style(t::badge_aviso),
|
||||
]
|
||||
.spacing(8)
|
||||
.align_y(Alignment::Center);
|
||||
|
||||
let mut col = column![titulo_row].spacing(12);
|
||||
|
||||
if total == 0 {
|
||||
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)
|
||||
.style(t::card)
|
||||
.into();
|
||||
}
|
||||
|
||||
let mut chaves: Vec<&ChaveSerie> = resultado.faltantes_por_serie.keys().collect();
|
||||
@@ -129,86 +177,131 @@ fn renderizar_faltantes(ui: &mut Ui, app: &mut App, resultado: &ResultadoAnalise
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
// Estatística de completude por série
|
||||
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 * 100.0;
|
||||
let percentual = total_notas as f64 / total_esperado as f64;
|
||||
let percentual_f32 = percentual as f32;
|
||||
|
||||
ui.horizontal(|ui| {
|
||||
ui.label(format!(
|
||||
"Série {} — {} faltante(s) — {}/{} notas ({:.1}% completo):",
|
||||
chave.label(),
|
||||
// Cabeçalho da série
|
||||
let cabecalho_serie = row![
|
||||
column![
|
||||
text(format!("Série {}", chave.label()))
|
||||
.size(14)
|
||||
.color(t::TEXT),
|
||||
text(format!(
|
||||
"{} faltante(s) — {}/{} notas ({:.1}% completo)",
|
||||
faltantes.len(),
|
||||
total_notas,
|
||||
total_esperado,
|
||||
percentual,
|
||||
));
|
||||
if ui
|
||||
.button("📋 Copiar")
|
||||
.on_hover_text("Copiar todos os números faltantes")
|
||||
.clicked()
|
||||
{
|
||||
let texto = faltantes
|
||||
.iter()
|
||||
.map(|n| n.to_string())
|
||||
.collect::<Vec<_>>()
|
||||
.join(", ");
|
||||
ui.ctx().copy_text(texto);
|
||||
}
|
||||
});
|
||||
percentual * 100.0,
|
||||
))
|
||||
.size(12)
|
||||
.color(t::TEXT_SECONDARY),
|
||||
]
|
||||
.spacing(2)
|
||||
.width(Length::Fill),
|
||||
button("Copiar")
|
||||
.on_press(Message::CopiarFaltantes(chave.clone()))
|
||||
.style(t::btn_ghost),
|
||||
]
|
||||
.spacing(8)
|
||||
.align_y(Alignment::Center);
|
||||
|
||||
// Paginação (por faltante individual)
|
||||
// Progress bar de completude
|
||||
let barra = progress_bar(0.0..=1.0, percentual_f32)
|
||||
.height(6)
|
||||
.style(t::progress_bar_por_percentual(percentual_f32));
|
||||
|
||||
col = col.push(cabecalho_serie);
|
||||
col = col.push(barra);
|
||||
|
||||
// Paginação e lista
|
||||
let total_paginas = (faltantes.len() + app.itens_por_pagina - 1) / app.itens_por_pagina;
|
||||
if app.pagina_faltantes >= total_paginas {
|
||||
app.pagina_faltantes = 0;
|
||||
}
|
||||
|
||||
let inicio = app.pagina_faltantes * app.itens_por_pagina;
|
||||
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());
|
||||
|
||||
// Exibir grupos contíguos da página atual
|
||||
let mut lista = column![].spacing(2);
|
||||
for (a, b) in agrupar_contiguos(&faltantes[inicio..fim]) {
|
||||
if a == b {
|
||||
ui.label(format!(" • {}", a));
|
||||
let txt = if a == b {
|
||||
text(format!(" {}", a)).size(13).color(t::TEXT_SECONDARY)
|
||||
} else {
|
||||
ui.label(format!(" • {}–{} ({} notas)", a, b, b - a + 1));
|
||||
}
|
||||
text(format!(" {}–{} ({} notas)", a, b, b - a + 1))
|
||||
.size(13)
|
||||
.color(t::TEXT_SECONDARY)
|
||||
};
|
||||
lista = lista.push(txt);
|
||||
}
|
||||
col = col.push(lista);
|
||||
|
||||
if total_paginas > 1 {
|
||||
ui.horizontal(|ui| {
|
||||
if ui.button("◀").clicked() && app.pagina_faltantes > 0 {
|
||||
app.pagina_faltantes -= 1;
|
||||
}
|
||||
ui.label(format!(
|
||||
"Página {} / {}",
|
||||
app.pagina_faltantes + 1,
|
||||
total_paginas
|
||||
col = col.push(crate::ui::components::paginacao::controles_paginacao(
|
||||
pagina,
|
||||
total_paginas,
|
||||
Message::PaginaFaltantesAlterada(chave.clone(), pagina.saturating_sub(1)),
|
||||
Message::PaginaFaltantesAlterada(chave.clone(), pagina + 1),
|
||||
));
|
||||
if ui.button("▶").clicked() && app.pagina_faltantes + 1 < total_paginas {
|
||||
app.pagina_faltantes += 1;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Separador
|
||||
col = col.push(
|
||||
container(iced::widget::horizontal_rule(1))
|
||||
.width(Length::Fill)
|
||||
.padding([4, 0]),
|
||||
);
|
||||
}
|
||||
|
||||
container(col)
|
||||
.padding(16)
|
||||
.width(Length::Fill)
|
||||
.style(t::card)
|
||||
.into()
|
||||
}
|
||||
|
||||
fn renderizar_duplicatas(ui: &mut Ui, app: &mut App, resultado: &ResultadoAnalise) {
|
||||
let total_dup = resultado.total_duplicatas();
|
||||
ui.label(
|
||||
egui::RichText::new(format!("Notas Duplicadas ({} grupo(s))", total_dup))
|
||||
.heading()
|
||||
.strong(),
|
||||
);
|
||||
ui.add_space(4.0);
|
||||
fn view_duplicatas<'a>(app: &'a App, resultado: &'a ResultadoAnalise) -> Element<'a, Message> {
|
||||
let total = resultado.total_duplicatas();
|
||||
|
||||
if total_dup == 0 {
|
||||
ui.label("✔ Nenhuma nota duplicada.");
|
||||
return;
|
||||
let titulo_row = row![
|
||||
text("Notas Duplicadas").size(18).color(t::TEXT),
|
||||
container(text(format!(" {} ", total)).size(12).color(t::DANGER))
|
||||
.padding([2, 8])
|
||||
.style(t::badge_perigo),
|
||||
]
|
||||
.spacing(8)
|
||||
.align_y(Alignment::Center);
|
||||
|
||||
let mut col = column![titulo_row].spacing(12);
|
||||
|
||||
if total == 0 {
|
||||
col = col.push(text("Nenhuma nota duplicada.").size(14).color(t::SUCCESS));
|
||||
return container(col)
|
||||
.padding(16)
|
||||
.width(Length::Fill)
|
||||
.style(t::card)
|
||||
.into();
|
||||
}
|
||||
|
||||
let mut chaves: Vec<&ChaveSerie> = resultado.duplicadas_por_serie.keys().collect();
|
||||
@@ -220,90 +313,125 @@ fn renderizar_duplicatas(ui: &mut Ui, app: &mut App, resultado: &ResultadoAnalis
|
||||
continue;
|
||||
}
|
||||
|
||||
ui.horizontal(|ui| {
|
||||
ui.label(format!(
|
||||
"Série {} — {} grupo(s) duplicado(s):",
|
||||
let cabecalho_serie = row![
|
||||
text(format!(
|
||||
"Série {} — {} grupo(s) duplicado(s)",
|
||||
chave.label(),
|
||||
duplicatas.len()
|
||||
));
|
||||
if ui
|
||||
.button("📋 Copiar")
|
||||
.on_hover_text("Copiar números duplicados")
|
||||
.clicked()
|
||||
{
|
||||
let texto = duplicatas
|
||||
.iter()
|
||||
.map(|(n, c)| format!("{} ({}x)", n, c))
|
||||
.collect::<Vec<_>>()
|
||||
.join(", ");
|
||||
ui.ctx().copy_text(texto);
|
||||
}
|
||||
});
|
||||
))
|
||||
.size(14)
|
||||
.color(t::TEXT)
|
||||
.width(Length::Fill),
|
||||
button("Copiar")
|
||||
.on_press(Message::CopiarDuplicatas(chave.clone()))
|
||||
.style(t::btn_ghost),
|
||||
]
|
||||
.spacing(8)
|
||||
.align_y(Alignment::Center);
|
||||
|
||||
col = col.push(cabecalho_serie);
|
||||
|
||||
let total_paginas = (duplicatas.len() + app.itens_por_pagina - 1) / app.itens_por_pagina;
|
||||
if app.pagina_duplicatas >= total_paginas {
|
||||
app.pagina_duplicatas = 0;
|
||||
}
|
||||
|
||||
let inicio = app.pagina_duplicatas * app.itens_por_pagina;
|
||||
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());
|
||||
|
||||
let mut lista = column![].spacing(2);
|
||||
for (numero, count) in &duplicatas[inicio..fim] {
|
||||
ui.label(format!(
|
||||
" • NF {} / Série {} — {} ocorrências",
|
||||
lista = lista.push(
|
||||
text(format!(
|
||||
" NF {} / Série {} — {} ocorrências",
|
||||
numero,
|
||||
chave.label(),
|
||||
count
|
||||
));
|
||||
}
|
||||
|
||||
if total_paginas > 1 {
|
||||
ui.horizontal(|ui| {
|
||||
if ui.button("◀").clicked() && app.pagina_duplicatas > 0 {
|
||||
app.pagina_duplicatas -= 1;
|
||||
}
|
||||
ui.label(format!(
|
||||
"Página {} / {}",
|
||||
app.pagina_duplicatas + 1,
|
||||
total_paginas
|
||||
));
|
||||
if ui.button("▶").clicked() && app.pagina_duplicatas + 1 < total_paginas {
|
||||
app.pagina_duplicatas += 1;
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn exportar_para_pdf(app: &mut App, resultado: &ResultadoAnalise) {
|
||||
if let Some(caminho) = rfd::FileDialog::new()
|
||||
.set_file_name("relatorio.pdf")
|
||||
.add_filter("PDF", &["pdf"])
|
||||
.save_file()
|
||||
{
|
||||
let gerador = GenpdfGenerator;
|
||||
let nome_layout = if app.nome_layout_atual.is_empty() {
|
||||
None
|
||||
} else {
|
||||
Some(app.nome_layout_atual.as_str())
|
||||
};
|
||||
|
||||
match exportar_pdf(
|
||||
&gerador,
|
||||
resultado,
|
||||
&app.nome_arquivo,
|
||||
nome_layout,
|
||||
&caminho,
|
||||
) {
|
||||
Ok(_) => {
|
||||
app.exibir_aviso(
|
||||
"Sucesso",
|
||||
format!("PDF exportado para: {}", caminho.display()),
|
||||
))
|
||||
.size(13)
|
||||
.color(t::TEXT_SECONDARY),
|
||||
);
|
||||
}
|
||||
Err(e) => {
|
||||
app.exibir_erro(format!("Erro ao exportar PDF: {}", e));
|
||||
}
|
||||
col = col.push(lista);
|
||||
|
||||
if total_paginas > 1 {
|
||||
col = col.push(crate::ui::components::paginacao::controles_paginacao(
|
||||
pagina,
|
||||
total_paginas,
|
||||
Message::PaginaDuplicatasAlterada(chave.clone(), pagina.saturating_sub(1)),
|
||||
Message::PaginaDuplicatasAlterada(chave.clone(), pagina + 1),
|
||||
));
|
||||
}
|
||||
|
||||
col = col.push(
|
||||
container(iced::widget::horizontal_rule(1))
|
||||
.width(Length::Fill)
|
||||
.padding([4, 0]),
|
||||
);
|
||||
}
|
||||
|
||||
container(col)
|
||||
.padding(16)
|
||||
.width(Length::Fill)
|
||||
.style(t::card)
|
||||
.into()
|
||||
}
|
||||
|
||||
fn view_totais(resultado: &ResultadoAnalise) -> Element<'_, Message> {
|
||||
let titulo = text("Totais por Série").size(18).color(t::TEXT);
|
||||
|
||||
let mut col = column![titulo].spacing(8);
|
||||
|
||||
let mut chaves: Vec<&ChaveSerie> = resultado.soma_por_serie.keys().collect();
|
||||
chaves.sort();
|
||||
|
||||
for chave in chaves {
|
||||
let soma = &resultado.soma_por_serie[chave];
|
||||
let total_notas = resultado.total_por_serie.get(chave).copied().unwrap_or(0);
|
||||
|
||||
let linha = row![
|
||||
text(format!("Série {}", chave.label()))
|
||||
.size(14)
|
||||
.color(t::TEXT)
|
||||
.width(Length::Fill),
|
||||
text(format!("{} nota(s)", total_notas))
|
||||
.size(13)
|
||||
.color(t::TEXT_SECONDARY),
|
||||
text(format!("R$ {}", formatar_valor_br(soma)))
|
||||
.size(13)
|
||||
.color(t::SUCCESS),
|
||||
]
|
||||
.spacing(12)
|
||||
.align_y(Alignment::Center);
|
||||
|
||||
col = col.push(linha);
|
||||
}
|
||||
|
||||
// Total geral
|
||||
col = col.push(
|
||||
container(iced::widget::horizontal_rule(1))
|
||||
.width(Length::Fill)
|
||||
.padding([4, 0]),
|
||||
);
|
||||
col = col.push(
|
||||
row![
|
||||
text("Total Geral")
|
||||
.size(15)
|
||||
.color(t::TEXT)
|
||||
.width(Length::Fill),
|
||||
text(format!("R$ {}", formatar_valor_br(&resultado.soma_total)))
|
||||
.size(15)
|
||||
.color(t::SUCCESS),
|
||||
]
|
||||
.spacing(12)
|
||||
.align_y(Alignment::Center),
|
||||
);
|
||||
|
||||
container(col)
|
||||
.padding(16)
|
||||
.width(Length::Fill)
|
||||
.style(t::card)
|
||||
.into()
|
||||
}
|
||||
|
||||
@@ -0,0 +1,156 @@
|
||||
use crate::ui::app::App;
|
||||
use crate::ui::message::Message;
|
||||
use crate::ui::theme as t;
|
||||
use iced::widget::{button, column, container, row, scrollable, text};
|
||||
use iced::{Alignment, Element, Length};
|
||||
use std::path::PathBuf;
|
||||
|
||||
/// Tela de seleção de aba de arquivo XLSX.
|
||||
pub fn view<'a>(app: &'a App, abas: &'a [String], caminho: &'a PathBuf) -> Element<'a, Message> {
|
||||
let nome_arquivo = caminho
|
||||
.file_name()
|
||||
.map(|n| n.to_string_lossy().to_string())
|
||||
.unwrap_or_else(|| caminho.display().to_string());
|
||||
|
||||
let nome_arquivo_owned = nome_arquivo.clone();
|
||||
|
||||
// ── Cabeçalho ─────────────────────────────────────────────────────────────
|
||||
let header = column![
|
||||
text("Selecionar Aba").size(20).color(t::TEXT),
|
||||
row![
|
||||
text("Arquivo:").size(12).color(t::TEXT_MUTED),
|
||||
text(nome_arquivo_owned).size(12).color(t::TEXT_SECONDARY),
|
||||
]
|
||||
.spacing(4)
|
||||
.align_y(Alignment::Center),
|
||||
]
|
||||
.spacing(4);
|
||||
|
||||
// ── Lista de abas ─────────────────────────────────────────────────────────
|
||||
let aba_atual = &app.layout_xlsx_atual.aba;
|
||||
let lista_abas = column(
|
||||
abas.iter()
|
||||
.map(|aba| {
|
||||
let selecionada = aba == aba_atual;
|
||||
let label = row![
|
||||
text(if selecionada { ">>" } else { " " })
|
||||
.size(12)
|
||||
.color(if selecionada {
|
||||
t::PRIMARY
|
||||
} else {
|
||||
t::TEXT_MUTED
|
||||
}),
|
||||
text(aba)
|
||||
.size(14)
|
||||
.color(if selecionada { t::PRIMARY } else { t::TEXT }),
|
||||
]
|
||||
.spacing(8)
|
||||
.align_y(Alignment::Center);
|
||||
|
||||
button(label)
|
||||
.on_press(Message::AbaSelecionada(aba.clone()))
|
||||
.style(if selecionada {
|
||||
t::btn_aba_ativa
|
||||
} else {
|
||||
t::btn_aba_inativa
|
||||
})
|
||||
.width(Length::Fill)
|
||||
.padding([8, 12])
|
||||
.into()
|
||||
})
|
||||
.collect::<Vec<_>>(),
|
||||
)
|
||||
.spacing(4)
|
||||
.width(Length::Fill);
|
||||
|
||||
let card_abas = container(
|
||||
column![
|
||||
text("Abas disponíveis").size(13).color(t::TEXT_SECONDARY),
|
||||
scrollable(lista_abas).height(Length::Fixed(220.0)),
|
||||
]
|
||||
.spacing(10),
|
||||
)
|
||||
.style(t::card)
|
||||
.padding(16)
|
||||
.width(Length::Fill);
|
||||
|
||||
// ── Preview ───────────────────────────────────────────────────────────────
|
||||
let preview_section: Element<Message> = if !aba_atual.is_empty() {
|
||||
if let Some(linhas) = &app.preview_arquivo {
|
||||
container(
|
||||
column![
|
||||
text(format!("Pré-visualização: {}", aba_atual))
|
||||
.size(13)
|
||||
.color(t::TEXT_SECONDARY),
|
||||
crate::ui::components::tabela_preview::tabela_preview(linhas),
|
||||
]
|
||||
.spacing(8),
|
||||
)
|
||||
.style(t::card)
|
||||
.padding(16)
|
||||
.width(Length::Fill)
|
||||
.into()
|
||||
} else {
|
||||
container(
|
||||
text("(sem pré-visualização disponível)")
|
||||
.size(12)
|
||||
.color(t::TEXT_MUTED),
|
||||
)
|
||||
.style(t::card)
|
||||
.padding(16)
|
||||
.width(Length::Fill)
|
||||
.into()
|
||||
}
|
||||
} else {
|
||||
container(
|
||||
text("Selecione uma aba para pré-visualizar o conteúdo.")
|
||||
.size(13)
|
||||
.color(t::TEXT_MUTED),
|
||||
)
|
||||
.style(t::card)
|
||||
.padding(16)
|
||||
.width(Length::Fill)
|
||||
.into()
|
||||
};
|
||||
|
||||
// ── Botões de ação ────────────────────────────────────────────────────────
|
||||
let tem_preset = !app.nome_layout_atual.is_empty();
|
||||
let aba_selecionada = !aba_atual.is_empty();
|
||||
|
||||
let mut botoes = row![button(text("Voltar").size(13))
|
||||
.on_press(Message::Voltar)
|
||||
.style(t::btn_ghost)
|
||||
.padding([9, 14]),]
|
||||
.spacing(8)
|
||||
.align_y(Alignment::Center);
|
||||
|
||||
if aba_selecionada && tem_preset {
|
||||
botoes = botoes.push(
|
||||
button(text("Processar").size(13))
|
||||
.on_press(Message::ExecutarImportacao)
|
||||
.style(t::btn_primary)
|
||||
.padding([9, 14]),
|
||||
);
|
||||
}
|
||||
|
||||
if aba_selecionada {
|
||||
botoes = botoes.push(
|
||||
button(text("Configurar Colunas").size(13))
|
||||
.on_press(Message::IrParaConfiguracaoColunas)
|
||||
.style(t::btn_secondary)
|
||||
.padding([9, 14]),
|
||||
);
|
||||
}
|
||||
|
||||
// ── Layout geral ──────────────────────────────────────────────────────────
|
||||
let content = column![header, card_abas, preview_section, botoes]
|
||||
.spacing(16)
|
||||
.padding([20, 24])
|
||||
.width(Length::Fill);
|
||||
|
||||
container(scrollable(content))
|
||||
.style(t::fundo)
|
||||
.width(Length::Fill)
|
||||
.height(Length::Fill)
|
||||
.into()
|
||||
}
|
||||
+481
@@ -0,0 +1,481 @@
|
||||
use iced::widget::{button, container, progress_bar, text_input};
|
||||
use iced::{Border, Color, Theme};
|
||||
|
||||
// ─── Paleta de cores ──────────────────────────────────────────────────────────
|
||||
|
||||
pub const BG: Color = Color {
|
||||
r: 0.059,
|
||||
g: 0.090,
|
||||
b: 0.165,
|
||||
a: 1.0,
|
||||
}; // #0F172A
|
||||
pub const SURFACE: Color = Color {
|
||||
r: 0.118,
|
||||
g: 0.161,
|
||||
b: 0.231,
|
||||
a: 1.0,
|
||||
}; // #1E293B
|
||||
pub const SURFACE_2: Color = Color {
|
||||
r: 0.200,
|
||||
g: 0.255,
|
||||
b: 0.333,
|
||||
a: 1.0,
|
||||
}; // #334155
|
||||
pub const BORDER: Color = Color {
|
||||
r: 0.200,
|
||||
g: 0.255,
|
||||
b: 0.333,
|
||||
a: 1.0,
|
||||
}; // #334155
|
||||
pub const TEXT: Color = Color {
|
||||
r: 0.945,
|
||||
g: 0.961,
|
||||
b: 0.980,
|
||||
a: 1.0,
|
||||
}; // #F1F5F9
|
||||
pub const TEXT_SECONDARY: Color = Color {
|
||||
r: 0.580,
|
||||
g: 0.639,
|
||||
b: 0.722,
|
||||
a: 1.0,
|
||||
}; // #94A3B8
|
||||
pub const TEXT_MUTED: Color = Color {
|
||||
r: 0.392,
|
||||
g: 0.455,
|
||||
b: 0.545,
|
||||
a: 1.0,
|
||||
}; // #64748B
|
||||
pub const PRIMARY: Color = Color {
|
||||
r: 0.231,
|
||||
g: 0.510,
|
||||
b: 0.965,
|
||||
a: 1.0,
|
||||
}; // #3B82F6
|
||||
pub const PRIMARY_HOVER: Color = Color {
|
||||
r: 0.145,
|
||||
g: 0.388,
|
||||
b: 0.922,
|
||||
a: 1.0,
|
||||
}; // #2563EB
|
||||
pub const SUCCESS: Color = Color {
|
||||
r: 0.133,
|
||||
g: 0.773,
|
||||
b: 0.369,
|
||||
a: 1.0,
|
||||
}; // #22C55E
|
||||
pub const WARNING: Color = Color {
|
||||
r: 0.961,
|
||||
g: 0.620,
|
||||
b: 0.043,
|
||||
a: 1.0,
|
||||
}; // #F59E0B
|
||||
pub const DANGER: Color = Color {
|
||||
r: 0.937,
|
||||
g: 0.267,
|
||||
b: 0.267,
|
||||
a: 1.0,
|
||||
}; // #EF4444
|
||||
pub const TRACK_BG: Color = Color {
|
||||
r: 0.067,
|
||||
g: 0.094,
|
||||
b: 0.153,
|
||||
a: 1.0,
|
||||
}; // #111827
|
||||
|
||||
// ─── Tema global ─────────────────────────────────────────────────────────────
|
||||
|
||||
pub fn tema_dark() -> Theme {
|
||||
Theme::custom(
|
||||
"dark".to_string(),
|
||||
iced::theme::Palette {
|
||||
background: BG,
|
||||
text: TEXT,
|
||||
primary: PRIMARY,
|
||||
success: SUCCESS,
|
||||
danger: DANGER,
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
// ─── Container styles ────────────────────────────────────────────────────────
|
||||
|
||||
/// Card principal: surface + borda + radius 8
|
||||
pub fn card(_theme: &Theme) -> container::Style {
|
||||
container::Style {
|
||||
background: Some(SURFACE.into()),
|
||||
border: Border {
|
||||
color: BORDER,
|
||||
width: 1.0,
|
||||
radius: 8.0.into(),
|
||||
},
|
||||
text_color: Some(TEXT),
|
||||
..Default::default()
|
||||
}
|
||||
}
|
||||
|
||||
/// Card secundário: surface_2 + borda + radius 6
|
||||
pub fn card_secondary(_theme: &Theme) -> container::Style {
|
||||
container::Style {
|
||||
background: Some(SURFACE_2.into()),
|
||||
border: Border {
|
||||
color: BORDER,
|
||||
width: 1.0,
|
||||
radius: 6.0.into(),
|
||||
},
|
||||
text_color: Some(TEXT),
|
||||
..Default::default()
|
||||
}
|
||||
}
|
||||
|
||||
/// Fundo geral da tela
|
||||
pub fn fundo(_theme: &Theme) -> container::Style {
|
||||
container::Style {
|
||||
background: Some(BG.into()),
|
||||
text_color: Some(TEXT),
|
||||
..Default::default()
|
||||
}
|
||||
}
|
||||
|
||||
/// Cabeçalho de tabela (surface_2 sem borda)
|
||||
pub fn cabecalho_tabela(_theme: &Theme) -> container::Style {
|
||||
container::Style {
|
||||
background: Some(SURFACE_2.into()),
|
||||
text_color: Some(TEXT_SECONDARY),
|
||||
..Default::default()
|
||||
}
|
||||
}
|
||||
|
||||
/// Badge de sucesso (OK)
|
||||
pub fn badge_sucesso(_theme: &Theme) -> container::Style {
|
||||
container::Style {
|
||||
background: Some(Color { a: 0.15, ..SUCCESS }.into()),
|
||||
border: Border {
|
||||
color: Color { a: 0.3, ..SUCCESS },
|
||||
width: 1.0,
|
||||
radius: 4.0.into(),
|
||||
},
|
||||
text_color: Some(SUCCESS),
|
||||
..Default::default()
|
||||
}
|
||||
}
|
||||
|
||||
/// Badge de aviso (faltante)
|
||||
pub fn badge_aviso(_theme: &Theme) -> container::Style {
|
||||
container::Style {
|
||||
background: Some(Color { a: 0.15, ..WARNING }.into()),
|
||||
border: Border {
|
||||
color: Color { a: 0.3, ..WARNING },
|
||||
width: 1.0,
|
||||
radius: 4.0.into(),
|
||||
},
|
||||
text_color: Some(WARNING),
|
||||
..Default::default()
|
||||
}
|
||||
}
|
||||
|
||||
/// Badge de perigo (duplicada)
|
||||
pub fn badge_perigo(_theme: &Theme) -> container::Style {
|
||||
container::Style {
|
||||
background: Some(Color { a: 0.15, ..DANGER }.into()),
|
||||
border: Border {
|
||||
color: Color { a: 0.3, ..DANGER },
|
||||
width: 1.0,
|
||||
radius: 4.0.into(),
|
||||
},
|
||||
text_color: Some(DANGER),
|
||||
..Default::default()
|
||||
}
|
||||
}
|
||||
|
||||
/// Área de erro/validação
|
||||
pub fn area_erro(_theme: &Theme) -> container::Style {
|
||||
container::Style {
|
||||
background: Some(Color { a: 0.10, ..DANGER }.into()),
|
||||
border: Border {
|
||||
color: Color { a: 0.4, ..DANGER },
|
||||
width: 1.0,
|
||||
radius: 6.0.into(),
|
||||
},
|
||||
text_color: Some(DANGER),
|
||||
..Default::default()
|
||||
}
|
||||
}
|
||||
|
||||
/// Stat card com borda colorida no topo
|
||||
pub fn stat_card(_theme: &Theme) -> container::Style {
|
||||
container::Style {
|
||||
background: Some(SURFACE.into()),
|
||||
border: Border {
|
||||
color: BORDER,
|
||||
width: 1.0,
|
||||
radius: 8.0.into(),
|
||||
},
|
||||
text_color: Some(TEXT),
|
||||
..Default::default()
|
||||
}
|
||||
}
|
||||
|
||||
/// Separador horizontal (linha fina)
|
||||
pub fn separador(_theme: &Theme) -> container::Style {
|
||||
container::Style {
|
||||
background: Some(BORDER.into()),
|
||||
..Default::default()
|
||||
}
|
||||
}
|
||||
|
||||
/// Fundo do breadcrumb
|
||||
pub fn breadcrumb_bg(_theme: &Theme) -> container::Style {
|
||||
container::Style {
|
||||
background: Some(SURFACE.into()),
|
||||
border: Border {
|
||||
color: BORDER,
|
||||
width: 0.0,
|
||||
radius: 0.0.into(),
|
||||
},
|
||||
text_color: Some(TEXT_SECONDARY),
|
||||
..Default::default()
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Button styles ───────────────────────────────────────────────────────────
|
||||
|
||||
/// Botão primário (azul sólido)
|
||||
pub fn btn_primary(theme: &Theme, status: button::Status) -> button::Style {
|
||||
let base = button::Style {
|
||||
background: Some(PRIMARY.into()),
|
||||
text_color: Color::WHITE,
|
||||
border: Border {
|
||||
radius: 6.0.into(),
|
||||
..Default::default()
|
||||
},
|
||||
..Default::default()
|
||||
};
|
||||
match status {
|
||||
button::Status::Hovered => button::Style {
|
||||
background: Some(PRIMARY_HOVER.into()),
|
||||
..base
|
||||
},
|
||||
button::Status::Pressed => button::Style {
|
||||
background: Some(
|
||||
Color {
|
||||
r: 0.114,
|
||||
g: 0.306,
|
||||
b: 0.847,
|
||||
a: 1.0,
|
||||
}
|
||||
.into(),
|
||||
), // #1D4ED8
|
||||
..base
|
||||
},
|
||||
button::Status::Disabled => button::Style {
|
||||
background: Some(Color { a: 0.4, ..PRIMARY }.into()),
|
||||
text_color: Color {
|
||||
a: 0.5,
|
||||
..Color::WHITE
|
||||
},
|
||||
..base
|
||||
},
|
||||
_ => {
|
||||
let _ = theme;
|
||||
base
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Botão secundário (surface_2)
|
||||
pub fn btn_secondary(theme: &Theme, status: button::Status) -> button::Style {
|
||||
let base = button::Style {
|
||||
background: Some(SURFACE_2.into()),
|
||||
text_color: TEXT,
|
||||
border: Border {
|
||||
radius: 6.0.into(),
|
||||
..Default::default()
|
||||
},
|
||||
..Default::default()
|
||||
};
|
||||
match status {
|
||||
button::Status::Hovered => button::Style {
|
||||
background: Some(
|
||||
Color {
|
||||
r: 0.25,
|
||||
g: 0.31,
|
||||
b: 0.40,
|
||||
a: 1.0,
|
||||
}
|
||||
.into(),
|
||||
),
|
||||
..base
|
||||
},
|
||||
button::Status::Disabled => button::Style {
|
||||
background: Some(
|
||||
Color {
|
||||
a: 0.5,
|
||||
..SURFACE_2
|
||||
}
|
||||
.into(),
|
||||
),
|
||||
text_color: Color { a: 0.4, ..TEXT },
|
||||
..base
|
||||
},
|
||||
_ => {
|
||||
let _ = theme;
|
||||
base
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Botão ghost (sem fundo, borda sutil)
|
||||
pub fn btn_ghost(theme: &Theme, status: button::Status) -> button::Style {
|
||||
let base = button::Style {
|
||||
background: Some(Color::TRANSPARENT.into()),
|
||||
text_color: TEXT_SECONDARY,
|
||||
border: Border {
|
||||
color: BORDER,
|
||||
width: 1.0,
|
||||
radius: 6.0.into(),
|
||||
},
|
||||
..Default::default()
|
||||
};
|
||||
match status {
|
||||
button::Status::Hovered => button::Style {
|
||||
background: Some(Color { a: 0.08, ..PRIMARY }.into()),
|
||||
text_color: TEXT,
|
||||
..base
|
||||
},
|
||||
button::Status::Disabled => button::Style {
|
||||
text_color: TEXT_MUTED,
|
||||
border: Border {
|
||||
color: Color { a: 0.3, ..BORDER },
|
||||
..base.border
|
||||
},
|
||||
..base
|
||||
},
|
||||
_ => {
|
||||
let _ = theme;
|
||||
base
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Botão de perigo (exclusão)
|
||||
pub fn btn_danger(theme: &Theme, status: button::Status) -> button::Style {
|
||||
let base = button::Style {
|
||||
background: Some(Color { a: 0.15, ..DANGER }.into()),
|
||||
text_color: DANGER,
|
||||
border: Border {
|
||||
color: Color { a: 0.3, ..DANGER },
|
||||
width: 1.0,
|
||||
radius: 6.0.into(),
|
||||
},
|
||||
..Default::default()
|
||||
};
|
||||
match status {
|
||||
button::Status::Hovered => button::Style {
|
||||
background: Some(Color { a: 0.25, ..DANGER }.into()),
|
||||
..base
|
||||
},
|
||||
_ => {
|
||||
let _ = theme;
|
||||
base
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Botão de aba selecionada (tela selecionar_aba)
|
||||
pub fn btn_aba_ativa(theme: &Theme, status: button::Status) -> button::Style {
|
||||
let base = button::Style {
|
||||
background: Some(Color { a: 0.20, ..PRIMARY }.into()),
|
||||
text_color: PRIMARY,
|
||||
border: Border {
|
||||
color: PRIMARY,
|
||||
width: 1.0,
|
||||
radius: 6.0.into(),
|
||||
},
|
||||
..Default::default()
|
||||
};
|
||||
match status {
|
||||
button::Status::Hovered => button::Style {
|
||||
background: Some(Color { a: 0.30, ..PRIMARY }.into()),
|
||||
..base
|
||||
},
|
||||
_ => {
|
||||
let _ = theme;
|
||||
base
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Botão de aba inativa (tela selecionar_aba)
|
||||
pub fn btn_aba_inativa(theme: &Theme, status: button::Status) -> button::Style {
|
||||
btn_secondary(theme, status)
|
||||
}
|
||||
|
||||
/// Botão de itens por página (ativo)
|
||||
pub fn btn_pagina_ativo(theme: &Theme, status: button::Status) -> button::Style {
|
||||
btn_primary(theme, status)
|
||||
}
|
||||
|
||||
/// Botão de itens por página (inativo)
|
||||
pub fn btn_pagina_inativo(theme: &Theme, status: button::Status) -> button::Style {
|
||||
btn_ghost(theme, status)
|
||||
}
|
||||
|
||||
// ─── Text input styles ────────────────────────────────────────────────────────
|
||||
|
||||
pub fn input_dark(theme: &Theme, status: text_input::Status) -> text_input::Style {
|
||||
let base = text_input::Style {
|
||||
background: BG.into(),
|
||||
border: Border {
|
||||
color: BORDER,
|
||||
width: 1.0,
|
||||
radius: 6.0.into(),
|
||||
},
|
||||
icon: TEXT_MUTED,
|
||||
placeholder: TEXT_MUTED,
|
||||
value: TEXT,
|
||||
selection: Color { a: 0.3, ..PRIMARY },
|
||||
};
|
||||
match status {
|
||||
text_input::Status::Focused => text_input::Style {
|
||||
border: Border {
|
||||
color: PRIMARY,
|
||||
..base.border
|
||||
},
|
||||
..base
|
||||
},
|
||||
text_input::Status::Hovered => text_input::Style {
|
||||
border: Border {
|
||||
color: TEXT_SECONDARY,
|
||||
..base.border
|
||||
},
|
||||
..base
|
||||
},
|
||||
_ => {
|
||||
let _ = theme;
|
||||
base
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Progress bar styles ─────────────────────────────────────────────────────
|
||||
|
||||
/// Progress bar colorida por percentual (0.0 – 1.0)
|
||||
pub fn progress_bar_por_percentual(percentual: f32) -> impl Fn(&Theme) -> progress_bar::Style {
|
||||
move |_theme| {
|
||||
let cor = if percentual >= 0.90 {
|
||||
SUCCESS
|
||||
} else if percentual >= 0.60 {
|
||||
WARNING
|
||||
} else {
|
||||
DANGER
|
||||
};
|
||||
progress_bar::Style {
|
||||
background: TRACK_BG.into(),
|
||||
bar: cor.into(),
|
||||
border: Border {
|
||||
radius: 10.0.into(),
|
||||
..Default::default()
|
||||
},
|
||||
}
|
||||
}
|
||||
}
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 1.1 MiB |
@@ -0,0 +1,109 @@
|
||||
{
|
||||
"color": {
|
||||
"primary": {
|
||||
"base": "#3B82F6",
|
||||
"hover": "#2563EB",
|
||||
"active": "#1D4ED8"
|
||||
},
|
||||
|
||||
"semantic": {
|
||||
"success": "#22C55E",
|
||||
"warning": "#F59E0B",
|
||||
"error": "#EF4444",
|
||||
"info": "#0EA5E9"
|
||||
},
|
||||
|
||||
"dark": {
|
||||
"background": "#0F172A",
|
||||
"surface": "#1E293B",
|
||||
"surfaceSecondary": "#334155",
|
||||
|
||||
"text": {
|
||||
"primary": "#F1F5F9",
|
||||
"secondary": "#94A3B8",
|
||||
"muted": "#64748B"
|
||||
},
|
||||
|
||||
"border": "#334155",
|
||||
|
||||
"interaction": {
|
||||
"hover": "#3B82F622",
|
||||
"selection": "#3B82F633",
|
||||
"focus": "#3B82F6"
|
||||
}
|
||||
},
|
||||
|
||||
"light": {
|
||||
"background": "#F8FAFC",
|
||||
"surface": "#FFFFFF",
|
||||
"surfaceSecondary": "#F1F5F9",
|
||||
|
||||
"text": {
|
||||
"primary": "#0F172A",
|
||||
"secondary": "#64748B",
|
||||
"muted": "#94A3B8"
|
||||
},
|
||||
|
||||
"border": "#E2E8F0",
|
||||
|
||||
"interaction": {
|
||||
"hover": "#3B82F611",
|
||||
"selection": "#3B82F622",
|
||||
"focus": "#3B82F6"
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
"table": {
|
||||
"row": {
|
||||
"hover": "#3B82F611",
|
||||
"selected": "#3B82F622"
|
||||
},
|
||||
|
||||
"status": {
|
||||
"ok": "#22C55E",
|
||||
"missing": "#F59E0B",
|
||||
"duplicate": "#EF4444",
|
||||
"invalid": "#EF4444"
|
||||
}
|
||||
},
|
||||
|
||||
"spacing": {
|
||||
"xs": 4,
|
||||
"sm": 8,
|
||||
"md": 12,
|
||||
"lg": 16,
|
||||
"xl": 24,
|
||||
"xxl": 32
|
||||
},
|
||||
|
||||
"radius": {
|
||||
"sm": 4,
|
||||
"md": 6,
|
||||
"lg": 8
|
||||
},
|
||||
|
||||
"font": {
|
||||
"family": "Inter, system-ui, sans-serif",
|
||||
|
||||
"size": {
|
||||
"xs": 11,
|
||||
"sm": 12,
|
||||
"md": 14,
|
||||
"lg": 16,
|
||||
"xl": 20
|
||||
},
|
||||
|
||||
"weight": {
|
||||
"normal": 400,
|
||||
"medium": 500,
|
||||
"bold": 600
|
||||
}
|
||||
},
|
||||
|
||||
"shadow": {
|
||||
"sm": "0 1px 2px rgba(0,0,0,0.05)",
|
||||
"md": "0 4px 8px rgba(0,0,0,0.08)",
|
||||
"lg": "0 10px 20px rgba(0,0,0,0.12)"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,287 @@
|
||||
```html
|
||||
<!doctype html>
|
||||
<html lang="pt-BR">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<title>Comparador de Notas - Mockup</title>
|
||||
|
||||
<style>
|
||||
:root {
|
||||
--bg: #0f172a;
|
||||
--surface: #1e293b;
|
||||
--surface-2: #334155;
|
||||
|
||||
--text: #f1f5f9;
|
||||
--text-secondary: #94a3b8;
|
||||
|
||||
--border: #334155;
|
||||
|
||||
--primary: #3b82f6;
|
||||
--success: #22c55e;
|
||||
--warning: #f59e0b;
|
||||
--error: #ef4444;
|
||||
}
|
||||
|
||||
* {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
body {
|
||||
margin: 0;
|
||||
background: var(--bg);
|
||||
color: var(--text);
|
||||
font-family: system-ui, sans-serif;
|
||||
}
|
||||
|
||||
.container {
|
||||
width: 1100px;
|
||||
margin: 40px auto;
|
||||
background: var(--surface);
|
||||
border-radius: 8px;
|
||||
padding: 24px;
|
||||
box-shadow: 0 10px 25px rgba(0, 0, 0, 0.4);
|
||||
}
|
||||
|
||||
.header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.title {
|
||||
font-size: 20px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.actions button {
|
||||
background: var(--primary);
|
||||
border: none;
|
||||
padding: 10px 16px;
|
||||
border-radius: 6px;
|
||||
color: white;
|
||||
margin-left: 8px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.actions button.secondary {
|
||||
background: var(--surface-2);
|
||||
}
|
||||
|
||||
.stats {
|
||||
display: flex;
|
||||
gap: 40px;
|
||||
margin: 20px 0;
|
||||
}
|
||||
|
||||
.stat {
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.stat strong {
|
||||
font-size: 20px;
|
||||
}
|
||||
|
||||
.series-box {
|
||||
background: var(--surface-2);
|
||||
padding: 16px;
|
||||
border-radius: 6px;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.series {
|
||||
margin-bottom: 14px;
|
||||
}
|
||||
|
||||
.progress {
|
||||
height: 8px;
|
||||
background: #111827;
|
||||
border-radius: 10px;
|
||||
overflow: hidden;
|
||||
margin-top: 6px;
|
||||
}
|
||||
|
||||
.bar {
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.green {
|
||||
background: var(--success);
|
||||
}
|
||||
.orange {
|
||||
background: var(--warning);
|
||||
}
|
||||
.red {
|
||||
background: var(--error);
|
||||
}
|
||||
|
||||
table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
margin-top: 16px;
|
||||
}
|
||||
|
||||
thead {
|
||||
background: var(--surface-2);
|
||||
}
|
||||
|
||||
th,
|
||||
td {
|
||||
padding: 10px;
|
||||
text-align: left;
|
||||
border-bottom: 1px solid var(--border);
|
||||
}
|
||||
|
||||
.status {
|
||||
padding: 4px 8px;
|
||||
border-radius: 4px;
|
||||
font-size: 12px;
|
||||
display: inline-block;
|
||||
}
|
||||
|
||||
.ok {
|
||||
background: rgba(34, 197, 94, 0.2);
|
||||
color: var(--success);
|
||||
}
|
||||
|
||||
.dup {
|
||||
background: rgba(239, 68, 68, 0.2);
|
||||
color: var(--error);
|
||||
}
|
||||
|
||||
.missing {
|
||||
background: rgba(245, 158, 11, 0.2);
|
||||
color: var(--warning);
|
||||
}
|
||||
|
||||
.footer {
|
||||
margin-top: 20px;
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.footer button {
|
||||
background: var(--surface-2);
|
||||
border: none;
|
||||
padding: 10px 14px;
|
||||
border-radius: 6px;
|
||||
color: var(--text);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.footer button.primary {
|
||||
background: var(--primary);
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
|
||||
<body>
|
||||
<div class="container">
|
||||
<div class="header">
|
||||
<div class="title">Comparador de Notas</div>
|
||||
|
||||
<div class="actions">
|
||||
<button>Importar Planilha</button>
|
||||
<button class="secondary">Configurar Campos</button>
|
||||
<button class="secondary">Exportar Relatório</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="stats">
|
||||
<div class="stat">
|
||||
<strong style="color: #3b82f6">48</strong> Notas Faltantes
|
||||
</div>
|
||||
<div class="stat">
|
||||
<strong style="color: #ef4444">6</strong> Notas Duplicadas
|
||||
</div>
|
||||
<div class="stat"><strong>R$ 125.600,00</strong> Total</div>
|
||||
</div>
|
||||
|
||||
<div class="series-box">
|
||||
<div class="series">
|
||||
Série 1 – NFE — 48 / 50 notas — 96% completo
|
||||
<div class="progress">
|
||||
<div class="bar green" style="width: 96%"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="series">
|
||||
Série 2 – NFCE — 20 / 25 notas — 80% completo
|
||||
<div class="progress">
|
||||
<div class="bar orange" style="width: 80%"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="series">
|
||||
Série 3 – NFE — 12 / 20 notas — 60% completo
|
||||
<div class="progress">
|
||||
<div class="bar red" style="width: 60%"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Número</th>
|
||||
<th>Série</th>
|
||||
<th>Tipo</th>
|
||||
<th>Valor</th>
|
||||
<th>Status</th>
|
||||
</tr>
|
||||
</thead>
|
||||
|
||||
<tbody>
|
||||
<tr>
|
||||
<td>1005</td>
|
||||
<td>1</td>
|
||||
<td>NFE</td>
|
||||
<td>R$ 2.500,00</td>
|
||||
<td><span class="status dup">Duplicada</span></td>
|
||||
</tr>
|
||||
|
||||
<tr>
|
||||
<td>1006</td>
|
||||
<td>1</td>
|
||||
<td>NFE</td>
|
||||
<td>R$ 3.200,00</td>
|
||||
<td><span class="status ok">OK</span></td>
|
||||
</tr>
|
||||
|
||||
<tr>
|
||||
<td>1010</td>
|
||||
<td>1</td>
|
||||
<td>NFE</td>
|
||||
<td>R$ 4.000,00</td>
|
||||
<td>
|
||||
<span class="status missing">Falta: 1010–1050</span>
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
<tr>
|
||||
<td>1051</td>
|
||||
<td>1</td>
|
||||
<td>NFE</td>
|
||||
<td>R$ 2.800,00</td>
|
||||
<td><span class="status ok">OK</span></td>
|
||||
</tr>
|
||||
|
||||
<tr>
|
||||
<td>1075</td>
|
||||
<td>2</td>
|
||||
<td>NFCE</td>
|
||||
<td>R$ 1.200,00</td>
|
||||
<td><span class="status dup">Duplicada</span></td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
<div class="footer">
|
||||
<button>Copiar Faltantes</button>
|
||||
<button>Copiar Duplicadas</button>
|
||||
<button class="primary">Exportar PDF</button>
|
||||
</div>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
```
|
||||
Reference in New Issue
Block a user