Compare commits
10
Commits
ad09c53a5d
...
change-ui
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
4c4f573cd1 | ||
|
|
857fcd6a82 | ||
|
|
841dfadde5 | ||
|
|
3af14ab957 | ||
|
|
06eca335db | ||
|
|
6a5d941a00 | ||
|
|
5745a78a5a | ||
|
|
1f4ebb4686 | ||
|
|
007f6c818e | ||
|
|
d06110240f |
@@ -1 +1,2 @@
|
||||
/target
|
||||
nul
|
||||
|
||||
@@ -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
@@ -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 60–89% |
|
||||
| `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`
|
||||
- 60–89% → `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.0–1.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.
|
||||
|
||||
## 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 (2–3h)
|
||||
**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
|
||||
|
||||
@@ -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 (2–3h)
|
||||
**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)
|
||||
|
||||
**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
|
||||
|
||||
| ID | Feature | Esforço | Impacto | Prioridade |
|
||||
|---|---|---|---|---|
|
||||
| F-01 | Exportar resultado em CSV | Médio | Alto | Alta |
|
||||
| F-02 | Validação de tamanho de arquivo | Baixo | Médio | Alta |
|
||||
| ID | Feature | Esforço | Impacto | Prioridade | Status |
|
||||
|---|---|---|---|---|---|
|
||||
| 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-08 | Auto-detecção de encoding CSV | Médio | Baixo | Baixa |
|
||||
| F-09 | Agrupamento de faltantes no PDF | Baixo | Baixo-Médio | Baixa |
|
||||
@@ -0,0 +1,205 @@
|
||||
# Application — AGENTS.md
|
||||
|
||||
Camada de aplicação do projeto `comparador-notas`. Orquestra os serviços de domínio
|
||||
e a infraestrutura para expor casos de uso coesos à camada de UI (Tauri/frontend).
|
||||
Não contém regras de negócio próprias; delega ao `domain` e ao `infrastructure`.
|
||||
|
||||
---
|
||||
|
||||
## Estrutura dos arquivos
|
||||
|
||||
```
|
||||
src/application/
|
||||
├── mod.rs
|
||||
└── usecases/
|
||||
├── mod.rs
|
||||
├── executar_analise.rs # Análise de sequência e duplicatas
|
||||
├── exportar_pdf.rs # Exportação de relatório PDF
|
||||
├── importar_arquivo.rs # Importação de CSV e XLSX → Vec<Nota>
|
||||
└── layouts.rs # CRUD e import/export JSON de layouts
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## usecases/mod.rs
|
||||
|
||||
Re-exporta os quatro módulos de casos de uso:
|
||||
|
||||
```rust
|
||||
pub mod executar_analise;
|
||||
pub mod exportar_pdf;
|
||||
pub mod importar_arquivo;
|
||||
pub mod layouts;
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## executar_analise.rs
|
||||
|
||||
Orquestra a análise em **duas etapas** para lidar com grandes intervalos de
|
||||
faltantes sem travar a UI (regra RF04).
|
||||
|
||||
### Etapa 1 — `pre_analisar(notas) -> ResultadoPreAnalise`
|
||||
|
||||
1. Agrupa as notas por `ChaveSerie` (`serie` + `documento_tipo`).
|
||||
2. Para cada grupo:
|
||||
- Acumula soma total e por série.
|
||||
- Chama `calcular_intervalo` (sem materializar faltantes).
|
||||
3. Detecta duplicidades via `duplicidades_por_serie`.
|
||||
4. Retorna `ResultadoPreAnalise` com intervalos, duplicadas e somas.
|
||||
|
||||
### Verificação intermediária — `series_com_intervalo_excessivo(pre) -> Vec<(ChaveSerie, IntervaloSerie)>`
|
||||
|
||||
Filtra as séries cujo `contagem_faltantes > LIMITE_FALTANTES` (10.000).
|
||||
O caller (UI) deve exibir confirmação ao usuário se a lista não for vazia.
|
||||
|
||||
### Etapa 2 — `expandir_analise(pre, notas) -> ResultadoAnalise`
|
||||
|
||||
Materializa a lista completa de faltantes via `detectar_faltantes` e combina
|
||||
com os dados já calculados na pré-análise (duplicadas, somas, totais).
|
||||
|
||||
### Fluxo de uso
|
||||
|
||||
```
|
||||
pre_analisar(notas)
|
||||
└─ ResultadoPreAnalise
|
||||
|
||||
series_com_intervalo_excessivo(&pre)
|
||||
├─ [] → chamar expandir_analise diretamente
|
||||
└─ [...] → exibir diálogo de confirmação na UI
|
||||
└─ confirmado → expandir_analise(pre, notas)
|
||||
|
||||
expandir_analise(pre, notas)
|
||||
└─ ResultadoAnalise (com faltantes materializados)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## exportar_pdf.rs
|
||||
|
||||
### `exportar_pdf(gerador, resultado, notas, nome_arquivo, nome_layout, caminho_saida) -> Result<(), String>`
|
||||
|
||||
Caso de uso simples que:
|
||||
|
||||
1. Constrói `MetadadosRelatorio` com `nome_arquivo`, `nome_layout` e timestamp
|
||||
`Local::now()`.
|
||||
2. Delega a geração para `gerador.gerar(...)` via a trait abstrata `PdfGenerator`.
|
||||
|
||||
A dependência em `&dyn PdfGenerator` (e não em `GenpdfGenerator` diretamente)
|
||||
mantém o use case desacoplado da implementação concreta e facilita testes.
|
||||
|
||||
---
|
||||
|
||||
## importar_arquivo.rs
|
||||
|
||||
Converte arquivos brutos (CSV ou XLSX) em `Vec<Nota>` prontas para análise,
|
||||
acumulando avisos não-fatais em `ResumoAvisos`.
|
||||
|
||||
### Tipos de saída
|
||||
|
||||
```rust
|
||||
pub struct ResultadoImportacao {
|
||||
pub notas: Vec<Nota>,
|
||||
pub avisos: ResumoAvisos,
|
||||
}
|
||||
|
||||
pub struct InfoXlsx {
|
||||
pub abas: Vec<String>,
|
||||
}
|
||||
```
|
||||
|
||||
### `listar_abas_xlsx(caminho) -> Result<InfoXlsx, ErroArquivo>`
|
||||
|
||||
Delega para `xlsx_reader::listar_abas`. Retorna `InfoXlsx` com os nomes das
|
||||
abas para que a UI permita ao usuário selecionar a aba correta.
|
||||
|
||||
### `importar_csv(caminho, config: &LayoutCsv) -> Result<ResultadoImportacao, ErroArquivo>`
|
||||
|
||||
1. Chama `csv_reader::ler_csv` com os parâmetros do layout.
|
||||
2. Passa as linhas brutas para `mapear_linhas_para_notas`.
|
||||
|
||||
### `importar_xlsx(caminho, config: &LayoutXlsx) -> Result<ResultadoImportacao, ErroArquivo>`
|
||||
|
||||
1. Converte `pos_numero` e `pos_serie` (LetraLinha) para coordenadas.
|
||||
2. Determina `linha_inicio` como o mínimo entre as linhas das duas posições.
|
||||
3. Chama `xlsx_reader::ler_xlsx`.
|
||||
4. Converte posições de todos os campos mapeados para índices de coluna (base 0).
|
||||
5. Passa as linhas brutas para `mapear_linhas_para_notas`.
|
||||
|
||||
### `mapear_linhas_para_notas(...)` (privada)
|
||||
|
||||
Função central de mapeamento. Para cada linha:
|
||||
|
||||
1. **Validação de índices** (na primeira linha disponível):
|
||||
- Campos obrigatórios (`Numero`, `Serie`): retorna `Err` se o índice não existe.
|
||||
- Campos opcionais (`Valor`, `Data`, `Tipo de Documento`): retorna `Err` se
|
||||
configurado com índice fora dos limites.
|
||||
2. **Numero**: tenta `parse::<u64>` direto; se falhar, extrai apenas dígitos.
|
||||
Rejeita zero. Incrementa `avisos.numeros_invalidos` e pula a linha se inválido.
|
||||
3. **Serie**: valida via `domain::entities::serie::validar_serie`. Pula linha se inválida.
|
||||
4. **Valor** (opcional): faz `parse_valor`; se inválido, registra em
|
||||
`avisos.valores_invalidos` e usa `None` (não descarta a linha).
|
||||
5. **Data** (opcional): tenta `dd/mm/aaaa`, `aaaa-mm-dd` e `dd-mm-aaaa`. `None`
|
||||
se nenhum formato casar (não gera aviso).
|
||||
6. **Tipo de Documento** (opcional): qualquer string não vazia.
|
||||
|
||||
### `parse_numero(s) -> Result<u64, String>` (privada)
|
||||
|
||||
- Tenta `s.parse::<u64>()` diretamente.
|
||||
- Se falhar, extrai apenas dígitos ASCII e tenta novamente.
|
||||
- Rejeita zero em ambos os casos.
|
||||
|
||||
### `parse_data(s) -> Option<NaiveDate>` (privada)
|
||||
|
||||
Tenta os formatos `%d/%m/%Y`, `%Y-%m-%d` e `%d-%m-%Y` nessa ordem.
|
||||
|
||||
---
|
||||
|
||||
## layouts.rs
|
||||
|
||||
CRUD de layouts sobre o banco SQLite e import/export em JSON.
|
||||
|
||||
### `salvar_layout(conn, layout) -> Result<i64, ErroLayout>`
|
||||
|
||||
- Valida que `nome` não está vazio.
|
||||
- Se `layout.id()` é `Some` → chama `layout_repository::atualizar` e retorna o id.
|
||||
- Se `None` → verifica conflito de nome via `existe_nome`; se existir, retorna
|
||||
`ErroLayout::NomeConflitante`; caso contrário, insere e retorna o novo id.
|
||||
|
||||
### `listar_layouts(conn) -> Result<Vec<Layout>, String>`
|
||||
|
||||
Delega para `layout_repository::listar`. Retorna todos os layouts ordenados
|
||||
por nome.
|
||||
|
||||
### `excluir_layout(conn, id) -> Result<(), String>`
|
||||
|
||||
Delega para `layout_repository::excluir`.
|
||||
|
||||
### `exportar_layout_json(layout) -> Result<(String, String), String>`
|
||||
|
||||
1. Converte `Layout` para `LayoutJson` via `From<&Layout>`.
|
||||
2. Serializa com `serde_json::to_string_pretty`.
|
||||
3. Retorna `(conteúdo_json, nome_arquivo_sugerido)` onde o nome é `"{nome}.json"`.
|
||||
|
||||
### `importar_layout_json(conn, json, sobrescrever_se_existir, novo_nome) -> Result<i64, ErroLayout>`
|
||||
|
||||
1. Desserializa `json` para `LayoutJson`.
|
||||
2. Converte para `Layout` via `TryFrom` (valida campos obrigatórios).
|
||||
3. Aplica `novo_nome` se fornecido (mutação direta no enum).
|
||||
4. Verifica conflito de nome:
|
||||
- Se existe e `sobrescrever_se_existir == true`: busca o id existente,
|
||||
injeta no layout e chama `atualizar`.
|
||||
- Se existe e `false`: retorna `ErroLayout::NomeConflitante`.
|
||||
5. Se não existe: chama `layout_repository::salvar`.
|
||||
|
||||
---
|
||||
|
||||
## Dependencias de outros módulos
|
||||
|
||||
```
|
||||
application::usecases
|
||||
├─ domain::entities::{chave_serie, nota, resultado_analise, layout, serie}
|
||||
├─ domain::services::{detector_duplicidade, detector_sequencia, parser_monetario}
|
||||
├─ domain::errors::{ErroArquivo, ErroLayout, ResumoAvisos}
|
||||
└─ infrastructure::{csv_reader, xlsx_reader, pdf_generator, sqlite::layout_repository}
|
||||
```
|
||||
@@ -2,7 +2,7 @@ use crate::domain::{
|
||||
entities::{
|
||||
chave_serie::ChaveSerie,
|
||||
nota::Nota,
|
||||
resultado_analise::{ResultadoAnalise, ResultadoPreAnalise},
|
||||
resultado_analise::{IntervaloSerie, ResultadoAnalise, ResultadoPreAnalise},
|
||||
},
|
||||
services::{
|
||||
detector_duplicidade::duplicidades_por_serie,
|
||||
@@ -56,12 +56,14 @@ pub fn pre_analisar(notas: &[Nota]) -> ResultadoPreAnalise {
|
||||
}
|
||||
|
||||
/// Verifica se algum grupo excede o limite de faltantes.
|
||||
/// Retorna lista de chaves que precisam de confirmação.
|
||||
pub fn series_com_intervalo_excessivo(pre: &ResultadoPreAnalise) -> Vec<(ChaveSerie, u64)> {
|
||||
/// Retorna lista de chaves com o intervalo completo para exibição ao usuário.
|
||||
pub fn series_com_intervalo_excessivo(
|
||||
pre: &ResultadoPreAnalise,
|
||||
) -> Vec<(ChaveSerie, IntervaloSerie)> {
|
||||
pre.intervalos_por_serie
|
||||
.iter()
|
||||
.filter(|(_, iv)| iv.excede_limite(LIMITE_FALTANTES))
|
||||
.map(|(chave, iv)| (chave.clone(), iv.contagem_faltantes))
|
||||
.map(|(chave, iv)| (chave.clone(), iv.clone()))
|
||||
.collect()
|
||||
}
|
||||
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
use crate::domain::entities::nota::Nota;
|
||||
use crate::domain::entities::resultado_analise::ResultadoAnalise;
|
||||
use crate::infrastructure::pdf_generator::{MetadadosRelatorio, PdfGenerator};
|
||||
use chrono::Local;
|
||||
@@ -9,6 +10,7 @@ use std::path::Path;
|
||||
pub fn exportar_pdf(
|
||||
gerador: &dyn PdfGenerator,
|
||||
resultado: &ResultadoAnalise,
|
||||
notas: &[Nota],
|
||||
nome_arquivo: &str,
|
||||
nome_layout: Option<&str>,
|
||||
caminho_saida: &Path,
|
||||
@@ -19,5 +21,5 @@ pub fn exportar_pdf(
|
||||
gerado_em: Local::now(),
|
||||
};
|
||||
|
||||
gerador.gerar(resultado, &meta, caminho_saida)
|
||||
gerador.gerar(resultado, notas, &meta, caminho_saida)
|
||||
}
|
||||
|
||||
@@ -44,7 +44,7 @@ pub fn importar_csv(
|
||||
config.indice_valor,
|
||||
config.indice_data,
|
||||
&mut avisos,
|
||||
);
|
||||
)?;
|
||||
|
||||
Ok(ResultadoImportacao { notas, avisos })
|
||||
}
|
||||
@@ -102,13 +102,16 @@ pub fn importar_xlsx(
|
||||
col_valor,
|
||||
col_data,
|
||||
&mut avisos,
|
||||
);
|
||||
)?;
|
||||
|
||||
Ok(ResultadoImportacao { notas, avisos })
|
||||
}
|
||||
|
||||
/// Converte linhas brutas (Vec<Vec<String>>) em notas fiscais,
|
||||
/// aplicando validações e coletando avisos.
|
||||
///
|
||||
/// Retorna `Err` se algum índice obrigatório ou opcional ativo excede o número de
|
||||
/// colunas da primeira linha, identificando qual campo está inválido.
|
||||
fn mapear_linhas_para_notas(
|
||||
linhas: &[Vec<String>],
|
||||
idx_numero: usize,
|
||||
@@ -117,9 +120,38 @@ fn mapear_linhas_para_notas(
|
||||
idx_valor: Option<usize>,
|
||||
idx_data: Option<usize>,
|
||||
avisos: &mut ResumoAvisos,
|
||||
) -> Vec<Nota> {
|
||||
) -> Result<Vec<Nota>, ErroArquivo> {
|
||||
let mut notas = Vec::new();
|
||||
|
||||
// Verificar se os índices existem na primeira linha disponível
|
||||
if let Some(primeira_linha) = linhas.first() {
|
||||
let total_colunas = primeira_linha.len();
|
||||
let obrigatorios: &[(&str, usize)] = &[("Numero", idx_numero), ("Serie", idx_serie)];
|
||||
for (nome, idx) in obrigatorios {
|
||||
if *idx >= total_colunas {
|
||||
return Err(ErroArquivo::ErroLeitura(format!(
|
||||
"Campo '{}': índice {} não existe — o arquivo tem {} coluna(s).",
|
||||
nome, idx, total_colunas
|
||||
)));
|
||||
}
|
||||
}
|
||||
let opcionais: &[(&str, Option<usize>)] = &[
|
||||
("Valor", idx_valor),
|
||||
("Data", idx_data),
|
||||
("Tipo de Documento", idx_documento_tipo),
|
||||
];
|
||||
for (nome, maybe_idx) in opcionais {
|
||||
if let Some(idx) = maybe_idx {
|
||||
if *idx >= total_colunas {
|
||||
return Err(ErroArquivo::ErroLeitura(format!(
|
||||
"Campo '{}': índice {} não existe — o arquivo tem {} coluna(s).",
|
||||
nome, idx, total_colunas
|
||||
)));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
for (i, linha) in linhas.iter().enumerate() {
|
||||
let linha_num = i + 1;
|
||||
|
||||
@@ -216,7 +248,7 @@ fn mapear_linhas_para_notas(
|
||||
notas.push(Nota::new(numero, serie, documento_tipo, valor, data));
|
||||
}
|
||||
|
||||
notas
|
||||
Ok(notas)
|
||||
}
|
||||
|
||||
/// Faz o parsing de um número, tentando extrair dígitos se necessário.
|
||||
|
||||
@@ -5,6 +5,17 @@ use crate::domain::{
|
||||
use crate::infrastructure::sqlite::layout_repository;
|
||||
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.
|
||||
/// Se o layout já tem um id, atualiza. Caso contrário, insere.
|
||||
/// 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()));
|
||||
}
|
||||
|
||||
// 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() {
|
||||
// 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)
|
||||
.map_err(|e| ErroLayout::JsonMalformado(e.to_string()))?;
|
||||
.map_err(|e| mapear_erro_banco(e, layout.nome()))?;
|
||||
Ok(id)
|
||||
} else {
|
||||
let nome = layout.nome().to_string();
|
||||
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 {
|
||||
return Err(ErroLayout::NomeConflitante(nome));
|
||||
}
|
||||
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, layout.nome()))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -79,13 +112,13 @@ pub fn importar_layout_json(
|
||||
// Verificar conflito de nome
|
||||
let nome_atual = layout.nome().to_string();
|
||||
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 sobrescrever_se_existir {
|
||||
// Buscar o id existente para sobrescrever
|
||||
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
|
||||
.iter()
|
||||
@@ -98,7 +131,7 @@ pub fn importar_layout_json(
|
||||
Layout::Xlsx { id: i, .. } => *i = Some(id),
|
||||
}
|
||||
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);
|
||||
}
|
||||
} else {
|
||||
@@ -107,5 +140,5 @@ pub fn importar_layout_json(
|
||||
}
|
||||
|
||||
// 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))
|
||||
}
|
||||
|
||||
@@ -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"`.
|
||||
@@ -24,6 +24,8 @@ pub enum ErroLayout {
|
||||
JsonMalformado(String),
|
||||
#[error("Conflito de nome: layout '{0}' já existe")]
|
||||
NomeConflitante(String),
|
||||
#[error("Erro no banco de dados: {0}")]
|
||||
ErroBanco(String),
|
||||
}
|
||||
|
||||
#[derive(Debug, Error, Clone)]
|
||||
|
||||
@@ -0,0 +1,207 @@
|
||||
# Infrastructure — AGENTS.md
|
||||
|
||||
Camada de infraestrutura do projeto `comparador-notas`. Responsável por toda I/O
|
||||
concreta: leitura de arquivos (CSV e XLSX), geração de PDF e persistência SQLite.
|
||||
Não contém regras de negócio; depende do `domain` para tipos e erros.
|
||||
|
||||
---
|
||||
|
||||
## Estrutura dos arquivos
|
||||
|
||||
```
|
||||
src/infrastructure/
|
||||
├── mod.rs # Re-exporta os submódulos públicos
|
||||
├── csv_reader.rs # Leitura e preview de arquivos CSV
|
||||
├── xlsx_reader.rs # Leitura e preview de arquivos XLSX/XLS
|
||||
├── pdf_generator.rs # Trait abstrata + implementação concreta de geração de PDF
|
||||
└── sqlite/ # Submódulo de persistência (ver sqlite/AGENTS.md)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## mod.rs
|
||||
|
||||
Re-exporta os quatro submódulos:
|
||||
|
||||
```rust
|
||||
pub mod csv_reader;
|
||||
pub mod pdf_generator;
|
||||
pub mod sqlite;
|
||||
pub mod xlsx_reader;
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## csv_reader.rs
|
||||
|
||||
Leitura de arquivos CSV com suporte a múltiplos encodings e delimitadores.
|
||||
|
||||
### Constante
|
||||
|
||||
```rust
|
||||
const LIMITE_BYTES: u64 = 50 * 1024 * 1024; // 50 MB
|
||||
```
|
||||
|
||||
### `ResultadoCsv`
|
||||
|
||||
```rust
|
||||
pub struct ResultadoCsv {
|
||||
pub linhas: Vec<Vec<String>>, // dados sem o cabeçalho
|
||||
pub avisos: ResumoAvisos,
|
||||
}
|
||||
```
|
||||
|
||||
### `ler_csv(caminho, delimitador, encoding, linha_cabecalho) -> Result<ResultadoCsv, ErroArquivo>`
|
||||
|
||||
Fluxo:
|
||||
|
||||
1. Verifica tamanho do arquivo — retorna `ErroArquivo::TamanhoExcedido` se > 50 MB.
|
||||
2. Lê os bytes brutos com `std::fs::read`.
|
||||
3. Decodifica o conteúdo:
|
||||
- `"windows-1252"`, `"latin-1"`, `"iso-8859-1"` → `encoding_rs::WINDOWS_1252`
|
||||
- qualquer outro → `String::from_utf8` (UTF-8)
|
||||
4. Constrói um `csv::ReaderBuilder` com `flexible(true)` e `has_headers(false)`.
|
||||
5. Itera sobre todos os registros:
|
||||
- Pula linhas até e incluindo `linha_cabecalho` (quando > 0).
|
||||
- Ignora linhas completamente em branco.
|
||||
- Registra erros de parse em `avisos.linhas_malformadas`.
|
||||
6. Retorna `ResultadoCsv` com as linhas de dados e os avisos.
|
||||
|
||||
### `preview_csv(caminho, delimitador, encoding, n) -> Result<Vec<Vec<String>>, ErroArquivo>`
|
||||
|
||||
Retorna as primeiras `n` linhas brutas (sem pular cabeçalho). Usado exclusivamente
|
||||
para pré-visualização na UI. Não verifica tamanho do arquivo.
|
||||
|
||||
---
|
||||
|
||||
## xlsx_reader.rs
|
||||
|
||||
Leitura de arquivos XLSX (e XLS por magic bytes) com suporte a coordenadas
|
||||
no formato `LetraLinha` (ex: `"B3"`).
|
||||
|
||||
### Constante
|
||||
|
||||
```rust
|
||||
const LIMITE_BYTES: u64 = 50 * 1024 * 1024; // 50 MB
|
||||
```
|
||||
|
||||
### Tipos auxiliares
|
||||
|
||||
```rust
|
||||
pub struct Coordenada {
|
||||
pub coluna: u32, // base 0
|
||||
pub linha: u32, // base 1
|
||||
}
|
||||
```
|
||||
|
||||
### `listar_abas(caminho) -> Result<Vec<String>, ErroArquivo>`
|
||||
|
||||
Abre o workbook via `calamine::open_workbook_auto` (detecção por magic bytes)
|
||||
e retorna os nomes das abas.
|
||||
|
||||
### `ler_xlsx(caminho, nome_aba, linha_inicio) -> Result<ResultadoXlsx, ErroArquivo>`
|
||||
|
||||
1. Verifica tamanho (50 MB).
|
||||
2. Abre workbook e seleciona a aba pelo nome.
|
||||
3. Itera sobre as linhas a partir de `linha_inicio - 1` (base 0 internamente).
|
||||
4. Converte cada célula para `String` via `celula_para_string` (ver abaixo).
|
||||
5. Ignora linhas completamente em branco.
|
||||
6. Retorna `ResultadoXlsx { linhas, avisos }`.
|
||||
|
||||
### `preview_xlsx(caminho, nome_aba) -> Result<Vec<Vec<String>>, ErroArquivo>`
|
||||
|
||||
Retorna as primeiras 5 linhas brutas da aba, a partir da linha 1. Usado para
|
||||
pré-visualização na UI.
|
||||
|
||||
### `parsear_letra_linha(s) -> Option<Coordenada>`
|
||||
|
||||
Converte notação Excel (`"B3"`) para `Coordenada { coluna: 1, linha: 3 }`:
|
||||
|
||||
- Normaliza para maiúsculas e faz trim.
|
||||
- Divide entre letras e dígitos.
|
||||
- Converte letras para índice de coluna base 0:
|
||||
`A=0, B=1, ..., Z=25, AA=26, ...`
|
||||
- Retorna `None` para notações inválidas (vazio, só números, zero, etc.).
|
||||
|
||||
### `celula_para_string(cell) -> String` (privada)
|
||||
|
||||
| Tipo calamine | Conversão |
|
||||
|---|---|
|
||||
| `Empty` | `""` |
|
||||
| `String(s)` | `s.clone()` |
|
||||
| `Float(f)` | sem `.0` quando `f.fract() == 0.0` |
|
||||
| `Int(i)` | `i.to_string()` |
|
||||
| `Bool(b)` | `b.to_string()` |
|
||||
| `DateTime` / `DateTimeIso` / `DurationIso` | representação string |
|
||||
| `Error(_)` | `""` |
|
||||
|
||||
---
|
||||
|
||||
## pdf_generator.rs
|
||||
|
||||
Geração de relatórios PDF com fontes embutidas no binário.
|
||||
|
||||
### Fontes embutidas
|
||||
|
||||
```rust
|
||||
const FONT_REGULAR: &[u8] = include_bytes!("../../assets/fonts/LiberationSans-Regular.ttf");
|
||||
const FONT_BOLD: &[u8] = include_bytes!("../../assets/fonts/LiberationSans-Bold.ttf");
|
||||
```
|
||||
|
||||
Liberation Sans (~402 KB/variante) é usada em vez de Arial do sistema (~993 KB),
|
||||
eliminando dependência externa e reduzindo o tamanho dos PDFs.
|
||||
|
||||
### `PdfGenerator` (trait pública)
|
||||
|
||||
```rust
|
||||
pub trait PdfGenerator {
|
||||
fn gerar(
|
||||
&self,
|
||||
resultado: &ResultadoAnalise,
|
||||
notas: &[Nota],
|
||||
meta: &MetadadosRelatorio,
|
||||
caminho_saida: &Path,
|
||||
) -> Result<(), String>;
|
||||
}
|
||||
```
|
||||
|
||||
Permite que o use case `exportar_pdf` dependa da abstração, não da crate `genpdf`.
|
||||
|
||||
### `GenpdfGenerator` (implementação concreta)
|
||||
|
||||
Implementa `PdfGenerator` usando a crate `genpdf`. Estrutura do PDF gerado:
|
||||
|
||||
1. **Título** — "Relatório de Análise de Notas Fiscais" (bold, 16pt)
|
||||
2. **Metadados** — nome do arquivo, layout (se presente) e data/hora de geração
|
||||
3. **Totais** — soma total e por série (`ChaveSerie.label()`)
|
||||
4. **Notas Faltantes por Série** — lista agrupada em intervalos contíguos
|
||||
(ex: `100–104 (5 notas)` em vez de `100, 101, 102, 103, 104`)
|
||||
5. **Duplicatas por Série** — grupo por número com contagem e data da última ocorrência
|
||||
|
||||
### `MetadadosRelatorio`
|
||||
|
||||
```rust
|
||||
pub struct MetadadosRelatorio {
|
||||
pub nome_arquivo: String,
|
||||
pub nome_layout: Option<String>,
|
||||
pub gerado_em: DateTime<Local>,
|
||||
}
|
||||
```
|
||||
|
||||
### `carregar_fonte_familia()` (privada)
|
||||
|
||||
Constrói `fonts::FontFamily` com os 4 slots exigidos por `genpdf`. Como o
|
||||
relatório não usa itálico, `italic` e `bold_italic` reutilizam os dados de
|
||||
`regular` e `bold` respectivamente.
|
||||
|
||||
---
|
||||
|
||||
## Dependencias externas relevantes
|
||||
|
||||
| Crate | Uso |
|
||||
|-------|-----|
|
||||
| `csv` | Parser de arquivos CSV com suporte a delimitadores e modo flexível |
|
||||
| `encoding_rs` | Decodificação Windows-1252 / Latin-1 |
|
||||
| `calamine` | Leitura de XLSX/XLS com detecção automática por magic bytes |
|
||||
| `genpdf` | Geração de PDF com layout de parágrafos e decorador de página |
|
||||
| `chrono` | `DateTime<Local>` para timestamp do relatório |
|
||||
@@ -1,11 +1,14 @@
|
||||
use crate::domain::entities::chave_serie::ChaveSerie;
|
||||
use crate::domain::entities::nota::Nota;
|
||||
use crate::domain::entities::resultado_analise::ResultadoAnalise;
|
||||
use crate::domain::services::detector_sequencia::agrupar_contiguos;
|
||||
use crate::domain::services::parser_monetario::formatar_valor_br;
|
||||
use chrono::{DateTime, Local};
|
||||
use genpdf::{
|
||||
elements::{Break, Paragraph},
|
||||
fonts, style, Document, SimplePageDecorator,
|
||||
};
|
||||
use std::collections::HashMap;
|
||||
use std::path::Path;
|
||||
|
||||
// Fontes embutidas no binário em tempo de compilação.
|
||||
@@ -28,6 +31,7 @@ pub trait PdfGenerator {
|
||||
fn gerar(
|
||||
&self,
|
||||
resultado: &ResultadoAnalise,
|
||||
notas: &[Nota],
|
||||
meta: &MetadadosRelatorio,
|
||||
caminho_saida: &Path,
|
||||
) -> Result<(), String>;
|
||||
@@ -40,9 +44,20 @@ impl PdfGenerator for GenpdfGenerator {
|
||||
fn gerar(
|
||||
&self,
|
||||
resultado: &ResultadoAnalise,
|
||||
notas: &[Nota],
|
||||
meta: &MetadadosRelatorio,
|
||||
caminho_saida: &Path,
|
||||
) -> Result<(), String> {
|
||||
// Construir mapa (chave_serie, numero) → data para uso na seção de duplicatas
|
||||
let datas: HashMap<(ChaveSerie, u64), String> = notas
|
||||
.iter()
|
||||
.filter_map(|n| {
|
||||
n.data.map(|d| {
|
||||
let chave = ChaveSerie::new(n.serie.clone(), n.documento_tipo.clone());
|
||||
((chave, n.numero), d.format("%d/%m/%Y").to_string())
|
||||
})
|
||||
})
|
||||
.collect();
|
||||
let font_family = carregar_fonte_familia()?;
|
||||
|
||||
let mut doc = Document::new(font_family);
|
||||
@@ -125,8 +140,19 @@ impl PdfGenerator for GenpdfGenerator {
|
||||
chave.label(),
|
||||
faltantes.len()
|
||||
)));
|
||||
let numeros: Vec<String> = faltantes.iter().map(|n| n.to_string()).collect();
|
||||
doc.push(Paragraph::new(format!(" {}", numeros.join(", "))));
|
||||
// Agrupar faltantes contíguos em intervalos (ex: 100–104 em vez de 100, 101, 102, 103, 104)
|
||||
let grupos = agrupar_contiguos(faltantes);
|
||||
let descricao: Vec<String> = grupos
|
||||
.iter()
|
||||
.map(|(ini, fim)| {
|
||||
if ini == fim {
|
||||
ini.to_string()
|
||||
} else {
|
||||
format!("{ini}–{fim} ({} notas)", fim - ini + 1)
|
||||
}
|
||||
})
|
||||
.collect();
|
||||
doc.push(Paragraph::new(format!(" {}", descricao.join(", "))));
|
||||
}
|
||||
|
||||
doc.push(Break::new(1));
|
||||
@@ -158,11 +184,16 @@ impl PdfGenerator for GenpdfGenerator {
|
||||
duplicatas.len()
|
||||
)));
|
||||
for (numero, count) in duplicatas {
|
||||
let data_str = datas
|
||||
.get(&((*chave).clone(), *numero))
|
||||
.map(|d| format!(" (última: {})", d))
|
||||
.unwrap_or_default();
|
||||
doc.push(Paragraph::new(format!(
|
||||
" NF {} / Série {} — {} ocorrências",
|
||||
" NF {} / Série {} — {} ocorrências{}",
|
||||
numero,
|
||||
chave.label(),
|
||||
count
|
||||
count,
|
||||
data_str,
|
||||
)));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 |
|
||||
@@ -28,8 +28,8 @@ pub fn abrir_banco_no_caminho(caminho: &Path) -> Result<(Connection, bool), Stri
|
||||
if caminho.exists() {
|
||||
match Connection::open(caminho) {
|
||||
Ok(conn) => {
|
||||
// Testar se o banco funciona com uma query simples
|
||||
match conn.execute_batch("SELECT 1;") {
|
||||
// Testar integridade estrutural do banco
|
||||
match conn.execute_batch("PRAGMA quick_check;") {
|
||||
Ok(_) => return Ok((conn, false)),
|
||||
Err(_e) => {
|
||||
// Banco corrompido
|
||||
|
||||
@@ -1,6 +1,22 @@
|
||||
use crate::domain::entities::layout::{Layout, LayoutCsv, LayoutXlsx};
|
||||
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.
|
||||
pub fn salvar(conn: &Connection, layout: &Layout) -> Result<i64> {
|
||||
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)",
|
||||
params![
|
||||
nome,
|
||||
config.delimitador.to_string(),
|
||||
delim_para_str(config.delimitador),
|
||||
config.encoding,
|
||||
config.linha_cabecalho 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.
|
||||
/// Zera explicitamente os campos do tipo oposto para evitar dados órfãos.
|
||||
pub fn atualizar(conn: &Connection, layout: &Layout) -> Result<()> {
|
||||
let id = layout
|
||||
.id()
|
||||
@@ -58,11 +75,13 @@ pub fn atualizar(conn: &Connection, layout: &Layout) -> Result<()> {
|
||||
"UPDATE layouts SET
|
||||
nome = ?1, delimitador = ?2, encoding = ?3,
|
||||
linha_cabecalho = ?4, indice_numero = ?5, indice_serie = ?6,
|
||||
indice_valor = ?7, indice_data = ?8, indice_documento_tipo = ?9
|
||||
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",
|
||||
params![
|
||||
nome,
|
||||
config.delimitador.to_string(),
|
||||
delim_para_str(config.delimitador),
|
||||
config.encoding,
|
||||
config.linha_cabecalho as i64,
|
||||
config.indice_numero as i64,
|
||||
@@ -78,7 +97,10 @@ pub fn atualizar(conn: &Connection, layout: &Layout) -> Result<()> {
|
||||
conn.execute(
|
||||
"UPDATE layouts SET
|
||||
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",
|
||||
params![
|
||||
nome,
|
||||
@@ -109,25 +131,31 @@ pub fn listar(conn: &Connection) -> Result<Vec<Layout>> {
|
||||
|
||||
let layouts: Result<Vec<Layout>> = stmt
|
||||
.query_map([], |row| {
|
||||
let id: i64 = row.get(0)?;
|
||||
let nome: String = row.get(1)?;
|
||||
let tipo: String = row.get(2)?;
|
||||
let id: i64 = row.get("id")?;
|
||||
let nome: String = row.get("nome")?;
|
||||
let tipo: String = row.get("tipo")?;
|
||||
|
||||
if tipo == "csv" {
|
||||
let delim_str: String = row.get(3)?;
|
||||
let delimitador = delim_str.chars().next().unwrap_or(';');
|
||||
let delim_str: String = row.get("delimitador")?;
|
||||
let delimitador = str_para_delim(&delim_str);
|
||||
Ok(Layout::Csv {
|
||||
id: Some(id),
|
||||
nome,
|
||||
config: LayoutCsv {
|
||||
delimitador,
|
||||
encoding: row.get(4)?,
|
||||
linha_cabecalho: row.get::<_, i64>(5)? as usize,
|
||||
indice_numero: row.get::<_, i64>(6)? as usize,
|
||||
indice_serie: row.get::<_, i64>(7)? as usize,
|
||||
indice_valor: row.get::<_, Option<i64>>(8)?.map(|v| v as usize),
|
||||
indice_data: row.get::<_, Option<i64>>(9)?.map(|v| v as usize),
|
||||
indice_documento_tipo: row.get::<_, Option<i64>>(15)?.map(|v| v as usize),
|
||||
encoding: row.get("encoding")?,
|
||||
linha_cabecalho: row.get::<_, i64>("linha_cabecalho")? as usize,
|
||||
indice_numero: row.get::<_, i64>("indice_numero")? as usize,
|
||||
indice_serie: row.get::<_, i64>("indice_serie")? as usize,
|
||||
indice_valor: row
|
||||
.get::<_, Option<i64>>("indice_valor")?
|
||||
.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 {
|
||||
@@ -135,12 +163,12 @@ pub fn listar(conn: &Connection) -> Result<Vec<Layout>> {
|
||||
id: Some(id),
|
||||
nome,
|
||||
config: LayoutXlsx {
|
||||
aba: row.get(10)?,
|
||||
pos_numero: row.get(11)?,
|
||||
pos_serie: row.get(12)?,
|
||||
pos_valor: row.get(13)?,
|
||||
pos_data: row.get(14)?,
|
||||
pos_documento_tipo: row.get(16)?,
|
||||
aba: row.get("aba")?,
|
||||
pos_numero: row.get("pos_numero")?,
|
||||
pos_serie: row.get("pos_serie")?,
|
||||
pos_valor: row.get("pos_valor")?,
|
||||
pos_data: row.get("pos_data")?,
|
||||
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)
|
||||
}
|
||||
|
||||
/// 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)
|
||||
}
|
||||
|
||||
@@ -1,8 +1,5 @@
|
||||
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
|
||||
/// para a versão mais recente.
|
||||
pub fn aplicar_migrations(conn: &Connection) -> Result<()> {
|
||||
@@ -29,42 +26,36 @@ pub fn aplicar_migrations(conn: &Connection) -> Result<()> {
|
||||
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(())
|
||||
}
|
||||
|
||||
/// Migration v2: adicionar índice único em layouts.nome.
|
||||
|
||||
/// Migration v3: adicionar colunas de tipo de documento nos layouts.
|
||||
fn migration_v3(conn: &Connection) -> Result<()> {
|
||||
conn.execute_batch(
|
||||
"ALTER TABLE layouts ADD COLUMN indice_documento_tipo INTEGER;
|
||||
ALTER TABLE layouts ADD COLUMN pos_documento_tipo TEXT;",
|
||||
"BEGIN;
|
||||
ALTER TABLE layouts ADD COLUMN indice_documento_tipo INTEGER;
|
||||
ALTER TABLE layouts ADD COLUMN pos_documento_tipo TEXT;
|
||||
UPDATE schema_version SET versao = 3;
|
||||
COMMIT;",
|
||||
)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Migration v2: adicionar índice único em layouts.nome.
|
||||
///
|
||||
/// Renomeia duplicatas (sufixo com id) antes de criar o índice para não falhar
|
||||
/// em bancos que já possuem nomes repetidos.
|
||||
fn migration_v2(conn: &Connection) -> Result<()> {
|
||||
conn.execute_batch(
|
||||
"UPDATE layouts
|
||||
SET nome = nome || ' (' || id || ')'
|
||||
WHERE id NOT IN (
|
||||
SELECT MIN(id) FROM layouts GROUP BY nome
|
||||
);
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS idx_layouts_nome ON layouts (nome);",
|
||||
"BEGIN;
|
||||
UPDATE layouts
|
||||
SET nome = nome || ' (' || id || ')'
|
||||
WHERE id NOT IN (
|
||||
SELECT MIN(id) FROM layouts GROUP BY nome
|
||||
);
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS idx_layouts_nome ON layouts (nome);
|
||||
UPDATE schema_version SET versao = 2;
|
||||
COMMIT;",
|
||||
)?;
|
||||
Ok(())
|
||||
}
|
||||
@@ -72,27 +63,30 @@ fn migration_v2(conn: &Connection) -> Result<()> {
|
||||
/// Migration v1: criar tabela de layouts.
|
||||
fn migration_v1(conn: &Connection) -> Result<()> {
|
||||
conn.execute_batch(
|
||||
"CREATE TABLE IF NOT EXISTS layouts (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
nome TEXT NOT NULL,
|
||||
tipo TEXT NOT NULL CHECK(tipo IN ('csv', 'xlsx')),
|
||||
"BEGIN;
|
||||
CREATE TABLE IF NOT EXISTS 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,
|
||||
-- Campos CSV
|
||||
delimitador TEXT,
|
||||
encoding TEXT,
|
||||
linha_cabecalho INTEGER,
|
||||
indice_numero INTEGER,
|
||||
indice_serie INTEGER,
|
||||
indice_valor INTEGER,
|
||||
indice_data INTEGER,
|
||||
|
||||
-- Campos XLSX
|
||||
aba TEXT,
|
||||
pos_numero TEXT,
|
||||
pos_serie TEXT,
|
||||
pos_valor TEXT,
|
||||
pos_data TEXT
|
||||
);",
|
||||
-- Campos XLSX
|
||||
aba TEXT,
|
||||
pos_numero TEXT,
|
||||
pos_serie TEXT,
|
||||
pos_valor TEXT,
|
||||
pos_data TEXT
|
||||
);
|
||||
INSERT INTO schema_version (versao) VALUES (1);
|
||||
COMMIT;",
|
||||
)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -62,29 +62,7 @@ pub fn ler_xlsx(
|
||||
continue;
|
||||
}
|
||||
|
||||
let campos: Vec<String> = row
|
||||
.iter()
|
||||
.map(|cell| {
|
||||
use calamine::Data;
|
||||
match cell {
|
||||
Data::Empty => String::new(),
|
||||
Data::String(s) => s.clone(),
|
||||
Data::Float(f) => {
|
||||
if f.fract() == 0.0 {
|
||||
format!("{}", *f as i64)
|
||||
} else {
|
||||
format!("{}", f)
|
||||
}
|
||||
}
|
||||
Data::Int(i) => i.to_string(),
|
||||
Data::Bool(b) => b.to_string(),
|
||||
Data::DateTime(dt) => dt.to_string(),
|
||||
Data::DateTimeIso(s) => s.clone(),
|
||||
Data::DurationIso(s) => s.clone(),
|
||||
Data::Error(_) => String::new(),
|
||||
}
|
||||
})
|
||||
.collect();
|
||||
let campos: Vec<String> = row.iter().map(celula_para_string).collect();
|
||||
|
||||
// Ignorar linhas completamente em branco
|
||||
if campos.iter().all(|s: &String| s.trim().is_empty()) {
|
||||
@@ -113,28 +91,7 @@ pub fn preview_xlsx(caminho: &Path, nome_aba: &str) -> Result<Vec<Vec<String>>,
|
||||
.rows()
|
||||
.take(5)
|
||||
.map(|row| {
|
||||
row.iter()
|
||||
.map(|cell| {
|
||||
use calamine::Data;
|
||||
match cell {
|
||||
Data::Empty => String::new(),
|
||||
Data::String(s) => s.clone(),
|
||||
Data::Float(f) => {
|
||||
if f.fract() == 0.0 {
|
||||
format!("{}", *f as i64)
|
||||
} else {
|
||||
format!("{}", f)
|
||||
}
|
||||
}
|
||||
Data::Int(i) => i.to_string(),
|
||||
Data::Bool(b) => b.to_string(),
|
||||
Data::DateTime(dt) => dt.to_string(),
|
||||
Data::DateTimeIso(s) => s.clone(),
|
||||
Data::DurationIso(s) => s.clone(),
|
||||
Data::Error(_) => String::new(),
|
||||
}
|
||||
})
|
||||
.collect()
|
||||
row.iter().map(celula_para_string).collect()
|
||||
})
|
||||
.collect();
|
||||
|
||||
@@ -178,6 +135,30 @@ pub fn parsear_letra_linha(s: &str) -> Option<Coordenada> {
|
||||
Some(Coordenada { coluna, linha })
|
||||
}
|
||||
|
||||
/// Converte uma célula calamine em String, usando representação legível.
|
||||
/// Inteiros sem parte decimal são formatados sem ".0"; erros e células vazias
|
||||
/// retornam string vazia.
|
||||
fn celula_para_string(cell: &calamine::Data) -> String {
|
||||
use calamine::Data;
|
||||
match cell {
|
||||
Data::Empty => String::new(),
|
||||
Data::String(s) => s.clone(),
|
||||
Data::Float(f) => {
|
||||
if f.fract() == 0.0 {
|
||||
format!("{}", *f as i64)
|
||||
} else {
|
||||
format!("{}", f)
|
||||
}
|
||||
}
|
||||
Data::Int(i) => i.to_string(),
|
||||
Data::Bool(b) => b.to_string(),
|
||||
Data::DateTime(dt) => dt.to_string(),
|
||||
Data::DateTimeIso(s) => s.clone(),
|
||||
Data::DurationIso(s) => s.clone(),
|
||||
Data::Error(_) => String::new(),
|
||||
}
|
||||
}
|
||||
|
||||
fn verificar_tamanho(caminho: &Path) -> Result<(), ErroArquivo> {
|
||||
let metadata =
|
||||
std::fs::metadata(caminho).map_err(|e| ErroArquivo::ErroLeitura(e.to_string()))?;
|
||||
|
||||
@@ -9,6 +9,7 @@ use ui::app::App;
|
||||
|
||||
fn main() -> iced::Result {
|
||||
iced::application("Comparador de Notas", App::update, App::view)
|
||||
.subscription(App::subscription)
|
||||
.theme(|_app| ui::theme::tema_dark())
|
||||
.window(iced::window::Settings {
|
||||
size: iced::Size::new(1024.0, 768.0),
|
||||
|
||||
@@ -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 60–89% |
|
||||
| `DANGER` | `#EF4444` | Badge duplicada, barra < 60% |
|
||||
| `TRACK_BG` | `#111827` | Trilha da progress bar |
|
||||
|
||||
### Helpers de estilo disponíveis em `theme.rs`
|
||||
|
||||
**Containers:**
|
||||
- `t::fundo` — fundo geral da janela
|
||||
- `t::card` — card principal (SURFACE + borda + radius 8)
|
||||
- `t::card_secondary` — card secundário (SURFACE_2 + borda + radius 6)
|
||||
- `t::cabecalho_tabela` — cabeçalho de tabela sem borda
|
||||
- `t::badge_sucesso` / `t::badge_aviso` / `t::badge_perigo` — badges coloridos
|
||||
- `t::area_erro` — área de validação com fundo vermelho sutil
|
||||
- `t::stat_card` — card de estatística (igual a `t::card`)
|
||||
- `t::separador` — linha divisória fina
|
||||
- `t::breadcrumb_bg` — fundo da barra de breadcrumb
|
||||
|
||||
**Botões:**
|
||||
- `t::btn_primary` — azul sólido, ação principal
|
||||
- `t::btn_secondary` — SURFACE_2 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.60–0.89 → WARNING, < 0.60 → DANGER
|
||||
|
||||
---
|
||||
|
||||
## 3. Regras de layout e responsividade
|
||||
|
||||
### Princípio geral
|
||||
|
||||
A janela tem tamanho mínimo de **800×600**. Todo layout deve funcionar bem nessa
|
||||
dimensão e escalar corretamente ao aumentar.
|
||||
|
||||
### O que usar
|
||||
|
||||
| Situação | Valor correto |
|
||||
|----------|--------------|
|
||||
| Widget que deve preencher o espaço disponível | `Length::Fill` |
|
||||
| Label ao lado de input em linha | `Length::FillPortion(3)` (label) + input com tamanho fixo pequeno ou `Fill` |
|
||||
| Input numérico curto (índice, posição) | `Length::Fixed(90.0)` ou `Length::Fixed(110.0)` |
|
||||
| Input de texto longo (nome, aba) | `Length::Fill` |
|
||||
| Pick list de opções | `Length::Fill` |
|
||||
| Modal/card centralizado com largura máxima | `.max_width(N)` + `Length::Fill` |
|
||||
| Botões em linha que podem quebrar | `.wrap()` no `row![]` |
|
||||
| Elemento que deve ter tamanho mínimo sem crescer | `Length::Shrink` |
|
||||
| Células de tabela com scroll horizontal | `Length::Fixed(100.0)` mínimo |
|
||||
|
||||
### O que **não** fazer
|
||||
|
||||
- **Não use `Length::Fixed` em labels de formulário.** Labels devem usar
|
||||
`FillPortion` para se adaptar ao espaço disponível.
|
||||
- **Não use `Length::Fixed` em pick lists ou text inputs de texto livre.**
|
||||
Use `Fill` para que se adaptem à largura do container pai.
|
||||
- **Não coloque valores maiores que `max_width` em modais.** Use `.max_width(N)`
|
||||
em vez de `Fixed(N)` para que o modal encolha em janelas menores.
|
||||
- **Não deixe telas sem `scrollable`.** Toda tela com conteúdo vertical deve ser
|
||||
envolvida em `scrollable()` para evitar clipping em janelas pequenas.
|
||||
- **Não use `row![]` com muitos itens sem `.wrap()`.** Botões de ação e grupos
|
||||
de controles devem usar `.wrap()` para quebrar linha quando não couberem.
|
||||
|
||||
### Padrão de campo de formulário
|
||||
|
||||
```rust
|
||||
// Linha de campo: label proporcional + input
|
||||
fn campo_row<'a>(label: &'a str, input: Element<'a, Message>) -> Element<'a, Message> {
|
||||
row![
|
||||
text(label)
|
||||
.size(13)
|
||||
.color(t::TEXT_SECONDARY)
|
||||
.width(Length::FillPortion(3)), // proporcional, não fixo
|
||||
input, // input define seu próprio tamanho
|
||||
]
|
||||
.spacing(10)
|
||||
.align_y(Alignment::Center)
|
||||
.into()
|
||||
}
|
||||
```
|
||||
|
||||
### Padrão de campo opcional com checkbox
|
||||
|
||||
```rust
|
||||
// Quando ativo: checkbox (FillPortion) + input
|
||||
// Quando inativo: só o checkbox
|
||||
if ativo {
|
||||
row![
|
||||
cb.width(Length::FillPortion(3)),
|
||||
text_input("...", &val)
|
||||
.width(Length::Fixed(90.0)), // input numérico curto
|
||||
]
|
||||
.spacing(10)
|
||||
.align_y(Alignment::Center)
|
||||
.into()
|
||||
} else {
|
||||
row![cb].into()
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 4. Estrutura de uma tela (screen)
|
||||
|
||||
Toda tela segue o mesmo padrão de função pública `view`:
|
||||
|
||||
```rust
|
||||
pub fn view(app: &App) -> Element<'_, Message> {
|
||||
// 1. Montar seções/componentes individuais
|
||||
let secao_x = ...;
|
||||
let secao_y = ...;
|
||||
|
||||
// 2. Combinar em coluna principal
|
||||
let conteudo = column![secao_x, secao_y]
|
||||
.spacing(14)
|
||||
.padding([20, 24])
|
||||
.width(Length::Fill);
|
||||
|
||||
// 3. Envolver em scrollable + container de fundo
|
||||
container(scrollable(conteudo))
|
||||
.style(t::fundo)
|
||||
.width(Length::Fill)
|
||||
.height(Length::Fill)
|
||||
.into()
|
||||
}
|
||||
```
|
||||
|
||||
**Toda tela deve ter `scrollable` e `container` com `t::fundo` na raiz.**
|
||||
|
||||
---
|
||||
|
||||
## 5. Componentes reutilizáveis
|
||||
|
||||
### `modal::view_com_modal(conteudo, modal)`
|
||||
|
||||
Envolve qualquer `Element` com um overlay de modal bloqueante.
|
||||
Chamado em `app.rs` quando `self.modal.is_some()`.
|
||||
|
||||
- Modal tem `max_width(420)` + `Length::Fill` para ser responsivo.
|
||||
- Tipos disponíveis: `Informacao`, `Aviso`, `Erro`, `Confirmacao`, `InputTexto`.
|
||||
- Disparar modal: usar os helpers em `app.rs` (`exibir_erro`, `exibir_aviso`, `exibir_confirmacao`).
|
||||
|
||||
### `tabela_preview::tabela_preview(linhas)`
|
||||
|
||||
Renderiza as primeiras N linhas do arquivo com cabeçalho estilo Excel (A, B, C...).
|
||||
|
||||
- Células com `Fixed(100.0)` — tamanho fixo mínimo com scroll horizontal.
|
||||
- A altura da área de scroll está fixada em `Fixed(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.
|
||||
+206
-54
@@ -9,12 +9,12 @@ use crate::domain::{
|
||||
nota::Nota,
|
||||
resultado_analise::{ResultadoAnalise, ResultadoPreAnalise},
|
||||
},
|
||||
errors::{ErroLayout, ResumoAvisos},
|
||||
errors::{ErroArquivo, ErroLayout, ResumoAvisos},
|
||||
};
|
||||
use crate::infrastructure::sqlite::{connection::abrir_banco, migrations::aplicar_migrations};
|
||||
use crate::ui::message::{Message, ResultadoPendente};
|
||||
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 rusqlite::Connection;
|
||||
use std::path::PathBuf;
|
||||
@@ -36,7 +36,6 @@ pub enum EstadoApp {
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub enum EstadoModal {
|
||||
Informacao { titulo: String, mensagem: String },
|
||||
Aviso { titulo: String, mensagem: String },
|
||||
Erro { titulo: String, mensagem: String },
|
||||
Confirmacao { titulo: String, mensagem: String, acao: AcaoModal },
|
||||
@@ -82,9 +81,9 @@ pub struct App {
|
||||
// Avisos da última importação
|
||||
pub avisos_importacao: Option<ResumoAvisos>,
|
||||
|
||||
// Paginação
|
||||
pub pagina_faltantes: usize,
|
||||
pub pagina_duplicatas: usize,
|
||||
// Paginação (independente por série)
|
||||
pub pagina_faltantes: std::collections::HashMap<crate::domain::entities::chave_serie::ChaveSerie, usize>,
|
||||
pub pagina_duplicatas: std::collections::HashMap<crate::domain::entities::chave_serie::ChaveSerie, usize>,
|
||||
pub itens_por_pagina: usize,
|
||||
|
||||
// Pré-visualização das primeiras linhas do arquivo
|
||||
@@ -92,6 +91,12 @@ pub struct App {
|
||||
|
||||
// Resultado anterior (preservado durante reanálise)
|
||||
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 {
|
||||
@@ -111,11 +116,13 @@ impl Default for App {
|
||||
layouts_salvos: Vec::new(),
|
||||
modal: None,
|
||||
avisos_importacao: None,
|
||||
pagina_faltantes: 0,
|
||||
pagina_duplicatas: 0,
|
||||
pagina_faltantes: std::collections::HashMap::new(),
|
||||
pagina_duplicatas: std::collections::HashMap::new(),
|
||||
itens_por_pagina: 100,
|
||||
preview_arquivo: None,
|
||||
resultado_anterior: None,
|
||||
arquivo_em_hover: false,
|
||||
spinner_frame: 0,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -206,7 +213,7 @@ impl App {
|
||||
},
|
||||
|result| match result {
|
||||
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()
|
||||
}
|
||||
|
||||
Message::CancelarExpansao => {
|
||||
self.estado = EstadoApp::ConfigurandoColunas;
|
||||
self.modal = None;
|
||||
Task::none()
|
||||
}
|
||||
|
||||
Message::NovaAnalise => {
|
||||
self.exibir_confirmacao(
|
||||
"Nova Análise",
|
||||
@@ -469,18 +470,18 @@ impl App {
|
||||
}
|
||||
|
||||
// ── Resultado ──
|
||||
Message::PaginaFaltantesAlterada(p) => {
|
||||
self.pagina_faltantes = p;
|
||||
Message::PaginaFaltantesAlterada(chave, p) => {
|
||||
self.pagina_faltantes.insert(chave, p);
|
||||
Task::none()
|
||||
}
|
||||
Message::PaginaDuplicatasAlterada(p) => {
|
||||
self.pagina_duplicatas = p;
|
||||
Message::PaginaDuplicatasAlterada(chave, p) => {
|
||||
self.pagina_duplicatas.insert(chave, p);
|
||||
Task::none()
|
||||
}
|
||||
Message::ItensPorPaginaAlterado(n) => {
|
||||
self.itens_por_pagina = n;
|
||||
self.pagina_faltantes = 0;
|
||||
self.pagina_duplicatas = 0;
|
||||
self.pagina_faltantes.clear();
|
||||
self.pagina_duplicatas.clear();
|
||||
Task::none()
|
||||
}
|
||||
Message::CopiarFaltantes(chave) => {
|
||||
@@ -613,9 +614,11 @@ impl App {
|
||||
let modal = self.modal.take();
|
||||
if let Some(estado) = modal {
|
||||
match estado {
|
||||
EstadoModal::Confirmacao { acao, .. }
|
||||
| EstadoModal::InputTexto { acao, .. } => {
|
||||
return self.executar_acao_modal(acao);
|
||||
EstadoModal::Confirmacao { acao, .. } => {
|
||||
return self.executar_acao_modal(acao, None);
|
||||
}
|
||||
EstadoModal::InputTexto { acao, texto, .. } => {
|
||||
return self.executar_acao_modal(acao, Some(texto));
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
@@ -655,11 +658,35 @@ impl App {
|
||||
}
|
||||
|
||||
Message::XlsxErroAoCarregar(e) => {
|
||||
self.caminho_arquivo = None;
|
||||
self.nome_arquivo = String::new();
|
||||
self.preview_arquivo = None;
|
||||
self.notas_importadas.clear();
|
||||
self.exibir_erro(format!("Erro ao ler abas do arquivo: {}", e));
|
||||
Task::none()
|
||||
}
|
||||
|
||||
// ── 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::SpinnerTick => {
|
||||
self.spinner_frame = self.spinner_frame.wrapping_add(1);
|
||||
Task::none()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -678,10 +705,12 @@ impl App {
|
||||
crate::ui::screens::resultado::view(self, resultado)
|
||||
}
|
||||
EstadoApp::GerenciandoLayouts => crate::ui::screens::layouts::view(self),
|
||||
EstadoApp::Analisando => {
|
||||
EstadoApp::Analisando => {
|
||||
const FRAMES: &[&str] = &["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"];
|
||||
let frame = FRAMES[self.spinner_frame % FRAMES.len()];
|
||||
container(
|
||||
column![
|
||||
text("Analisando...").size(20).color(t::TEXT_SECONDARY),
|
||||
text(format!("{} Analisando...", frame)).size(20).color(t::TEXT_SECONDARY),
|
||||
text("Aguarde enquanto o arquivo é processado.").size(14).color(t::TEXT_MUTED),
|
||||
]
|
||||
.spacing(8)
|
||||
@@ -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 ──────────────────────────────────────────────────────────
|
||||
|
||||
fn view_breadcrumb(&self) -> Element<'_, Message> {
|
||||
@@ -734,6 +788,12 @@ impl App {
|
||||
let mut items: Vec<Element<'_, Message>> = Vec::new();
|
||||
|
||||
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 {
|
||||
container(text(*label).size(13).color(t::PRIMARY))
|
||||
.padding([4, 10])
|
||||
@@ -744,13 +804,23 @@ impl App {
|
||||
})
|
||||
.into()
|
||||
} 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 {
|
||||
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);
|
||||
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_xlsx = self.layout_xlsx_atual.clone();
|
||||
|
||||
// Validação de campos duplicados no mapeamento (RF02.3)
|
||||
if let Some(erro) = validar_mapeamento_duplicado(&tipo, &layout_csv, &layout_xlsx) {
|
||||
self.exibir_erro(erro);
|
||||
return Task::none();
|
||||
}
|
||||
|
||||
self.estado = EstadoApp::Analisando;
|
||||
|
||||
Task::perform(
|
||||
@@ -893,11 +969,13 @@ impl App {
|
||||
self.avisos_importacao = if avisos.tem_avisos() { Some(avisos) } else { None };
|
||||
let msg = series_excessivas
|
||||
.iter()
|
||||
.map(|(chave, count)| {
|
||||
.map(|(chave, iv)| {
|
||||
format!(
|
||||
"Série {}: intervalo de {} faltantes detectado",
|
||||
"Série {}: intervalo de {} faltantes detectado (de {} a {})",
|
||||
chave.label(),
|
||||
count
|
||||
iv.contagem_faltantes,
|
||||
iv.minimo,
|
||||
iv.maximo,
|
||||
)
|
||||
})
|
||||
.collect::<Vec<_>>()
|
||||
@@ -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 {
|
||||
AcaoModal::ConfirmarExpansaoFaltantes => {
|
||||
self.update(Message::ConfirmarExpansaoFaltantes)
|
||||
@@ -949,13 +1027,7 @@ impl App {
|
||||
Task::none()
|
||||
}
|
||||
AcaoModal::SalvarLayoutConfig => {
|
||||
let texto = if let Some(EstadoModal::InputTexto { texto, .. }) = &self.modal {
|
||||
texto.clone()
|
||||
} else {
|
||||
String::new()
|
||||
};
|
||||
self.modal = None;
|
||||
let nome = texto.trim().to_string();
|
||||
let nome = texto_input.unwrap_or_default().trim().to_string();
|
||||
if nome.is_empty() {
|
||||
self.exibir_erro("O nome do layout não pode ser vazio.");
|
||||
return Task::none();
|
||||
@@ -1036,6 +1108,7 @@ impl App {
|
||||
} else {
|
||||
Some(self.nome_layout_atual.clone())
|
||||
};
|
||||
let notas = self.notas_importadas.clone();
|
||||
|
||||
Task::perform(
|
||||
async move {
|
||||
@@ -1058,6 +1131,7 @@ impl App {
|
||||
exportar_pdf(
|
||||
&gerador,
|
||||
&resultado,
|
||||
¬as,
|
||||
&nome_arquivo,
|
||||
nome_layout.as_deref(),
|
||||
&caminho,
|
||||
@@ -1086,10 +1160,10 @@ impl App {
|
||||
let (conteudo, nome_sugerido) = match exportar_layout_json(&layout) {
|
||||
Ok(r) => r,
|
||||
Err(e) => {
|
||||
// Não temos &mut self aqui; retornamos um command que exibe erro
|
||||
return Task::perform(async move { e.to_string() }, |e| {
|
||||
Message::PdfExportado(Err(e))
|
||||
});
|
||||
// Não temos &mut self aqui — reutilizamos PdfExportado(Err) para
|
||||
// exibir o modal de erro (o handler chama self.exibir_erro).
|
||||
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 {
|
||||
Ok(_) => 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 ─────
|
||||
|
||||
/// Verifica se algum par de campos mapeados aponta para o mesmo índice (CSV)
|
||||
/// ou posição (XLSX). Retorna uma mensagem de erro descritiva se houver conflito.
|
||||
fn validar_mapeamento_duplicado(
|
||||
tipo: &TipoArquivo,
|
||||
layout_csv: &LayoutCsv,
|
||||
layout_xlsx: &LayoutXlsx,
|
||||
) -> Option<String> {
|
||||
match tipo {
|
||||
TipoArquivo::Csv => {
|
||||
// Campos obrigatórios + opcionais ativos
|
||||
let mut campos: Vec<(&str, usize)> = vec![
|
||||
("Numero", layout_csv.indice_numero),
|
||||
("Serie", layout_csv.indice_serie),
|
||||
];
|
||||
if let Some(i) = layout_csv.indice_valor {
|
||||
campos.push(("Valor", i));
|
||||
}
|
||||
if let Some(i) = layout_csv.indice_data {
|
||||
campos.push(("Data", i));
|
||||
}
|
||||
if let Some(i) = layout_csv.indice_documento_tipo {
|
||||
campos.push(("Tipo de Documento", i));
|
||||
}
|
||||
// Verificar duplicatas
|
||||
for i in 0..campos.len() {
|
||||
for j in (i + 1)..campos.len() {
|
||||
if campos[i].1 == campos[j].1 {
|
||||
return Some(format!(
|
||||
"Mapeamento inválido: campos '{}' e '{}' apontam para o mesmo índice ({}).",
|
||||
campos[i].0, campos[j].0, campos[i].1
|
||||
));
|
||||
}
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
TipoArquivo::Xlsx => {
|
||||
let mut campos: Vec<(&str, &str)> = vec![
|
||||
("Numero", &layout_xlsx.pos_numero),
|
||||
("Serie", &layout_xlsx.pos_serie),
|
||||
];
|
||||
if let Some(ref p) = layout_xlsx.pos_valor {
|
||||
if !p.is_empty() {
|
||||
campos.push(("Valor", p));
|
||||
}
|
||||
}
|
||||
if let Some(ref p) = layout_xlsx.pos_data {
|
||||
if !p.is_empty() {
|
||||
campos.push(("Data", p));
|
||||
}
|
||||
}
|
||||
if let Some(ref p) = layout_xlsx.pos_documento_tipo {
|
||||
if !p.is_empty() {
|
||||
campos.push(("Tipo de Documento", p));
|
||||
}
|
||||
}
|
||||
// Verificar duplicatas (comparação case-insensitive)
|
||||
for i in 0..campos.len() {
|
||||
for j in (i + 1)..campos.len() {
|
||||
if campos[i].1.to_uppercase() == campos[j].1.to_uppercase() {
|
||||
return Some(format!(
|
||||
"Mapeamento inválido: campos '{}' e '{}' apontam para a mesma posição ('{}').",
|
||||
campos[i].0, campos[j].0, campos[i].1
|
||||
));
|
||||
}
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn executar_importacao_sync(
|
||||
caminho: PathBuf,
|
||||
tipo: TipoArquivo,
|
||||
@@ -1192,8 +1332,20 @@ fn executar_importacao_sync(
|
||||
layout_xlsx: LayoutXlsx,
|
||||
) -> ResultadoPendente {
|
||||
let res_importacao = match tipo {
|
||||
TipoArquivo::Csv => importar_csv(&caminho, &layout_csv).map_err(|e| e.to_string()),
|
||||
TipoArquivo::Xlsx => importar_xlsx(&caminho, &layout_xlsx).map_err(|e| e.to_string()),
|
||||
TipoArquivo::Csv => importar_csv(&caminho, &layout_csv).map_err(|e| match e {
|
||||
ErroArquivo::TamanhoExcedido(bytes) => format!(
|
||||
"Arquivo muito grande ({:.1} MB). O limite é 50 MB.",
|
||||
bytes as f64 / (1024.0 * 1024.0)
|
||||
),
|
||||
other => other.to_string(),
|
||||
}),
|
||||
TipoArquivo::Xlsx => importar_xlsx(&caminho, &layout_xlsx).map_err(|e| match e {
|
||||
ErroArquivo::TamanhoExcedido(bytes) => format!(
|
||||
"Arquivo muito grande ({:.1} MB). O limite é 50 MB.",
|
||||
bytes as f64 / (1024.0 * 1024.0)
|
||||
),
|
||||
other => other.to_string(),
|
||||
}),
|
||||
};
|
||||
|
||||
match res_importacao {
|
||||
|
||||
@@ -28,9 +28,6 @@ pub fn view_com_modal<'a>(
|
||||
|
||||
fn view_modal(modal: &EstadoModal) -> Element<'_, Message> {
|
||||
match modal {
|
||||
EstadoModal::Informacao { titulo, mensagem } => {
|
||||
caixa_modal(titulo, mensagem, TipoModal::Info, None, false)
|
||||
}
|
||||
EstadoModal::Aviso { titulo, mensagem } => {
|
||||
caixa_modal(titulo, mensagem, TipoModal::Aviso, None, false)
|
||||
}
|
||||
@@ -138,7 +135,8 @@ fn caixa_modal<'a>(
|
||||
col = col.push(botoes);
|
||||
|
||||
container(col.align_x(Alignment::Start))
|
||||
.width(Length::Fixed(420.0))
|
||||
.max_width(420)
|
||||
.width(Length::Fill)
|
||||
.padding(24)
|
||||
.style(t::card)
|
||||
.into()
|
||||
|
||||
@@ -10,11 +10,11 @@ pub fn controles_paginacao(
|
||||
msg_anterior: Message,
|
||||
msg_proximo: Message,
|
||||
) -> Element<'static, Message> {
|
||||
let btn_anterior = button("◀")
|
||||
let btn_anterior = button("<")
|
||||
.on_press_maybe((pagina_atual > 0).then_some(msg_anterior))
|
||||
.style(t::btn_ghost);
|
||||
|
||||
let btn_proximo = button("▶")
|
||||
let btn_proximo = button(">")
|
||||
.on_press_maybe((pagina_atual + 1 < total_paginas).then_some(msg_proximo))
|
||||
.style(t::btn_ghost);
|
||||
|
||||
|
||||
@@ -12,7 +12,7 @@ pub fn tabela_preview(linhas: &[Vec<String>]) -> Element<'_, Message> {
|
||||
return text("(vazio)").size(12).color(t::TEXT_MUTED).into();
|
||||
}
|
||||
|
||||
// Cabeçalho estilo Excel — fundo SURFACE_2
|
||||
// Cabeçalho estilo Excel — usa t::cabecalho_tabela centralizado
|
||||
let cabecalho = container(
|
||||
row((0..num_colunas)
|
||||
.map(|i| {
|
||||
@@ -22,7 +22,7 @@ pub fn tabela_preview(linhas: &[Vec<String>]) -> Element<'_, Message> {
|
||||
.size(12)
|
||||
.color(t::TEXT_SECONDARY),
|
||||
)
|
||||
.width(Length::Fixed(120.0))
|
||||
.width(Length::Fixed(100.0))
|
||||
.padding([4, 6])
|
||||
.into()
|
||||
})
|
||||
@@ -30,15 +30,7 @@ pub fn tabela_preview(linhas: &[Vec<String>]) -> Element<'_, Message> {
|
||||
.spacing(0),
|
||||
)
|
||||
.width(Length::Shrink)
|
||||
.style(|_theme| iced::widget::container::Style {
|
||||
background: Some(t::SURFACE_2.into()),
|
||||
border: Border {
|
||||
color: t::BORDER,
|
||||
width: 0.0,
|
||||
radius: 0.0.into(),
|
||||
},
|
||||
..Default::default()
|
||||
});
|
||||
.style(t::cabecalho_tabela);
|
||||
|
||||
let linhas_view = linhas.iter().enumerate().map(|(idx, linha)| {
|
||||
// Linhas alternadas: SURFACE e SURFACE_2 levemente
|
||||
@@ -63,7 +55,7 @@ pub fn tabela_preview(linhas: &[Vec<String>]) -> Element<'_, Message> {
|
||||
celula.to_string()
|
||||
};
|
||||
container(text(truncado).font(Font::MONOSPACE).size(11).color(t::TEXT))
|
||||
.width(Length::Fixed(120.0))
|
||||
.width(Length::Fixed(100.0))
|
||||
.padding([3, 6])
|
||||
.into()
|
||||
})
|
||||
@@ -90,7 +82,7 @@ pub fn tabela_preview(linhas: &[Vec<String>]) -> Element<'_, Message> {
|
||||
.direction(scrollable::Direction::Horizontal(
|
||||
scrollable::Scrollbar::default(),
|
||||
))
|
||||
.height(Length::Fixed(160.0)),
|
||||
.height(Length::Fixed(200.0)),
|
||||
)
|
||||
.style(|_theme| iced::widget::container::Style {
|
||||
background: Some(t::SURFACE.into()),
|
||||
|
||||
+13
-5
@@ -2,7 +2,7 @@ use crate::domain::entities::nota::Nota;
|
||||
use crate::domain::entities::{
|
||||
chave_serie::ChaveSerie,
|
||||
layout::{Layout, LayoutXlsx},
|
||||
resultado_analise::{ResultadoAnalise, ResultadoPreAnalise},
|
||||
resultado_analise::{IntervaloSerie, ResultadoAnalise, ResultadoPreAnalise},
|
||||
};
|
||||
use crate::domain::errors::ResumoAvisos;
|
||||
use rusqlite::Connection;
|
||||
@@ -25,6 +25,12 @@ pub enum Message {
|
||||
// --- Arquivo ---
|
||||
SelecionarArquivo,
|
||||
ArquivoSelecionado(PathBuf),
|
||||
/// Arquivo arrastado e solto sobre a janela (drag-and-drop).
|
||||
ArquivoSolto(PathBuf),
|
||||
/// Arquivo está sendo arrastado sobre a janela (hover).
|
||||
ArquivoEmHover,
|
||||
/// Arquivo arrastado saiu da janela sem ser solto.
|
||||
ArquivoHoverSaiu,
|
||||
AbaSelecionada(String),
|
||||
|
||||
// --- XLSX: abas carregadas em background ---
|
||||
@@ -67,12 +73,11 @@ pub enum Message {
|
||||
ExecutarImportacao,
|
||||
ReanalisarArquivo,
|
||||
ConfirmarExpansaoFaltantes,
|
||||
CancelarExpansao,
|
||||
NovaAnalise,
|
||||
|
||||
// --- Resultado ---
|
||||
PaginaFaltantesAlterada(usize),
|
||||
PaginaDuplicatasAlterada(usize),
|
||||
PaginaFaltantesAlterada(ChaveSerie, usize),
|
||||
PaginaDuplicatasAlterada(ChaveSerie, usize),
|
||||
ItensPorPaginaAlterado(usize),
|
||||
CopiarFaltantes(ChaveSerie),
|
||||
CopiarDuplicatas(ChaveSerie),
|
||||
@@ -97,6 +102,9 @@ pub enum Message {
|
||||
|
||||
// --- Sem operação (used as fallback) ---
|
||||
Noop,
|
||||
|
||||
// --- Animação ---
|
||||
SpinnerTick,
|
||||
}
|
||||
|
||||
/// Resultado enviado pela task de análise em background para a UI.
|
||||
@@ -111,7 +119,7 @@ pub enum ResultadoPendente {
|
||||
/// Pré-análise concluída mas precisa de confirmação do usuário.
|
||||
AguardandoConfirmacao {
|
||||
pre: ResultadoPreAnalise,
|
||||
series_excessivas: Vec<(ChaveSerie, u64)>,
|
||||
series_excessivas: Vec<(ChaveSerie, IntervaloSerie)>,
|
||||
avisos: ResumoAvisos,
|
||||
notas: Vec<Nota>,
|
||||
},
|
||||
|
||||
@@ -59,7 +59,7 @@ pub fn view(app: &App) -> Element<'_, Message> {
|
||||
text("Layout:")
|
||||
.size(13)
|
||||
.color(t::TEXT_SECONDARY)
|
||||
.width(Length::Fixed(80.0)),
|
||||
.width(Length::Shrink),
|
||||
pick_list(opcoes_layout, nome_layout_sel, {
|
||||
let layouts = app.layouts_salvos.clone();
|
||||
move |nome_selecionado: String| {
|
||||
@@ -76,7 +76,7 @@ pub fn view(app: &App) -> Element<'_, Message> {
|
||||
})
|
||||
.placeholder("— Selecionar layout —")
|
||||
.style(input_style_pick)
|
||||
.width(Length::Fixed(260.0)),
|
||||
.width(Length::Fill),
|
||||
]
|
||||
.spacing(8)
|
||||
.align_y(Alignment::Center),
|
||||
@@ -120,7 +120,7 @@ pub fn view(app: &App) -> Element<'_, Message> {
|
||||
Column::with_children(
|
||||
erros
|
||||
.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<_>>(),
|
||||
)
|
||||
.spacing(4),
|
||||
@@ -137,19 +137,19 @@ pub fn view(app: &App) -> Element<'_, Message> {
|
||||
|
||||
let botoes = container(
|
||||
row![
|
||||
button(text("◀ Voltar").size(13))
|
||||
button(text("Voltar").size(13))
|
||||
.on_press(Message::Voltar)
|
||||
.style(t::btn_ghost)
|
||||
.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))
|
||||
.style(t::btn_primary)
|
||||
.padding([9, 14]),
|
||||
button(text("🔄 Reanalisar").size(13))
|
||||
button(text("Reanalisar").size(13))
|
||||
.on_press_maybe((valido && tem_notas).then_some(Message::ReanalisarArquivo))
|
||||
.style(t::btn_secondary)
|
||||
.padding([9, 14]),
|
||||
button(text("💾 Salvar layout").size(13))
|
||||
button(text("Salvar layout").size(13))
|
||||
.on_press(Message::SalvarLayout)
|
||||
.style(t::btn_ghost)
|
||||
.padding([9, 14]),
|
||||
@@ -251,7 +251,7 @@ fn view_csv(app: &App) -> Element<'_, Message> {
|
||||
Message::DelimitadorAlterado(c)
|
||||
})
|
||||
.style(input_style_pick)
|
||||
.width(Length::Fixed(200.0))
|
||||
.width(Length::Fill)
|
||||
.into(),
|
||||
),
|
||||
campo_row(
|
||||
@@ -262,7 +262,7 @@ fn view_csv(app: &App) -> Element<'_, Message> {
|
||||
Message::EncodingAlterado
|
||||
)
|
||||
.style(input_style_pick)
|
||||
.width(Length::Fixed(200.0))
|
||||
.width(Length::Fill)
|
||||
.into(),
|
||||
),
|
||||
campo_row(
|
||||
@@ -274,7 +274,7 @@ fn view_csv(app: &App) -> Element<'_, Message> {
|
||||
.unwrap_or(Message::Noop)
|
||||
})
|
||||
.style(t::input_dark)
|
||||
.width(Length::Fixed(80.0))
|
||||
.width(Length::Fixed(90.0))
|
||||
.into(),
|
||||
),
|
||||
secao_subtitulo("Mapeamento de colunas (índice base 0)"),
|
||||
@@ -287,7 +287,7 @@ fn view_csv(app: &App) -> Element<'_, Message> {
|
||||
.unwrap_or(Message::Noop)
|
||||
})
|
||||
.style(t::input_dark)
|
||||
.width(Length::Fixed(80.0))
|
||||
.width(Length::Fixed(90.0))
|
||||
.into(),
|
||||
),
|
||||
campo_row(
|
||||
@@ -299,7 +299,7 @@ fn view_csv(app: &App) -> Element<'_, Message> {
|
||||
.unwrap_or(Message::Noop)
|
||||
})
|
||||
.style(t::input_dark)
|
||||
.width(Length::Fixed(80.0))
|
||||
.width(Length::Fixed(90.0))
|
||||
.into(),
|
||||
),
|
||||
campo_indice_opcional_csv(
|
||||
@@ -341,7 +341,7 @@ fn view_xlsx(app: &App) -> Element<'_, Message> {
|
||||
text_input("Nome da aba", &c.aba)
|
||||
.on_input(Message::AbaXlsxAlterada)
|
||||
.style(t::input_dark)
|
||||
.width(Length::Fixed(200.0))
|
||||
.width(Length::Fill)
|
||||
.into(),
|
||||
)
|
||||
} else {
|
||||
@@ -354,7 +354,7 @@ fn view_xlsx(app: &App) -> Element<'_, Message> {
|
||||
"Aba",
|
||||
pick_list(app.abas_xlsx.clone(), aba_sel, Message::AbaXlsxAlterada)
|
||||
.style(input_style_pick)
|
||||
.width(Length::Fixed(200.0))
|
||||
.width(Length::Fill)
|
||||
.into(),
|
||||
)
|
||||
};
|
||||
@@ -416,7 +416,7 @@ fn campo_row<'a>(label: &'a str, input: Element<'a, Message>) -> Element<'a, Mes
|
||||
text(label)
|
||||
.size(13)
|
||||
.color(t::TEXT_SECONDARY)
|
||||
.width(Length::Fixed(220.0)),
|
||||
.width(Length::FillPortion(3)),
|
||||
input,
|
||||
]
|
||||
.spacing(10)
|
||||
@@ -437,11 +437,11 @@ fn campo_indice_opcional_csv(
|
||||
|
||||
if ativo {
|
||||
row![
|
||||
cb.width(Length::Fixed(220.0)),
|
||||
cb.width(Length::FillPortion(3)),
|
||||
text_input("0", &val_str)
|
||||
.on_input(move |s| { s.parse::<usize>().map(&msg_valor).unwrap_or(Message::Noop) })
|
||||
.style(t::input_dark)
|
||||
.width(Length::Fixed(80.0)),
|
||||
.width(Length::Fixed(90.0)),
|
||||
]
|
||||
.spacing(10)
|
||||
.align_y(Alignment::Center)
|
||||
@@ -461,7 +461,7 @@ fn campo_letra_linha<'a>(
|
||||
text_input("ex: B3", valor)
|
||||
.on_input(msg)
|
||||
.style(t::input_dark)
|
||||
.width(Length::Fixed(100.0))
|
||||
.width(Length::Fixed(110.0))
|
||||
.into(),
|
||||
)
|
||||
}
|
||||
@@ -479,11 +479,11 @@ fn campo_letra_linha_opcional<'a>(
|
||||
|
||||
if ativo {
|
||||
row![
|
||||
cb.width(Length::Fixed(220.0)),
|
||||
cb.width(Length::FillPortion(3)),
|
||||
text_input("ex: B3", &val_str)
|
||||
.on_input(msg_valor)
|
||||
.style(t::input_dark)
|
||||
.width(Length::Fixed(100.0)),
|
||||
.width(Length::Fixed(110.0)),
|
||||
]
|
||||
.spacing(10)
|
||||
.align_y(Alignment::Center)
|
||||
|
||||
+53
-17
@@ -1,7 +1,7 @@
|
||||
use crate::ui::app::App;
|
||||
use crate::ui::message::Message;
|
||||
use crate::ui::theme as t;
|
||||
use iced::widget::{button, column, container, pick_list, row, text};
|
||||
use iced::widget::{button, column, container, pick_list, row, scrollable, text};
|
||||
use iced::{Alignment, Element, Length};
|
||||
|
||||
/// Tela de importação de arquivos.
|
||||
@@ -13,14 +13,45 @@ pub fn view(app: &App) -> Element<'_, Message> {
|
||||
(app.nome_arquivo.clone(), true)
|
||||
};
|
||||
|
||||
let icone_arquivo: Element<Message> = text(if tem_arquivo { "📄" } else { "📂" })
|
||||
.size(32)
|
||||
.color(if tem_arquivo {
|
||||
t::PRIMARY
|
||||
} else {
|
||||
t::TEXT_MUTED
|
||||
})
|
||||
.into();
|
||||
let em_hover = app.arquivo_em_hover;
|
||||
|
||||
let icone_arquivo: Element<Message> = container(
|
||||
text(if tem_arquivo { "CSV / XLSX" } else { "Arquivo" })
|
||||
.size(12)
|
||||
.color(if tem_arquivo {
|
||||
t::PRIMARY
|
||||
} else {
|
||||
t::TEXT_MUTED
|
||||
}),
|
||||
)
|
||||
.padding([6, 12])
|
||||
.style(move |_theme| iced::widget::container::Style {
|
||||
background: Some(
|
||||
iced::Color {
|
||||
a: 0.1,
|
||||
..if tem_arquivo {
|
||||
t::PRIMARY
|
||||
} else {
|
||||
t::TEXT_MUTED
|
||||
}
|
||||
}
|
||||
.into(),
|
||||
),
|
||||
border: iced::Border {
|
||||
color: iced::Color {
|
||||
a: 0.3,
|
||||
..if tem_arquivo {
|
||||
t::PRIMARY
|
||||
} else {
|
||||
t::TEXT_MUTED
|
||||
}
|
||||
},
|
||||
width: 1.0,
|
||||
radius: 6.0.into(),
|
||||
},
|
||||
..Default::default()
|
||||
})
|
||||
.into();
|
||||
|
||||
let texto_arquivo: Element<Message> = if tem_arquivo {
|
||||
let nome_str = nome_arquivo.clone();
|
||||
@@ -35,7 +66,7 @@ pub fn view(app: &App) -> Element<'_, Message> {
|
||||
.into()
|
||||
} else {
|
||||
column![
|
||||
text("Selecione um arquivo CSV ou XLSX")
|
||||
text("Selecione ou arraste um arquivo CSV ou XLSX")
|
||||
.size(14)
|
||||
.color(t::TEXT_SECONDARY),
|
||||
text("Suportado: .csv, .xlsx, .xls")
|
||||
@@ -56,13 +87,18 @@ pub fn view(app: &App) -> Element<'_, Message> {
|
||||
.style(move |_theme| iced::widget::container::Style {
|
||||
background: Some(
|
||||
iced::Color {
|
||||
a: 0.05,
|
||||
a: if em_hover { 0.12 } else { 0.05 },
|
||||
..t::PRIMARY
|
||||
}
|
||||
.into(),
|
||||
),
|
||||
border: iced::Border {
|
||||
color: if tem_arquivo {
|
||||
color: if em_hover {
|
||||
iced::Color {
|
||||
a: 0.9,
|
||||
..t::PRIMARY
|
||||
}
|
||||
} else if tem_arquivo {
|
||||
iced::Color {
|
||||
a: 0.5,
|
||||
..t::PRIMARY
|
||||
@@ -73,7 +109,7 @@ pub fn view(app: &App) -> Element<'_, Message> {
|
||||
..t::BORDER
|
||||
}
|
||||
},
|
||||
width: 1.5,
|
||||
width: if em_hover { 2.0 } else { 1.5 },
|
||||
radius: 8.0.into(),
|
||||
},
|
||||
..Default::default()
|
||||
@@ -110,7 +146,7 @@ pub fn view(app: &App) -> Element<'_, Message> {
|
||||
text("Layout salvo:")
|
||||
.size(13)
|
||||
.color(t::TEXT_SECONDARY)
|
||||
.width(Length::Fixed(110.0)),
|
||||
.width(Length::Shrink),
|
||||
pick_list(opcoes_layout, nome_layout_sel, {
|
||||
let layouts = app.layouts_salvos.clone();
|
||||
move |nome_selecionado: String| {
|
||||
@@ -137,7 +173,7 @@ pub fn view(app: &App) -> Element<'_, Message> {
|
||||
|
||||
// ── Botão avançar ─────────────────────────────────────────────────────────
|
||||
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)
|
||||
.style(t::btn_primary)
|
||||
.width(Length::Fill)
|
||||
@@ -173,7 +209,7 @@ pub fn view(app: &App) -> Element<'_, Message> {
|
||||
.width(Length::Fill);
|
||||
|
||||
// ── Layout geral ──────────────────────────────────────────────────────────
|
||||
container(
|
||||
container(scrollable(
|
||||
column![
|
||||
text("Importar Arquivo").size(20).color(t::TEXT),
|
||||
text("Selecione e configure sua planilha para análise")
|
||||
@@ -185,7 +221,7 @@ pub fn view(app: &App) -> Element<'_, Message> {
|
||||
.align_x(Alignment::Center)
|
||||
.padding([32, 20])
|
||||
.width(Length::Fill),
|
||||
)
|
||||
))
|
||||
.style(t::fundo)
|
||||
.width(Length::Fill)
|
||||
.height(Length::Fill)
|
||||
|
||||
@@ -8,7 +8,7 @@ use iced::{Alignment, Element, Length};
|
||||
/// Tela de gerenciamento de layouts.
|
||||
pub fn view(app: &App) -> Element<'_, Message> {
|
||||
let cabecalho = row![
|
||||
button("< Voltar")
|
||||
button("Voltar")
|
||||
.on_press(Message::IrParaImportacao)
|
||||
.style(t::btn_secondary),
|
||||
horizontal_space(),
|
||||
|
||||
+82
-21
@@ -29,24 +29,29 @@ pub fn view<'a>(app: &'a App, resultado: &'a ResultadoAnalise) -> Element<'a, Me
|
||||
|
||||
// ── Botões de ação ────────────────────────────────────────────────────────
|
||||
let botoes_topo = row![
|
||||
button("< Nova Análise")
|
||||
button("Nova Análise")
|
||||
.on_press(Message::NovaAnalise)
|
||||
.style(t::btn_secondary),
|
||||
button("Reconfigurar Colunas")
|
||||
.style(t::btn_secondary)
|
||||
.width(Length::Shrink),
|
||||
button("Reconfigurar")
|
||||
.on_press(Message::IrParaConfiguracaoColunas)
|
||||
.style(t::btn_ghost),
|
||||
button("Reanalisar Arquivo")
|
||||
.style(t::btn_ghost)
|
||||
.width(Length::Shrink),
|
||||
button("Reanalisar")
|
||||
.on_press_maybe(
|
||||
app.caminho_arquivo
|
||||
.as_ref()
|
||||
.map(|_| Message::ReanalisarArquivo)
|
||||
)
|
||||
.style(t::btn_ghost),
|
||||
.style(t::btn_ghost)
|
||||
.width(Length::Shrink),
|
||||
button("Exportar PDF")
|
||||
.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 ──────────────────────────────────────────
|
||||
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_totais = view_totais(resultado);
|
||||
|
||||
let conteudo = column![
|
||||
let mut col_principal = column![
|
||||
row![text("Resultado da Análise").size(22).color(t::TEXT),],
|
||||
botoes_topo,
|
||||
stat_row,
|
||||
controle_pagina,
|
||||
secao_faltantes,
|
||||
secao_duplicatas,
|
||||
secao_totais,
|
||||
]
|
||||
.spacing(16)
|
||||
.padding(20)
|
||||
.width(Length::Fill);
|
||||
|
||||
container(scrollable(conteudo))
|
||||
// Badge "Sem inconsistências" quando não há faltantes nem duplicatas
|
||||
if resultado.sem_inconsistencias() {
|
||||
col_principal = col_principal.push(
|
||||
container(
|
||||
row![
|
||||
container(text("✓").size(13).color(t::SUCCESS))
|
||||
.padding([2, 8])
|
||||
.style(t::badge_sucesso),
|
||||
text("Sem inconsistências — todas as séries estão completas.")
|
||||
.size(14)
|
||||
.color(t::SUCCESS),
|
||||
]
|
||||
.spacing(8)
|
||||
.align_y(Alignment::Center),
|
||||
)
|
||||
.padding([10, 16])
|
||||
.width(Length::Fill)
|
||||
.style(t::card),
|
||||
);
|
||||
}
|
||||
|
||||
col_principal = col_principal
|
||||
.push(secao_faltantes)
|
||||
.push(secao_duplicatas)
|
||||
.push(secao_totais);
|
||||
|
||||
container(scrollable(col_principal))
|
||||
.width(Length::Fill)
|
||||
.height(Length::Fill)
|
||||
.style(t::fundo)
|
||||
@@ -127,7 +155,16 @@ fn view_faltantes<'a>(app: &'a App, resultado: &'a ResultadoAnalise) -> Element<
|
||||
let mut col = column![titulo_row].spacing(12);
|
||||
|
||||
if total == 0 {
|
||||
col = col.push(text("Nenhuma nota faltante.").size(14).color(t::SUCCESS));
|
||||
col = col.push(
|
||||
row![
|
||||
container(text(" OK ").size(12).color(t::SUCCESS))
|
||||
.padding([2, 8])
|
||||
.style(t::badge_sucesso),
|
||||
text("Nenhuma nota faltante.").size(14).color(t::SUCCESS),
|
||||
]
|
||||
.spacing(8)
|
||||
.align_y(Alignment::Center),
|
||||
);
|
||||
return container(col)
|
||||
.padding(16)
|
||||
.width(Length::Fill)
|
||||
@@ -140,11 +177,25 @@ fn view_faltantes<'a>(app: &'a App, resultado: &'a ResultadoAnalise) -> Element<
|
||||
|
||||
for chave in chaves {
|
||||
let faltantes = &resultado.faltantes_por_serie[chave];
|
||||
|
||||
let total_notas = resultado.total_por_serie.get(chave).copied().unwrap_or(0);
|
||||
|
||||
if faltantes.is_empty() {
|
||||
// Série completa — exibir badge verde
|
||||
let linha_serie = row![
|
||||
container(text(" OK ").size(12).color(t::SUCCESS))
|
||||
.padding([2, 8])
|
||||
.style(t::badge_sucesso),
|
||||
text(format!("Série {} — completa", chave.label()))
|
||||
.size(14)
|
||||
.color(t::SUCCESS),
|
||||
]
|
||||
.spacing(8)
|
||||
.align_y(Alignment::Center);
|
||||
col = col.push(linha_serie);
|
||||
continue;
|
||||
}
|
||||
|
||||
let total_notas = resultado.total_por_serie.get(chave).copied().unwrap_or(0);
|
||||
let total_esperado = total_notas + faltantes.len();
|
||||
let percentual = total_notas as f64 / total_esperado as f64;
|
||||
let percentual_f32 = percentual as f32;
|
||||
@@ -184,7 +235,12 @@ fn view_faltantes<'a>(app: &'a App, resultado: &'a ResultadoAnalise) -> Element<
|
||||
|
||||
// Paginação e lista
|
||||
let total_paginas = (faltantes.len() + app.itens_por_pagina - 1) / app.itens_por_pagina;
|
||||
let pagina = app.pagina_faltantes.min(total_paginas.saturating_sub(1));
|
||||
let pagina = app
|
||||
.pagina_faltantes
|
||||
.get(chave)
|
||||
.copied()
|
||||
.unwrap_or(0)
|
||||
.min(total_paginas.saturating_sub(1));
|
||||
let inicio = pagina * app.itens_por_pagina;
|
||||
let fim = (inicio + app.itens_por_pagina).min(faltantes.len());
|
||||
|
||||
@@ -205,8 +261,8 @@ fn view_faltantes<'a>(app: &'a App, resultado: &'a ResultadoAnalise) -> Element<
|
||||
col = col.push(crate::ui::components::paginacao::controles_paginacao(
|
||||
pagina,
|
||||
total_paginas,
|
||||
Message::PaginaFaltantesAlterada(pagina.saturating_sub(1)),
|
||||
Message::PaginaFaltantesAlterada(pagina + 1),
|
||||
Message::PaginaFaltantesAlterada(chave.clone(), pagina.saturating_sub(1)),
|
||||
Message::PaginaFaltantesAlterada(chave.clone(), pagina + 1),
|
||||
));
|
||||
}
|
||||
|
||||
@@ -276,7 +332,12 @@ fn view_duplicatas<'a>(app: &'a App, resultado: &'a ResultadoAnalise) -> Element
|
||||
col = col.push(cabecalho_serie);
|
||||
|
||||
let total_paginas = (duplicatas.len() + app.itens_por_pagina - 1) / app.itens_por_pagina;
|
||||
let pagina = app.pagina_duplicatas.min(total_paginas.saturating_sub(1));
|
||||
let pagina = app
|
||||
.pagina_duplicatas
|
||||
.get(chave)
|
||||
.copied()
|
||||
.unwrap_or(0)
|
||||
.min(total_paginas.saturating_sub(1));
|
||||
let inicio = pagina * app.itens_por_pagina;
|
||||
let fim = (inicio + app.itens_por_pagina).min(duplicatas.len());
|
||||
|
||||
@@ -299,8 +360,8 @@ fn view_duplicatas<'a>(app: &'a App, resultado: &'a ResultadoAnalise) -> Element
|
||||
col = col.push(crate::ui::components::paginacao::controles_paginacao(
|
||||
pagina,
|
||||
total_paginas,
|
||||
Message::PaginaDuplicatasAlterada(pagina.saturating_sub(1)),
|
||||
Message::PaginaDuplicatasAlterada(pagina + 1),
|
||||
Message::PaginaDuplicatasAlterada(chave.clone(), pagina.saturating_sub(1)),
|
||||
Message::PaginaDuplicatasAlterada(chave.clone(), pagina + 1),
|
||||
));
|
||||
}
|
||||
|
||||
|
||||
@@ -33,7 +33,7 @@ pub fn view<'a>(app: &'a App, abas: &'a [String], caminho: &'a PathBuf) -> Eleme
|
||||
.map(|aba| {
|
||||
let selecionada = aba == aba_atual;
|
||||
let label = row![
|
||||
text(if selecionada { "●" } else { "○" })
|
||||
text(if selecionada { ">>" } else { " " })
|
||||
.size(12)
|
||||
.color(if selecionada {
|
||||
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 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)
|
||||
.style(t::btn_ghost)
|
||||
.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 {
|
||||
botoes = botoes.push(
|
||||
button(text("Processar ▶").size(13))
|
||||
button(text("Processar").size(13))
|
||||
.on_press(Message::ExecutarImportacao)
|
||||
.style(t::btn_primary)
|
||||
.padding([9, 14]),
|
||||
@@ -135,7 +135,7 @@ pub fn view<'a>(app: &'a App, abas: &'a [String], caminho: &'a PathBuf) -> Eleme
|
||||
|
||||
if aba_selecionada {
|
||||
botoes = botoes.push(
|
||||
button(text("⚙ Configurar Colunas").size(13))
|
||||
button(text("Configurar Colunas").size(13))
|
||||
.on_press(Message::IrParaConfiguracaoColunas)
|
||||
.style(t::btn_secondary)
|
||||
.padding([9, 14]),
|
||||
|
||||
+51
-5
@@ -288,8 +288,12 @@ pub fn btn_secondary(theme: &Theme, status: button::Status) -> button::Style {
|
||||
background: Some(SURFACE_2.into()),
|
||||
text_color: TEXT,
|
||||
border: Border {
|
||||
color: Color {
|
||||
a: 0.55,
|
||||
..TEXT_SECONDARY
|
||||
},
|
||||
width: 1.0,
|
||||
radius: 6.0.into(),
|
||||
..Default::default()
|
||||
},
|
||||
..Default::default()
|
||||
};
|
||||
@@ -304,6 +308,10 @@ pub fn btn_secondary(theme: &Theme, status: button::Status) -> button::Style {
|
||||
}
|
||||
.into(),
|
||||
),
|
||||
border: Border {
|
||||
color: TEXT_SECONDARY,
|
||||
..base.border
|
||||
},
|
||||
..base
|
||||
},
|
||||
button::Status::Disabled => button::Style {
|
||||
@@ -315,6 +323,10 @@ pub fn btn_secondary(theme: &Theme, status: button::Status) -> button::Style {
|
||||
.into(),
|
||||
),
|
||||
text_color: Color { a: 0.4, ..TEXT },
|
||||
border: Border {
|
||||
color: Color { a: 0.2, ..BORDER },
|
||||
..base.border
|
||||
},
|
||||
..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 {
|
||||
let base = button::Style {
|
||||
background: Some(Color::TRANSPARENT.into()),
|
||||
text_color: TEXT_SECONDARY,
|
||||
text_color: TEXT,
|
||||
border: Border {
|
||||
color: BORDER,
|
||||
color: Color {
|
||||
a: 0.55,
|
||||
..TEXT_SECONDARY
|
||||
},
|
||||
width: 1.0,
|
||||
radius: 6.0.into(),
|
||||
},
|
||||
@@ -340,12 +355,16 @@ pub fn btn_ghost(theme: &Theme, status: button::Status) -> button::Style {
|
||||
button::Status::Hovered => button::Style {
|
||||
background: Some(Color { a: 0.08, ..PRIMARY }.into()),
|
||||
text_color: TEXT,
|
||||
border: Border {
|
||||
color: PRIMARY,
|
||||
..base.border
|
||||
},
|
||||
..base
|
||||
},
|
||||
button::Status::Disabled => button::Style {
|
||||
text_color: TEXT_MUTED,
|
||||
border: Border {
|
||||
color: Color { a: 0.3, ..BORDER },
|
||||
color: Color { a: 0.2, ..BORDER },
|
||||
..base.border
|
||||
},
|
||||
..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)
|
||||
pub fn btn_aba_ativa(theme: &Theme, status: button::Status) -> button::Style {
|
||||
let base = button::Style {
|
||||
|
||||
Reference in New Issue
Block a user