Compare commits

...
10 Commits
Author SHA1 Message Date
FelipeCN 06eca335db update. 2026-03-04 17:27:02 -03:00
FelipeCN 6a5d941a00 Add roteiro para corrigir warnings. 2026-03-04 13:48:51 -03:00
FelipeCN 5745a78a5a Update .gitignore 2026-03-04 13:39:15 -03:00
Felipe 1f4ebb4686 Drag and Drop 2026-03-04 12:35:36 -03:00
Felipe 007f6c818e Fix icons and fonts 2026-03-04 12:25:08 -03:00
Felipe d06110240f Corrected: Responsive design for low-resolution monitors. 2026-03-04 12:12:23 -03:00
FelipeCN ad09c53a5d feat: implement UI redesign for Comparador de Notas
- Introduced a new theme for the application based on dark navy aesthetics.
- Created design tokens for colors, spacings, and border radii.
- Developed a theme module in Rust to manage styles for various UI components.
- Updated multiple screens and components to align with the new design, including cards, buttons, badges, and progress bars.
- Enhanced user experience with improved layouts and visual elements across the application.
2026-03-04 10:58:54 -03:00
FelipeCN 8d00cfc4d7 Refactor UI components for layout management and results display
- Replaced the `renderizar` function in `layouts.rs` with a new `view` function using Iced for a more modern UI approach.
- Introduced a new `view_secao_layouts` function to handle the display of saved layouts.
- Updated the `resultado.rs` file to use Iced for rendering the results screen, including buttons for actions and pagination controls.
- Created a new `selecionar_aba.rs` file for the selection of XLSX sheet tabs, implementing a preview feature.
- Removed old rendering functions and replaced them with Iced components for better performance and maintainability.
- Added design tokens in `design_tokens.json` for consistent styling across the application.
- Created a mockup HTML file to visualize the UI design.
2026-03-04 10:33:40 -03:00
FelipeCN 2d1d29ce3d Create a MIGRATION_ICED.md 2026-03-04 09:43:43 -03:00
FelipeCN 12b8501ffb feat: adiciona novas dependências, implementa reimportação de arquivos e melhorias na interface 2026-03-04 09:19:47 -03:00
40 changed files with 6882 additions and 2682 deletions
+1
View File
@@ -1 +1,2 @@
/target
nul
Generated
+1236 -694
View File
File diff suppressed because it is too large Load Diff
+5 -2
View File
@@ -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"
+9 -13
View File
@@ -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:** 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 |
+353
View File
@@ -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.
+504
View File
@@ -0,0 +1,504 @@
# Migração egui → iced: Estado e Referência
**Branch:** `change-ui`
**Versão iced:** 0.13.1
**Última atualização:** 04/03/2026
**Status:****COMPILANDO**`cargo build` passa sem erros (5 warnings não-bloqueantes, todos em camadas intocáveis)
---
## Estado Atual
### O que foi feito (100% completo)
| Arquivo | Status | Descrição |
|---|---|---|
| `Cargo.toml` | ✅ | `eframe`/`egui`/`image` removidos; `iced 0.13` + `tokio` adicionados |
| `src/main.rs` | ✅ | Reescrito com `iced::application(...)` |
| `src/ui/mod.rs` | ✅ | `pub mod app; pub mod components; pub mod message; pub mod screens;` |
| `src/ui/message.rs` | ✅ | Enum `Message` completo + enum `ResultadoPendente` |
| `src/ui/app.rs` | ✅ | Reescrito para iced (Elm architecture) — `App`, `update()`, `view()` |
| `src/ui/screens/mod.rs` | ✅ | Re-exports + `pub fn indice_para_letra(idx: usize) -> String` |
| `src/ui/screens/import.rs` | ✅ | Tela de importação — pick_list de layout usa `LayoutSelecionado` |
| `src/ui/screens/selecionar_aba.rs` | ✅ | Tela de seleção de aba XLSX |
| `src/ui/screens/configuracao_colunas.rs` | ✅ | Tela de configuração — pick_list de layout usa `LayoutSelecionado` |
| `src/ui/screens/resultado.rs` | ✅ | Tela de resultado — botão "Nova Análise" usa `Message::NovaAnalise` |
| `src/ui/screens/layouts.rs` | ✅ | Tela de gerenciamento de layouts |
| `src/ui/components/mod.rs` | ✅ | `pub mod modal; pub mod paginacao; pub mod tabela_preview;` |
| `src/ui/components/modal.rs` | ✅ | Overlay real com `stack!` + `mouse_area` |
| `src/ui/components/tabela_preview.rs` | ✅ | Tabela com scroll horizontal e cabeçalho estilo Excel |
| `src/ui/components/paginacao.rs` | ✅ | Controles reutilizáveis de paginação |
### O que NÃO foi alterado (intocado por design)
- `src/domain/` — entidades, serviços, erros
- `src/application/` — casos de uso
- `src/infrastructure/` — leitores CSV/XLSX, gerador PDF, SQLite
---
## Arquitetura da UI
### Padrão Elm (iced)
```
App (estado) ──→ view(&self) ──→ Element (widgets renderizados)
↑ ↓
│ usuário interage
│ ↓
└── update(&mut self, msg) ←── Message (enum)
retorna Task<Message>
```
- `view()` é **pura e somente leitura** — nunca muta estado
- Toda mutação acontece **exclusivamente** em `update()`
- Background tasks retornam via `Task::perform(async { ... }, Message::Variante)`
### Estrutura de arquivos
```
src/
├── main.rs
└── ui/
├── mod.rs
├── app.rs (struct App + update + view)
├── message.rs (enum Message + ResultadoPendente)
├── screens/
│ ├── mod.rs (re-exports + indice_para_letra)
│ ├── import.rs
│ ├── selecionar_aba.rs
│ ├── configuracao_colunas.rs
│ ├── resultado.rs
│ └── layouts.rs
└── components/
├── mod.rs
├── modal.rs
├── tabela_preview.rs
└── paginacao.rs
```
---
## Diferenças Críticas da API — iced 0.13
Estas diferenças causaram erros de compilação e **devem ser lembradas** em qualquer adição futura:
### 1. `Command` foi renomeado para `Task`
```rust
// ❌ NÃO EXISTE em iced 0.13
use iced::Command;
Command::none()
// ✅ CORRETO
use iced::Task;
Task::none()
Task::perform(future, mapper)
```
Todos os retornos de `update()` são `Task<Message>`.
### 2. `.align_items()` foi dividido
```rust
// ❌ NÃO EXISTE em iced 0.13
row![...].align_items(Alignment::Center)
// ✅ CORRETO — Row usa align_y, Column usa align_x
row![...].align_y(Alignment::Center)
column![...].align_x(Alignment::Center)
```
### 3. `.center_x()` / `.center_y()` exigem argumento `Length`
```rust
// ❌ NÃO COMPILA
container(...).center_x().center_y()
// ✅ CORRETO
container(...).center_x(Length::Fill).center_y(Length::Fill)
```
### 4. `iced::clipboard::write` retorna `Task<Message>`
```rust
// Pode ser retornado diretamente de update():
return iced::clipboard::write(texto);
```
### 5. Entry point usa API funcional, não trait `Application`
```rust
// main.rs
fn main() -> iced::Result {
iced::application("Título", App::update, App::view)
.window(iced::window::Settings { ... })
.run_with(App::new)
}
// App::new retorna (Self, Task<Message>)
pub fn new() -> (Self, Task<Message>) { ... }
```
### 6. Lifetimes em funções que retornam `Element`
Sempre usar `Element<'_, Message>` (não `Element<Message>`) em funções que recebem referências:
```rust
// ❌ Gera warning mismatched_lifetime_syntaxes
pub fn view(app: &App) -> Element<Message>
// ✅ CORRETO
pub fn view(app: &App) -> Element<'_, Message>
```
**Armadilha com Vec local:** criar `Vec<T>` local e passar `&vec` para função que retorna `Element<'a>` causa E0515. Solução: filtrar diretamente de dados que vivem no `&app`.
### 7. Borrow checker com `Arc<Mutex<Connection>>`
```rust
// ❌ Mantém borrow imutável de &self enquanto tenta &mut self
if let Some(conn_arc) = &self.conn {
let conn = conn_arc.lock().unwrap();
self.exibir_erro(...); // ERRO: borrow ativo
}
// ✅ Clonar o Arc primeiro (O(1), não clona a Connection)
let conn_arc = self.conn.as_ref().map(Arc::clone);
if let Some(conn_arc) = conn_arc {
let conn = conn_arc.lock().unwrap();
drop(conn); // liberar antes de &mut self
self.exibir_erro(...);
}
```
---
## Componentes Implementados
### `App` (src/ui/app.rs)
**Estado:**
```rust
pub struct App {
pub estado: EstadoApp,
pub conn: Option<Arc<Mutex<Connection>>>,
pub banco_foi_recriado: bool,
pub notas_importadas: Vec<Nota>,
pub caminho_arquivo: Option<PathBuf>, // ← populado para CSV e XLSX
pub nome_arquivo: String,
pub tipo_arquivo_atual: TipoArquivo,
pub layout_csv_atual: LayoutCsv,
pub layout_xlsx_atual: LayoutXlsx,
pub nome_layout_atual: String,
pub abas_xlsx: Vec<String>,
pub layouts_salvos: Vec<Layout>,
pub modal: Option<EstadoModal>,
pub avisos_importacao: Option<ResumoAvisos>,
pub pagina_faltantes: usize,
pub pagina_duplicatas: usize,
pub itens_por_pagina: usize,
pub preview_arquivo: Option<Vec<Vec<String>>>,
pub resultado_anterior: Option<ResultadoAnalise>,
}
```
**Máquina de estados (`EstadoApp`):**
```
Importando
→ ArquivoSelecionado (CSV) → ConfigurandoColunas
→ ArquivoSelecionado (XLSX) → SelecionandoAba → ConfigurandoColunas
ConfigurandoColunas
→ ExecutarImportacao → Analisando
Analisando
→ AnaliseCompleta (ok, sem excessivos) → ExibindoResultado
→ AnaliseCompleta (excessivos) → ConfirmandoIntervalo (+ modal)
ConfirmandoIntervalo
→ ModalConfirmado (ConfirmarExpansaoFaltantes) → Analisando → ExibindoResultado
→ ModalCancelado → ConfigurandoColunas
ExibindoResultado
→ ReanalisarArquivo → Analisando
→ IrParaConfiguracaoColunas → ConfigurandoColunas
→ NovaAnalise (modal) → Importando (após confirmação)
GerenciandoLayouts
→ IrParaImportacao → Importando
```
**Modal (`EstadoModal` / `AcaoModal`):**
```rust
pub enum EstadoModal {
Informacao { titulo: String, mensagem: String }, // definido, não usado ainda
Aviso { titulo: String, mensagem: String },
Erro { titulo: String, mensagem: String },
Confirmacao { titulo: String, mensagem: String, acao: AcaoModal },
InputTexto { titulo: String, mensagem: String, texto: String, acao: AcaoModal },
}
pub enum AcaoModal {
ConfirmarExpansaoFaltantes,
ConfirmarExclusaoLayout(i64),
SobrescreverLayout(Layout),
ConfirmarNovaAnalise,
SalvarLayoutConfig,
}
```
**Comportamento do `ModalCancelado`:**
- Fecha o modal (`self.modal = None`)
- Se o estado for `ConfirmandoIntervalo`, **também** reseta para `ConfigurandoColunas`
### `Message` (src/ui/message.rs) — enum completo
```rust
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),
AbaSelecionada(String),
AbaxlsxCarregadas { caminho: PathBuf, abas: Vec<String>, layout_xlsx: LayoutXlsx, nome_layout: String },
XlsxErroAoCarregar(String),
// Background
AnaliseCompleta(ResultadoPendente),
// Config CSV
DelimitadorAlterado(char), EncodingAlterado(String), LinhaCabecalhoAlterada(usize),
IndiceNumeroAlterado(usize), IndiceSerieAlterado(usize),
IndiceValorToggle(bool), IndiceValorAlterado(usize),
IndiceDataToggle(bool), IndiceDataAlterado(usize),
IndiceDocTipoToggle(bool), IndiceDocTipoAlterado(usize),
// Config XLSX
AbaXlsxAlterada(String),
PosNumeroAlterada(String), PosSerieAlterada(String),
PosValorToggle(bool), PosValorAlterada(String),
PosDataToggle(bool), PosDataAlterada(String),
PosDocTipoToggle(bool), PosDocTipoAlterada(String),
// Análise
ExecutarImportacao, ReanalisarArquivo,
ConfirmarExpansaoFaltantes,
CancelarExpansao, // arm existe em update() mas nenhum widget o dispara diretamente
NovaAnalise, // abre modal de confirmação (AcaoModal::ConfirmarNovaAnalise)
// Resultado
PaginaFaltantesAlterada(usize), PaginaDuplicatasAlterada(usize),
ItensPorPaginaAlterado(usize),
CopiarFaltantes(ChaveSerie), CopiarDuplicatas(ChaveSerie),
ExportarPdf, PdfExportado(Result<PathBuf, String>),
// Layouts
LayoutSelecionado(i64), // aplica layout (chama aplicar_layout) + atualiza nome
SalvarLayout, // abre modal InputTexto
NomeLayoutAlterado(String),
ExcluirLayout(i64), ExclusaoConfirmada(i64),
ExportarLayoutJson(i64), ImportarLayoutJson, LayoutJsonImportado(String),
SobrescreverLayout(Layout),
// Modal
ModalTextoAlterado(String), ModalConfirmado, ModalCancelado,
Noop,
}
```
### `modal.rs` (src/ui/components/modal.rs)
Overlay real: `stack![conteudo, overlay_escuro]`. O `mouse_area` captura cliques fora da caixa e dispara `ModalCancelado`.
```rust
pub fn view_com_modal<'a>(
conteudo: Element<'a, Message>,
modal: &'a EstadoModal,
) -> Element<'a, Message>
```
Botões da caixa modal:
- Sempre: **"Fechar"** → `ModalCancelado`
- Se `com_confirmar`: **"Confirmar"** → `ModalConfirmado`
### `tabela_preview.rs` (src/ui/components/tabela_preview.rs)
Cabeçalho com letras estilo Excel (A, B, C...) + índice numérico. Scroll horizontal. Trunca células em 30 caracteres. Altura fixa de 160px.
### `paginacao.rs` (src/ui/components/paginacao.rs)
```rust
pub fn controles_paginacao(
pagina_atual: usize,
total_paginas: usize,
msg_anterior: Message,
msg_proximo: Message,
) -> Element<'static, Message>
```
Botões ◀ / ▶ desabilitados automaticamente na primeira/última página via `on_press_maybe`.
---
## Regras de Seleção de Layout (pick_list)
**Em todas as telas** que exibem um `pick_list` de layouts, o handler **deve** usar `LayoutSelecionado(id)`, não `NomeLayoutAlterado`:
```rust
pick_list(opcoes_layout, nome_sel, {
let layouts = app.layouts_salvos.clone();
move |nome: String| {
if let Some(id) = layouts.iter().find(|l| l.nome() == nome).and_then(|l| l.id()) {
Message::LayoutSelecionado(id)
} else {
Message::NomeLayoutAlterado(nome) // fallback (nunca deve ocorrer com layouts do banco)
}
}
})
```
Isso vale para: `import.rs`, `configuracao_colunas.rs`. A diferença:
- `NomeLayoutAlterado` — só atualiza `self.nome_layout_atual` (string), **não preenche o formulário**
- `LayoutSelecionado(id)` — chama `aplicar_layout()`, que preenche `layout_csv_atual` ou `layout_xlsx_atual`
---
## Fluxo XLSX — Detalhe Crítico
### Onde `caminho_arquivo` é populado
Para **CSV**: em `processar_arquivo_selecionado()`, logo após detectar a extensão.
Para **XLSX**: o caminho fica dentro do estado `SelecionandoAba { caminho }` e é copiado para `self.caminho_arquivo` **somente quando o usuário seleciona uma aba** (handler `AbaSelecionada`).
```
ArquivoSelecionado(xlsx)
→ AbaxlsxCarregadas
→ aba já definida no layout e existe no arquivo?
Sim → caminho_arquivo setado aqui → disparar_importacao()
Não → estado = SelecionandoAba { caminho } ← caminho_arquivo ainda None aqui
→ AbaSelecionada(aba)
→ caminho_arquivo = Some(caminho) ← só aqui é setado
→ estado permanece SelecionandoAba
→ IrParaConfiguracaoColunas
→ estado = ConfigurandoColunas
→ tem_arquivo = true ✓
```
**Consequência:** nunca verificar `app.caminho_arquivo.is_some()` como condição de habilitação antes do usuário ter selecionado uma aba no fluxo XLSX.
---
## Background Tasks
Todas as operações pesadas usam `Task::perform`:
```rust
// Padrão para operações síncronas em background:
Task::perform(
async move {
tokio::task::spawn_blocking(move || {
// código síncrono (rusqlite, calamine, etc.)
})
.await
.unwrap_or_else(|e| ResultadoPendente::Erro(e.to_string()))
},
Message::AnaliseCompleta,
)
```
| Operação | Message de retorno |
|---|---|
| Abrir banco + migrations + listar layouts | `BancoInicializado` |
| Listar layouts após salvar/excluir | `LayoutsRecarregados` |
| Listar abas de arquivo XLSX | `AbaxlsxCarregadas` / `XlsxErroAoCarregar` |
| Importar CSV/XLSX + analisar | `AnaliseCompleta` |
| Expandir análise com faltantes | `AnaliseCompleta` |
| Exportar PDF | `PdfExportado` |
| Exportar layout JSON | `Noop` (salva diretamente) |
| Importar layout JSON | `LayoutJsonImportado` |
**Diálogos de arquivo** usam `rfd::AsyncFileDialog` (não bloqueia):
```rust
Task::perform(
async {
rfd::AsyncFileDialog::new()
.add_filter("Planilhas", &["csv", "xlsx", "xls"])
.pick_file()
.await
.map(|h| h.path().to_path_buf())
},
|r| r.map(Message::ArquivoSelecionado).unwrap_or(Message::Noop),
)
```
---
## Warnings Existentes (não-bloqueantes)
| Warning | Local | Situação |
|---|---|---|
| `field data is never read` | `domain/nota.rs` | Domínio intocável — ignorar |
| `fields minimo/maximo never read` | `domain/resultado_analise.rs` | Domínio intocável — ignorar |
| `method sem_inconsistencias never used` | `domain/resultado_analise.rs` | Domínio intocável — ignorar |
| `variant Informacao never constructed` | `ui/app.rs` | Mantido para uso futuro (modal informativo) |
| `variant CancelarExpansao never constructed` | `ui/message.rs` | O arm existe em `update()`, mas nenhum widget o dispara; o cancelamento do intervalo ocorre via `ModalCancelado` |
---
## O Que Falta Implementar
### Bugs/lacunas conhecidos
1. **`selecionar_aba.rs` — botões sem diferenciação visual entre aba selecionada e não selecionada** — ambos os branches do `if selecionada` são idênticos (sem estilo diferente). A aba ativa deveria ter destaque visual.
2. **`selecionar_aba.rs` — layout não é aplicável nessa tela** — se o usuário chegou aqui sem ter selecionado um layout previamente, não há como selecionar um layout antes de avançar para ConfigurandoColunas. Considerar adicionar um `pick_list` de layouts na tela `SelecionandoAba`.
3. **Sem tema dark/light** — usa o tema padrão do sistema. Para adicionar:
```rust
// Em main.rs: encadear .theme(|app, _| app.tema.clone())
// Em App: campo pub tema: iced::Theme
// Mensagem: Message::TemaAlterado(iced::Theme)
```
4. **Sem drag-and-drop de arquivos** — importação tem apenas o botão de seleção. Verificar suporte nativo em iced 0.13 antes de implementar.
5. **Sem filtro/busca nos resultados** — sem `text_input` para filtrar `faltantes_por_serie`.
6. **`EstadoModal::Informacao` nunca é usado** — definido mas sem chamadas. Remover ou usar.
### Funcionalidades futuras (Fase 6 do plano original)
- Histórico de análises (nova tela + schema SQLite v4)
- Gráficos de completude por série
- Exportação para Excel/CSV
---
## Como Rodar
```bash
# Verificar sem compilar binário (rápido)
cargo check
# Compilar (primeira vez ~1 min por baixar iced + wgpu)
cargo build
# Executar em modo debug
cargo run
# Build de release (otimizado, sem console no Windows)
cargo build --release
```
---
## Referências
- [iced 0.13 changelog](https://github.com/iced-rs/iced/blob/master/CHANGELOG.md)
- [iced exemplos oficiais](https://github.com/iced-rs/iced/tree/master/examples)
- [rfd AsyncFileDialog](https://docs.rs/rfd/latest/rfd/struct.AsyncFileDialog.html)
- [iced::clipboard::write](https://docs.rs/iced/0.13.1/iced/clipboard/fn.write.html)
+217
View File
@@ -0,0 +1,217 @@
# Roteiro de Desenvolvimento — Comparador de Notas
**Gerado em:** 04/03/2026
**Baseado em:** PRD v1.7 + análise estática do código atual
---
## 1. Warnings do Compilador
São 7 warnings ativos (`cargo build`). Nenhum é crítico, mas todos devem ser eliminados para manter o código limpo.
### W-01 — `Nota.data` nunca lida
**Arquivo:** `src/domain/entities/nota.rs:17`
**Causa:** O campo `data: Option<NaiveDate>` é armazenado na struct `Nota`, mas nenhum código de negócio ou de UI o consome atualmente.
**Contexto PRD:** O campo Data é definido no modelo de dados (seção 5) como opcional. O PRD diz que é "exibida como informação adicional no relatório PDF. Não participa de nenhuma regra de validação ou cálculo."
**Solução:** Usar o campo `data` na geração do PDF (`pdf_generator.rs`), exibindo a data da nota nas seções de faltantes ou duplicatas quando disponível. Isso resolve o warning e implementa o requisito do PRD.
---
### W-02 — `IntervaloSerie.minimo` e `IntervaloSerie.maximo` nunca lidos
**Arquivo:** `src/domain/entities/resultado_analise.rs:23-24`
**Causa:** Os campos `minimo: u64` e `maximo: u64` existem na struct `IntervaloSerie`, mas nenhum código os consome após o cálculo.
**Contexto PRD:** RF04 define que o intervalo de faltantes é avaliado entre o menor e o maior número encontrado. O aviso de confirmação (RF04 — Proteção contra intervalos anormalmente grandes) exibe o intervalo calculado ao usuário.
**Solução:** Exibir `minimo` e `maximo` na mensagem de confirmação em `app.rs` quando o intervalo exceder 10.000. Exemplo: `"Série 001 / NFE: intervalo de 999.996 faltantes detectado (de 1 a 1.000.000)"`. O PRD especifica exatamente esse formato de aviso.
---
### W-03 — `ResultadoAnalise::sem_inconsistencias` nunca usada
**Arquivo:** `src/domain/entities/resultado_analise.rs:51`
**Causa:** O método `pub fn sem_inconsistencias()` está definido mas nunca chamado.
**Contexto PRD:** Nenhuma funcionalidade específica é mapeada diretamente para este método.
**Solução (opção A — remover):** Remover o método se não houver uso planejado próximo. É código morto.
**Solução (opção B — usar):** Usar o método na tela de resultado (`resultado.rs`) para exibir um badge "Sem inconsistências" ou mensagem de sucesso no topo quando `sem_inconsistencias()` for `true`. Melhora a UX e elimina o warning.
---
### W-04 — `EstadoModal::Informacao` nunca construída
**Arquivo:** `src/ui/app.rs:39`
**Causa:** A variante `Informacao { titulo, mensagem }` existe no enum `EstadoModal` mas nenhum código a instancia. Existe `Aviso` e `Erro` para as demais situações.
**Solução:** Remover a variante `Informacao` do enum se não houver distinção visual planejada entre "Informação" e "Aviso". Alternativamente, usá-la onde hoje se usa `Aviso` para casos puramente informativos (sem cor amarela de alerta). A variante mais simples é a remoção.
---
### W-05 — `Message::CancelarExpansao` nunca construída
**Arquivo:** `src/ui/message.rs:76`
**Causa:** A variante `CancelarExpansao` existe no enum `Message` e é tratada em `update()` (muda o estado para `ConfigurandoColunas`), mas nenhuma tela emite essa mensagem. O cancelamento do intervalo excessivo é feito via `Message::ModalCancelado`.
**Solução:** Remover `Message::CancelarExpansao` do enum. O comportamento de cancelar a expansão já está implementado em `Message::ModalCancelado` (`app.rs:629-636`).
---
### W-06 — `theme::cabecalho_tabela` nunca usada
**Arquivo:** `src/ui/theme.rs:140`
**Causa:** A função de estilo `cabecalho_tabela` está definida mas nenhuma tela a usa.
**Contexto:** O componente `tabela_preview.rs` usa estilo inline para o cabeçalho em vez desta função.
**Solução:** Aplicar `t::cabecalho_tabela` no cabeçalho da tabela de preview em `tabela_preview.rs`, substituindo o estilo inline atual. Isso centraliza o estilo e elimina o warning.
---
### W-07 — `theme::badge_sucesso` nunca usada
**Arquivo:** `src/ui/theme.rs:149`
**Causa:** A função de estilo `badge_sucesso` está definida mas nenhuma tela a usa. Os badges de sucesso são aplicados via `t::badge_aviso` (amarelo) ou texto colorido com `t::SUCCESS`.
**Contexto PRD:** A tela de resultado exibe indicador de completude por série. Quando 100% completo (sem faltantes), poderia exibir um badge verde.
**Solução (opção A — usar):** Exibir badge `t::badge_sucesso` na tela de resultado quando uma série não tem faltantes, em vez de apenas texto verde. Melhora a distinção visual.
**Solução (opção B — remover):** Remover se não houver uso planejado.
---
## 2. Funcionalidades Faltantes (vs. PRD v1.7)
### F-01 — Campo `data` no PDF (RF07.1)
**Status:** Não implementado
**PRD:** Seção 5 e RF07.1 — "Data de emissão da nota. Exibida como informação adicional no relatório PDF."
**Situação atual:** O campo `data` é lido do arquivo (`csv_reader.rs`, `xlsx_reader.rs`) e armazenado em `Nota.data`, mas `pdf_generator.rs` não o utiliza em nenhuma seção.
**O que falta:** Exibir a data de cada nota nas seções de faltantes e/ou duplicatas do PDF quando disponível. Por exemplo, no detalhamento de duplicatas: `"NF 42 / Série 001 — 3 ocorrências (última: 15/01/2025)"`.
---
### F-02 — Mensagem de confirmação com intervalo exato (RF04)
**Status:** Parcialmente implementado
**PRD:** RF04 — "Exibir aviso informando o intervalo calculado (ex: 'Série 001 / NFE: intervalo de 999.996 faltantes detectado')"
**Situação atual:** O aviso de confirmação em `app.rs:930-948` exibe apenas `"Série X: intervalo de N faltantes detectado"`, mas `minimo` e `maximo` de `IntervaloSerie` não são incluídos na mensagem. Os campos existem mas não são usados (W-02 acima).
**O que falta:** Incluir `minimo` e `maximo` na mensagem de confirmação para que o usuário veja o intervalo completo.
---
### F-03 — Limite de tamanho de arquivo 50 MB (RF01.3)
**Status:** Não implementado
**PRD:** RF01.3 — "O sistema deve recusar arquivos maiores que 50 MB e exibir mensagem de erro ao usuário."
**Situação atual:** Nenhuma verificação de tamanho de arquivo existe nos readers (`csv_reader.rs`, `xlsx_reader.rs`) nem em `processar_arquivo_selecionado` (`app.rs`).
**O que falta:** Verificar `std::fs::metadata(caminho)?.len()` antes de processar. Se > 50 MB, retornar `ErroArquivo::TamanhoExcedido` (o tipo já está definido em `domain/errors.rs`) e exibir modal de erro.
---
### F-04 — Arquivo XLSX corrompido: limpar estado (RF01.2)
**Status:** Parcialmente implementado
**PRD:** RF01.2 — "Se o arquivo não puder ser lido, exibir mensagem de erro em modal e limpar o arquivo carregado; o estado anterior é descartado."
**Situação atual:** `Message::XlsxErroAoCarregar` exibe o erro em modal, mas não limpa `self.caminho_arquivo` nem `self.nome_arquivo` nem `self.preview_arquivo`. O estado anterior do arquivo permanece em memória.
**O que falta:** No handler de `Message::XlsxErroAoCarregar`, zerar `self.caminho_arquivo = None`, `self.nome_arquivo = String::new()`, `self.preview_arquivo = None` e `self.notas_importadas.clear()` antes de exibir o erro.
---
### F-05 — Relatório de linhas malformadas ao usuário (RF01.1 / RF01.2)
**Status:** Parcialmente implementado
**PRD:** RF01.1/RF01.2 — "o sistema deve reportar ao usuário quais linhas foram descartadas, sem interromper a importação"
**Situação atual:** `ResumoAvisos` agrega contagens, e o modal de avisos exibe resumos do tipo "32 linhas descartadas por malformação". Porém, **os números de linha específicos** (ex: "linhas 15, 42, 103 descartadas") não são rastreados nem exibidos.
**O que falta:** Avaliar se o PRD exige listagem de números de linha individuais (o texto diz "quais linhas foram descartadas"). A interpretação atual (contagens por categoria) pode ser suficiente, mas merece revisão explícita com o product owner. Se linhas individuais forem necessárias, `ResumoAvisos` precisa armazenar `Vec<usize>` por categoria.
---
### F-06 — Validação de campos duplicados no mapeamento (RF02.3)
**Status:** Não implementado
**PRD:** RF02 — Tratamento de Erros de Configuração: "Dois campos mapeados para o mesmo índice/posição → Bloquear e exibir erro de validação imediatamente, antes de executar a análise"
**Situação atual:** `executar_importacao_sync` delega a validação ao reader, mas não há verificação explícita de índices/posições duplicados antes do disparo da análise. Se o usuário mapear, por exemplo, Numero e Serie para o mesmo índice CSV, os dados serão importados incorretamente sem aviso.
**O que falta:** Adicionar validação em `disparar_importacao` (ou no use case `importar_arquivo`) que verifique se algum índice (CSV) ou posição (XLSX) aparece em mais de um campo mapeado e retorne erro descritivo antes de iniciar o processamento.
---
### F-07 — Índice/posição inválido: identificar qual campo (RF02)
**Status:** Parcialmente implementado
**PRD:** RF02 — "Índice/posição configurado não existe no arquivo importado → Exibir erro ao usuário identificando qual campo está inválido"
**Situação atual:** Os readers retornam erros quando um índice não existe, mas a mensagem de erro pode não identificar claramente qual campo (Numero, Serie, Valor etc.) causou o problema.
**O que falta:** Garantir que as mensagens de erro de importação identifiquem o campo problemático pelo nome lógico (ex: "Campo 'Numero': índice 5 não existe — o arquivo tem 4 colunas").
---
### F-08 — Spinner/indicador visual durante análise (RNF03 / RNF04)
**Status:** Minimamente implementado
**PRD:** RNF03 — "A análise é executada em uma thread separada para não bloquear a interface gráfica"
**Situação atual:** O estado `EstadoApp::Analisando` exibe apenas dois textos estáticos ("Analisando..." e "Aguarde...") sem nenhum indicador de progresso animado.
**O que falta:** Implementar um spinner ou progress bar indeterminado na tela de análise para dar feedback visual ao usuário de que o processamento está ativo. O iced suporta animações via `Subscription` com `time::every`.
---
### F-09 — Paginação independente por série (RF07)
**Status:** Não implementado (limitação funcional)
**PRD:** RF07 — paginação para listas longas
**Situação atual:** `app.pagina_faltantes` e `app.pagina_duplicatas` são contadores globais únicos, compartilhados entre todas as séries. Ao navegar para a página 2 de faltantes da Série 001, a paginação também afeta a Série 002 se ambas aparecerem na mesma tela.
**O que falta:** Tornar a paginação independente por `ChaveSerie`, usando um `HashMap<ChaveSerie, usize>` no estado da aplicação em vez de um único `usize` global. Isso requer mudanças em `App`, em `Message` e na tela `resultado.rs`.
---
### F-10 — Exportação do resultado em CSV (seção 4.2 / seção 14)
**Status:** Não incluído no MVP (seção 4.2)
**PRD:** Listado como "Não Incluído (MVP)" na seção 4.2 e como evolução futura na seção 14.
**Nota:** Não é um requisito do MVP. Documentado aqui apenas para rastreabilidade.
---
## 3. Resumo por Prioridade
| ID | Tipo | Arquivo(s) afetado(s) | Esforço estimado |
|----|------|-----------------------|-----------------|
| W-01 | Warning → usar campo `data` | `pdf_generator.rs` | Baixo |
| W-02 | Warning → usar `minimo`/`maximo` | `app.rs` | Baixo |
| W-03 | Warning → remover ou usar | `resultado_analise.rs`, `resultado.rs` | Baixo |
| W-04 | Warning → remover variante | `app.rs` | Baixo |
| W-05 | Warning → remover variante | `message.rs`, `app.rs` | Baixo |
| W-06 | Warning → aplicar estilo | `tabela_preview.rs` | Baixo |
| W-07 | Warning → usar ou remover | `resultado.rs` ou `theme.rs` | Baixo |
| F-01 | Funcionalidade faltante | `pdf_generator.rs` | Baixo |
| F-02 | Funcionalidade incompleta | `app.rs` | Baixo |
| F-03 | Funcionalidade faltante | `csv_reader.rs`, `xlsx_reader.rs`, `app.rs` | Médio |
| F-04 | Funcionalidade incompleta | `app.rs` | Baixo |
| F-05 | Revisão de requisito | `domain/errors.rs`, readers | Médio |
| F-06 | Funcionalidade faltante | `app.rs` ou `importar_arquivo.rs` | Médio |
| F-07 | Funcionalidade incompleta | `csv_reader.rs`, `xlsx_reader.rs` | Médio |
| F-08 | UX faltante | `app.rs` | Médio |
| F-09 | Limitação funcional | `app.rs`, `message.rs`, `resultado.rs` | Alto |
| F-10 | Fora do MVP | — | — |
---
## 4. Ordem de Execução Sugerida
### Fase 1 — Warnings (todos baixo esforço, fazem parte da limpeza)
1. W-05: Remover `Message::CancelarExpansao`
2. W-04: Remover `EstadoModal::Informacao`
3. W-03: Decidir entre remover `sem_inconsistencias` ou usá-la em `resultado.rs`
4. W-07: Decidir entre remover `badge_sucesso` ou usá-la em `resultado.rs`
5. W-06: Aplicar `t::cabecalho_tabela` em `tabela_preview.rs`
6. W-02 + F-02: Usar `minimo`/`maximo` na mensagem de confirmação de intervalo
7. W-01 + F-01: Usar `data` no PDF
### Fase 2 — Bugs e requisitos críticos
8. F-04: Limpar estado ao falhar leitura XLSX
9. F-03: Validar limite de 50 MB
10. F-06: Validar campos duplicados no mapeamento
11. F-07: Mensagens de erro com nome do campo
### Fase 3 — Melhorias de UX
12. F-08: Spinner animado na tela de análise
13. F-09: Paginação independente por série
14. F-05: Revisar requisito de rastreamento de linhas individuais
---
*Fim do roteiro.*
-113
View File
@@ -1,113 +0,0 @@
# Sugestões de Melhoria — Fluxo da Interface
**Data:** 02/03/2026
---
## 1. Indicador de progresso das etapas
O fluxo possui 34 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 35 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 |
+291
View File
@@ -0,0 +1,291 @@
# UI Redesign — Comparador de Notas
**Data:** 04/03/2026
**Branch:** `change-ui`
**Base:** iced 0.13.1 (Elm architecture)
**Referência visual:** `ui-ideia/mockup.html` + `ui-ideia/design_tokens.json`
---
## 1. Objetivo
Aplicar o visual do mockup (tema dark navy, cards, badges coloridos, progress bars) a todas as telas da aplicação, mantendo a lógica de negócio e a arquitetura Elm intocadas.
---
## 2. Design Tokens (mapeados para Rust)
### Paleta de cores
| Token | Hex | Uso |
|------------------------|-----------|---------------------------------------|
| `BG` | `#0F172A` | Fundo geral da janela |
| `SURFACE` | `#1E293B` | Cards / containers 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, texto muted |
| `TEXT_MUTED` | `#64748B` | Texto desabilitado |
| `PRIMARY` | `#3B82F6` | Botões primários, links, step ativo |
| `PRIMARY_HOVER` | `#2563EB` | Hover em botões primários |
| `SUCCESS` | `#22C55E` | Badge OK, progress bar ≥ 90% |
| `WARNING` | `#F59E0B` | Badge Faltante, progress bar 6089% |
| `ERROR` | `#EF4444` | Badge Duplicada, progress bar < 60% |
| `OVERLAY` | rgba(0,0,0,0.6) | Fundo do modal |
### Espaçamentos
| Token | px |
|-------|----|
| `XS` | 4 |
| `SM` | 8 |
| `MD` | 12 |
| `LG` | 16 |
| `XL` | 24 |
| `XXL` | 32 |
### Border radius
| Token | px |
|--------|----|
| `SM` | 4 |
| `MD` | 6 |
| `LG` | 8 |
---
## 3. Arquivo de tema: `src/ui/theme.rs`
Módulo responsável por expor:
- `PALETA`: constantes `Color` para todas as cores acima
- Funções de estilo para `container::Style`, `button::Style`, `text_input::Style`, `progress_bar::Style`
- Nenhuma lógica de negócio — apenas aparência
### Estratégia de tema iced
```rust
// main.rs — encadear .theme()
iced::application(...)
.theme(|_app, _| tema_dark())
.run_with(App::new)
// theme.rs
pub fn tema_dark() -> iced::Theme {
iced::Theme::custom("dark".to_string(), iced::theme::Palette {
background: hex("#0F172A"),
text: hex("#F1F5F9"),
primary: hex("#3B82F6"),
success: hex("#22C55E"),
danger: hex("#EF4444"),
})
}
```
Widgets que precisam de aparência customizada além da paleta (cards, badges) recebem closure `.style(|theme| ...)` inline ou via função helper em `theme.rs`.
---
## 4. Componentes visuais novos / modificados
### 4.1 Card container
Container com background `SURFACE`, borda `BORDER` 1px, radius `LG` (8px), padding `LG` (16px).
```rust
// theme.rs
pub fn card(theme: &iced::Theme) -> container::Style { ... }
pub fn card_secondary(theme: &iced::Theme) -> container::Style { ... }
```
### 4.2 Botão primary
Background `PRIMARY`, texto `TEXT`, radius `MD` (6px), sem borda.
Hover: background `PRIMARY_HOVER`.
### 4.3 Botão secondary / ghost
Background `SURFACE_2`, texto `TEXT`, radius `MD`.
### 4.4 Botão danger
Background semi-transparente `ERROR` (20% alpha), texto `ERROR`, radius `MD`.
### 4.5 Badge de status (inline)
Container com padding `[2, 8]`, radius `SM` (4px), background 20% alpha da cor semântica.
Implementado como `container(text(...).size(12))` com style closure.
| Status | Cor texto | Background alpha |
|------------|------------|-----------------|
| OK | `SUCCESS` | 20% |
| Faltante | `WARNING` | 20% |
| Duplicada | `ERROR` | 20% |
### 4.6 Progress bar por série
Componente `progress_bar` nativo do iced com style closure que escolhe cor baseada no percentual:
- ≥ 90% → `SUCCESS`
- 6089% → `WARNING`
- < 60% → `ERROR`
Background da trilha: `#111827` (mais escuro que SURFACE).
### 4.7 Stat cards (tela de resultado)
Row de 3 cards com número grande colorido + label. Componente reutilizável `stat_card(valor, label, cor)`.
### 4.8 Breadcrumb
Row no topo da janela (exceto Layouts e Analisando). Steps separados por ``. Background `SURFACE`, padding `[8, 16]`, borda inferior 1px `BORDER`.
| Estado do step | Cor | Tamanho |
|---------------|-------------|---------|
| Ativo | `PRIMARY` | 14px |
| Concluído | `TEXT_SECONDARY` | 13px |
| Futuro | `TEXT_MUTED` | 13px |
---
## 5. Telas — mudanças por arquivo
### 5.1 `screens/import.rs`
**Atual:** Coluna plana com título, row de arquivo e row de layout.
**Novo:**
- Card central (max-width 600px) centrado na tela
- Área de drop zone estilizada com borda tracejada `BORDER`, radius `LG`, padding `XL`
- Ícone `📂` grande + texto instrucional
- Nome do arquivo selecionado com truncamento
- Seção de layout com separador visual
- Botão primary "▶ Configurar Colunas" ocupando largura do card
### 5.2 `screens/selecionar_aba.rs`
**Atual:** Lista de botões idênticos (bug de highlight).
**Novo:**
- Card com lista de abas scrollável
- Aba selecionada: background `PRIMARY` (20% alpha), texto `PRIMARY`, borda `PRIMARY`
- Aba não selecionada: background `SURFACE_2`, texto `TEXT`
- Preview abaixo da lista em card separado
### 5.3 `screens/configuracao_colunas.rs`
**Atual:** Coluna plana de inputs.
**Novo:**
- Seção de configuração em card `SURFACE`
- Labels com `TEXT_SECONDARY`, inputs com fundo `BG`, borda `BORDER`
- Campos opcionais: checkbox com estilo consistente + input inline
- Erros em card com borda `ERROR` (20% alpha)
- Botões na barra inferior fixada: "Voltar" (ghost), "Analisar" (primary), "Reanalisar" (secondary), "Salvar" (ghost)
### 5.4 `screens/resultado.rs`
**Atual:** Coluna de texto puro.
**Novo (alinhado ao mockup):**
1. **Header row:** título + botões de ação à direita
2. **Stat cards row:** 3 cards — "Notas Faltantes" (azul), "Duplicadas" (vermelho), "Total R$" (texto branco)
3. **Card de completude por série:** para cada série, label + progress bar colorida por threshold
4. **Controle de itens/página:** botões com highlight no ativo
5. **Seções faltantes/duplicatas:** cabeçalho de série em row com badge de contagem + botão Copiar; itens em lista
### 5.5 `screens/layouts.rs`
**Atual:** Coluna plana.
**Novo:**
- Seções CSV e XLSX em cards separados
- Cada layout numa row com hover highlight
- Botões de ação menores (ícone + texto compacto)
- Linha de importar JSON no rodapé do card
### 5.6 `components/modal.rs`
**Atual:** Box com estilo do tema padrão.
**Novo:**
- Background `SURFACE`, borda `BORDER`, radius `LG`
- Título `TEXT` 18px, mensagem `TEXT_SECONDARY` 14px
- Separador entre conteúdo e botões
- Botão "Fechar" ghost, "Confirmar" primary
- Tipos Erro/Aviso com ícone + cor no título
### 5.7 `components/tabela_preview.rs`
**Atual:** Monospace puro.
**Novo:**
- Cabeçalho com background `SURFACE_2`, texto `TEXT_SECONDARY`
- Células com background `SURFACE`, texto `TEXT`, fonte monospace
- Borda inferior `BORDER` nas células
### 5.8 `components/paginacao.rs`
**Atual:** Row simples de botões.
**Novo:**
- Botões ◀/▶ com estilo ghost
- "Página X / Y" em `TEXT_SECONDARY`
---
## 6. Arquivos a criar/modificar
| Arquivo | Ação |
|---------|------|
| `src/main.rs` | Adicionar `.theme(...)` |
| `src/ui/mod.rs` | Adicionar `pub mod theme;` |
| `src/ui/theme.rs` | **Criar** — paleta + helpers de estilo |
| `src/ui/app.rs` | Breadcrumb novo estilo + tela Analisando centralizada |
| `src/ui/screens/import.rs` | Reescrever |
| `src/ui/screens/selecionar_aba.rs` | Reescrever (corrigir bug highlight) |
| `src/ui/screens/configuracao_colunas.rs` | Reescrever |
| `src/ui/screens/resultado.rs` | Reescrever |
| `src/ui/screens/layouts.rs` | Reescrever |
| `src/ui/components/modal.rs` | Reescrever |
| `src/ui/components/tabela_preview.rs` | Reescrever |
| `src/ui/components/paginacao.rs` | Reescrever |
---
## 7. Limitações do iced 0.13 e workarounds
| Limitação CSS | Workaround iced |
|---------------|----------------|
| `box-shadow` | Cor de borda ou sem sombra (aceitar diferença) |
| `rgba(r,g,b,0.2)` | `Color { r, g, b, a: 0.2 }` com valores 0.01.0 |
| Font Inter | Usa fonte do sistema (system-ui) — sem mudança necessária |
| `display: flex; gap` | `row![...].spacing(N)` |
| `border-bottom` nas células | `container` com border bottom via `border.width` fracional não suportado — usar separador visual alternativo |
---
## 8. Ordem de implementação
1. `theme.rs` — paleta e helpers (base para tudo)
2. `main.rs` — ativar tema
3. `modal.rs` — usado por todas as telas
4. `resultado.rs` — tela principal do mockup
5. `import.rs`
6. `selecionar_aba.rs`
7. `configuracao_colunas.rs`
8. `layouts.rs`
9. `app.rs` — breadcrumb + Analisando
10. `tabela_preview.rs` + `paginacao.rs`
11. Compilar e corrigir
---
## 9. Notas de compatibilidade iced 0.13
- `button::Style` inclui `background`, `text_color`, `border: Border { color, width, radius }`, `shadow`
- `container::Style` inclui `background`, `text_color`, `border`, `shadow`
- `progress_bar::Style` inclui `background` e `bar`
- `text_input::Style` inclui `background`, `border`, `icon`, `placeholder`, `value`, `selection`
- Closures de estilo recebem `&Theme` e retornam o `Style` concreto do widget
- `iced::Border` aceita `radius: iced::border::Radius` — usar `N.into()` para uniform radius
+7
View File
@@ -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();
}
}
BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 158 KiB

+14 -6
View File
@@ -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]
+3 -1
View File
@@ -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)
}
+36 -4
View File
@@ -44,7 +44,7 @@ pub fn importar_csv(
config.indice_valor,
config.indice_data,
&mut avisos,
);
)?;
Ok(ResultadoImportacao { notas, avisos })
}
@@ -102,13 +102,16 @@ pub fn importar_xlsx(
col_valor,
col_data,
&mut avisos,
);
)?;
Ok(ResultadoImportacao { notas, avisos })
}
/// Converte linhas brutas (Vec<Vec<String>>) em notas fiscais,
/// aplicando validações e coletando avisos.
///
/// Retorna `Err` se algum índice obrigatório ou opcional ativo excede o número de
/// colunas da primeira linha, identificando qual campo está inválido.
fn mapear_linhas_para_notas(
linhas: &[Vec<String>],
idx_numero: usize,
@@ -117,9 +120,38 @@ fn mapear_linhas_para_notas(
idx_valor: Option<usize>,
idx_data: Option<usize>,
avisos: &mut ResumoAvisos,
) -> Vec<Nota> {
) -> Result<Vec<Nota>, ErroArquivo> {
let mut notas = Vec::new();
// Verificar se os índices existem na primeira linha disponível
if let Some(primeira_linha) = linhas.first() {
let total_colunas = primeira_linha.len();
let obrigatorios: &[(&str, usize)] = &[("Numero", idx_numero), ("Serie", idx_serie)];
for (nome, idx) in obrigatorios {
if *idx >= total_colunas {
return Err(ErroArquivo::ErroLeitura(format!(
"Campo '{}': índice {} não existe — o arquivo tem {} coluna(s).",
nome, idx, total_colunas
)));
}
}
let opcionais: &[(&str, Option<usize>)] = &[
("Valor", idx_valor),
("Data", idx_data),
("Tipo de Documento", idx_documento_tipo),
];
for (nome, maybe_idx) in opcionais {
if let Some(idx) = maybe_idx {
if *idx >= total_colunas {
return Err(ErroArquivo::ErroLeitura(format!(
"Campo '{}': índice {} não existe — o arquivo tem {} coluna(s).",
nome, idx, total_colunas
)));
}
}
}
}
for (i, linha) in linhas.iter().enumerate() {
let linha_num = i + 1;
@@ -216,7 +248,7 @@ fn mapear_linhas_para_notas(
notas.push(Nota::new(numero, serie, documento_tipo, valor, data));
}
notas
Ok(notas)
}
/// Faz o parsing de um número, tentando extrair dígitos se necessário.
+1 -1
View File
@@ -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;
+19 -4
View File
@@ -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(&notas);
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(&notas);
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]
+6 -10
View File
@@ -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" => {
+59 -21
View File
@@ -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: 100104 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,
)));
}
}
@@ -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 {
+13 -9
View File
@@ -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<()> {
+35 -57
View File
@@ -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
View File
@@ -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)
}
+324
View File
@@ -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 6089% |
| `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.600.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
View File
File diff suppressed because it is too large Load Diff
+3
View File
@@ -0,0 +1,3 @@
pub mod modal;
pub mod paginacao;
pub mod tabela_preview;
+143
View File
@@ -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()
}
+31
View File
@@ -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()
}
+97
View File
@@ -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()
}
+130
View File
@@ -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),
}
+3
View File
@@ -1,2 +1,5 @@
pub mod app;
pub mod components;
pub mod message;
pub mod screens;
pub mod theme;
+446 -344
View File
@@ -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()
};
// ── Botões ────────────────────────────────────────────────────────────────
let tem_arquivo = app.caminho_arquivo.is_some();
let tem_notas = !app.notas_importadas.is_empty();
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);
// ── Layout geral ──────────────────────────────────────────────────────────
let content = column![
header,
secao_layout,
config_section,
preview_section,
erros_section,
botoes,
]
.spacing(14)
.padding([20, 24])
.width(Length::Fill);
container(scrollable(content))
.style(t::fundo)
.width(Length::Fill)
.height(Length::Fill)
.into()
}
ui.horizontal(|ui| {
if ui.button("< Voltar").clicked() {
app.estado = EstadoApp::Importando;
}
// ─── Estilo do pick_list ──────────────────────────────────────────────────────
ui.add_enabled_ui(valido && app.caminho_arquivo.is_some(), |ui| {
if ui.button("▶ Importar e Analisar").clicked() {
executar_importacao(app, ctx);
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
}
});
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();
}
}
if ui.button("💾 Salvar como layout...").clicked() {
app.exibir_modal_salvar_layout();
}
});
}
// ─── Configuração CSV ────────────────────────────────────────────────────────
fn renderizar_csv(ui: &mut Ui, app: &mut App) {
ui.group(|ui| {
ui.label("Configurações CSV");
ui.add_space(4.0);
fn view_csv(app: &App) -> Element<'_, Message> {
let c = &app.layout_csv_atual;
// Delimitador
ui.horizontal(|ui| {
ui.label("Delimitador:");
let delim_str = match app.layout_csv_atual.delimitador {
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;
.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);
container(inner)
.style(t::card)
.padding([14, 18])
.width(Length::Fill)
.into()
}
if ui
.selectable_label(
app.layout_csv_atual.delimitador == '\t',
"Tabulação (Tab)",
// ─── Configuração XLSX ────────────────────────────────────────────────────────
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(),
)
.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();
}
}
});
// 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,
);
});
}
fn renderizar_xlsx(ui: &mut Ui, app: &mut App) {
ui.group(|ui| {
ui.label("Configurações XLSX");
ui.add_space(4.0);
ui.horizontal(|ui| {
ui.label("Aba:");
if app.abas_xlsx.is_empty() {
ui.text_edit_singleline(&mut app.layout_xlsx_atual.aba);
} 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 };
}
if let Some(v) = valor {
ui.add(egui::DragValue::new(v).range(0..=999usize));
}
});
fn secao_subtitulo(label: &str) -> Element<'_, Message> {
text(label).size(12).color(t::TEXT_SECONDARY).into()
}
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_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_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_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.text_edit_singleline(v);
}
});
}
/// Valida a configuração atual. Retorna (é_válido, lista_de_erros).
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<'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()
}
}
// ─── 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(&notas);
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, &notas);
ResultadoPendente::Concluido {
resultado,
avisos: Some(avisos),
notas: Some(notas),
}
}
}
}
};
let _ = tx.send(res);
});
}
+202 -300
View File
@@ -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.");
}
}
}
// ── Separador visual ──────────────────────────────────────────────────────
let separador = container(text(""))
.height(Length::Fixed(1.0))
.width(Length::Fill)
.style(t::separador);
/// 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,
};
// ── Card central ──────────────────────────────────────────────────────────
let card_inner = column![
drop_zone,
btn_selecionar,
separador,
secao_layout,
botao_avancar,
]
.spacing(14)
.padding(24)
.width(Length::Fill);
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();
let card = container(card_inner)
.style(t::card)
.max_width(560)
.width(Length::Fill);
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(&notas);
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, &notas);
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;
}
}
// ── 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
View File
@@ -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
View File
@@ -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
View File
@@ -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;
}
});
}
}
}
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(),
// Separador
col = col.push(
container(iced::widget::horizontal_rule(1))
.width(Length::Fill)
.padding([4, 0]),
);
ui.add_space(4.0);
}
if total_dup == 0 {
ui.label("✔ Nenhuma nota duplicada.");
return;
container(col)
.padding(16)
.width(Length::Fill)
.style(t::card)
.into()
}
fn view_duplicatas<'a>(app: &'a App, resultado: &'a ResultadoAnalise) -> Element<'a, Message> {
let total = resultado.total_duplicatas();
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()
}
+156
View File
@@ -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
View File
@@ -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

+109
View File
@@ -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)"
}
}
+287
View File
@@ -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: 10101050</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>
```