Compare commits

...
10 Commits
Author SHA1 Message Date
Felipe 4c4f573cd1 Fix button colors. 2026-03-04 22:46:27 -03:00
Felipe 857fcd6a82 Fix SalvarLayoutConfig 2026-03-04 21:45:52 -03:00
Felipe 841dfadde5 update 2026-03-04 18:33:32 -03:00
Felipe 3af14ab957 Create AGENTS.md 2026-03-04 18:18:11 -03:00
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
31 changed files with 1959 additions and 1157 deletions
+1
View File
@@ -1 +1,2 @@
/target /target
nul
-504
View File
@@ -1,504 +0,0 @@
# 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)
-291
View File
@@ -1,291 +0,0 @@
# 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
@@ -2,44 +2,6 @@
> Features avaliadas em 03/03/2026. Organizadas por categoria e esforço estimado. > Features avaliadas em 03/03/2026. Organizadas por categoria e esforço estimado.
## Alta Prioridade
### F-01 — Exportar Resultado em CSV.
**Problema:** O único formato de exportação é PDF. Para processar os resultados em ferramentas externas (Excel, Power BI, sistemas ERP), o usuário precisa redigitar dados do PDF.
**Solução:** Botão "Exportar CSV" na tela de resultado, gerando dois arquivos (ou um com duas seções):
- `faltantes.csv`: `serie,documento_tipo,numero`
- `duplicatas.csv`: `serie,documento_tipo,numero,ocorrencias`
**Escopo técnico:**
- `use case` `exportar_csv(resultado: &ResultadoAnalise, caminho: &Path)`
- Trait `CsvExporter` análoga à `PdfGenerator` (opcional, para testabilidade)
- Botão na barra de ações da tela `resultado.rs`
- Dependência `csv` já está no `Cargo.toml`
**Esforço estimado:** Médio (23h)
**Impacto:** Alto — elimina retrabalho manual
**Observações:**
Se não for muito custoso para implementar, pode ser uma boa ideia adicionar uma essa função.
---
### F-02 — Validação de Tamanho de Arquivo (PRD RF01.3) (Implementar)
**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.
**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)
**Impacto:** Médio — evita travamentos inesperados
---
## Média Prioridade ## Média Prioridade
@@ -63,43 +25,6 @@ Verificar se já existe no codigo, pois na tela de configuração do Layout ele
--- ---
### F-04 — Busca por Número na Tela de Resultado (Util)
**Problema:** Com listas longas, o usuário precisa navegar páginas para verificar se um número específico está faltante ou duplicado.
**Solução:** Campo de busca no topo da tela de resultado. Ao digitar `1234`, destaca se a nota:
- está **faltante** (aparece na lista de faltantes)
- está **duplicada** (aparece na lista de duplicatas)
- está **presente** (está nas notas importadas)
- **não encontrada** (fora do intervalo conhecido)
**Escopo técnico:**
- Campo `filtro_numero: String` no `App`
- Busca em `resultado.faltantes_por_serie` e `resultado.duplicadas_por_serie`
- Para "presente": busca em `app.notas_importadas` (já mantido em memória)
- Banner de resultado no topo da tela
**Esforço estimado:** Médio (23h)
**Impacto:** Médio — uso diário em auditorias pontuais
---
### 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).
**Solução:** Botão "🔄 Reanalisar Arquivo" na tela de resultado que reimporta o mesmo caminho com o mesmo layout atual, sem nenhuma interação adicional.
**Escopo técnico:**
- Persistir `caminho_arquivo_atual: Option<PathBuf>` no `App` (já existe parcialmente como `nome_arquivo`)
- Reaproveitar o fluxo de `executar_importacao()` com os parâmetros atuais
- Botão na barra de ações da tela `resultado.rs`
**Esforço estimado:** Baixo (1h)
**Impacto:** Médio — elimina atrito no ciclo corrigir → verificar
---
### F-06 — Auto-detecção de Delimitador CSV (Util) ### F-06 — Auto-detecção de Delimitador CSV (Util)
**Problema:** O usuário precisa saber antecipadamente qual delimitador o arquivo usa (`,`, `;`, `\t`). Arquivos gerados por diferentes sistemas variam. **Problema:** O usuário precisa saber antecipadamente qual delimitador o arquivo usa (`,`, `;`, `\t`). Arquivos gerados por diferentes sistemas variam.
@@ -154,13 +79,8 @@ Verificar se já existe no codigo, pois na tela de configuração do Layout ele
## Resumo ## 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 | | F-03 | Preview de colunas antes da análise | Alto | Alto | Média |
| F-04 | Busca por número no resultado | Médio | Médio | Média |
| 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-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-08 | Auto-detecção de encoding CSV | Médio | Baixo | Baixa |
| F-09 | Agrupamento de faltantes no PDF | Baixo | Baixo-Médio | Baixa |
+205
View File
@@ -0,0 +1,205 @@
# Application — AGENTS.md
Camada de aplicação do projeto `comparador-notas`. Orquestra os serviços de domínio
e a infraestrutura para expor casos de uso coesos à camada de UI (Tauri/frontend).
Não contém regras de negócio próprias; delega ao `domain` e ao `infrastructure`.
---
## Estrutura dos arquivos
```
src/application/
├── mod.rs
└── usecases/
├── mod.rs
├── executar_analise.rs # Análise de sequência e duplicatas
├── exportar_pdf.rs # Exportação de relatório PDF
├── importar_arquivo.rs # Importação de CSV e XLSX → Vec<Nota>
└── layouts.rs # CRUD e import/export JSON de layouts
```
---
## usecases/mod.rs
Re-exporta os quatro módulos de casos de uso:
```rust
pub mod executar_analise;
pub mod exportar_pdf;
pub mod importar_arquivo;
pub mod layouts;
```
---
## executar_analise.rs
Orquestra a análise em **duas etapas** para lidar com grandes intervalos de
faltantes sem travar a UI (regra RF04).
### Etapa 1 — `pre_analisar(notas) -> ResultadoPreAnalise`
1. Agrupa as notas por `ChaveSerie` (`serie` + `documento_tipo`).
2. Para cada grupo:
- Acumula soma total e por série.
- Chama `calcular_intervalo` (sem materializar faltantes).
3. Detecta duplicidades via `duplicidades_por_serie`.
4. Retorna `ResultadoPreAnalise` com intervalos, duplicadas e somas.
### Verificação intermediária — `series_com_intervalo_excessivo(pre) -> Vec<(ChaveSerie, IntervaloSerie)>`
Filtra as séries cujo `contagem_faltantes > LIMITE_FALTANTES` (10.000).
O caller (UI) deve exibir confirmação ao usuário se a lista não for vazia.
### Etapa 2 — `expandir_analise(pre, notas) -> ResultadoAnalise`
Materializa a lista completa de faltantes via `detectar_faltantes` e combina
com os dados já calculados na pré-análise (duplicadas, somas, totais).
### Fluxo de uso
```
pre_analisar(notas)
└─ ResultadoPreAnalise
series_com_intervalo_excessivo(&pre)
├─ [] → chamar expandir_analise diretamente
└─ [...] → exibir diálogo de confirmação na UI
└─ confirmado → expandir_analise(pre, notas)
expandir_analise(pre, notas)
└─ ResultadoAnalise (com faltantes materializados)
```
---
## exportar_pdf.rs
### `exportar_pdf(gerador, resultado, notas, nome_arquivo, nome_layout, caminho_saida) -> Result<(), String>`
Caso de uso simples que:
1. Constrói `MetadadosRelatorio` com `nome_arquivo`, `nome_layout` e timestamp
`Local::now()`.
2. Delega a geração para `gerador.gerar(...)` via a trait abstrata `PdfGenerator`.
A dependência em `&dyn PdfGenerator` (e não em `GenpdfGenerator` diretamente)
mantém o use case desacoplado da implementação concreta e facilita testes.
---
## importar_arquivo.rs
Converte arquivos brutos (CSV ou XLSX) em `Vec<Nota>` prontas para análise,
acumulando avisos não-fatais em `ResumoAvisos`.
### Tipos de saída
```rust
pub struct ResultadoImportacao {
pub notas: Vec<Nota>,
pub avisos: ResumoAvisos,
}
pub struct InfoXlsx {
pub abas: Vec<String>,
}
```
### `listar_abas_xlsx(caminho) -> Result<InfoXlsx, ErroArquivo>`
Delega para `xlsx_reader::listar_abas`. Retorna `InfoXlsx` com os nomes das
abas para que a UI permita ao usuário selecionar a aba correta.
### `importar_csv(caminho, config: &LayoutCsv) -> Result<ResultadoImportacao, ErroArquivo>`
1. Chama `csv_reader::ler_csv` com os parâmetros do layout.
2. Passa as linhas brutas para `mapear_linhas_para_notas`.
### `importar_xlsx(caminho, config: &LayoutXlsx) -> Result<ResultadoImportacao, ErroArquivo>`
1. Converte `pos_numero` e `pos_serie` (LetraLinha) para coordenadas.
2. Determina `linha_inicio` como o mínimo entre as linhas das duas posições.
3. Chama `xlsx_reader::ler_xlsx`.
4. Converte posições de todos os campos mapeados para índices de coluna (base 0).
5. Passa as linhas brutas para `mapear_linhas_para_notas`.
### `mapear_linhas_para_notas(...)` (privada)
Função central de mapeamento. Para cada linha:
1. **Validação de índices** (na primeira linha disponível):
- Campos obrigatórios (`Numero`, `Serie`): retorna `Err` se o índice não existe.
- Campos opcionais (`Valor`, `Data`, `Tipo de Documento`): retorna `Err` se
configurado com índice fora dos limites.
2. **Numero**: tenta `parse::<u64>` direto; se falhar, extrai apenas dígitos.
Rejeita zero. Incrementa `avisos.numeros_invalidos` e pula a linha se inválido.
3. **Serie**: valida via `domain::entities::serie::validar_serie`. Pula linha se inválida.
4. **Valor** (opcional): faz `parse_valor`; se inválido, registra em
`avisos.valores_invalidos` e usa `None` (não descarta a linha).
5. **Data** (opcional): tenta `dd/mm/aaaa`, `aaaa-mm-dd` e `dd-mm-aaaa`. `None`
se nenhum formato casar (não gera aviso).
6. **Tipo de Documento** (opcional): qualquer string não vazia.
### `parse_numero(s) -> Result<u64, String>` (privada)
- Tenta `s.parse::<u64>()` diretamente.
- Se falhar, extrai apenas dígitos ASCII e tenta novamente.
- Rejeita zero em ambos os casos.
### `parse_data(s) -> Option<NaiveDate>` (privada)
Tenta os formatos `%d/%m/%Y`, `%Y-%m-%d` e `%d-%m-%Y` nessa ordem.
---
## layouts.rs
CRUD de layouts sobre o banco SQLite e import/export em JSON.
### `salvar_layout(conn, layout) -> Result<i64, ErroLayout>`
- Valida que `nome` não está vazio.
- Se `layout.id()` é `Some` → chama `layout_repository::atualizar` e retorna o id.
- Se `None` → verifica conflito de nome via `existe_nome`; se existir, retorna
`ErroLayout::NomeConflitante`; caso contrário, insere e retorna o novo id.
### `listar_layouts(conn) -> Result<Vec<Layout>, String>`
Delega para `layout_repository::listar`. Retorna todos os layouts ordenados
por nome.
### `excluir_layout(conn, id) -> Result<(), String>`
Delega para `layout_repository::excluir`.
### `exportar_layout_json(layout) -> Result<(String, String), String>`
1. Converte `Layout` para `LayoutJson` via `From<&Layout>`.
2. Serializa com `serde_json::to_string_pretty`.
3. Retorna `(conteúdo_json, nome_arquivo_sugerido)` onde o nome é `"{nome}.json"`.
### `importar_layout_json(conn, json, sobrescrever_se_existir, novo_nome) -> Result<i64, ErroLayout>`
1. Desserializa `json` para `LayoutJson`.
2. Converte para `Layout` via `TryFrom` (valida campos obrigatórios).
3. Aplica `novo_nome` se fornecido (mutação direta no enum).
4. Verifica conflito de nome:
- Se existe e `sobrescrever_se_existir == true`: busca o id existente,
injeta no layout e chama `atualizar`.
- Se existe e `false`: retorna `ErroLayout::NomeConflitante`.
5. Se não existe: chama `layout_repository::salvar`.
---
## Dependencias de outros módulos
```
application::usecases
├─ domain::entities::{chave_serie, nota, resultado_analise, layout, serie}
├─ domain::services::{detector_duplicidade, detector_sequencia, parser_monetario}
├─ domain::errors::{ErroArquivo, ErroLayout, ResumoAvisos}
└─ infrastructure::{csv_reader, xlsx_reader, pdf_generator, sqlite::layout_repository}
```
+6 -4
View File
@@ -2,7 +2,7 @@ use crate::domain::{
entities::{ entities::{
chave_serie::ChaveSerie, chave_serie::ChaveSerie,
nota::Nota, nota::Nota,
resultado_analise::{ResultadoAnalise, ResultadoPreAnalise}, resultado_analise::{IntervaloSerie, ResultadoAnalise, ResultadoPreAnalise},
}, },
services::{ services::{
detector_duplicidade::duplicidades_por_serie, detector_duplicidade::duplicidades_por_serie,
@@ -56,12 +56,14 @@ pub fn pre_analisar(notas: &[Nota]) -> ResultadoPreAnalise {
} }
/// Verifica se algum grupo excede o limite de faltantes. /// Verifica se algum grupo excede o limite de faltantes.
/// Retorna lista de chaves que precisam de confirmação. /// Retorna lista de chaves com o intervalo completo para exibição ao usuário.
pub fn series_com_intervalo_excessivo(pre: &ResultadoPreAnalise) -> Vec<(ChaveSerie, u64)> { pub fn series_com_intervalo_excessivo(
pre: &ResultadoPreAnalise,
) -> Vec<(ChaveSerie, IntervaloSerie)> {
pre.intervalos_por_serie pre.intervalos_por_serie
.iter() .iter()
.filter(|(_, iv)| iv.excede_limite(LIMITE_FALTANTES)) .filter(|(_, iv)| iv.excede_limite(LIMITE_FALTANTES))
.map(|(chave, iv)| (chave.clone(), iv.contagem_faltantes)) .map(|(chave, iv)| (chave.clone(), iv.clone()))
.collect() .collect()
} }
+3 -1
View File
@@ -1,3 +1,4 @@
use crate::domain::entities::nota::Nota;
use crate::domain::entities::resultado_analise::ResultadoAnalise; use crate::domain::entities::resultado_analise::ResultadoAnalise;
use crate::infrastructure::pdf_generator::{MetadadosRelatorio, PdfGenerator}; use crate::infrastructure::pdf_generator::{MetadadosRelatorio, PdfGenerator};
use chrono::Local; use chrono::Local;
@@ -9,6 +10,7 @@ use std::path::Path;
pub fn exportar_pdf( pub fn exportar_pdf(
gerador: &dyn PdfGenerator, gerador: &dyn PdfGenerator,
resultado: &ResultadoAnalise, resultado: &ResultadoAnalise,
notas: &[Nota],
nome_arquivo: &str, nome_arquivo: &str,
nome_layout: Option<&str>, nome_layout: Option<&str>,
caminho_saida: &Path, caminho_saida: &Path,
@@ -19,5 +21,5 @@ pub fn exportar_pdf(
gerado_em: Local::now(), 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_valor,
config.indice_data, config.indice_data,
&mut avisos, &mut avisos,
); )?;
Ok(ResultadoImportacao { notas, avisos }) Ok(ResultadoImportacao { notas, avisos })
} }
@@ -102,13 +102,16 @@ pub fn importar_xlsx(
col_valor, col_valor,
col_data, col_data,
&mut avisos, &mut avisos,
); )?;
Ok(ResultadoImportacao { notas, avisos }) Ok(ResultadoImportacao { notas, avisos })
} }
/// Converte linhas brutas (Vec<Vec<String>>) em notas fiscais, /// Converte linhas brutas (Vec<Vec<String>>) em notas fiscais,
/// aplicando validações e coletando avisos. /// 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( fn mapear_linhas_para_notas(
linhas: &[Vec<String>], linhas: &[Vec<String>],
idx_numero: usize, idx_numero: usize,
@@ -117,9 +120,38 @@ fn mapear_linhas_para_notas(
idx_valor: Option<usize>, idx_valor: Option<usize>,
idx_data: Option<usize>, idx_data: Option<usize>,
avisos: &mut ResumoAvisos, avisos: &mut ResumoAvisos,
) -> Vec<Nota> { ) -> Result<Vec<Nota>, ErroArquivo> {
let mut notas = Vec::new(); 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() { for (i, linha) in linhas.iter().enumerate() {
let linha_num = i + 1; 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.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. /// Faz o parsing de um número, tentando extrair dígitos se necessário.
+41 -8
View File
@@ -5,6 +5,17 @@ use crate::domain::{
use crate::infrastructure::sqlite::layout_repository; use crate::infrastructure::sqlite::layout_repository;
use rusqlite::Connection; use rusqlite::Connection;
/// Mapeia um erro rusqlite para `ErroLayout`, distinguindo conflito de nome
/// de erros genéricos de banco.
fn mapear_erro_banco(e: rusqlite::Error, nome: &str) -> ErroLayout {
let msg = e.to_string();
if msg.contains("UNIQUE constraint failed") {
ErroLayout::NomeConflitante(nome.to_string())
} else {
ErroLayout::ErroBanco(msg)
}
}
/// Salva um layout no banco de dados. /// Salva um layout no banco de dados.
/// Se o layout já tem um id, atualiza. Caso contrário, insere. /// Se o layout já tem um id, atualiza. Caso contrário, insere.
/// Retorna `ErroLayout::NomeConflitante` se já existir um layout com o mesmo nome. /// Retorna `ErroLayout::NomeConflitante` se já existir um layout com o mesmo nome.
@@ -14,19 +25,41 @@ pub fn salvar_layout(conn: &Connection, layout: &Layout) -> Result<i64, ErroLayo
return Err(ErroLayout::CampoObrigatorioAusente("nome".to_string())); return Err(ErroLayout::CampoObrigatorioAusente("nome".to_string()));
} }
// Validar campos XLSX obrigatórios
if let Layout::Xlsx { config, .. } = layout {
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()));
}
}
if let Some(id) = layout.id() { if let Some(id) = layout.id() {
// Verificar conflito de nome com outro layout (excluindo o próprio)
layout_repository::existe_nome_excluindo_id(conn, layout.nome(), id)
.map_err(|e| ErroLayout::ErroBanco(e.to_string()))
.and_then(|conflito| {
if conflito {
Err(ErroLayout::NomeConflitante(layout.nome().to_string()))
} else {
Ok(())
}
})?;
layout_repository::atualizar(conn, layout) layout_repository::atualizar(conn, layout)
.map_err(|e| ErroLayout::JsonMalformado(e.to_string()))?; .map_err(|e| mapear_erro_banco(e, layout.nome()))?;
Ok(id) Ok(id)
} else { } else {
let nome = layout.nome().to_string(); let nome = layout.nome().to_string();
let existe = layout_repository::existe_nome(conn, &nome) let existe = layout_repository::existe_nome(conn, &nome)
.map_err(|e| ErroLayout::JsonMalformado(e.to_string()))?; .map_err(|e| ErroLayout::ErroBanco(e.to_string()))?;
if existe { if existe {
return Err(ErroLayout::NomeConflitante(nome)); return Err(ErroLayout::NomeConflitante(nome));
} }
layout_repository::salvar(conn, layout) layout_repository::salvar(conn, layout).map_err(|e| mapear_erro_banco(e, layout.nome()))
.map_err(|e| ErroLayout::JsonMalformado(e.to_string()))
} }
} }
@@ -79,13 +112,13 @@ pub fn importar_layout_json(
// Verificar conflito de nome // Verificar conflito de nome
let nome_atual = layout.nome().to_string(); let nome_atual = layout.nome().to_string();
let existe = layout_repository::existe_nome(conn, &nome_atual) let existe = layout_repository::existe_nome(conn, &nome_atual)
.map_err(|e| ErroLayout::JsonMalformado(e.to_string()))?; .map_err(|e| ErroLayout::ErroBanco(e.to_string()))?;
if existe { if existe {
if sobrescrever_se_existir { if sobrescrever_se_existir {
// Buscar o id existente para sobrescrever // Buscar o id existente para sobrescrever
let layouts_existentes = layout_repository::listar(conn) let layouts_existentes = layout_repository::listar(conn)
.map_err(|e| ErroLayout::JsonMalformado(e.to_string()))?; .map_err(|e| ErroLayout::ErroBanco(e.to_string()))?;
let id_existente = layouts_existentes let id_existente = layouts_existentes
.iter() .iter()
@@ -98,7 +131,7 @@ pub fn importar_layout_json(
Layout::Xlsx { id: i, .. } => *i = Some(id), Layout::Xlsx { id: i, .. } => *i = Some(id),
} }
layout_repository::atualizar(conn, &layout) layout_repository::atualizar(conn, &layout)
.map_err(|e| ErroLayout::JsonMalformado(e.to_string()))?; .map_err(|e| mapear_erro_banco(e, &nome_atual))?;
return Ok(id); return Ok(id);
} }
} else { } else {
@@ -107,5 +140,5 @@ pub fn importar_layout_json(
} }
// Inserir novo // Inserir novo
layout_repository::salvar(conn, &layout).map_err(|e| ErroLayout::JsonMalformado(e.to_string())) layout_repository::salvar(conn, &layout).map_err(|e| mapear_erro_banco(e, &nome_atual))
} }
+304
View File
@@ -0,0 +1,304 @@
# Domain — AGENTS.md
Camada de domínio do projeto `comparador-notas`. Contém as entidades, serviços
de negócio e erros tipados. Não possui dependências de infraestrutura — todo
acesso a banco, arquivos ou UI é responsabilidade das camadas superiores.
---
## Estrutura dos arquivos
```
src/domain/
├── mod.rs
├── errors.rs # Enums de erro tipados
├── entities/
│ ├── mod.rs
│ ├── chave_serie.rs # Chave composta (serie + documento_tipo)
│ ├── layout.rs # Entidades de configuração de layout CSV/XLSX
│ ├── nota.rs # Entidade Nota Fiscal
│ ├── resultado_analise.rs # Resultados de análise (pré e completo)
│ └── serie.rs # Validação de série
└── services/
├── mod.rs
├── detector_duplicidade.rs # Detecção de notas duplicadas
├── detector_sequencia.rs # Detecção de notas faltantes na sequência
└── parser_monetario.rs # Parsing e formatação de valores monetários
```
---
## errors.rs
Erros tipados com a crate `thiserror`. Cada domínio tem seu próprio enum.
### `ErroSerie`
| Variante | Mensagem |
|---|---|
| `Invalida(String)` | Série inválida: deve conter de 1 a 3 dígitos numéricos |
| `Vazia` | Série vazia |
### `ErroValor`
| Variante | Mensagem |
|---|---|
| `Negativo(String)` | Valor negativo não é permitido |
| `NaoNumerico(String)` | Valor não numérico |
### `ErroLayout`
| Variante | Mensagem |
|---|---|
| `CampoObrigatorioAusente(String)` | Campo obrigatório ausente |
| `JsonMalformado(String)` | JSON malformado |
| `NomeConflitante(String)` | Layout com esse nome já existe |
### `ErroArquivo`
| Variante | Mensagem |
|---|---|
| `TamanhoExcedido(u64)` | Arquivo > 50 MB |
| `Corrompido(String)` | Arquivo corrompido ou ilegível |
| `ErroLeitura(String)` | Erro genérico de leitura |
### `ResumoAvisos`
Estrutura de avisos não-fatais acumulados durante a importação de um arquivo.
```rust
pub struct ResumoAvisos {
pub linhas_malformadas: usize,
pub numeros_invalidos: usize,
pub series_invalidas: usize,
pub valores_invalidos: usize,
pub detalhes: Vec<String>, // mensagens individuais por linha
}
```
- `tem_avisos()``true` se qualquer contador > 0
- `linhas_para_exibir()``Vec<String>` com resumo para exibição em modal
---
## entities/
### `chave_serie.rs` — `ChaveSerie`
Chave composta que identifica um grupo de notas fiscais. Combina série com
tipo de documento (`NFE`, `NFCE`, etc.).
```rust
pub struct ChaveSerie {
pub serie: String,
pub documento_tipo: Option<String>,
}
```
- Deriva `Hash`, `Eq`, `Ord` — usada como chave em `HashMap` e para ordenação.
- `new(serie, documento_tipo)` — construtor.
- `label()` — formata para exibição:
- `Some(tipo)``"001 / NFE"`
- `None``"001"`
### `nota.rs` — `Nota`
Entidade central. `(numero, serie, documento_tipo)` é o identificador único.
```rust
pub struct Nota {
pub numero: u64,
pub serie: String,
pub documento_tipo: Option<String>, // None quando não mapeado
pub valor: Option<Decimal>,
pub data: Option<NaiveDate>, // usado no PDF, não em regras
}
```
### `serie.rs` — `validar_serie`
```rust
pub fn validar_serie(s: &str) -> Result<String, ErroSerie>
```
Valida via regex `^[0-9]{1,3}$` (1 a 3 dígitos numéricos). Faz trim antes
de validar. Usa `OnceLock` para compilar o regex uma única vez.
### `resultado_analise.rs`
Dois tipos de resultado que modelam o fluxo de análise em duas etapas:
#### `ResultadoPreAnalise`
Resultado intermediário — calculado sem expandir a lista completa de faltantes.
Usado para verificar se algum intervalo excede 10.000 registros (RF04) antes
de pedir confirmação ao usuário.
```rust
pub struct ResultadoPreAnalise {
pub intervalos_por_serie: HashMap<ChaveSerie, IntervaloSerie>,
pub duplicadas_por_serie: HashMap<ChaveSerie, Vec<(u64, usize)>>,
pub soma_total: Decimal,
pub soma_por_serie: HashMap<ChaveSerie, Decimal>,
pub total_por_serie: HashMap<ChaveSerie, usize>,
}
```
#### `IntervaloSerie`
```rust
pub struct IntervaloSerie {
pub minimo: u64,
pub maximo: u64,
pub contagem_faltantes: u64,
}
```
- `excede_limite(limite)``contagem_faltantes > limite`
#### `ResultadoAnalise`
Resultado completo com a lista materializada de faltantes.
```rust
pub struct ResultadoAnalise {
pub faltantes_por_serie: HashMap<ChaveSerie, Vec<u64>>, // ordenados crescentemente
pub duplicadas_por_serie: HashMap<ChaveSerie, Vec<(u64, usize)>>,
pub soma_total: Decimal,
pub soma_por_serie: HashMap<ChaveSerie, Decimal>,
pub total_por_serie: HashMap<ChaveSerie, usize>,
}
```
Métodos auxiliares:
- `sem_inconsistencias()``true` se não há faltantes nem duplicatas
- `total_faltantes()` → soma do tamanho de todas as listas de faltantes
- `total_duplicatas()` → soma do número de grupos de duplicatas
### `layout.rs`
Entidades de configuração de layout de arquivo.
#### `TipoArquivo`
```rust
pub enum TipoArquivo { Csv, Xlsx }
```
#### `LayoutCsv`
| Campo | Tipo | Descrição |
|---|---|---|
| `delimitador` | `char` | `','`, `';'` ou `'\t'` |
| `encoding` | `String` | `"utf-8"` ou `"windows-1252"` |
| `linha_cabecalho` | `usize` | Linha do cabeçalho (base 1). `0` = sem cabeçalho |
| `indice_numero` | `usize` | Índice da coluna Numero (base 0) |
| `indice_serie` | `usize` | Índice da coluna Serie (base 0) |
| `indice_valor` | `Option<usize>` | Índice da coluna Valor (base 0), opcional |
| `indice_data` | `Option<usize>` | Índice da coluna Data (base 0), opcional |
| `indice_documento_tipo` | `Option<usize>` | Índice da coluna Tipo Documento (base 0), opcional |
#### `LayoutXlsx`
| Campo | Tipo | Descrição |
|---|---|---|
| `aba` | `String` | Nome da aba a processar |
| `pos_numero` | `String` | Posição LetraLinha (ex: `"D3"`) |
| `pos_serie` | `String` | Posição LetraLinha (ex: `"B3"`) |
| `pos_valor` | `Option<String>` | Posição LetraLinha, opcional |
| `pos_data` | `Option<String>` | Posição LetraLinha, opcional |
| `pos_documento_tipo` | `Option<String>` | Posição LetraLinha, opcional |
#### `Layout` (enum)
```rust
pub enum Layout {
Csv { id: Option<i64>, nome: String, config: LayoutCsv },
Xlsx { id: Option<i64>, nome: String, config: LayoutXlsx },
}
```
Métodos: `id()`, `nome()`, `tipo()`.
#### `LayoutJson` (serialização)
Representação `serde` com tag `"tipo"` para importação/exportação em JSON.
Implementa `TryFrom<LayoutJson> for Layout` (valida campos obrigatórios) e
`From<&Layout> for LayoutJson`.
---
## services/
### `detector_duplicidade.rs`
#### `detectar_duplicidades(notas) -> HashMap<(u64, String, Option<String>), usize>`
Conta ocorrências de cada `(numero, serie, documento_tipo)`. Retém apenas
grupos com mais de uma ocorrência.
#### `duplicidades_por_serie(notas) -> HashMap<ChaveSerie, Vec<(u64, usize)>>`
Agrupa o resultado de `detectar_duplicidades` por `ChaveSerie`. Cada vetor
é ordenado por `numero` crescente.
Regras:
- O mesmo número em séries diferentes **não** é duplicata.
- O mesmo número com `documento_tipo` diferente **não** é duplicata.
- O mesmo número com mesma série e mesmo `documento_tipo` **é** duplicata.
---
### `detector_sequencia.rs`
#### Constante
```rust
pub const LIMITE_FALTANTES: u64 = 10_000;
```
#### `calcular_intervalo(notas) -> Option<IntervaloSerie>`
Calcula `minimo`, `maximo` e `contagem_faltantes` de forma incremental
(usando `windows(2)`) **sem materializar** a lista de faltantes. Deduplica
números antes do cálculo para não contar duplicatas como faltantes.
#### `detectar_faltantes(notas) -> Vec<u64>`
Materializa a lista completa de faltantes em ordem crescente. Deve ser
chamado apenas após confirmação do usuário quando `calcular_intervalo`
indica que o intervalo excede `LIMITE_FALTANTES`.
#### `agrupar_contiguos(faltantes) -> Vec<(u64, u64)>`
Recebe uma lista **já ordenada** de números faltantes e retorna intervalos
contíguos como pares `(inicio, fim)`. Números isolados têm `inicio == fim`.
Exemplo: `[1, 2, 3, 5, 8, 9]``[(1, 3), (5, 5), (8, 9)]`
---
### `parser_monetario.rs`
#### `parse_valor(input) -> Result<Decimal, ErroValor>`
Faz o parsing de uma string monetária suportando formatos brasileiro e americano.
Rejeita valores negativos.
Algoritmo (RF06):
| Condição | Regra |
|---|---|
| Contém ponto **e** vírgula | Último separador é o decimal |
| Apenas ponto, 3 dígitos após | Separador de milhar (`1.234``1234`) |
| Apenas ponto, outros casos | Decimal (`1000.00`) |
| Apenas vírgula, 3 dígitos após | Separador de milhar (`1,234``1234`) |
| Apenas vírgula, outros casos | Decimal (`1000,00``1000.00`) |
| Sem separador | Número inteiro |
Aceita prefixo `R$` (case-insensitive).
#### `formatar_valor_br(valor) -> String`
Formata `Decimal` para exibição brasileira com 2 casas decimais e pontos de
milhar. Ex: `1234567.89``"1.234.567,89"`.
+2
View File
@@ -24,6 +24,8 @@ pub enum ErroLayout {
JsonMalformado(String), JsonMalformado(String),
#[error("Conflito de nome: layout '{0}' já existe")] #[error("Conflito de nome: layout '{0}' já existe")]
NomeConflitante(String), NomeConflitante(String),
#[error("Erro no banco de dados: {0}")]
ErroBanco(String),
} }
#[derive(Debug, Error, Clone)] #[derive(Debug, Error, Clone)]
+207
View File
@@ -0,0 +1,207 @@
# Infrastructure — AGENTS.md
Camada de infraestrutura do projeto `comparador-notas`. Responsável por toda I/O
concreta: leitura de arquivos (CSV e XLSX), geração de PDF e persistência SQLite.
Não contém regras de negócio; depende do `domain` para tipos e erros.
---
## Estrutura dos arquivos
```
src/infrastructure/
├── mod.rs # Re-exporta os submódulos públicos
├── csv_reader.rs # Leitura e preview de arquivos CSV
├── xlsx_reader.rs # Leitura e preview de arquivos XLSX/XLS
├── pdf_generator.rs # Trait abstrata + implementação concreta de geração de PDF
└── sqlite/ # Submódulo de persistência (ver sqlite/AGENTS.md)
```
---
## mod.rs
Re-exporta os quatro submódulos:
```rust
pub mod csv_reader;
pub mod pdf_generator;
pub mod sqlite;
pub mod xlsx_reader;
```
---
## csv_reader.rs
Leitura de arquivos CSV com suporte a múltiplos encodings e delimitadores.
### Constante
```rust
const LIMITE_BYTES: u64 = 50 * 1024 * 1024; // 50 MB
```
### `ResultadoCsv`
```rust
pub struct ResultadoCsv {
pub linhas: Vec<Vec<String>>, // dados sem o cabeçalho
pub avisos: ResumoAvisos,
}
```
### `ler_csv(caminho, delimitador, encoding, linha_cabecalho) -> Result<ResultadoCsv, ErroArquivo>`
Fluxo:
1. Verifica tamanho do arquivo — retorna `ErroArquivo::TamanhoExcedido` se > 50 MB.
2. Lê os bytes brutos com `std::fs::read`.
3. Decodifica o conteúdo:
- `"windows-1252"`, `"latin-1"`, `"iso-8859-1"``encoding_rs::WINDOWS_1252`
- qualquer outro → `String::from_utf8` (UTF-8)
4. Constrói um `csv::ReaderBuilder` com `flexible(true)` e `has_headers(false)`.
5. Itera sobre todos os registros:
- Pula linhas até e incluindo `linha_cabecalho` (quando > 0).
- Ignora linhas completamente em branco.
- Registra erros de parse em `avisos.linhas_malformadas`.
6. Retorna `ResultadoCsv` com as linhas de dados e os avisos.
### `preview_csv(caminho, delimitador, encoding, n) -> Result<Vec<Vec<String>>, ErroArquivo>`
Retorna as primeiras `n` linhas brutas (sem pular cabeçalho). Usado exclusivamente
para pré-visualização na UI. Não verifica tamanho do arquivo.
---
## xlsx_reader.rs
Leitura de arquivos XLSX (e XLS por magic bytes) com suporte a coordenadas
no formato `LetraLinha` (ex: `"B3"`).
### Constante
```rust
const LIMITE_BYTES: u64 = 50 * 1024 * 1024; // 50 MB
```
### Tipos auxiliares
```rust
pub struct Coordenada {
pub coluna: u32, // base 0
pub linha: u32, // base 1
}
```
### `listar_abas(caminho) -> Result<Vec<String>, ErroArquivo>`
Abre o workbook via `calamine::open_workbook_auto` (detecção por magic bytes)
e retorna os nomes das abas.
### `ler_xlsx(caminho, nome_aba, linha_inicio) -> Result<ResultadoXlsx, ErroArquivo>`
1. Verifica tamanho (50 MB).
2. Abre workbook e seleciona a aba pelo nome.
3. Itera sobre as linhas a partir de `linha_inicio - 1` (base 0 internamente).
4. Converte cada célula para `String` via `celula_para_string` (ver abaixo).
5. Ignora linhas completamente em branco.
6. Retorna `ResultadoXlsx { linhas, avisos }`.
### `preview_xlsx(caminho, nome_aba) -> Result<Vec<Vec<String>>, ErroArquivo>`
Retorna as primeiras 5 linhas brutas da aba, a partir da linha 1. Usado para
pré-visualização na UI.
### `parsear_letra_linha(s) -> Option<Coordenada>`
Converte notação Excel (`"B3"`) para `Coordenada { coluna: 1, linha: 3 }`:
- Normaliza para maiúsculas e faz trim.
- Divide entre letras e dígitos.
- Converte letras para índice de coluna base 0:
`A=0, B=1, ..., Z=25, AA=26, ...`
- Retorna `None` para notações inválidas (vazio, só números, zero, etc.).
### `celula_para_string(cell) -> String` (privada)
| Tipo calamine | Conversão |
|---|---|
| `Empty` | `""` |
| `String(s)` | `s.clone()` |
| `Float(f)` | sem `.0` quando `f.fract() == 0.0` |
| `Int(i)` | `i.to_string()` |
| `Bool(b)` | `b.to_string()` |
| `DateTime` / `DateTimeIso` / `DurationIso` | representação string |
| `Error(_)` | `""` |
---
## pdf_generator.rs
Geração de relatórios PDF com fontes embutidas no binário.
### Fontes embutidas
```rust
const FONT_REGULAR: &[u8] = include_bytes!("../../assets/fonts/LiberationSans-Regular.ttf");
const FONT_BOLD: &[u8] = include_bytes!("../../assets/fonts/LiberationSans-Bold.ttf");
```
Liberation Sans (~402 KB/variante) é usada em vez de Arial do sistema (~993 KB),
eliminando dependência externa e reduzindo o tamanho dos PDFs.
### `PdfGenerator` (trait pública)
```rust
pub trait PdfGenerator {
fn gerar(
&self,
resultado: &ResultadoAnalise,
notas: &[Nota],
meta: &MetadadosRelatorio,
caminho_saida: &Path,
) -> Result<(), String>;
}
```
Permite que o use case `exportar_pdf` dependa da abstração, não da crate `genpdf`.
### `GenpdfGenerator` (implementação concreta)
Implementa `PdfGenerator` usando a crate `genpdf`. Estrutura do PDF gerado:
1. **Título** — "Relatório de Análise de Notas Fiscais" (bold, 16pt)
2. **Metadados** — nome do arquivo, layout (se presente) e data/hora de geração
3. **Totais** — soma total e por série (`ChaveSerie.label()`)
4. **Notas Faltantes por Série** — lista agrupada em intervalos contíguos
(ex: `100104 (5 notas)` em vez de `100, 101, 102, 103, 104`)
5. **Duplicatas por Série** — grupo por número com contagem e data da última ocorrência
### `MetadadosRelatorio`
```rust
pub struct MetadadosRelatorio {
pub nome_arquivo: String,
pub nome_layout: Option<String>,
pub gerado_em: DateTime<Local>,
}
```
### `carregar_fonte_familia()` (privada)
Constrói `fonts::FontFamily` com os 4 slots exigidos por `genpdf`. Como o
relatório não usa itálico, `italic` e `bold_italic` reutilizam os dados de
`regular` e `bold` respectivamente.
---
## Dependencias externas relevantes
| Crate | Uso |
|-------|-----|
| `csv` | Parser de arquivos CSV com suporte a delimitadores e modo flexível |
| `encoding_rs` | Decodificação Windows-1252 / Latin-1 |
| `calamine` | Leitura de XLSX/XLS com detecção automática por magic bytes |
| `genpdf` | Geração de PDF com layout de parágrafos e decorador de página |
| `chrono` | `DateTime<Local>` para timestamp do relatório |
+35 -4
View File
@@ -1,11 +1,14 @@
use crate::domain::entities::chave_serie::ChaveSerie; use crate::domain::entities::chave_serie::ChaveSerie;
use crate::domain::entities::nota::Nota;
use crate::domain::entities::resultado_analise::ResultadoAnalise; 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 crate::domain::services::parser_monetario::formatar_valor_br;
use chrono::{DateTime, Local}; use chrono::{DateTime, Local};
use genpdf::{ use genpdf::{
elements::{Break, Paragraph}, elements::{Break, Paragraph},
fonts, style, Document, SimplePageDecorator, fonts, style, Document, SimplePageDecorator,
}; };
use std::collections::HashMap;
use std::path::Path; use std::path::Path;
// Fontes embutidas no binário em tempo de compilação. // Fontes embutidas no binário em tempo de compilação.
@@ -28,6 +31,7 @@ pub trait PdfGenerator {
fn gerar( fn gerar(
&self, &self,
resultado: &ResultadoAnalise, resultado: &ResultadoAnalise,
notas: &[Nota],
meta: &MetadadosRelatorio, meta: &MetadadosRelatorio,
caminho_saida: &Path, caminho_saida: &Path,
) -> Result<(), String>; ) -> Result<(), String>;
@@ -40,9 +44,20 @@ impl PdfGenerator for GenpdfGenerator {
fn gerar( fn gerar(
&self, &self,
resultado: &ResultadoAnalise, resultado: &ResultadoAnalise,
notas: &[Nota],
meta: &MetadadosRelatorio, meta: &MetadadosRelatorio,
caminho_saida: &Path, caminho_saida: &Path,
) -> Result<(), String> { ) -> 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 font_family = carregar_fonte_familia()?;
let mut doc = Document::new(font_family); let mut doc = Document::new(font_family);
@@ -125,8 +140,19 @@ impl PdfGenerator for GenpdfGenerator {
chave.label(), chave.label(),
faltantes.len() faltantes.len()
))); )));
let numeros: Vec<String> = faltantes.iter().map(|n| n.to_string()).collect(); // Agrupar faltantes contíguos em intervalos (ex: 100104 em vez de 100, 101, 102, 103, 104)
doc.push(Paragraph::new(format!(" {}", numeros.join(", ")))); 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)); doc.push(Break::new(1));
@@ -158,11 +184,16 @@ impl PdfGenerator for GenpdfGenerator {
duplicatas.len() duplicatas.len()
))); )));
for (numero, count) in duplicatas { 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!( doc.push(Paragraph::new(format!(
" NF {} / Série {}{} ocorrências", " NF {} / Série {}{} ocorrências{}",
numero, numero,
chave.label(), chave.label(),
count count,
data_str,
))); )));
} }
} }
+212
View File
@@ -0,0 +1,212 @@
# SQLite Infrastructure — AGENTS.md
Visão geral da camada de persistência SQLite do projeto `comparador-notas`.
---
## Estrutura dos arquivos
```
src/infrastructure/sqlite/
├── mod.rs # Re-exporta os módulos públicos
├── connection.rs # Abertura e validação da conexão
├── migrations.rs # Controle de versão do schema
└── layout_repository.rs # CRUD da entidade Layout
```
---
## mod.rs
Ponto de entrada do módulo. Apenas re-exporta os três submódulos:
```rust
pub mod connection;
pub mod layout_repository;
pub mod migrations;
```
---
## connection.rs
Responsável por localizar, abrir e validar o arquivo SQLite.
### Caminho do banco
`caminho_banco()` resolve o diretório de configuração do sistema operacional via
`dirs::config_dir()` e retorna:
```
<config_dir>/comparador-notas/config.db
```
Exemplos por SO:
- **Linux**: `~/.config/comparador-notas/config.db`
- **macOS**: `~/Library/Application Support/comparador-notas/config.db`
- **Windows**: `%APPDATA%\comparador-notas\config.db`
### Abertura da conexão — `abrir_banco()`
Delega para `abrir_banco_no_caminho()` com o caminho padrão. Retorna
`Result<(Connection, bool), String>`, onde o `bool` indica se o banco foi
**recriado** (era corrompido).
### Lógica de recuperação de corrupção — `abrir_banco_no_caminho(path)`
1. Cria o diretório pai caso não exista (`create_dir_all`).
2. Se o arquivo já existe, tenta abri-lo com `Connection::open`.
3. Executa `SELECT 1;` como teste de sanidade.
- Sucesso → retorna a conexão com flag `false` (não recriado).
- Falha (corrupção ou erro de abertura) → renomeia o arquivo para
`config.db.bak` e segue para a criação de um banco novo.
4. Cria um banco vazio e retorna com flag `true` (banco foi recriado).
> **Nota:** `SELECT 1` não acessa páginas de dados do SQLite e não detecta corrupção real. Um banco com tabelas ou índices corrompidos passaria nessa verificação. `PRAGMA quick_check` seria mais confiável (ver `docs/FIX_SALVAR_LAYOUT.md`, Problema 8).
---
## migrations.rs
Controla a evolução incremental do schema via uma tabela interna de versão.
### Tabela de controle
```sql
CREATE TABLE IF NOT EXISTS schema_version (
versao INTEGER NOT NULL
);
```
Armazena apenas uma linha com a versão atual do schema.
### `aplicar_migrations(conn)`
Fluxo:
1. Garante que `schema_version` existe.
2. Lê a versão atual (padrão `0` caso a tabela esteja vazia).
3. Executa sequencialmente as migrations pendentes:
- `versao_atual < 1``migration_v1`
- `versao_atual < 2``migration_v2`
- `versao_atual < 3``migration_v3`
4. Persiste a nova versão na tabela `schema_version`:
- `versao_atual == 0``INSERT INTO schema_version (versao) VALUES (3)`
- `versao_atual > 0 && versao_atual < 3``UPDATE schema_version SET versao = 3`
> **Atenção:** A atualização de `schema_version` ocorre **fora** das migrations individuais, após todas terem sido executadas. Isso significa que uma falha parcial entre migrations pode causar re-execução problemática na próxima inicialização (ver `docs/FIX_SALVAR_LAYOUT.md`, Problemas 1 e 2).
### Histórico de migrations
| Versão | Descrição |
|--------|-----------|
| **v1** | Cria a tabela `layouts` com campos para CSV e XLSX. |
| **v2** | Renomeia layouts com nomes duplicados (sufixo `(id)`) e cria índice único `idx_layouts_nome` em `layouts.nome`. |
| **v3** | Adiciona colunas `indice_documento_tipo` (INTEGER) e `pos_documento_tipo` (TEXT) na tabela `layouts`. |
### Schema final da tabela `layouts`
```sql
CREATE TABLE layouts (
id INTEGER PRIMARY KEY AUTOINCREMENT,
nome TEXT NOT NULL,
tipo TEXT NOT NULL CHECK(tipo IN ('csv', 'xlsx')),
-- Campos CSV
delimitador TEXT,
encoding TEXT,
linha_cabecalho INTEGER,
indice_numero INTEGER,
indice_serie INTEGER,
indice_valor INTEGER,
indice_data INTEGER,
indice_documento_tipo INTEGER, -- adicionado em v3
-- Campos XLSX
aba TEXT,
pos_numero TEXT,
pos_serie TEXT,
pos_valor TEXT,
pos_data TEXT,
pos_documento_tipo TEXT -- adicionado em v3
);
CREATE UNIQUE INDEX idx_layouts_nome ON layouts (nome); -- adicionado em v2
```
---
## layout_repository.rs
Implementa as operações CRUD sobre a entidade `Layout` (enum com variantes
`Layout::Csv` e `Layout::Xlsx`).
### Funções públicas
#### `salvar(conn, layout) -> Result<i64>`
Insere um novo layout e retorna o `rowid` gerado.
- `Layout::Csv` → preenche colunas CSV; colunas XLSX ficam `NULL`.
- `Layout::Xlsx` → preenche colunas XLSX; colunas CSV ficam `NULL`.
#### `atualizar(conn, layout) -> Result<()>`
Atualiza um layout existente pelo `id` embutido na variante. Retorna erro se
`id` for `None`.
- `Layout::Csv` → atualiza apenas as colunas CSV; colunas XLSX ficam inalteradas no banco.
- `Layout::Xlsx` → atualiza apenas as colunas XLSX; colunas CSV ficam inalteradas no banco.
> **Atenção:** O UPDATE não zera os campos do tipo oposto. Se um layout mudar de tipo (ex: via importação JSON com mesmo nome), os campos do tipo anterior persistem no banco (ver `docs/FIX_SALVAR_LAYOUT.md`, Problema 9).
#### `listar(conn) -> Result<Vec<Layout>>`
Seleciona todos os layouts ordenados por `nome ASC`. Para cada linha:
- `tipo == "csv"` → constrói `Layout::Csv` mapeando as colunas de índice.
- `tipo == "xlsx"` → constrói `Layout::Xlsx` mapeando as colunas de posição.
Campos opcionais (`Option<usize>`) são lidos como `Option<i64>` e convertidos.
> **Atenção:** O mapeamento usa índices posicionais (`row.get(0)`, `row.get(1)`, etc.) e não nomes de colunas. Qualquer reordenação no `SELECT` quebra silenciosamente o resultado (ver `docs/FIX_SALVAR_LAYOUT.md`, Problema 6).
#### `excluir(conn, id) -> Result<()>`
Remove o registro com o `id` informado via `DELETE`.
#### `existe_nome(conn, nome) -> Result<bool>`
Conta registros com o nome fornecido; retorna `true` se `COUNT(*) > 0`.
Usado para validar unicidade antes de salvar.
---
## Fluxo de inicialização
```
abrir_banco()
└─> abrir_banco_no_caminho(caminho)
├─ cria diretório se necessário
├─ testa banco existente (SELECT 1)
│ ├─ OK → retorna (conn, false)
│ └─ ERR → renomeia para .bak, cria banco novo → (conn, true)
└─ banco novo → retorna (conn, true)
aplicar_migrations(conn)
├─ cria schema_version se necessário
├─ lê versão atual
├─ executa migrations pendentes (v1 → v2 → v3)
└─ grava versão final (INSERT se versao=0, UPDATE se versao<3)
```
Após esse fluxo, a conexão está pronta para uso pelo `layout_repository`.
---
## Dependencias externas relevantes
| Crate | Uso |
|-------|-----|
| `rusqlite` | Driver SQLite embutido (sem servidor externo) |
| `dirs` | Resolve `config_dir()` conforme o SO |
+2 -2
View File
@@ -28,8 +28,8 @@ pub fn abrir_banco_no_caminho(caminho: &Path) -> Result<(Connection, bool), Stri
if caminho.exists() { if caminho.exists() {
match Connection::open(caminho) { match Connection::open(caminho) {
Ok(conn) => { Ok(conn) => {
// Testar se o banco funciona com uma query simples // Testar integridade estrutural do banco
match conn.execute_batch("SELECT 1;") { match conn.execute_batch("PRAGMA quick_check;") {
Ok(_) => return Ok((conn, false)), Ok(_) => return Ok((conn, false)),
Err(_e) => { Err(_e) => {
// Banco corrompido // Banco corrompido
+61 -22
View File
@@ -1,6 +1,22 @@
use crate::domain::entities::layout::{Layout, LayoutCsv, LayoutXlsx}; use crate::domain::entities::layout::{Layout, LayoutCsv, LayoutXlsx};
use rusqlite::{params, Connection, Result}; use rusqlite::{params, Connection, Result};
/// Converte o char delimitador para string legível no banco.
fn delim_para_str(c: char) -> String {
match c {
'\t' => "tab".to_string(),
c => c.to_string(),
}
}
/// Converte a string armazenada no banco de volta para char delimitador.
fn str_para_delim(s: &str) -> char {
match s {
"tab" => '\t',
s => s.chars().next().unwrap_or(';'),
}
}
/// Salva um layout no banco. Retorna o id gerado. /// Salva um layout no banco. Retorna o id gerado.
pub fn salvar(conn: &Connection, layout: &Layout) -> Result<i64> { pub fn salvar(conn: &Connection, layout: &Layout) -> Result<i64> {
match layout { match layout {
@@ -13,7 +29,7 @@ pub fn salvar(conn: &Connection, layout: &Layout) -> Result<i64> {
VALUES (?1, 'csv', ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9)", VALUES (?1, 'csv', ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9)",
params![ params![
nome, nome,
config.delimitador.to_string(), delim_para_str(config.delimitador),
config.encoding, config.encoding,
config.linha_cabecalho as i64, config.linha_cabecalho as i64,
config.indice_numero as i64, config.indice_numero as i64,
@@ -47,6 +63,7 @@ pub fn salvar(conn: &Connection, layout: &Layout) -> Result<i64> {
} }
/// Atualiza um layout existente no banco. /// Atualiza um layout existente no banco.
/// Zera explicitamente os campos do tipo oposto para evitar dados órfãos.
pub fn atualizar(conn: &Connection, layout: &Layout) -> Result<()> { pub fn atualizar(conn: &Connection, layout: &Layout) -> Result<()> {
let id = layout let id = layout
.id() .id()
@@ -58,11 +75,13 @@ pub fn atualizar(conn: &Connection, layout: &Layout) -> Result<()> {
"UPDATE layouts SET "UPDATE layouts SET
nome = ?1, delimitador = ?2, encoding = ?3, nome = ?1, delimitador = ?2, encoding = ?3,
linha_cabecalho = ?4, indice_numero = ?5, indice_serie = ?6, linha_cabecalho = ?4, indice_numero = ?5, indice_serie = ?6,
indice_valor = ?7, indice_data = ?8, indice_documento_tipo = ?9 indice_valor = ?7, indice_data = ?8, indice_documento_tipo = ?9,
aba = NULL, pos_numero = NULL, pos_serie = NULL,
pos_valor = NULL, pos_data = NULL, pos_documento_tipo = NULL
WHERE id = ?10", WHERE id = ?10",
params![ params![
nome, nome,
config.delimitador.to_string(), delim_para_str(config.delimitador),
config.encoding, config.encoding,
config.linha_cabecalho as i64, config.linha_cabecalho as i64,
config.indice_numero as i64, config.indice_numero as i64,
@@ -78,7 +97,10 @@ pub fn atualizar(conn: &Connection, layout: &Layout) -> Result<()> {
conn.execute( conn.execute(
"UPDATE layouts SET "UPDATE layouts SET
nome = ?1, aba = ?2, pos_numero = ?3, pos_serie = ?4, nome = ?1, aba = ?2, pos_numero = ?3, pos_serie = ?4,
pos_valor = ?5, pos_data = ?6, pos_documento_tipo = ?7 pos_valor = ?5, pos_data = ?6, pos_documento_tipo = ?7,
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", WHERE id = ?8",
params![ params![
nome, nome,
@@ -109,25 +131,31 @@ pub fn listar(conn: &Connection) -> Result<Vec<Layout>> {
let layouts: Result<Vec<Layout>> = stmt let layouts: Result<Vec<Layout>> = stmt
.query_map([], |row| { .query_map([], |row| {
let id: i64 = row.get(0)?; let id: i64 = row.get("id")?;
let nome: String = row.get(1)?; let nome: String = row.get("nome")?;
let tipo: String = row.get(2)?; let tipo: String = row.get("tipo")?;
if tipo == "csv" { if tipo == "csv" {
let delim_str: String = row.get(3)?; let delim_str: String = row.get("delimitador")?;
let delimitador = delim_str.chars().next().unwrap_or(';'); let delimitador = str_para_delim(&delim_str);
Ok(Layout::Csv { Ok(Layout::Csv {
id: Some(id), id: Some(id),
nome, nome,
config: LayoutCsv { config: LayoutCsv {
delimitador, delimitador,
encoding: row.get(4)?, encoding: row.get("encoding")?,
linha_cabecalho: row.get::<_, i64>(5)? as usize, linha_cabecalho: row.get::<_, i64>("linha_cabecalho")? as usize,
indice_numero: row.get::<_, i64>(6)? as usize, indice_numero: row.get::<_, i64>("indice_numero")? as usize,
indice_serie: row.get::<_, i64>(7)? as usize, indice_serie: row.get::<_, i64>("indice_serie")? as usize,
indice_valor: row.get::<_, Option<i64>>(8)?.map(|v| v as usize), indice_valor: row
indice_data: row.get::<_, Option<i64>>(9)?.map(|v| v as usize), .get::<_, Option<i64>>("indice_valor")?
indice_documento_tipo: row.get::<_, Option<i64>>(15)?.map(|v| v as usize), .map(|v| v as usize),
indice_data: row
.get::<_, Option<i64>>("indice_data")?
.map(|v| v as usize),
indice_documento_tipo: row
.get::<_, Option<i64>>("indice_documento_tipo")?
.map(|v| v as usize),
}, },
}) })
} else { } else {
@@ -135,12 +163,12 @@ pub fn listar(conn: &Connection) -> Result<Vec<Layout>> {
id: Some(id), id: Some(id),
nome, nome,
config: LayoutXlsx { config: LayoutXlsx {
aba: row.get(10)?, aba: row.get("aba")?,
pos_numero: row.get(11)?, pos_numero: row.get("pos_numero")?,
pos_serie: row.get(12)?, pos_serie: row.get("pos_serie")?,
pos_valor: row.get(13)?, pos_valor: row.get("pos_valor")?,
pos_data: row.get(14)?, pos_data: row.get("pos_data")?,
pos_documento_tipo: row.get(16)?, pos_documento_tipo: row.get("pos_documento_tipo")?,
}, },
}) })
} }
@@ -165,3 +193,14 @@ pub fn existe_nome(conn: &Connection, nome: &str) -> Result<bool> {
)?; )?;
Ok(count > 0) Ok(count > 0)
} }
/// Verifica se existe um layout com o nome fornecido, excluindo o registro com o id dado.
/// Usado para validar conflito de nome ao renomear um layout existente.
pub fn existe_nome_excluindo_id(conn: &Connection, nome: &str, id: i64) -> Result<bool> {
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)
}
+17 -23
View File
@@ -1,8 +1,5 @@
use rusqlite::{Connection, Result}; use rusqlite::{Connection, Result};
/// Versão atual do schema do banco de dados.
const VERSAO_SCHEMA_ATUAL: i64 = 3;
/// Aplica todas as migrations necessárias para atualizar o banco /// Aplica todas as migrations necessárias para atualizar o banco
/// para a versão mais recente. /// para a versão mais recente.
pub fn aplicar_migrations(conn: &Connection) -> Result<()> { pub fn aplicar_migrations(conn: &Connection) -> Result<()> {
@@ -29,42 +26,36 @@ pub fn aplicar_migrations(conn: &Connection) -> Result<()> {
migration_v3(conn)?; migration_v3(conn)?;
} }
if versao_atual == 0 {
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],
)?;
}
Ok(()) Ok(())
} }
/// Migration v2: adicionar índice único em layouts.nome.
/// Migration v3: adicionar colunas de tipo de documento nos layouts. /// Migration v3: adicionar colunas de tipo de documento nos layouts.
fn migration_v3(conn: &Connection) -> Result<()> { fn migration_v3(conn: &Connection) -> Result<()> {
conn.execute_batch( conn.execute_batch(
"ALTER TABLE layouts ADD COLUMN indice_documento_tipo INTEGER; "BEGIN;
ALTER TABLE layouts ADD COLUMN pos_documento_tipo TEXT;", 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(()) Ok(())
} }
/// Migration v2: adicionar índice único em layouts.nome.
///
/// Renomeia duplicatas (sufixo com id) antes de criar o índice para não falhar /// Renomeia duplicatas (sufixo com id) antes de criar o índice para não falhar
/// em bancos que já possuem nomes repetidos. /// em bancos que já possuem nomes repetidos.
fn migration_v2(conn: &Connection) -> Result<()> { fn migration_v2(conn: &Connection) -> Result<()> {
conn.execute_batch( conn.execute_batch(
"UPDATE layouts "BEGIN;
UPDATE layouts
SET nome = nome || ' (' || id || ')' SET nome = nome || ' (' || id || ')'
WHERE id NOT IN ( WHERE id NOT IN (
SELECT MIN(id) FROM layouts GROUP BY nome SELECT MIN(id) FROM layouts GROUP BY nome
); );
CREATE UNIQUE INDEX IF NOT EXISTS idx_layouts_nome ON layouts (nome);", CREATE UNIQUE INDEX IF NOT EXISTS idx_layouts_nome ON layouts (nome);
UPDATE schema_version SET versao = 2;
COMMIT;",
)?; )?;
Ok(()) Ok(())
} }
@@ -72,7 +63,8 @@ fn migration_v2(conn: &Connection) -> Result<()> {
/// Migration v1: criar tabela de layouts. /// Migration v1: criar tabela de layouts.
fn migration_v1(conn: &Connection) -> Result<()> { fn migration_v1(conn: &Connection) -> Result<()> {
conn.execute_batch( conn.execute_batch(
"CREATE TABLE IF NOT EXISTS layouts ( "BEGIN;
CREATE TABLE IF NOT EXISTS layouts (
id INTEGER PRIMARY KEY AUTOINCREMENT, id INTEGER PRIMARY KEY AUTOINCREMENT,
nome TEXT NOT NULL, nome TEXT NOT NULL,
tipo TEXT NOT NULL CHECK(tipo IN ('csv', 'xlsx')), tipo TEXT NOT NULL CHECK(tipo IN ('csv', 'xlsx')),
@@ -92,7 +84,9 @@ fn migration_v1(conn: &Connection) -> Result<()> {
pos_serie TEXT, pos_serie TEXT,
pos_valor TEXT, pos_valor TEXT,
pos_data TEXT pos_data TEXT
);", );
INSERT INTO schema_version (versao) VALUES (1);
COMMIT;",
)?; )?;
Ok(()) Ok(())
} }
+26 -45
View File
@@ -62,29 +62,7 @@ pub fn ler_xlsx(
continue; continue;
} }
let campos: Vec<String> = row let campos: Vec<String> = row.iter().map(celula_para_string).collect();
.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();
// Ignorar linhas completamente em branco // Ignorar linhas completamente em branco
if campos.iter().all(|s: &String| s.trim().is_empty()) { if campos.iter().all(|s: &String| s.trim().is_empty()) {
@@ -113,28 +91,7 @@ pub fn preview_xlsx(caminho: &Path, nome_aba: &str) -> Result<Vec<Vec<String>>,
.rows() .rows()
.take(5) .take(5)
.map(|row| { .map(|row| {
row.iter() row.iter().map(celula_para_string).collect()
.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()
}) })
.collect(); .collect();
@@ -178,6 +135,30 @@ pub fn parsear_letra_linha(s: &str) -> Option<Coordenada> {
Some(Coordenada { coluna, linha }) 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> { fn verificar_tamanho(caminho: &Path) -> Result<(), ErroArquivo> {
let metadata = let metadata =
std::fs::metadata(caminho).map_err(|e| ErroArquivo::ErroLeitura(e.to_string()))?; std::fs::metadata(caminho).map_err(|e| ErroArquivo::ErroLeitura(e.to_string()))?;
+1
View File
@@ -9,6 +9,7 @@ use ui::app::App;
fn main() -> iced::Result { fn main() -> iced::Result {
iced::application("Comparador de Notas", App::update, App::view) iced::application("Comparador de Notas", App::update, App::view)
.subscription(App::subscription)
.theme(|_app| ui::theme::tema_dark()) .theme(|_app| ui::theme::tema_dark())
.window(iced::window::Settings { .window(iced::window::Settings {
size: iced::Size::new(1024.0, 768.0), size: iced::Size::new(1024.0, 768.0),
+338
View File
@@ -0,0 +1,338 @@
# 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 com borda `TEXT_SECONDARY` 55%, ação secundária
- `t::btn_ghost` — transparente com borda `TEXT_SECONDARY` 55%, 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
- `t::btn_breadcrumb_visitado` — botão sem fundo para passos anteriores do breadcrumb
### Contraste de estados de botão
`btn_secondary` e `btn_ghost` usam borda visível (`TEXT_SECONDARY` a 55% de opacidade)
no estado normal para se destacar de containers com fundo `SURFACE_2`. O estado
`Disabled` reduz a borda para 20% de opacidade, criando contraste claro entre ativo e
inativo. **Nunca remova a borda desses estilos** — sem ela os botões se perdem no fundo.
**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(200.0)` — acomoda 5 linhas + cabeçalho + scrollbar horizontal.
- 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`.
### Comportamento de navegação do breadcrumb
- **Passo ativo** — destaque azul com fundo semitransparente (`PRIMARY` a 15%).
- **Passos anteriores** — `button` com `t::btn_breadcrumb_visitado` e `on_press` com a mensagem de navegação correspondente (`Message::IrParaImportacao` para passo 1, `Message::IrParaConfiguracaoColunas` para passo 2). O hover revela fundo sutil + borda, sinalizando interatividade.
- **Passos futuros** — `container` com texto `TEXT_MUTED`, sem interação (não é possível pular à frente).
---
## 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.
+205 -53
View File
@@ -9,12 +9,12 @@ use crate::domain::{
nota::Nota, nota::Nota,
resultado_analise::{ResultadoAnalise, ResultadoPreAnalise}, resultado_analise::{ResultadoAnalise, ResultadoPreAnalise},
}, },
errors::{ErroLayout, ResumoAvisos}, errors::{ErroArquivo, ErroLayout, ResumoAvisos},
}; };
use crate::infrastructure::sqlite::{connection::abrir_banco, migrations::aplicar_migrations}; use crate::infrastructure::sqlite::{connection::abrir_banco, migrations::aplicar_migrations};
use crate::ui::message::{Message, ResultadoPendente}; use crate::ui::message::{Message, ResultadoPendente};
use crate::ui::theme as t; use crate::ui::theme as t;
use iced::widget::{column, container, text, Row}; use iced::widget::{button, column, container, text, Row};
use iced::{Alignment, Element, Length, Task}; use iced::{Alignment, Element, Length, Task};
use rusqlite::Connection; use rusqlite::Connection;
use std::path::PathBuf; use std::path::PathBuf;
@@ -36,7 +36,6 @@ pub enum EstadoApp {
#[derive(Debug, Clone)] #[derive(Debug, Clone)]
pub enum EstadoModal { pub enum EstadoModal {
Informacao { titulo: String, mensagem: String },
Aviso { titulo: String, mensagem: String }, Aviso { titulo: String, mensagem: String },
Erro { titulo: String, mensagem: String }, Erro { titulo: String, mensagem: String },
Confirmacao { titulo: String, mensagem: String, acao: AcaoModal }, Confirmacao { titulo: String, mensagem: String, acao: AcaoModal },
@@ -82,9 +81,9 @@ pub struct App {
// Avisos da última importação // Avisos da última importação
pub avisos_importacao: Option<ResumoAvisos>, pub avisos_importacao: Option<ResumoAvisos>,
// Paginação // Paginação (independente por série)
pub pagina_faltantes: usize, pub pagina_faltantes: std::collections::HashMap<crate::domain::entities::chave_serie::ChaveSerie, usize>,
pub pagina_duplicatas: usize, pub pagina_duplicatas: std::collections::HashMap<crate::domain::entities::chave_serie::ChaveSerie, usize>,
pub itens_por_pagina: usize, pub itens_por_pagina: usize,
// Pré-visualização das primeiras linhas do arquivo // Pré-visualização das primeiras linhas do arquivo
@@ -92,6 +91,12 @@ pub struct App {
// Resultado anterior (preservado durante reanálise) // Resultado anterior (preservado durante reanálise)
pub resultado_anterior: Option<ResultadoAnalise>, pub resultado_anterior: Option<ResultadoAnalise>,
// Drag-and-drop: indica que um arquivo está sendo arrastado sobre a janela
pub arquivo_em_hover: bool,
// Animação do spinner na tela de análise
pub spinner_frame: usize,
} }
impl Default for App { impl Default for App {
@@ -111,11 +116,13 @@ impl Default for App {
layouts_salvos: Vec::new(), layouts_salvos: Vec::new(),
modal: None, modal: None,
avisos_importacao: None, avisos_importacao: None,
pagina_faltantes: 0, pagina_faltantes: std::collections::HashMap::new(),
pagina_duplicatas: 0, pagina_duplicatas: std::collections::HashMap::new(),
itens_por_pagina: 100, itens_por_pagina: 100,
preview_arquivo: None, preview_arquivo: None,
resultado_anterior: None, resultado_anterior: None,
arquivo_em_hover: false,
spinner_frame: 0,
} }
} }
} }
@@ -206,7 +213,7 @@ impl App {
}, },
|result| match result { |result| match result {
Ok(layouts) => Message::LayoutsRecarregados(layouts), Ok(layouts) => Message::LayoutsRecarregados(layouts),
Err(e) => Message::ModalTextoAlterado(format!("Erro ao recarregar layouts: {}", e)), Err(e) => { eprintln!("Erro ao recarregar layouts: {e}"); Message::Noop },
}, },
); );
} }
@@ -448,12 +455,6 @@ impl App {
Task::none() Task::none()
} }
Message::CancelarExpansao => {
self.estado = EstadoApp::ConfigurandoColunas;
self.modal = None;
Task::none()
}
Message::NovaAnalise => { Message::NovaAnalise => {
self.exibir_confirmacao( self.exibir_confirmacao(
"Nova Análise", "Nova Análise",
@@ -469,18 +470,18 @@ impl App {
} }
// ── Resultado ── // ── Resultado ──
Message::PaginaFaltantesAlterada(p) => { Message::PaginaFaltantesAlterada(chave, p) => {
self.pagina_faltantes = p; self.pagina_faltantes.insert(chave, p);
Task::none() Task::none()
} }
Message::PaginaDuplicatasAlterada(p) => { Message::PaginaDuplicatasAlterada(chave, p) => {
self.pagina_duplicatas = p; self.pagina_duplicatas.insert(chave, p);
Task::none() Task::none()
} }
Message::ItensPorPaginaAlterado(n) => { Message::ItensPorPaginaAlterado(n) => {
self.itens_por_pagina = n; self.itens_por_pagina = n;
self.pagina_faltantes = 0; self.pagina_faltantes.clear();
self.pagina_duplicatas = 0; self.pagina_duplicatas.clear();
Task::none() Task::none()
} }
Message::CopiarFaltantes(chave) => { Message::CopiarFaltantes(chave) => {
@@ -613,9 +614,11 @@ impl App {
let modal = self.modal.take(); let modal = self.modal.take();
if let Some(estado) = modal { if let Some(estado) = modal {
match estado { match estado {
EstadoModal::Confirmacao { acao, .. } EstadoModal::Confirmacao { acao, .. } => {
| EstadoModal::InputTexto { acao, .. } => { return self.executar_acao_modal(acao, None);
return self.executar_acao_modal(acao); }
EstadoModal::InputTexto { acao, texto, .. } => {
return self.executar_acao_modal(acao, Some(texto));
} }
_ => {} _ => {}
} }
@@ -655,11 +658,35 @@ impl App {
} }
Message::XlsxErroAoCarregar(e) => { Message::XlsxErroAoCarregar(e) => {
self.caminho_arquivo = None;
self.nome_arquivo = String::new();
self.preview_arquivo = None;
self.notas_importadas.clear();
self.exibir_erro(format!("Erro ao ler abas do arquivo: {}", e)); self.exibir_erro(format!("Erro ao ler abas do arquivo: {}", e));
Task::none() Task::none()
} }
// ── Drag-and-drop ──
Message::ArquivoSolto(caminho) => {
self.arquivo_em_hover = false;
self.processar_arquivo_selecionado(caminho)
}
Message::ArquivoEmHover => {
self.arquivo_em_hover = true;
Task::none()
}
Message::ArquivoHoverSaiu => {
self.arquivo_em_hover = false;
Task::none()
}
// ── Sem operação ──
Message::Noop => Task::none(), Message::Noop => Task::none(),
Message::SpinnerTick => {
self.spinner_frame = self.spinner_frame.wrapping_add(1);
Task::none()
}
} }
} }
@@ -679,9 +706,11 @@ impl App {
} }
EstadoApp::GerenciandoLayouts => crate::ui::screens::layouts::view(self), EstadoApp::GerenciandoLayouts => crate::ui::screens::layouts::view(self),
EstadoApp::Analisando => { EstadoApp::Analisando => {
const FRAMES: &[&str] = &["", "", "", "", "", "", "", "", "", ""];
let frame = FRAMES[self.spinner_frame % FRAMES.len()];
container( container(
column![ column![
text("Analisando...").size(20).color(t::TEXT_SECONDARY), text(format!("{} Analisando...", frame)).size(20).color(t::TEXT_SECONDARY),
text("Aguarde enquanto o arquivo é processado.").size(14).color(t::TEXT_MUTED), text("Aguarde enquanto o arquivo é processado.").size(14).color(t::TEXT_MUTED),
] ]
.spacing(8) .spacing(8)
@@ -720,6 +749,31 @@ impl App {
} }
} }
// ─── Subscription (eventos de janela) ───────────────────────────────────
pub fn subscription(&self) -> iced::Subscription<Message> {
let drag_drop = iced::event::listen_with(|event, _status, _id| match event {
iced::Event::Window(iced::window::Event::FileDropped(path)) => {
Some(Message::ArquivoSolto(path))
}
iced::Event::Window(iced::window::Event::FileHovered(_)) => {
Some(Message::ArquivoEmHover)
}
iced::Event::Window(iced::window::Event::FilesHoveredLeft) => {
Some(Message::ArquivoHoverSaiu)
}
_ => None,
});
if matches!(self.estado, EstadoApp::Analisando) {
let spinner = iced::time::every(std::time::Duration::from_millis(100))
.map(|_| Message::SpinnerTick);
iced::Subscription::batch([drag_drop, spinner])
} else {
drag_drop
}
}
// ─── Breadcrumb ────────────────────────────────────────────────────────── // ─── Breadcrumb ──────────────────────────────────────────────────────────
fn view_breadcrumb(&self) -> Element<'_, Message> { fn view_breadcrumb(&self) -> Element<'_, Message> {
@@ -734,6 +788,12 @@ impl App {
let mut items: Vec<Element<'_, Message>> = Vec::new(); let mut items: Vec<Element<'_, Message>> = Vec::new();
for (i, (label, n)) in passos.iter().enumerate() { for (i, (label, n)) in passos.iter().enumerate() {
let msg_nav = match n {
1 => Some(Message::IrParaImportacao),
2 => Some(Message::IrParaConfiguracaoColunas),
_ => None,
};
let elem: Element<'_, Message> = if *n == passo_ativo { let elem: Element<'_, Message> = if *n == passo_ativo {
container(text(*label).size(13).color(t::PRIMARY)) container(text(*label).size(13).color(t::PRIMARY))
.padding([4, 10]) .padding([4, 10])
@@ -744,13 +804,23 @@ impl App {
}) })
.into() .into()
} else if *n < passo_ativo { } else if *n < passo_ativo {
text(*label).size(13).color(t::TEXT_SECONDARY).into() // Passo já visitado — botão clicável
let mut btn = button(text(*label).size(13))
.padding([4, 10])
.style(t::btn_breadcrumb_visitado);
if let Some(msg) = msg_nav {
btn = btn.on_press(msg);
}
btn.into()
} else { } else {
text(*label).size(13).color(t::TEXT_MUTED).into() // Passo futuro — ainda não disponível
container(text(*label).size(13).color(t::TEXT_MUTED))
.padding([4, 10])
.into()
}; };
items.push(elem); items.push(elem);
if i < passos.len() - 1 { if i < passos.len() - 1 {
items.push(text(" ").size(13).color(t::TEXT_MUTED).into()); items.push(text(" > ").size(13).color(t::TEXT_MUTED).into());
} }
} }
@@ -826,6 +896,12 @@ impl App {
let layout_csv = self.layout_csv_atual.clone(); let layout_csv = self.layout_csv_atual.clone();
let layout_xlsx = self.layout_xlsx_atual.clone(); let layout_xlsx = self.layout_xlsx_atual.clone();
// Validação de campos duplicados no mapeamento (RF02.3)
if let Some(erro) = validar_mapeamento_duplicado(&tipo, &layout_csv, &layout_xlsx) {
self.exibir_erro(erro);
return Task::none();
}
self.estado = EstadoApp::Analisando; self.estado = EstadoApp::Analisando;
Task::perform( Task::perform(
@@ -893,11 +969,13 @@ impl App {
self.avisos_importacao = if avisos.tem_avisos() { Some(avisos) } else { None }; self.avisos_importacao = if avisos.tem_avisos() { Some(avisos) } else { None };
let msg = series_excessivas let msg = series_excessivas
.iter() .iter()
.map(|(chave, count)| { .map(|(chave, iv)| {
format!( format!(
"Série {}: intervalo de {} faltantes detectado", "Série {}: intervalo de {} faltantes detectado (de {} a {})",
chave.label(), chave.label(),
count iv.contagem_faltantes,
iv.minimo,
iv.maximo,
) )
}) })
.collect::<Vec<_>>() .collect::<Vec<_>>()
@@ -931,7 +1009,7 @@ impl App {
} }
} }
fn executar_acao_modal(&mut self, acao: AcaoModal) -> Task<Message> { fn executar_acao_modal(&mut self, acao: AcaoModal, texto_input: Option<String>) -> Task<Message> {
match acao { match acao {
AcaoModal::ConfirmarExpansaoFaltantes => { AcaoModal::ConfirmarExpansaoFaltantes => {
self.update(Message::ConfirmarExpansaoFaltantes) self.update(Message::ConfirmarExpansaoFaltantes)
@@ -949,13 +1027,7 @@ impl App {
Task::none() Task::none()
} }
AcaoModal::SalvarLayoutConfig => { AcaoModal::SalvarLayoutConfig => {
let texto = if let Some(EstadoModal::InputTexto { texto, .. }) = &self.modal { let nome = texto_input.unwrap_or_default().trim().to_string();
texto.clone()
} else {
String::new()
};
self.modal = None;
let nome = texto.trim().to_string();
if nome.is_empty() { if nome.is_empty() {
self.exibir_erro("O nome do layout não pode ser vazio."); self.exibir_erro("O nome do layout não pode ser vazio.");
return Task::none(); return Task::none();
@@ -1036,6 +1108,7 @@ impl App {
} else { } else {
Some(self.nome_layout_atual.clone()) Some(self.nome_layout_atual.clone())
}; };
let notas = self.notas_importadas.clone();
Task::perform( Task::perform(
async move { async move {
@@ -1058,6 +1131,7 @@ impl App {
exportar_pdf( exportar_pdf(
&gerador, &gerador,
&resultado, &resultado,
&notas,
&nome_arquivo, &nome_arquivo,
nome_layout.as_deref(), nome_layout.as_deref(),
&caminho, &caminho,
@@ -1086,10 +1160,10 @@ impl App {
let (conteudo, nome_sugerido) = match exportar_layout_json(&layout) { let (conteudo, nome_sugerido) = match exportar_layout_json(&layout) {
Ok(r) => r, Ok(r) => r,
Err(e) => { Err(e) => {
// Não temos &mut self aqui; retornamos um command que exibe erro // Não temos &mut self aqui reutilizamos PdfExportado(Err) para
return Task::perform(async move { e.to_string() }, |e| { // exibir o modal de erro (o handler chama self.exibir_erro).
Message::PdfExportado(Err(e)) let msg = format!("Erro ao exportar layout JSON: {}", e);
}); return Task::perform(async move { msg }, |e| Message::PdfExportado(Err(e)));
} }
}; };
@@ -1114,7 +1188,8 @@ impl App {
|result| match result { |result| match result {
Ok(_) => Message::Noop, Ok(_) => Message::Noop,
Err(e) if e.is_empty() => Message::Noop, Err(e) if e.is_empty() => Message::Noop,
Err(e) => Message::PdfExportado(Err(format!("Erro ao salvar JSON: {}", e))), // Reutiliza PdfExportado(Err) para exibir modal de erro.
Err(e) => Message::PdfExportado(Err(format!("Erro ao salvar layout JSON: {}", e))),
}, },
) )
} }
@@ -1176,15 +1251,80 @@ impl App {
} }
} }
// ─── Mensagens extras para o fluxo XLSX ─────────────────────────────────────
// (precisamos adicionar estas variantes ao enum Message)
// Nota: as variantes AbaxlsxCarregadas e XlsxErroAoCarregar serão tratadas
// no match do update() principal via um segundo match arm de Message.
// Para isso, precisamos adicionar estas variantes ao message.rs também.
// ─── Função auxiliar síncrona para executar importação em spawn_blocking ───── // ─── Função auxiliar síncrona para executar importação em spawn_blocking ─────
/// Verifica se algum par de campos mapeados aponta para o mesmo índice (CSV)
/// ou posição (XLSX). Retorna uma mensagem de erro descritiva se houver conflito.
fn validar_mapeamento_duplicado(
tipo: &TipoArquivo,
layout_csv: &LayoutCsv,
layout_xlsx: &LayoutXlsx,
) -> Option<String> {
match tipo {
TipoArquivo::Csv => {
// Campos obrigatórios + opcionais ativos
let mut campos: Vec<(&str, usize)> = vec![
("Numero", layout_csv.indice_numero),
("Serie", layout_csv.indice_serie),
];
if let Some(i) = layout_csv.indice_valor {
campos.push(("Valor", i));
}
if let Some(i) = layout_csv.indice_data {
campos.push(("Data", i));
}
if let Some(i) = layout_csv.indice_documento_tipo {
campos.push(("Tipo de Documento", i));
}
// Verificar duplicatas
for i in 0..campos.len() {
for j in (i + 1)..campos.len() {
if campos[i].1 == campos[j].1 {
return Some(format!(
"Mapeamento inválido: campos '{}' e '{}' apontam para o mesmo índice ({}).",
campos[i].0, campos[j].0, campos[i].1
));
}
}
}
None
}
TipoArquivo::Xlsx => {
let mut campos: Vec<(&str, &str)> = vec![
("Numero", &layout_xlsx.pos_numero),
("Serie", &layout_xlsx.pos_serie),
];
if let Some(ref p) = layout_xlsx.pos_valor {
if !p.is_empty() {
campos.push(("Valor", p));
}
}
if let Some(ref p) = layout_xlsx.pos_data {
if !p.is_empty() {
campos.push(("Data", p));
}
}
if let Some(ref p) = layout_xlsx.pos_documento_tipo {
if !p.is_empty() {
campos.push(("Tipo de Documento", p));
}
}
// Verificar duplicatas (comparação case-insensitive)
for i in 0..campos.len() {
for j in (i + 1)..campos.len() {
if campos[i].1.to_uppercase() == campos[j].1.to_uppercase() {
return Some(format!(
"Mapeamento inválido: campos '{}' e '{}' apontam para a mesma posição ('{}').",
campos[i].0, campos[j].0, campos[i].1
));
}
}
}
None
}
}
}
fn executar_importacao_sync( fn executar_importacao_sync(
caminho: PathBuf, caminho: PathBuf,
tipo: TipoArquivo, tipo: TipoArquivo,
@@ -1192,8 +1332,20 @@ fn executar_importacao_sync(
layout_xlsx: LayoutXlsx, layout_xlsx: LayoutXlsx,
) -> ResultadoPendente { ) -> ResultadoPendente {
let res_importacao = match tipo { let res_importacao = match tipo {
TipoArquivo::Csv => importar_csv(&caminho, &layout_csv).map_err(|e| e.to_string()), TipoArquivo::Csv => importar_csv(&caminho, &layout_csv).map_err(|e| match e {
TipoArquivo::Xlsx => importar_xlsx(&caminho, &layout_xlsx).map_err(|e| e.to_string()), ErroArquivo::TamanhoExcedido(bytes) => format!(
"Arquivo muito grande ({:.1} MB). O limite é 50 MB.",
bytes as f64 / (1024.0 * 1024.0)
),
other => other.to_string(),
}),
TipoArquivo::Xlsx => importar_xlsx(&caminho, &layout_xlsx).map_err(|e| match e {
ErroArquivo::TamanhoExcedido(bytes) => format!(
"Arquivo muito grande ({:.1} MB). O limite é 50 MB.",
bytes as f64 / (1024.0 * 1024.0)
),
other => other.to_string(),
}),
}; };
match res_importacao { match res_importacao {
+2 -4
View File
@@ -28,9 +28,6 @@ pub fn view_com_modal<'a>(
fn view_modal(modal: &EstadoModal) -> Element<'_, Message> { fn view_modal(modal: &EstadoModal) -> Element<'_, Message> {
match modal { match modal {
EstadoModal::Informacao { titulo, mensagem } => {
caixa_modal(titulo, mensagem, TipoModal::Info, None, false)
}
EstadoModal::Aviso { titulo, mensagem } => { EstadoModal::Aviso { titulo, mensagem } => {
caixa_modal(titulo, mensagem, TipoModal::Aviso, None, false) caixa_modal(titulo, mensagem, TipoModal::Aviso, None, false)
} }
@@ -138,7 +135,8 @@ fn caixa_modal<'a>(
col = col.push(botoes); col = col.push(botoes);
container(col.align_x(Alignment::Start)) container(col.align_x(Alignment::Start))
.width(Length::Fixed(420.0)) .max_width(420)
.width(Length::Fill)
.padding(24) .padding(24)
.style(t::card) .style(t::card)
.into() .into()
+2 -2
View File
@@ -10,11 +10,11 @@ pub fn controles_paginacao(
msg_anterior: Message, msg_anterior: Message,
msg_proximo: Message, msg_proximo: Message,
) -> Element<'static, Message> { ) -> Element<'static, Message> {
let btn_anterior = button("") let btn_anterior = button("<")
.on_press_maybe((pagina_atual > 0).then_some(msg_anterior)) .on_press_maybe((pagina_atual > 0).then_some(msg_anterior))
.style(t::btn_ghost); .style(t::btn_ghost);
let btn_proximo = button("") let btn_proximo = button(">")
.on_press_maybe((pagina_atual + 1 < total_paginas).then_some(msg_proximo)) .on_press_maybe((pagina_atual + 1 < total_paginas).then_some(msg_proximo))
.style(t::btn_ghost); .style(t::btn_ghost);
+5 -13
View File
@@ -12,7 +12,7 @@ pub fn tabela_preview(linhas: &[Vec<String>]) -> Element<'_, Message> {
return text("(vazio)").size(12).color(t::TEXT_MUTED).into(); return text("(vazio)").size(12).color(t::TEXT_MUTED).into();
} }
// Cabeçalho estilo Excel — fundo SURFACE_2 // Cabeçalho estilo Excel — usa t::cabecalho_tabela centralizado
let cabecalho = container( let cabecalho = container(
row((0..num_colunas) row((0..num_colunas)
.map(|i| { .map(|i| {
@@ -22,7 +22,7 @@ pub fn tabela_preview(linhas: &[Vec<String>]) -> Element<'_, Message> {
.size(12) .size(12)
.color(t::TEXT_SECONDARY), .color(t::TEXT_SECONDARY),
) )
.width(Length::Fixed(120.0)) .width(Length::Fixed(100.0))
.padding([4, 6]) .padding([4, 6])
.into() .into()
}) })
@@ -30,15 +30,7 @@ pub fn tabela_preview(linhas: &[Vec<String>]) -> Element<'_, Message> {
.spacing(0), .spacing(0),
) )
.width(Length::Shrink) .width(Length::Shrink)
.style(|_theme| iced::widget::container::Style { .style(t::cabecalho_tabela);
background: Some(t::SURFACE_2.into()),
border: Border {
color: t::BORDER,
width: 0.0,
radius: 0.0.into(),
},
..Default::default()
});
let linhas_view = linhas.iter().enumerate().map(|(idx, linha)| { let linhas_view = linhas.iter().enumerate().map(|(idx, linha)| {
// Linhas alternadas: SURFACE e SURFACE_2 levemente // Linhas alternadas: SURFACE e SURFACE_2 levemente
@@ -63,7 +55,7 @@ pub fn tabela_preview(linhas: &[Vec<String>]) -> Element<'_, Message> {
celula.to_string() celula.to_string()
}; };
container(text(truncado).font(Font::MONOSPACE).size(11).color(t::TEXT)) container(text(truncado).font(Font::MONOSPACE).size(11).color(t::TEXT))
.width(Length::Fixed(120.0)) .width(Length::Fixed(100.0))
.padding([3, 6]) .padding([3, 6])
.into() .into()
}) })
@@ -90,7 +82,7 @@ pub fn tabela_preview(linhas: &[Vec<String>]) -> Element<'_, Message> {
.direction(scrollable::Direction::Horizontal( .direction(scrollable::Direction::Horizontal(
scrollable::Scrollbar::default(), scrollable::Scrollbar::default(),
)) ))
.height(Length::Fixed(160.0)), .height(Length::Fixed(200.0)),
) )
.style(|_theme| iced::widget::container::Style { .style(|_theme| iced::widget::container::Style {
background: Some(t::SURFACE.into()), background: Some(t::SURFACE.into()),
+13 -5
View File
@@ -2,7 +2,7 @@ use crate::domain::entities::nota::Nota;
use crate::domain::entities::{ use crate::domain::entities::{
chave_serie::ChaveSerie, chave_serie::ChaveSerie,
layout::{Layout, LayoutXlsx}, layout::{Layout, LayoutXlsx},
resultado_analise::{ResultadoAnalise, ResultadoPreAnalise}, resultado_analise::{IntervaloSerie, ResultadoAnalise, ResultadoPreAnalise},
}; };
use crate::domain::errors::ResumoAvisos; use crate::domain::errors::ResumoAvisos;
use rusqlite::Connection; use rusqlite::Connection;
@@ -25,6 +25,12 @@ pub enum Message {
// --- Arquivo --- // --- Arquivo ---
SelecionarArquivo, SelecionarArquivo,
ArquivoSelecionado(PathBuf), 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), AbaSelecionada(String),
// --- XLSX: abas carregadas em background --- // --- XLSX: abas carregadas em background ---
@@ -67,12 +73,11 @@ pub enum Message {
ExecutarImportacao, ExecutarImportacao,
ReanalisarArquivo, ReanalisarArquivo,
ConfirmarExpansaoFaltantes, ConfirmarExpansaoFaltantes,
CancelarExpansao,
NovaAnalise, NovaAnalise,
// --- Resultado --- // --- Resultado ---
PaginaFaltantesAlterada(usize), PaginaFaltantesAlterada(ChaveSerie, usize),
PaginaDuplicatasAlterada(usize), PaginaDuplicatasAlterada(ChaveSerie, usize),
ItensPorPaginaAlterado(usize), ItensPorPaginaAlterado(usize),
CopiarFaltantes(ChaveSerie), CopiarFaltantes(ChaveSerie),
CopiarDuplicatas(ChaveSerie), CopiarDuplicatas(ChaveSerie),
@@ -97,6 +102,9 @@ pub enum Message {
// --- Sem operação (used as fallback) --- // --- Sem operação (used as fallback) ---
Noop, Noop,
// --- Animação ---
SpinnerTick,
} }
/// Resultado enviado pela task de análise em background para a UI. /// Resultado enviado pela task de análise em background para a UI.
@@ -111,7 +119,7 @@ pub enum ResultadoPendente {
/// Pré-análise concluída mas precisa de confirmação do usuário. /// Pré-análise concluída mas precisa de confirmação do usuário.
AguardandoConfirmacao { AguardandoConfirmacao {
pre: ResultadoPreAnalise, pre: ResultadoPreAnalise,
series_excessivas: Vec<(ChaveSerie, u64)>, series_excessivas: Vec<(ChaveSerie, IntervaloSerie)>,
avisos: ResumoAvisos, avisos: ResumoAvisos,
notas: Vec<Nota>, notas: Vec<Nota>,
}, },
+20 -20
View File
@@ -59,7 +59,7 @@ pub fn view(app: &App) -> Element<'_, Message> {
text("Layout:") text("Layout:")
.size(13) .size(13)
.color(t::TEXT_SECONDARY) .color(t::TEXT_SECONDARY)
.width(Length::Fixed(80.0)), .width(Length::Shrink),
pick_list(opcoes_layout, nome_layout_sel, { pick_list(opcoes_layout, nome_layout_sel, {
let layouts = app.layouts_salvos.clone(); let layouts = app.layouts_salvos.clone();
move |nome_selecionado: String| { move |nome_selecionado: String| {
@@ -76,7 +76,7 @@ pub fn view(app: &App) -> Element<'_, Message> {
}) })
.placeholder("— Selecionar layout —") .placeholder("— Selecionar layout —")
.style(input_style_pick) .style(input_style_pick)
.width(Length::Fixed(260.0)), .width(Length::Fill),
] ]
.spacing(8) .spacing(8)
.align_y(Alignment::Center), .align_y(Alignment::Center),
@@ -120,7 +120,7 @@ pub fn view(app: &App) -> Element<'_, Message> {
Column::with_children( Column::with_children(
erros erros
.iter() .iter()
.map(|e| text(format!(" {}", e)).size(13).color(t::DANGER).into()) .map(|e| text(format!("(!) {}", e)).size(13).color(t::DANGER).into())
.collect::<Vec<_>>(), .collect::<Vec<_>>(),
) )
.spacing(4), .spacing(4),
@@ -137,19 +137,19 @@ pub fn view(app: &App) -> Element<'_, Message> {
let botoes = container( let botoes = container(
row![ row![
button(text("◀ Voltar").size(13)) button(text("Voltar").size(13))
.on_press(Message::Voltar) .on_press(Message::Voltar)
.style(t::btn_ghost) .style(t::btn_ghost)
.padding([9, 14]), .padding([9, 14]),
button(text("Importar e Analisar").size(13)) button(text("Importar e Analisar").size(13))
.on_press_maybe((valido && tem_arquivo).then_some(Message::ExecutarImportacao)) .on_press_maybe((valido && tem_arquivo).then_some(Message::ExecutarImportacao))
.style(t::btn_primary) .style(t::btn_primary)
.padding([9, 14]), .padding([9, 14]),
button(text("🔄 Reanalisar").size(13)) button(text("Reanalisar").size(13))
.on_press_maybe((valido && tem_notas).then_some(Message::ReanalisarArquivo)) .on_press_maybe((valido && tem_notas).then_some(Message::ReanalisarArquivo))
.style(t::btn_secondary) .style(t::btn_secondary)
.padding([9, 14]), .padding([9, 14]),
button(text("💾 Salvar layout").size(13)) button(text("Salvar layout").size(13))
.on_press(Message::SalvarLayout) .on_press(Message::SalvarLayout)
.style(t::btn_ghost) .style(t::btn_ghost)
.padding([9, 14]), .padding([9, 14]),
@@ -251,7 +251,7 @@ fn view_csv(app: &App) -> Element<'_, Message> {
Message::DelimitadorAlterado(c) Message::DelimitadorAlterado(c)
}) })
.style(input_style_pick) .style(input_style_pick)
.width(Length::Fixed(200.0)) .width(Length::Fill)
.into(), .into(),
), ),
campo_row( campo_row(
@@ -262,7 +262,7 @@ fn view_csv(app: &App) -> Element<'_, Message> {
Message::EncodingAlterado Message::EncodingAlterado
) )
.style(input_style_pick) .style(input_style_pick)
.width(Length::Fixed(200.0)) .width(Length::Fill)
.into(), .into(),
), ),
campo_row( campo_row(
@@ -274,7 +274,7 @@ fn view_csv(app: &App) -> Element<'_, Message> {
.unwrap_or(Message::Noop) .unwrap_or(Message::Noop)
}) })
.style(t::input_dark) .style(t::input_dark)
.width(Length::Fixed(80.0)) .width(Length::Fixed(90.0))
.into(), .into(),
), ),
secao_subtitulo("Mapeamento de colunas (índice base 0)"), secao_subtitulo("Mapeamento de colunas (índice base 0)"),
@@ -287,7 +287,7 @@ fn view_csv(app: &App) -> Element<'_, Message> {
.unwrap_or(Message::Noop) .unwrap_or(Message::Noop)
}) })
.style(t::input_dark) .style(t::input_dark)
.width(Length::Fixed(80.0)) .width(Length::Fixed(90.0))
.into(), .into(),
), ),
campo_row( campo_row(
@@ -299,7 +299,7 @@ fn view_csv(app: &App) -> Element<'_, Message> {
.unwrap_or(Message::Noop) .unwrap_or(Message::Noop)
}) })
.style(t::input_dark) .style(t::input_dark)
.width(Length::Fixed(80.0)) .width(Length::Fixed(90.0))
.into(), .into(),
), ),
campo_indice_opcional_csv( campo_indice_opcional_csv(
@@ -341,7 +341,7 @@ fn view_xlsx(app: &App) -> Element<'_, Message> {
text_input("Nome da aba", &c.aba) text_input("Nome da aba", &c.aba)
.on_input(Message::AbaXlsxAlterada) .on_input(Message::AbaXlsxAlterada)
.style(t::input_dark) .style(t::input_dark)
.width(Length::Fixed(200.0)) .width(Length::Fill)
.into(), .into(),
) )
} else { } else {
@@ -354,7 +354,7 @@ fn view_xlsx(app: &App) -> Element<'_, Message> {
"Aba", "Aba",
pick_list(app.abas_xlsx.clone(), aba_sel, Message::AbaXlsxAlterada) pick_list(app.abas_xlsx.clone(), aba_sel, Message::AbaXlsxAlterada)
.style(input_style_pick) .style(input_style_pick)
.width(Length::Fixed(200.0)) .width(Length::Fill)
.into(), .into(),
) )
}; };
@@ -416,7 +416,7 @@ fn campo_row<'a>(label: &'a str, input: Element<'a, Message>) -> Element<'a, Mes
text(label) text(label)
.size(13) .size(13)
.color(t::TEXT_SECONDARY) .color(t::TEXT_SECONDARY)
.width(Length::Fixed(220.0)), .width(Length::FillPortion(3)),
input, input,
] ]
.spacing(10) .spacing(10)
@@ -437,11 +437,11 @@ fn campo_indice_opcional_csv(
if ativo { if ativo {
row![ row![
cb.width(Length::Fixed(220.0)), cb.width(Length::FillPortion(3)),
text_input("0", &val_str) text_input("0", &val_str)
.on_input(move |s| { s.parse::<usize>().map(&msg_valor).unwrap_or(Message::Noop) }) .on_input(move |s| { s.parse::<usize>().map(&msg_valor).unwrap_or(Message::Noop) })
.style(t::input_dark) .style(t::input_dark)
.width(Length::Fixed(80.0)), .width(Length::Fixed(90.0)),
] ]
.spacing(10) .spacing(10)
.align_y(Alignment::Center) .align_y(Alignment::Center)
@@ -461,7 +461,7 @@ fn campo_letra_linha<'a>(
text_input("ex: B3", valor) text_input("ex: B3", valor)
.on_input(msg) .on_input(msg)
.style(t::input_dark) .style(t::input_dark)
.width(Length::Fixed(100.0)) .width(Length::Fixed(110.0))
.into(), .into(),
) )
} }
@@ -479,11 +479,11 @@ fn campo_letra_linha_opcional<'a>(
if ativo { if ativo {
row![ row![
cb.width(Length::Fixed(220.0)), cb.width(Length::FillPortion(3)),
text_input("ex: B3", &val_str) text_input("ex: B3", &val_str)
.on_input(msg_valor) .on_input(msg_valor)
.style(t::input_dark) .style(t::input_dark)
.width(Length::Fixed(100.0)), .width(Length::Fixed(110.0)),
] ]
.spacing(10) .spacing(10)
.align_y(Alignment::Center) .align_y(Alignment::Center)
+47 -11
View File
@@ -1,7 +1,7 @@
use crate::ui::app::App; use crate::ui::app::App;
use crate::ui::message::Message; use crate::ui::message::Message;
use crate::ui::theme as t; use crate::ui::theme as t;
use iced::widget::{button, column, container, pick_list, row, text}; use iced::widget::{button, column, container, pick_list, row, scrollable, text};
use iced::{Alignment, Element, Length}; use iced::{Alignment, Element, Length};
/// Tela de importação de arquivos. /// Tela de importação de arquivos.
@@ -13,12 +13,43 @@ pub fn view(app: &App) -> Element<'_, Message> {
(app.nome_arquivo.clone(), true) (app.nome_arquivo.clone(), true)
}; };
let icone_arquivo: Element<Message> = text(if tem_arquivo { "📄" } else { "📂" }) let em_hover = app.arquivo_em_hover;
.size(32)
let icone_arquivo: Element<Message> = container(
text(if tem_arquivo { "CSV / XLSX" } else { "Arquivo" })
.size(12)
.color(if tem_arquivo { .color(if tem_arquivo {
t::PRIMARY t::PRIMARY
} else { } else {
t::TEXT_MUTED 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(); .into();
@@ -35,7 +66,7 @@ pub fn view(app: &App) -> Element<'_, Message> {
.into() .into()
} else { } else {
column![ column![
text("Selecione um arquivo CSV ou XLSX") text("Selecione ou arraste um arquivo CSV ou XLSX")
.size(14) .size(14)
.color(t::TEXT_SECONDARY), .color(t::TEXT_SECONDARY),
text("Suportado: .csv, .xlsx, .xls") text("Suportado: .csv, .xlsx, .xls")
@@ -56,13 +87,18 @@ pub fn view(app: &App) -> Element<'_, Message> {
.style(move |_theme| iced::widget::container::Style { .style(move |_theme| iced::widget::container::Style {
background: Some( background: Some(
iced::Color { iced::Color {
a: 0.05, a: if em_hover { 0.12 } else { 0.05 },
..t::PRIMARY ..t::PRIMARY
} }
.into(), .into(),
), ),
border: iced::Border { border: iced::Border {
color: if tem_arquivo { color: if em_hover {
iced::Color {
a: 0.9,
..t::PRIMARY
}
} else if tem_arquivo {
iced::Color { iced::Color {
a: 0.5, a: 0.5,
..t::PRIMARY ..t::PRIMARY
@@ -73,7 +109,7 @@ pub fn view(app: &App) -> Element<'_, Message> {
..t::BORDER ..t::BORDER
} }
}, },
width: 1.5, width: if em_hover { 2.0 } else { 1.5 },
radius: 8.0.into(), radius: 8.0.into(),
}, },
..Default::default() ..Default::default()
@@ -110,7 +146,7 @@ pub fn view(app: &App) -> Element<'_, Message> {
text("Layout salvo:") text("Layout salvo:")
.size(13) .size(13)
.color(t::TEXT_SECONDARY) .color(t::TEXT_SECONDARY)
.width(Length::Fixed(110.0)), .width(Length::Shrink),
pick_list(opcoes_layout, nome_layout_sel, { pick_list(opcoes_layout, nome_layout_sel, {
let layouts = app.layouts_salvos.clone(); let layouts = app.layouts_salvos.clone();
move |nome_selecionado: String| { move |nome_selecionado: String| {
@@ -137,7 +173,7 @@ pub fn view(app: &App) -> Element<'_, Message> {
// ── Botão avançar ───────────────────────────────────────────────────────── // ── Botão avançar ─────────────────────────────────────────────────────────
let botao_avancar: Element<Message> = if tem_arquivo { let botao_avancar: Element<Message> = if tem_arquivo {
button(row![text("Configurar Colunas").size(14)].align_y(Alignment::Center)) button(row![text("Configurar Colunas").size(14)].align_y(Alignment::Center))
.on_press(Message::IrParaConfiguracaoColunas) .on_press(Message::IrParaConfiguracaoColunas)
.style(t::btn_primary) .style(t::btn_primary)
.width(Length::Fill) .width(Length::Fill)
@@ -173,7 +209,7 @@ pub fn view(app: &App) -> Element<'_, Message> {
.width(Length::Fill); .width(Length::Fill);
// ── Layout geral ────────────────────────────────────────────────────────── // ── Layout geral ──────────────────────────────────────────────────────────
container( container(scrollable(
column![ column![
text("Importar Arquivo").size(20).color(t::TEXT), text("Importar Arquivo").size(20).color(t::TEXT),
text("Selecione e configure sua planilha para análise") text("Selecione e configure sua planilha para análise")
@@ -185,7 +221,7 @@ pub fn view(app: &App) -> Element<'_, Message> {
.align_x(Alignment::Center) .align_x(Alignment::Center)
.padding([32, 20]) .padding([32, 20])
.width(Length::Fill), .width(Length::Fill),
) ))
.style(t::fundo) .style(t::fundo)
.width(Length::Fill) .width(Length::Fill)
.height(Length::Fill) .height(Length::Fill)
+1 -1
View File
@@ -8,7 +8,7 @@ use iced::{Alignment, Element, Length};
/// Tela de gerenciamento de layouts. /// Tela de gerenciamento de layouts.
pub fn view(app: &App) -> Element<'_, Message> { pub fn view(app: &App) -> Element<'_, Message> {
let cabecalho = row![ let cabecalho = row![
button("< Voltar") button("Voltar")
.on_press(Message::IrParaImportacao) .on_press(Message::IrParaImportacao)
.style(t::btn_secondary), .style(t::btn_secondary),
horizontal_space(), horizontal_space(),
+82 -21
View File
@@ -29,24 +29,29 @@ pub fn view<'a>(app: &'a App, resultado: &'a ResultadoAnalise) -> Element<'a, Me
// ── Botões de ação ──────────────────────────────────────────────────────── // ── Botões de ação ────────────────────────────────────────────────────────
let botoes_topo = row![ let botoes_topo = row![
button("< Nova Análise") button("Nova Análise")
.on_press(Message::NovaAnalise) .on_press(Message::NovaAnalise)
.style(t::btn_secondary), .style(t::btn_secondary)
button("Reconfigurar Colunas") .width(Length::Shrink),
button("Reconfigurar")
.on_press(Message::IrParaConfiguracaoColunas) .on_press(Message::IrParaConfiguracaoColunas)
.style(t::btn_ghost), .style(t::btn_ghost)
button("Reanalisar Arquivo") .width(Length::Shrink),
button("Reanalisar")
.on_press_maybe( .on_press_maybe(
app.caminho_arquivo app.caminho_arquivo
.as_ref() .as_ref()
.map(|_| Message::ReanalisarArquivo) .map(|_| Message::ReanalisarArquivo)
) )
.style(t::btn_ghost), .style(t::btn_ghost)
.width(Length::Shrink),
button("Exportar PDF") button("Exportar PDF")
.on_press(Message::ExportarPdf) .on_press(Message::ExportarPdf)
.style(t::btn_primary), .style(t::btn_primary)
.width(Length::Shrink),
] ]
.spacing(8); .spacing(8)
.wrap();
// ── Controle de itens por página ────────────────────────────────────────── // ── Controle de itens por página ──────────────────────────────────────────
let opcoes_por_pagina = row(OPCOES_PAGINA let opcoes_por_pagina = row(OPCOES_PAGINA
@@ -77,20 +82,43 @@ pub fn view<'a>(app: &'a App, resultado: &'a ResultadoAnalise) -> Element<'a, Me
let secao_duplicatas = view_duplicatas(app, resultado); let secao_duplicatas = view_duplicatas(app, resultado);
let secao_totais = view_totais(resultado); let secao_totais = view_totais(resultado);
let conteudo = column![ let mut col_principal = column![
row![text("Resultado da Análise").size(22).color(t::TEXT),], row![text("Resultado da Análise").size(22).color(t::TEXT),],
botoes_topo, botoes_topo,
stat_row, stat_row,
controle_pagina, controle_pagina,
secao_faltantes,
secao_duplicatas,
secao_totais,
] ]
.spacing(16) .spacing(16)
.padding(20) .padding(20)
.width(Length::Fill); .width(Length::Fill);
container(scrollable(conteudo)) // Badge "Sem inconsistências" quando não há faltantes nem duplicatas
if resultado.sem_inconsistencias() {
col_principal = col_principal.push(
container(
row![
container(text("").size(13).color(t::SUCCESS))
.padding([2, 8])
.style(t::badge_sucesso),
text("Sem inconsistências — todas as séries estão completas.")
.size(14)
.color(t::SUCCESS),
]
.spacing(8)
.align_y(Alignment::Center),
)
.padding([10, 16])
.width(Length::Fill)
.style(t::card),
);
}
col_principal = col_principal
.push(secao_faltantes)
.push(secao_duplicatas)
.push(secao_totais);
container(scrollable(col_principal))
.width(Length::Fill) .width(Length::Fill)
.height(Length::Fill) .height(Length::Fill)
.style(t::fundo) .style(t::fundo)
@@ -127,7 +155,16 @@ fn view_faltantes<'a>(app: &'a App, resultado: &'a ResultadoAnalise) -> Element<
let mut col = column![titulo_row].spacing(12); let mut col = column![titulo_row].spacing(12);
if total == 0 { if total == 0 {
col = col.push(text("Nenhuma nota faltante.").size(14).color(t::SUCCESS)); col = col.push(
row![
container(text(" OK ").size(12).color(t::SUCCESS))
.padding([2, 8])
.style(t::badge_sucesso),
text("Nenhuma nota faltante.").size(14).color(t::SUCCESS),
]
.spacing(8)
.align_y(Alignment::Center),
);
return container(col) return container(col)
.padding(16) .padding(16)
.width(Length::Fill) .width(Length::Fill)
@@ -140,11 +177,25 @@ fn view_faltantes<'a>(app: &'a App, resultado: &'a ResultadoAnalise) -> Element<
for chave in chaves { for chave in chaves {
let faltantes = &resultado.faltantes_por_serie[chave]; let faltantes = &resultado.faltantes_por_serie[chave];
let total_notas = resultado.total_por_serie.get(chave).copied().unwrap_or(0);
if faltantes.is_empty() { 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; continue;
} }
let total_notas = resultado.total_por_serie.get(chave).copied().unwrap_or(0);
let total_esperado = total_notas + faltantes.len(); let total_esperado = total_notas + faltantes.len();
let percentual = total_notas as f64 / total_esperado as f64; let percentual = total_notas as f64 / total_esperado as f64;
let percentual_f32 = percentual as f32; let percentual_f32 = percentual as f32;
@@ -184,7 +235,12 @@ fn view_faltantes<'a>(app: &'a App, resultado: &'a ResultadoAnalise) -> Element<
// Paginação e lista // Paginação e lista
let total_paginas = (faltantes.len() + app.itens_por_pagina - 1) / app.itens_por_pagina; let total_paginas = (faltantes.len() + app.itens_por_pagina - 1) / app.itens_por_pagina;
let pagina = app.pagina_faltantes.min(total_paginas.saturating_sub(1)); let pagina = app
.pagina_faltantes
.get(chave)
.copied()
.unwrap_or(0)
.min(total_paginas.saturating_sub(1));
let inicio = pagina * app.itens_por_pagina; let inicio = pagina * app.itens_por_pagina;
let fim = (inicio + app.itens_por_pagina).min(faltantes.len()); let fim = (inicio + app.itens_por_pagina).min(faltantes.len());
@@ -205,8 +261,8 @@ fn view_faltantes<'a>(app: &'a App, resultado: &'a ResultadoAnalise) -> Element<
col = col.push(crate::ui::components::paginacao::controles_paginacao( col = col.push(crate::ui::components::paginacao::controles_paginacao(
pagina, pagina,
total_paginas, total_paginas,
Message::PaginaFaltantesAlterada(pagina.saturating_sub(1)), Message::PaginaFaltantesAlterada(chave.clone(), pagina.saturating_sub(1)),
Message::PaginaFaltantesAlterada(pagina + 1), Message::PaginaFaltantesAlterada(chave.clone(), pagina + 1),
)); ));
} }
@@ -276,7 +332,12 @@ fn view_duplicatas<'a>(app: &'a App, resultado: &'a ResultadoAnalise) -> Element
col = col.push(cabecalho_serie); col = col.push(cabecalho_serie);
let total_paginas = (duplicatas.len() + app.itens_por_pagina - 1) / app.itens_por_pagina; let total_paginas = (duplicatas.len() + app.itens_por_pagina - 1) / app.itens_por_pagina;
let pagina = app.pagina_duplicatas.min(total_paginas.saturating_sub(1)); let pagina = app
.pagina_duplicatas
.get(chave)
.copied()
.unwrap_or(0)
.min(total_paginas.saturating_sub(1));
let inicio = pagina * app.itens_por_pagina; let inicio = pagina * app.itens_por_pagina;
let fim = (inicio + app.itens_por_pagina).min(duplicatas.len()); let fim = (inicio + app.itens_por_pagina).min(duplicatas.len());
@@ -299,8 +360,8 @@ fn view_duplicatas<'a>(app: &'a App, resultado: &'a ResultadoAnalise) -> Element
col = col.push(crate::ui::components::paginacao::controles_paginacao( col = col.push(crate::ui::components::paginacao::controles_paginacao(
pagina, pagina,
total_paginas, total_paginas,
Message::PaginaDuplicatasAlterada(pagina.saturating_sub(1)), Message::PaginaDuplicatasAlterada(chave.clone(), pagina.saturating_sub(1)),
Message::PaginaDuplicatasAlterada(pagina + 1), Message::PaginaDuplicatasAlterada(chave.clone(), pagina + 1),
)); ));
} }
+4 -4
View File
@@ -33,7 +33,7 @@ pub fn view<'a>(app: &'a App, abas: &'a [String], caminho: &'a PathBuf) -> Eleme
.map(|aba| { .map(|aba| {
let selecionada = aba == aba_atual; let selecionada = aba == aba_atual;
let label = row![ let label = row![
text(if selecionada { "" } else { "" }) text(if selecionada { ">>" } else { " " })
.size(12) .size(12)
.color(if selecionada { .color(if selecionada {
t::PRIMARY t::PRIMARY
@@ -117,7 +117,7 @@ pub fn view<'a>(app: &'a App, abas: &'a [String], caminho: &'a PathBuf) -> Eleme
let tem_preset = !app.nome_layout_atual.is_empty(); let tem_preset = !app.nome_layout_atual.is_empty();
let aba_selecionada = !aba_atual.is_empty(); let aba_selecionada = !aba_atual.is_empty();
let mut botoes = row![button(text("◀ Voltar").size(13)) let mut botoes = row![button(text("Voltar").size(13))
.on_press(Message::Voltar) .on_press(Message::Voltar)
.style(t::btn_ghost) .style(t::btn_ghost)
.padding([9, 14]),] .padding([9, 14]),]
@@ -126,7 +126,7 @@ pub fn view<'a>(app: &'a App, abas: &'a [String], caminho: &'a PathBuf) -> Eleme
if aba_selecionada && tem_preset { if aba_selecionada && tem_preset {
botoes = botoes.push( botoes = botoes.push(
button(text("Processar ▶").size(13)) button(text("Processar").size(13))
.on_press(Message::ExecutarImportacao) .on_press(Message::ExecutarImportacao)
.style(t::btn_primary) .style(t::btn_primary)
.padding([9, 14]), .padding([9, 14]),
@@ -135,7 +135,7 @@ pub fn view<'a>(app: &'a App, abas: &'a [String], caminho: &'a PathBuf) -> Eleme
if aba_selecionada { if aba_selecionada {
botoes = botoes.push( botoes = botoes.push(
button(text("Configurar Colunas").size(13)) button(text("Configurar Colunas").size(13))
.on_press(Message::IrParaConfiguracaoColunas) .on_press(Message::IrParaConfiguracaoColunas)
.style(t::btn_secondary) .style(t::btn_secondary)
.padding([9, 14]), .padding([9, 14]),
+51 -5
View File
@@ -288,8 +288,12 @@ pub fn btn_secondary(theme: &Theme, status: button::Status) -> button::Style {
background: Some(SURFACE_2.into()), background: Some(SURFACE_2.into()),
text_color: TEXT, text_color: TEXT,
border: Border { border: Border {
color: Color {
a: 0.55,
..TEXT_SECONDARY
},
width: 1.0,
radius: 6.0.into(), radius: 6.0.into(),
..Default::default()
}, },
..Default::default() ..Default::default()
}; };
@@ -304,6 +308,10 @@ pub fn btn_secondary(theme: &Theme, status: button::Status) -> button::Style {
} }
.into(), .into(),
), ),
border: Border {
color: TEXT_SECONDARY,
..base.border
},
..base ..base
}, },
button::Status::Disabled => button::Style { button::Status::Disabled => button::Style {
@@ -315,6 +323,10 @@ pub fn btn_secondary(theme: &Theme, status: button::Status) -> button::Style {
.into(), .into(),
), ),
text_color: Color { a: 0.4, ..TEXT }, text_color: Color { a: 0.4, ..TEXT },
border: Border {
color: Color { a: 0.2, ..BORDER },
..base.border
},
..base ..base
}, },
_ => { _ => {
@@ -324,13 +336,16 @@ pub fn btn_secondary(theme: &Theme, status: button::Status) -> button::Style {
} }
} }
/// Botão ghost (sem fundo, borda sutil) /// Botão ghost (sem fundo, borda visível)
pub fn btn_ghost(theme: &Theme, status: button::Status) -> button::Style { pub fn btn_ghost(theme: &Theme, status: button::Status) -> button::Style {
let base = button::Style { let base = button::Style {
background: Some(Color::TRANSPARENT.into()), background: Some(Color::TRANSPARENT.into()),
text_color: TEXT_SECONDARY, text_color: TEXT,
border: Border { border: Border {
color: BORDER, color: Color {
a: 0.55,
..TEXT_SECONDARY
},
width: 1.0, width: 1.0,
radius: 6.0.into(), radius: 6.0.into(),
}, },
@@ -340,12 +355,16 @@ pub fn btn_ghost(theme: &Theme, status: button::Status) -> button::Style {
button::Status::Hovered => button::Style { button::Status::Hovered => button::Style {
background: Some(Color { a: 0.08, ..PRIMARY }.into()), background: Some(Color { a: 0.08, ..PRIMARY }.into()),
text_color: TEXT, text_color: TEXT,
border: Border {
color: PRIMARY,
..base.border
},
..base ..base
}, },
button::Status::Disabled => button::Style { button::Status::Disabled => button::Style {
text_color: TEXT_MUTED, text_color: TEXT_MUTED,
border: Border { border: Border {
color: Color { a: 0.3, ..BORDER }, color: Color { a: 0.2, ..BORDER },
..base.border ..base.border
}, },
..base ..base
@@ -381,6 +400,33 @@ pub fn btn_danger(theme: &Theme, status: button::Status) -> button::Style {
} }
} }
/// Botão de passo anterior no breadcrumb (clicável, mas sem destaque)
pub fn btn_breadcrumb_visitado(_theme: &Theme, status: button::Status) -> button::Style {
let base = button::Style {
background: None,
text_color: TEXT_SECONDARY,
border: Border {
color: Color::TRANSPARENT,
width: 0.0,
radius: 4.0.into(),
},
..Default::default()
};
match status {
button::Status::Hovered => button::Style {
background: Some(Color { a: 0.08, ..TEXT }.into()),
text_color: TEXT,
border: Border {
color: Color { a: 0.15, ..TEXT },
width: 1.0,
radius: 4.0.into(),
},
..base
},
_ => base,
}
}
/// Botão de aba selecionada (tela selecionar_aba) /// Botão de aba selecionada (tela selecionar_aba)
pub fn btn_aba_ativa(theme: &Theme, status: button::Status) -> button::Style { pub fn btn_aba_ativa(theme: &Theme, status: button::Status) -> button::Style {
let base = button::Style { let base = button::Style {