Compare commits
4
Commits
master
..
ad09c53a5d
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ad09c53a5d | ||
|
|
8d00cfc4d7 | ||
|
|
2d1d29ce3d | ||
|
|
12b8501ffb |
Generated
+1236
-694
File diff suppressed because it is too large
Load Diff
+5
-2
@@ -4,8 +4,8 @@ version = "0.1.0"
|
|||||||
edition = "2024"
|
edition = "2024"
|
||||||
|
|
||||||
[dependencies]
|
[dependencies]
|
||||||
eframe = "0.31"
|
iced = { version = "0.13", features = ["tokio", "image"] }
|
||||||
egui = "0.31"
|
tokio = { version = "1", features = ["full"] }
|
||||||
rusqlite = { version = "0.32", features = ["bundled"] }
|
rusqlite = { version = "0.32", features = ["bundled"] }
|
||||||
csv = "1.3"
|
csv = "1.3"
|
||||||
calamine = "0.26"
|
calamine = "0.26"
|
||||||
@@ -20,3 +20,6 @@ dirs = "5"
|
|||||||
thiserror = "2"
|
thiserror = "2"
|
||||||
regex = "1"
|
regex = "1"
|
||||||
rfd = "0.15"
|
rfd = "0.15"
|
||||||
|
|
||||||
|
[build-dependencies]
|
||||||
|
winres = "0.1"
|
||||||
|
|||||||
+1
-1
@@ -84,7 +84,7 @@ Verificar se já existe no codigo, pois na tela de configuração do Layout ele
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
### F-05 — Recarregar Arquivo Sem Reconfigurar (Sim bem necessario)
|
### F-05 — Recarregar Arquivo Sem Reconfigurar (Implementado)
|
||||||
|
|
||||||
**Problema:** Quando o usuário corrige o arquivo fonte e quer re-verificar, precisa navegar todo o fluxo novamente (selecionar arquivo → configurar colunas → analisar).
|
**Problema:** Quando o usuário corrige o arquivo fonte e quer re-verificar, precisa navegar todo o fluxo novamente (selecionar arquivo → configurar colunas → analisar).
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,504 @@
|
|||||||
|
# Migração egui → iced: Estado e Referência
|
||||||
|
|
||||||
|
**Branch:** `change-ui`
|
||||||
|
**Versão iced:** 0.13.1
|
||||||
|
**Última atualização:** 04/03/2026
|
||||||
|
**Status:** ✅ **COMPILANDO** — `cargo build` passa sem erros (5 warnings não-bloqueantes, todos em camadas intocáveis)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Estado Atual
|
||||||
|
|
||||||
|
### O que foi feito (100% completo)
|
||||||
|
|
||||||
|
| Arquivo | Status | Descrição |
|
||||||
|
|---|---|---|
|
||||||
|
| `Cargo.toml` | ✅ | `eframe`/`egui`/`image` removidos; `iced 0.13` + `tokio` adicionados |
|
||||||
|
| `src/main.rs` | ✅ | Reescrito com `iced::application(...)` |
|
||||||
|
| `src/ui/mod.rs` | ✅ | `pub mod app; pub mod components; pub mod message; pub mod screens;` |
|
||||||
|
| `src/ui/message.rs` | ✅ | Enum `Message` completo + enum `ResultadoPendente` |
|
||||||
|
| `src/ui/app.rs` | ✅ | Reescrito para iced (Elm architecture) — `App`, `update()`, `view()` |
|
||||||
|
| `src/ui/screens/mod.rs` | ✅ | Re-exports + `pub fn indice_para_letra(idx: usize) -> String` |
|
||||||
|
| `src/ui/screens/import.rs` | ✅ | Tela de importação — pick_list de layout usa `LayoutSelecionado` |
|
||||||
|
| `src/ui/screens/selecionar_aba.rs` | ✅ | Tela de seleção de aba XLSX |
|
||||||
|
| `src/ui/screens/configuracao_colunas.rs` | ✅ | Tela de configuração — pick_list de layout usa `LayoutSelecionado` |
|
||||||
|
| `src/ui/screens/resultado.rs` | ✅ | Tela de resultado — botão "Nova Análise" usa `Message::NovaAnalise` |
|
||||||
|
| `src/ui/screens/layouts.rs` | ✅ | Tela de gerenciamento de layouts |
|
||||||
|
| `src/ui/components/mod.rs` | ✅ | `pub mod modal; pub mod paginacao; pub mod tabela_preview;` |
|
||||||
|
| `src/ui/components/modal.rs` | ✅ | Overlay real com `stack!` + `mouse_area` |
|
||||||
|
| `src/ui/components/tabela_preview.rs` | ✅ | Tabela com scroll horizontal e cabeçalho estilo Excel |
|
||||||
|
| `src/ui/components/paginacao.rs` | ✅ | Controles reutilizáveis de paginação |
|
||||||
|
|
||||||
|
### O que NÃO foi alterado (intocado por design)
|
||||||
|
|
||||||
|
- `src/domain/` — entidades, serviços, erros
|
||||||
|
- `src/application/` — casos de uso
|
||||||
|
- `src/infrastructure/` — leitores CSV/XLSX, gerador PDF, SQLite
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Arquitetura da UI
|
||||||
|
|
||||||
|
### Padrão Elm (iced)
|
||||||
|
|
||||||
|
```
|
||||||
|
App (estado) ──→ view(&self) ──→ Element (widgets renderizados)
|
||||||
|
↑ ↓
|
||||||
|
│ usuário interage
|
||||||
|
│ ↓
|
||||||
|
└── update(&mut self, msg) ←── Message (enum)
|
||||||
|
retorna Task<Message>
|
||||||
|
```
|
||||||
|
|
||||||
|
- `view()` é **pura e somente leitura** — nunca muta estado
|
||||||
|
- Toda mutação acontece **exclusivamente** em `update()`
|
||||||
|
- Background tasks retornam via `Task::perform(async { ... }, Message::Variante)`
|
||||||
|
|
||||||
|
### Estrutura de arquivos
|
||||||
|
|
||||||
|
```
|
||||||
|
src/
|
||||||
|
├── main.rs
|
||||||
|
└── ui/
|
||||||
|
├── mod.rs
|
||||||
|
├── app.rs (struct App + update + view)
|
||||||
|
├── message.rs (enum Message + ResultadoPendente)
|
||||||
|
├── screens/
|
||||||
|
│ ├── mod.rs (re-exports + indice_para_letra)
|
||||||
|
│ ├── import.rs
|
||||||
|
│ ├── selecionar_aba.rs
|
||||||
|
│ ├── configuracao_colunas.rs
|
||||||
|
│ ├── resultado.rs
|
||||||
|
│ └── layouts.rs
|
||||||
|
└── components/
|
||||||
|
├── mod.rs
|
||||||
|
├── modal.rs
|
||||||
|
├── tabela_preview.rs
|
||||||
|
└── paginacao.rs
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Diferenças Críticas da API — iced 0.13
|
||||||
|
|
||||||
|
Estas diferenças causaram erros de compilação e **devem ser lembradas** em qualquer adição futura:
|
||||||
|
|
||||||
|
### 1. `Command` foi renomeado para `Task`
|
||||||
|
|
||||||
|
```rust
|
||||||
|
// ❌ NÃO EXISTE em iced 0.13
|
||||||
|
use iced::Command;
|
||||||
|
Command::none()
|
||||||
|
|
||||||
|
// ✅ CORRETO
|
||||||
|
use iced::Task;
|
||||||
|
Task::none()
|
||||||
|
Task::perform(future, mapper)
|
||||||
|
```
|
||||||
|
|
||||||
|
Todos os retornos de `update()` são `Task<Message>`.
|
||||||
|
|
||||||
|
### 2. `.align_items()` foi dividido
|
||||||
|
|
||||||
|
```rust
|
||||||
|
// ❌ NÃO EXISTE em iced 0.13
|
||||||
|
row![...].align_items(Alignment::Center)
|
||||||
|
|
||||||
|
// ✅ CORRETO — Row usa align_y, Column usa align_x
|
||||||
|
row![...].align_y(Alignment::Center)
|
||||||
|
column![...].align_x(Alignment::Center)
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3. `.center_x()` / `.center_y()` exigem argumento `Length`
|
||||||
|
|
||||||
|
```rust
|
||||||
|
// ❌ NÃO COMPILA
|
||||||
|
container(...).center_x().center_y()
|
||||||
|
|
||||||
|
// ✅ CORRETO
|
||||||
|
container(...).center_x(Length::Fill).center_y(Length::Fill)
|
||||||
|
```
|
||||||
|
|
||||||
|
### 4. `iced::clipboard::write` retorna `Task<Message>`
|
||||||
|
|
||||||
|
```rust
|
||||||
|
// Pode ser retornado diretamente de update():
|
||||||
|
return iced::clipboard::write(texto);
|
||||||
|
```
|
||||||
|
|
||||||
|
### 5. Entry point usa API funcional, não trait `Application`
|
||||||
|
|
||||||
|
```rust
|
||||||
|
// main.rs
|
||||||
|
fn main() -> iced::Result {
|
||||||
|
iced::application("Título", App::update, App::view)
|
||||||
|
.window(iced::window::Settings { ... })
|
||||||
|
.run_with(App::new)
|
||||||
|
}
|
||||||
|
|
||||||
|
// App::new retorna (Self, Task<Message>)
|
||||||
|
pub fn new() -> (Self, Task<Message>) { ... }
|
||||||
|
```
|
||||||
|
|
||||||
|
### 6. Lifetimes em funções que retornam `Element`
|
||||||
|
|
||||||
|
Sempre usar `Element<'_, Message>` (não `Element<Message>`) em funções que recebem referências:
|
||||||
|
|
||||||
|
```rust
|
||||||
|
// ❌ Gera warning mismatched_lifetime_syntaxes
|
||||||
|
pub fn view(app: &App) -> Element<Message>
|
||||||
|
|
||||||
|
// ✅ CORRETO
|
||||||
|
pub fn view(app: &App) -> Element<'_, Message>
|
||||||
|
```
|
||||||
|
|
||||||
|
**Armadilha com Vec local:** criar `Vec<T>` local e passar `&vec` para função que retorna `Element<'a>` causa E0515. Solução: filtrar diretamente de dados que vivem no `&app`.
|
||||||
|
|
||||||
|
### 7. Borrow checker com `Arc<Mutex<Connection>>`
|
||||||
|
|
||||||
|
```rust
|
||||||
|
// ❌ Mantém borrow imutável de &self enquanto tenta &mut self
|
||||||
|
if let Some(conn_arc) = &self.conn {
|
||||||
|
let conn = conn_arc.lock().unwrap();
|
||||||
|
self.exibir_erro(...); // ERRO: borrow ativo
|
||||||
|
}
|
||||||
|
|
||||||
|
// ✅ Clonar o Arc primeiro (O(1), não clona a Connection)
|
||||||
|
let conn_arc = self.conn.as_ref().map(Arc::clone);
|
||||||
|
if let Some(conn_arc) = conn_arc {
|
||||||
|
let conn = conn_arc.lock().unwrap();
|
||||||
|
drop(conn); // liberar antes de &mut self
|
||||||
|
self.exibir_erro(...);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Componentes Implementados
|
||||||
|
|
||||||
|
### `App` (src/ui/app.rs)
|
||||||
|
|
||||||
|
**Estado:**
|
||||||
|
```rust
|
||||||
|
pub struct App {
|
||||||
|
pub estado: EstadoApp,
|
||||||
|
pub conn: Option<Arc<Mutex<Connection>>>,
|
||||||
|
pub banco_foi_recriado: bool,
|
||||||
|
pub notas_importadas: Vec<Nota>,
|
||||||
|
pub caminho_arquivo: Option<PathBuf>, // ← populado para CSV e XLSX
|
||||||
|
pub nome_arquivo: String,
|
||||||
|
pub tipo_arquivo_atual: TipoArquivo,
|
||||||
|
pub layout_csv_atual: LayoutCsv,
|
||||||
|
pub layout_xlsx_atual: LayoutXlsx,
|
||||||
|
pub nome_layout_atual: String,
|
||||||
|
pub abas_xlsx: Vec<String>,
|
||||||
|
pub layouts_salvos: Vec<Layout>,
|
||||||
|
pub modal: Option<EstadoModal>,
|
||||||
|
pub avisos_importacao: Option<ResumoAvisos>,
|
||||||
|
pub pagina_faltantes: usize,
|
||||||
|
pub pagina_duplicatas: usize,
|
||||||
|
pub itens_por_pagina: usize,
|
||||||
|
pub preview_arquivo: Option<Vec<Vec<String>>>,
|
||||||
|
pub resultado_anterior: Option<ResultadoAnalise>,
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Máquina de estados (`EstadoApp`):**
|
||||||
|
```
|
||||||
|
Importando
|
||||||
|
→ ArquivoSelecionado (CSV) → ConfigurandoColunas
|
||||||
|
→ ArquivoSelecionado (XLSX) → SelecionandoAba → ConfigurandoColunas
|
||||||
|
ConfigurandoColunas
|
||||||
|
→ ExecutarImportacao → Analisando
|
||||||
|
Analisando
|
||||||
|
→ AnaliseCompleta (ok, sem excessivos) → ExibindoResultado
|
||||||
|
→ AnaliseCompleta (excessivos) → ConfirmandoIntervalo (+ modal)
|
||||||
|
ConfirmandoIntervalo
|
||||||
|
→ ModalConfirmado (ConfirmarExpansaoFaltantes) → Analisando → ExibindoResultado
|
||||||
|
→ ModalCancelado → ConfigurandoColunas
|
||||||
|
ExibindoResultado
|
||||||
|
→ ReanalisarArquivo → Analisando
|
||||||
|
→ IrParaConfiguracaoColunas → ConfigurandoColunas
|
||||||
|
→ NovaAnalise (modal) → Importando (após confirmação)
|
||||||
|
GerenciandoLayouts
|
||||||
|
→ IrParaImportacao → Importando
|
||||||
|
```
|
||||||
|
|
||||||
|
**Modal (`EstadoModal` / `AcaoModal`):**
|
||||||
|
```rust
|
||||||
|
pub enum EstadoModal {
|
||||||
|
Informacao { titulo: String, mensagem: String }, // definido, não usado ainda
|
||||||
|
Aviso { titulo: String, mensagem: String },
|
||||||
|
Erro { titulo: String, mensagem: String },
|
||||||
|
Confirmacao { titulo: String, mensagem: String, acao: AcaoModal },
|
||||||
|
InputTexto { titulo: String, mensagem: String, texto: String, acao: AcaoModal },
|
||||||
|
}
|
||||||
|
|
||||||
|
pub enum AcaoModal {
|
||||||
|
ConfirmarExpansaoFaltantes,
|
||||||
|
ConfirmarExclusaoLayout(i64),
|
||||||
|
SobrescreverLayout(Layout),
|
||||||
|
ConfirmarNovaAnalise,
|
||||||
|
SalvarLayoutConfig,
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Comportamento do `ModalCancelado`:**
|
||||||
|
- Fecha o modal (`self.modal = None`)
|
||||||
|
- Se o estado for `ConfirmandoIntervalo`, **também** reseta para `ConfigurandoColunas`
|
||||||
|
|
||||||
|
### `Message` (src/ui/message.rs) — enum completo
|
||||||
|
|
||||||
|
```rust
|
||||||
|
pub enum Message {
|
||||||
|
// Inicialização
|
||||||
|
BancoInicializado(Result<(Arc<Mutex<Connection>>, bool, Vec<Layout>), String>),
|
||||||
|
LayoutsRecarregados(Vec<Layout>),
|
||||||
|
|
||||||
|
// Navegação
|
||||||
|
IrParaImportacao, IrParaConfiguracaoColunas, IrParaLayouts, Voltar,
|
||||||
|
|
||||||
|
// Arquivo
|
||||||
|
SelecionarArquivo,
|
||||||
|
ArquivoSelecionado(PathBuf),
|
||||||
|
AbaSelecionada(String),
|
||||||
|
AbaxlsxCarregadas { caminho: PathBuf, abas: Vec<String>, layout_xlsx: LayoutXlsx, nome_layout: String },
|
||||||
|
XlsxErroAoCarregar(String),
|
||||||
|
|
||||||
|
// Background
|
||||||
|
AnaliseCompleta(ResultadoPendente),
|
||||||
|
|
||||||
|
// Config CSV
|
||||||
|
DelimitadorAlterado(char), EncodingAlterado(String), LinhaCabecalhoAlterada(usize),
|
||||||
|
IndiceNumeroAlterado(usize), IndiceSerieAlterado(usize),
|
||||||
|
IndiceValorToggle(bool), IndiceValorAlterado(usize),
|
||||||
|
IndiceDataToggle(bool), IndiceDataAlterado(usize),
|
||||||
|
IndiceDocTipoToggle(bool), IndiceDocTipoAlterado(usize),
|
||||||
|
|
||||||
|
// Config XLSX
|
||||||
|
AbaXlsxAlterada(String),
|
||||||
|
PosNumeroAlterada(String), PosSerieAlterada(String),
|
||||||
|
PosValorToggle(bool), PosValorAlterada(String),
|
||||||
|
PosDataToggle(bool), PosDataAlterada(String),
|
||||||
|
PosDocTipoToggle(bool), PosDocTipoAlterada(String),
|
||||||
|
|
||||||
|
// Análise
|
||||||
|
ExecutarImportacao, ReanalisarArquivo,
|
||||||
|
ConfirmarExpansaoFaltantes,
|
||||||
|
CancelarExpansao, // arm existe em update() mas nenhum widget o dispara diretamente
|
||||||
|
NovaAnalise, // abre modal de confirmação (AcaoModal::ConfirmarNovaAnalise)
|
||||||
|
|
||||||
|
// Resultado
|
||||||
|
PaginaFaltantesAlterada(usize), PaginaDuplicatasAlterada(usize),
|
||||||
|
ItensPorPaginaAlterado(usize),
|
||||||
|
CopiarFaltantes(ChaveSerie), CopiarDuplicatas(ChaveSerie),
|
||||||
|
ExportarPdf, PdfExportado(Result<PathBuf, String>),
|
||||||
|
|
||||||
|
// Layouts
|
||||||
|
LayoutSelecionado(i64), // aplica layout (chama aplicar_layout) + atualiza nome
|
||||||
|
SalvarLayout, // abre modal InputTexto
|
||||||
|
NomeLayoutAlterado(String),
|
||||||
|
ExcluirLayout(i64), ExclusaoConfirmada(i64),
|
||||||
|
ExportarLayoutJson(i64), ImportarLayoutJson, LayoutJsonImportado(String),
|
||||||
|
SobrescreverLayout(Layout),
|
||||||
|
|
||||||
|
// Modal
|
||||||
|
ModalTextoAlterado(String), ModalConfirmado, ModalCancelado,
|
||||||
|
|
||||||
|
Noop,
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### `modal.rs` (src/ui/components/modal.rs)
|
||||||
|
|
||||||
|
Overlay real: `stack![conteudo, overlay_escuro]`. O `mouse_area` captura cliques fora da caixa e dispara `ModalCancelado`.
|
||||||
|
|
||||||
|
```rust
|
||||||
|
pub fn view_com_modal<'a>(
|
||||||
|
conteudo: Element<'a, Message>,
|
||||||
|
modal: &'a EstadoModal,
|
||||||
|
) -> Element<'a, Message>
|
||||||
|
```
|
||||||
|
|
||||||
|
Botões da caixa modal:
|
||||||
|
- Sempre: **"Fechar"** → `ModalCancelado`
|
||||||
|
- Se `com_confirmar`: **"Confirmar"** → `ModalConfirmado`
|
||||||
|
|
||||||
|
### `tabela_preview.rs` (src/ui/components/tabela_preview.rs)
|
||||||
|
|
||||||
|
Cabeçalho com letras estilo Excel (A, B, C...) + índice numérico. Scroll horizontal. Trunca células em 30 caracteres. Altura fixa de 160px.
|
||||||
|
|
||||||
|
### `paginacao.rs` (src/ui/components/paginacao.rs)
|
||||||
|
|
||||||
|
```rust
|
||||||
|
pub fn controles_paginacao(
|
||||||
|
pagina_atual: usize,
|
||||||
|
total_paginas: usize,
|
||||||
|
msg_anterior: Message,
|
||||||
|
msg_proximo: Message,
|
||||||
|
) -> Element<'static, Message>
|
||||||
|
```
|
||||||
|
|
||||||
|
Botões ◀ / ▶ desabilitados automaticamente na primeira/última página via `on_press_maybe`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Regras de Seleção de Layout (pick_list)
|
||||||
|
|
||||||
|
**Em todas as telas** que exibem um `pick_list` de layouts, o handler **deve** usar `LayoutSelecionado(id)`, não `NomeLayoutAlterado`:
|
||||||
|
|
||||||
|
```rust
|
||||||
|
pick_list(opcoes_layout, nome_sel, {
|
||||||
|
let layouts = app.layouts_salvos.clone();
|
||||||
|
move |nome: String| {
|
||||||
|
if let Some(id) = layouts.iter().find(|l| l.nome() == nome).and_then(|l| l.id()) {
|
||||||
|
Message::LayoutSelecionado(id)
|
||||||
|
} else {
|
||||||
|
Message::NomeLayoutAlterado(nome) // fallback (nunca deve ocorrer com layouts do banco)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
```
|
||||||
|
|
||||||
|
Isso vale para: `import.rs`, `configuracao_colunas.rs`. A diferença:
|
||||||
|
- `NomeLayoutAlterado` — só atualiza `self.nome_layout_atual` (string), **não preenche o formulário**
|
||||||
|
- `LayoutSelecionado(id)` — chama `aplicar_layout()`, que preenche `layout_csv_atual` ou `layout_xlsx_atual`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Fluxo XLSX — Detalhe Crítico
|
||||||
|
|
||||||
|
### Onde `caminho_arquivo` é populado
|
||||||
|
|
||||||
|
Para **CSV**: em `processar_arquivo_selecionado()`, logo após detectar a extensão.
|
||||||
|
|
||||||
|
Para **XLSX**: o caminho fica dentro do estado `SelecionandoAba { caminho }` e é copiado para `self.caminho_arquivo` **somente quando o usuário seleciona uma aba** (handler `AbaSelecionada`).
|
||||||
|
|
||||||
|
```
|
||||||
|
ArquivoSelecionado(xlsx)
|
||||||
|
→ AbaxlsxCarregadas
|
||||||
|
→ aba já definida no layout e existe no arquivo?
|
||||||
|
Sim → caminho_arquivo setado aqui → disparar_importacao()
|
||||||
|
Não → estado = SelecionandoAba { caminho } ← caminho_arquivo ainda None aqui
|
||||||
|
→ AbaSelecionada(aba)
|
||||||
|
→ caminho_arquivo = Some(caminho) ← só aqui é setado
|
||||||
|
→ estado permanece SelecionandoAba
|
||||||
|
→ IrParaConfiguracaoColunas
|
||||||
|
→ estado = ConfigurandoColunas
|
||||||
|
→ tem_arquivo = true ✓
|
||||||
|
```
|
||||||
|
|
||||||
|
**Consequência:** nunca verificar `app.caminho_arquivo.is_some()` como condição de habilitação antes do usuário ter selecionado uma aba no fluxo XLSX.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Background Tasks
|
||||||
|
|
||||||
|
Todas as operações pesadas usam `Task::perform`:
|
||||||
|
|
||||||
|
```rust
|
||||||
|
// Padrão para operações síncronas em background:
|
||||||
|
Task::perform(
|
||||||
|
async move {
|
||||||
|
tokio::task::spawn_blocking(move || {
|
||||||
|
// código síncrono (rusqlite, calamine, etc.)
|
||||||
|
})
|
||||||
|
.await
|
||||||
|
.unwrap_or_else(|e| ResultadoPendente::Erro(e.to_string()))
|
||||||
|
},
|
||||||
|
Message::AnaliseCompleta,
|
||||||
|
)
|
||||||
|
```
|
||||||
|
|
||||||
|
| Operação | Message de retorno |
|
||||||
|
|---|---|
|
||||||
|
| Abrir banco + migrations + listar layouts | `BancoInicializado` |
|
||||||
|
| Listar layouts após salvar/excluir | `LayoutsRecarregados` |
|
||||||
|
| Listar abas de arquivo XLSX | `AbaxlsxCarregadas` / `XlsxErroAoCarregar` |
|
||||||
|
| Importar CSV/XLSX + analisar | `AnaliseCompleta` |
|
||||||
|
| Expandir análise com faltantes | `AnaliseCompleta` |
|
||||||
|
| Exportar PDF | `PdfExportado` |
|
||||||
|
| Exportar layout JSON | `Noop` (salva diretamente) |
|
||||||
|
| Importar layout JSON | `LayoutJsonImportado` |
|
||||||
|
|
||||||
|
**Diálogos de arquivo** usam `rfd::AsyncFileDialog` (não bloqueia):
|
||||||
|
```rust
|
||||||
|
Task::perform(
|
||||||
|
async {
|
||||||
|
rfd::AsyncFileDialog::new()
|
||||||
|
.add_filter("Planilhas", &["csv", "xlsx", "xls"])
|
||||||
|
.pick_file()
|
||||||
|
.await
|
||||||
|
.map(|h| h.path().to_path_buf())
|
||||||
|
},
|
||||||
|
|r| r.map(Message::ArquivoSelecionado).unwrap_or(Message::Noop),
|
||||||
|
)
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Warnings Existentes (não-bloqueantes)
|
||||||
|
|
||||||
|
| Warning | Local | Situação |
|
||||||
|
|---|---|---|
|
||||||
|
| `field data is never read` | `domain/nota.rs` | Domínio intocável — ignorar |
|
||||||
|
| `fields minimo/maximo never read` | `domain/resultado_analise.rs` | Domínio intocável — ignorar |
|
||||||
|
| `method sem_inconsistencias never used` | `domain/resultado_analise.rs` | Domínio intocável — ignorar |
|
||||||
|
| `variant Informacao never constructed` | `ui/app.rs` | Mantido para uso futuro (modal informativo) |
|
||||||
|
| `variant CancelarExpansao never constructed` | `ui/message.rs` | O arm existe em `update()`, mas nenhum widget o dispara; o cancelamento do intervalo ocorre via `ModalCancelado` |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## O Que Falta Implementar
|
||||||
|
|
||||||
|
### Bugs/lacunas conhecidos
|
||||||
|
|
||||||
|
1. **`selecionar_aba.rs` — botões sem diferenciação visual entre aba selecionada e não selecionada** — ambos os branches do `if selecionada` são idênticos (sem estilo diferente). A aba ativa deveria ter destaque visual.
|
||||||
|
|
||||||
|
2. **`selecionar_aba.rs` — layout não é aplicável nessa tela** — se o usuário chegou aqui sem ter selecionado um layout previamente, não há como selecionar um layout antes de avançar para ConfigurandoColunas. Considerar adicionar um `pick_list` de layouts na tela `SelecionandoAba`.
|
||||||
|
|
||||||
|
3. **Sem tema dark/light** — usa o tema padrão do sistema. Para adicionar:
|
||||||
|
```rust
|
||||||
|
// Em main.rs: encadear .theme(|app, _| app.tema.clone())
|
||||||
|
// Em App: campo pub tema: iced::Theme
|
||||||
|
// Mensagem: Message::TemaAlterado(iced::Theme)
|
||||||
|
```
|
||||||
|
|
||||||
|
4. **Sem drag-and-drop de arquivos** — importação tem apenas o botão de seleção. Verificar suporte nativo em iced 0.13 antes de implementar.
|
||||||
|
|
||||||
|
5. **Sem filtro/busca nos resultados** — sem `text_input` para filtrar `faltantes_por_serie`.
|
||||||
|
|
||||||
|
6. **`EstadoModal::Informacao` nunca é usado** — definido mas sem chamadas. Remover ou usar.
|
||||||
|
|
||||||
|
### Funcionalidades futuras (Fase 6 do plano original)
|
||||||
|
|
||||||
|
- Histórico de análises (nova tela + schema SQLite v4)
|
||||||
|
- Gráficos de completude por série
|
||||||
|
- Exportação para Excel/CSV
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Como Rodar
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Verificar sem compilar binário (rápido)
|
||||||
|
cargo check
|
||||||
|
|
||||||
|
# Compilar (primeira vez ~1 min por baixar iced + wgpu)
|
||||||
|
cargo build
|
||||||
|
|
||||||
|
# Executar em modo debug
|
||||||
|
cargo run
|
||||||
|
|
||||||
|
# Build de release (otimizado, sem console no Windows)
|
||||||
|
cargo build --release
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Referências
|
||||||
|
|
||||||
|
- [iced 0.13 changelog](https://github.com/iced-rs/iced/blob/master/CHANGELOG.md)
|
||||||
|
- [iced exemplos oficiais](https://github.com/iced-rs/iced/tree/master/examples)
|
||||||
|
- [rfd AsyncFileDialog](https://docs.rs/rfd/latest/rfd/struct.AsyncFileDialog.html)
|
||||||
|
- [iced::clipboard::write](https://docs.rs/iced/0.13.1/iced/clipboard/fn.write.html)
|
||||||
-113
@@ -1,113 +0,0 @@
|
|||||||
# Sugestões de Melhoria — Fluxo da Interface
|
|
||||||
|
|
||||||
**Data:** 02/03/2026
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 1. Indicador de progresso das etapas
|
|
||||||
|
|
||||||
O fluxo possui 3–4 passos bem definidos (`Importando → ConfigurandoColunas → ExibindoResultado`), mas não há nenhum indicador visual de onde o usuário está.
|
|
||||||
|
|
||||||
**Sugestão:** Adicionar um breadcrumb simples no topo de todas as telas:
|
|
||||||
|
|
||||||
```
|
|
||||||
① Arquivo ② Colunas ③ Resultado
|
|
||||||
```
|
|
||||||
|
|
||||||
O passo atual ficaria destacado. Isso orienta o usuário sobre o que falta sem exigir nenhuma regra de negócio adicional.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 2. Unificar importação e configuração de colunas em um único painel
|
|
||||||
|
|
||||||
Atualmente o caminho é:
|
|
||||||
1. Selecionar arquivo → clicar **"▶ Configurar Colunas"**
|
|
||||||
2. Configurar colunas → clicar **"▶ Importar e Analisar"**
|
|
||||||
|
|
||||||
São 3 ações separadas para chegar à análise.
|
|
||||||
|
|
||||||
**Sugestão:** Mover as configurações CSV/XLSX para a mesma tela de importação como uma seção expansível ("Configurações avançadas"), deixando o botão principal como **"▶ Importar e Analisar"** direto.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 3. Pré-visualização das primeiras linhas do arquivo
|
|
||||||
|
|
||||||
Após selecionar o arquivo, o usuário precisa alternar entre a aplicação e a planilha para descobrir quais índices correspondem a cada campo.
|
|
||||||
|
|
||||||
**Sugestão:** Exibir as primeiras 3–5 linhas do arquivo em uma tabela simples logo após a seleção, para que o usuário identifique visualmente o índice de cada coluna sem sair do app.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 4. Confirmação ao clicar em "Nova Análise"
|
|
||||||
|
|
||||||
O botão **"< Nova Análise"** em `resultado.rs` executa `app.notas_importadas.clear()` imediatamente, sem nenhum modal de confirmação. Um clique acidental descarta o resultado atual sem aviso.
|
|
||||||
|
|
||||||
**Sugestão:** Exibir modal de confirmação com a mensagem:
|
|
||||||
> "Deseja iniciar uma nova análise? O resultado atual será descartado."
|
|
||||||
|
|
||||||
Botões: **Confirmar** | **Cancelar**
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 5. Reorganizar seções do resultado
|
|
||||||
|
|
||||||
A ordem atual das seções em `resultado.rs` é:
|
|
||||||
|
|
||||||
```
|
|
||||||
Totais → Faltantes → Duplicatas
|
|
||||||
```
|
|
||||||
|
|
||||||
O objetivo principal do software é detectar faltantes e duplicatas; os totais são informação complementar.
|
|
||||||
|
|
||||||
**Sugestão:** Inverter para:
|
|
||||||
|
|
||||||
```
|
|
||||||
Faltantes → Duplicatas → Totais
|
|
||||||
```
|
|
||||||
|
|
||||||
Isso coloca a informação mais relevante no topo da tela.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 6. Seletor de layout também na tela de configuração de colunas
|
|
||||||
|
|
||||||
O dropdown de layouts está disponível apenas em `import.rs`. O usuário frequentemente percebe que precisa de um layout diferente **depois** de visitar a tela de configuração e ver os campos.
|
|
||||||
|
|
||||||
**Sugestão:** Duplicar o seletor de layout no topo de `configuracao_colunas.rs`, evitando que o usuário volte à tela anterior só para trocar o layout.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 7. Botão "Salvar como layout..." na tela de configuração
|
|
||||||
|
|
||||||
Para salvar um layout atualmente o usuário precisa navegar para `GerenciandoLayouts`. Esse desvio quebra o fluxo principal.
|
|
||||||
|
|
||||||
**Sugestão:** Adicionar um botão **"💾 Salvar como layout..."** diretamente em `configuracao_colunas.rs` que abre um modal simples pedindo apenas o nome do layout. Internamente, chama o mesmo use case `salvar_layout`.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 8. Feedback visual durante a análise
|
|
||||||
|
|
||||||
A transição `ConfigurandoColunas → ExibindoResultado` pode demorar com arquivos grandes (até 100k registros, conforme RNF03). Atualmente o app não exibe nenhum sinal enquanto processa, parecendo travado.
|
|
||||||
|
|
||||||
**Sugestão:** Adicionar o estado `Analisando` no `EstadoApp` (já previsto no IMPLEMENTACAO.md mas não implementado) e exibir uma mensagem simples tipo:
|
|
||||||
|
|
||||||
```
|
|
||||||
⏳ Analisando... aguarde.
|
|
||||||
```
|
|
||||||
|
|
||||||
Mesmo sem progresso percentual, já elimina a percepção de travamento.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Resumo de Impacto
|
|
||||||
|
|
||||||
| # | Sugestão | Impacto UX | Esforço estimado |
|
|
||||||
|---|----------|------------|-----------------|
|
|
||||||
| 1 | Breadcrumb de etapas | Médio | Baixo |
|
|
||||||
| 2 | Unificar importação + configuração | Alto | Médio |
|
|
||||||
| 3 | Pré-visualização do arquivo | Alto | Médio |
|
|
||||||
| 4 | Confirmação em "Nova Análise" | Baixo | Baixo |
|
|
||||||
| 5 | Reordenar seções do resultado | Médio | Baixo |
|
|
||||||
| 6 | Seletor de layout em configuração | Médio | Baixo |
|
|
||||||
| 7 | Salvar layout na tela de configuração | Médio | Baixo |
|
|
||||||
| 8 | Feedback durante análise | Alto | Baixo |
|
|
||||||
+291
@@ -0,0 +1,291 @@
|
|||||||
|
# UI Redesign — Comparador de Notas
|
||||||
|
|
||||||
|
**Data:** 04/03/2026
|
||||||
|
**Branch:** `change-ui`
|
||||||
|
**Base:** iced 0.13.1 (Elm architecture)
|
||||||
|
**Referência visual:** `ui-ideia/mockup.html` + `ui-ideia/design_tokens.json`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. Objetivo
|
||||||
|
|
||||||
|
Aplicar o visual do mockup (tema dark navy, cards, badges coloridos, progress bars) a todas as telas da aplicação, mantendo a lógica de negócio e a arquitetura Elm intocadas.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. Design Tokens (mapeados para Rust)
|
||||||
|
|
||||||
|
### Paleta de cores
|
||||||
|
|
||||||
|
| Token | Hex | Uso |
|
||||||
|
|------------------------|-----------|---------------------------------------|
|
||||||
|
| `BG` | `#0F172A` | Fundo geral da janela |
|
||||||
|
| `SURFACE` | `#1E293B` | Cards / containers primários |
|
||||||
|
| `SURFACE_2` | `#334155` | Cards secundários, cabeçalho de tabela|
|
||||||
|
| `BORDER` | `#334155` | Bordas de inputs e cards |
|
||||||
|
| `TEXT` | `#F1F5F9` | Texto principal |
|
||||||
|
| `TEXT_SECONDARY` | `#94A3B8` | Labels, placeholders, texto muted |
|
||||||
|
| `TEXT_MUTED` | `#64748B` | Texto desabilitado |
|
||||||
|
| `PRIMARY` | `#3B82F6` | Botões primários, links, step ativo |
|
||||||
|
| `PRIMARY_HOVER` | `#2563EB` | Hover em botões primários |
|
||||||
|
| `SUCCESS` | `#22C55E` | Badge OK, progress bar ≥ 90% |
|
||||||
|
| `WARNING` | `#F59E0B` | Badge Faltante, progress bar 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
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
fn main() {
|
||||||
|
if std::env::var("CARGO_CFG_TARGET_OS").unwrap() == "windows" {
|
||||||
|
let mut res = winres::WindowsResource::new();
|
||||||
|
res.set_icon("icon.ico");
|
||||||
|
res.compile().unwrap();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -6,7 +6,7 @@ use crate::domain::{
|
|||||||
},
|
},
|
||||||
services::{
|
services::{
|
||||||
detector_duplicidade::duplicidades_por_serie,
|
detector_duplicidade::duplicidades_por_serie,
|
||||||
detector_sequencia::{LIMITE_FALTANTES, calcular_intervalo, detectar_faltantes},
|
detector_sequencia::{calcular_intervalo, detectar_faltantes, LIMITE_FALTANTES},
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
use rust_decimal::Decimal;
|
use rust_decimal::Decimal;
|
||||||
@@ -107,7 +107,13 @@ mod tests {
|
|||||||
}
|
}
|
||||||
|
|
||||||
fn nota_com_tipo(numero: u64, serie: &str, tipo: &str) -> Nota {
|
fn nota_com_tipo(numero: u64, serie: &str, tipo: &str) -> Nota {
|
||||||
Nota::new(numero, serie.to_string(), Some(tipo.to_string()), None, None)
|
Nota::new(
|
||||||
|
numero,
|
||||||
|
serie.to_string(),
|
||||||
|
Some(tipo.to_string()),
|
||||||
|
None,
|
||||||
|
None,
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
|
pub mod chave_serie;
|
||||||
pub mod layout;
|
pub mod layout;
|
||||||
pub mod nota;
|
pub mod nota;
|
||||||
pub mod resultado_analise;
|
pub mod resultado_analise;
|
||||||
pub mod serie;
|
pub mod serie;
|
||||||
pub mod chave_serie;
|
|
||||||
|
|||||||
@@ -50,7 +50,13 @@ mod tests {
|
|||||||
}
|
}
|
||||||
|
|
||||||
fn nota_com_tipo(numero: u64, serie: &str, tipo: &str) -> Nota {
|
fn nota_com_tipo(numero: u64, serie: &str, tipo: &str) -> Nota {
|
||||||
Nota::new(numero, serie.to_string(), Some(tipo.to_string()), None, None)
|
Nota::new(
|
||||||
|
numero,
|
||||||
|
serie.to_string(),
|
||||||
|
Some(tipo.to_string()),
|
||||||
|
None,
|
||||||
|
None,
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
@@ -84,16 +90,25 @@ mod tests {
|
|||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn mesmo_numero_serie_tipos_diferentes_nao_e_duplicata() {
|
fn mesmo_numero_serie_tipos_diferentes_nao_e_duplicata() {
|
||||||
let notas = vec![nota_com_tipo(1, "001", "NFE"), nota_com_tipo(1, "001", "NFCE")];
|
let notas = vec![
|
||||||
|
nota_com_tipo(1, "001", "NFE"),
|
||||||
|
nota_com_tipo(1, "001", "NFCE"),
|
||||||
|
];
|
||||||
let dup = detectar_duplicidades(¬as);
|
let dup = detectar_duplicidades(¬as);
|
||||||
assert!(dup.is_empty());
|
assert!(dup.is_empty());
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn mesmo_numero_serie_tipo_igual_e_duplicata() {
|
fn mesmo_numero_serie_tipo_igual_e_duplicata() {
|
||||||
let notas = vec![nota_com_tipo(1, "001", "NFE"), nota_com_tipo(1, "001", "NFE")];
|
let notas = vec![
|
||||||
|
nota_com_tipo(1, "001", "NFE"),
|
||||||
|
nota_com_tipo(1, "001", "NFE"),
|
||||||
|
];
|
||||||
let dup = detectar_duplicidades(¬as);
|
let dup = detectar_duplicidades(¬as);
|
||||||
assert_eq!(dup.get(&(1, "001".to_string(), Some("NFE".to_string()))), Some(&2));
|
assert_eq!(
|
||||||
|
dup.get(&(1, "001".to_string(), Some("NFE".to_string()))),
|
||||||
|
Some(&2)
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
|
|||||||
@@ -26,15 +26,14 @@ pub fn ler_csv(
|
|||||||
linha_cabecalho: usize,
|
linha_cabecalho: usize,
|
||||||
) -> Result<ResultadoCsv, ErroArquivo> {
|
) -> Result<ResultadoCsv, ErroArquivo> {
|
||||||
// Verificar tamanho
|
// Verificar tamanho
|
||||||
let metadata = std::fs::metadata(caminho)
|
let metadata =
|
||||||
.map_err(|e| ErroArquivo::ErroLeitura(e.to_string()))?;
|
std::fs::metadata(caminho).map_err(|e| ErroArquivo::ErroLeitura(e.to_string()))?;
|
||||||
if metadata.len() > LIMITE_BYTES {
|
if metadata.len() > LIMITE_BYTES {
|
||||||
return Err(ErroArquivo::TamanhoExcedido(metadata.len()));
|
return Err(ErroArquivo::TamanhoExcedido(metadata.len()));
|
||||||
}
|
}
|
||||||
|
|
||||||
// Ler conteúdo bruto
|
// Ler conteúdo bruto
|
||||||
let bytes = std::fs::read(caminho)
|
let bytes = std::fs::read(caminho).map_err(|e| ErroArquivo::ErroLeitura(e.to_string()))?;
|
||||||
.map_err(|e| ErroArquivo::ErroLeitura(e.to_string()))?;
|
|
||||||
|
|
||||||
// Decodificar encoding
|
// Decodificar encoding
|
||||||
let conteudo = match encoding.to_lowercase().as_str() {
|
let conteudo = match encoding.to_lowercase().as_str() {
|
||||||
@@ -42,10 +41,8 @@ pub fn ler_csv(
|
|||||||
let (decoded, _, _) = WINDOWS_1252.decode(&bytes);
|
let (decoded, _, _) = WINDOWS_1252.decode(&bytes);
|
||||||
decoded.into_owned()
|
decoded.into_owned()
|
||||||
}
|
}
|
||||||
_ => {
|
_ => String::from_utf8(bytes)
|
||||||
String::from_utf8(bytes)
|
.map_err(|e| ErroArquivo::ErroLeitura(format!("Encoding inválido: {}", e)))?,
|
||||||
.map_err(|e| ErroArquivo::ErroLeitura(format!("Encoding inválido: {}", e)))?
|
|
||||||
}
|
|
||||||
};
|
};
|
||||||
|
|
||||||
let mut avisos = ResumoAvisos::default();
|
let mut avisos = ResumoAvisos::default();
|
||||||
@@ -97,8 +94,7 @@ pub fn preview_csv(
|
|||||||
encoding: &str,
|
encoding: &str,
|
||||||
n: usize,
|
n: usize,
|
||||||
) -> Result<Vec<Vec<String>>, ErroArquivo> {
|
) -> Result<Vec<Vec<String>>, ErroArquivo> {
|
||||||
let bytes = std::fs::read(caminho)
|
let bytes = std::fs::read(caminho).map_err(|e| ErroArquivo::ErroLeitura(e.to_string()))?;
|
||||||
.map_err(|e| ErroArquivo::ErroLeitura(e.to_string()))?;
|
|
||||||
|
|
||||||
let conteudo = match encoding.to_lowercase().as_str() {
|
let conteudo = match encoding.to_lowercase().as_str() {
|
||||||
"windows-1252" | "latin-1" | "iso-8859-1" => {
|
"windows-1252" | "latin-1" | "iso-8859-1" => {
|
||||||
|
|||||||
@@ -11,10 +11,8 @@ use std::path::Path;
|
|||||||
// Fontes embutidas no binário em tempo de compilação.
|
// Fontes embutidas no binário em tempo de compilação.
|
||||||
// Liberation Sans (~402 KB cada) substitui Arial do sistema (~993 KB cada),
|
// Liberation Sans (~402 KB cada) substitui Arial do sistema (~993 KB cada),
|
||||||
// eliminando dependência de fonte externa e reduzindo o tamanho dos PDFs gerados.
|
// eliminando dependência de fonte externa e reduzindo o tamanho dos PDFs gerados.
|
||||||
const FONT_REGULAR: &[u8] =
|
const FONT_REGULAR: &[u8] = include_bytes!("../../assets/fonts/LiberationSans-Regular.ttf");
|
||||||
include_bytes!("../../assets/fonts/LiberationSans-Regular.ttf");
|
const FONT_BOLD: &[u8] = include_bytes!("../../assets/fonts/LiberationSans-Bold.ttf");
|
||||||
const FONT_BOLD: &[u8] =
|
|
||||||
include_bytes!("../../assets/fonts/LiberationSans-Bold.ttf");
|
|
||||||
|
|
||||||
/// Metadados do relatório.
|
/// Metadados do relatório.
|
||||||
pub struct MetadadosRelatorio {
|
pub struct MetadadosRelatorio {
|
||||||
@@ -56,12 +54,10 @@ impl PdfGenerator for GenpdfGenerator {
|
|||||||
doc.set_page_decorator(decorator);
|
doc.set_page_decorator(decorator);
|
||||||
|
|
||||||
// Título
|
// Título
|
||||||
doc.push(
|
doc.push(Paragraph::new("").styled_string(
|
||||||
Paragraph::new("").styled_string(
|
|
||||||
"Relatório de Análise de Notas Fiscais",
|
"Relatório de Análise de Notas Fiscais",
|
||||||
style::Style::new().bold().with_font_size(16),
|
style::Style::new().bold().with_font_size(16),
|
||||||
),
|
));
|
||||||
);
|
|
||||||
doc.push(Break::new(1));
|
doc.push(Break::new(1));
|
||||||
|
|
||||||
// Metadados
|
// Metadados
|
||||||
@@ -81,7 +77,8 @@ impl PdfGenerator for GenpdfGenerator {
|
|||||||
|
|
||||||
// Totais
|
// Totais
|
||||||
doc.push(
|
doc.push(
|
||||||
Paragraph::new("").styled_string("Totais", style::Style::new().bold().with_font_size(14)),
|
Paragraph::new("")
|
||||||
|
.styled_string("Totais", style::Style::new().bold().with_font_size(14)),
|
||||||
);
|
);
|
||||||
doc.push(Paragraph::new(format!(
|
doc.push(Paragraph::new(format!(
|
||||||
"Total Geral: R$ {}",
|
"Total Geral: R$ {}",
|
||||||
@@ -102,9 +99,10 @@ impl PdfGenerator for GenpdfGenerator {
|
|||||||
doc.push(Break::new(1));
|
doc.push(Break::new(1));
|
||||||
|
|
||||||
// Notas Faltantes
|
// Notas Faltantes
|
||||||
doc.push(
|
doc.push(Paragraph::new("").styled_string(
|
||||||
Paragraph::new("").styled_string("Notas Faltantes por Série", style::Style::new().bold().with_font_size(14)),
|
"Notas Faltantes por Série",
|
||||||
);
|
style::Style::new().bold().with_font_size(14),
|
||||||
|
));
|
||||||
|
|
||||||
// Use faltantes keys for this section (may differ from soma keys if no values)
|
// Use faltantes keys for this section (may differ from soma keys if no values)
|
||||||
let mut chaves_faltantes: Vec<&ChaveSerie> = resultado.faltantes_por_serie.keys().collect();
|
let mut chaves_faltantes: Vec<&ChaveSerie> = resultado.faltantes_por_serie.keys().collect();
|
||||||
@@ -114,7 +112,10 @@ impl PdfGenerator for GenpdfGenerator {
|
|||||||
let faltantes = match resultado.faltantes_por_serie.get(*chave) {
|
let faltantes = match resultado.faltantes_por_serie.get(*chave) {
|
||||||
Some(f) if !f.is_empty() => f,
|
Some(f) if !f.is_empty() => f,
|
||||||
_ => {
|
_ => {
|
||||||
doc.push(Paragraph::new(format!(" Série {}: nenhuma faltante", chave.label())));
|
doc.push(Paragraph::new(format!(
|
||||||
|
" Série {}: nenhuma faltante",
|
||||||
|
chave.label()
|
||||||
|
)));
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
@@ -131,9 +132,10 @@ impl PdfGenerator for GenpdfGenerator {
|
|||||||
doc.push(Break::new(1));
|
doc.push(Break::new(1));
|
||||||
|
|
||||||
// Duplicatas
|
// Duplicatas
|
||||||
doc.push(
|
doc.push(Paragraph::new("").styled_string(
|
||||||
Paragraph::new("").styled_string("Duplicatas por Série", style::Style::new().bold().with_font_size(14)),
|
"Duplicatas por Série",
|
||||||
);
|
style::Style::new().bold().with_font_size(14),
|
||||||
|
));
|
||||||
|
|
||||||
let mut chaves_dup: Vec<&ChaveSerie> = resultado.duplicadas_por_serie.keys().collect();
|
let mut chaves_dup: Vec<&ChaveSerie> = resultado.duplicadas_por_serie.keys().collect();
|
||||||
chaves_dup.sort();
|
chaves_dup.sort();
|
||||||
@@ -142,7 +144,10 @@ impl PdfGenerator for GenpdfGenerator {
|
|||||||
let duplicatas = match resultado.duplicadas_por_serie.get(*chave) {
|
let duplicatas = match resultado.duplicadas_por_serie.get(*chave) {
|
||||||
Some(d) if !d.is_empty() => d,
|
Some(d) if !d.is_empty() => d,
|
||||||
_ => {
|
_ => {
|
||||||
doc.push(Paragraph::new(format!(" Série {}: nenhuma duplicata", chave.label())));
|
doc.push(Paragraph::new(format!(
|
||||||
|
" Série {}: nenhuma duplicata",
|
||||||
|
chave.label()
|
||||||
|
)));
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
@@ -155,7 +160,9 @@ impl PdfGenerator for GenpdfGenerator {
|
|||||||
for (numero, count) in duplicatas {
|
for (numero, count) in duplicatas {
|
||||||
doc.push(Paragraph::new(format!(
|
doc.push(Paragraph::new(format!(
|
||||||
" NF {} / Série {} — {} ocorrências",
|
" NF {} / Série {} — {} ocorrências",
|
||||||
numero, chave.label(), count
|
numero,
|
||||||
|
chave.label(),
|
||||||
|
count
|
||||||
)));
|
)));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -125,15 +125,9 @@ pub fn listar(conn: &Connection) -> Result<Vec<Layout>> {
|
|||||||
linha_cabecalho: row.get::<_, i64>(5)? as usize,
|
linha_cabecalho: row.get::<_, i64>(5)? as usize,
|
||||||
indice_numero: row.get::<_, i64>(6)? as usize,
|
indice_numero: row.get::<_, i64>(6)? as usize,
|
||||||
indice_serie: row.get::<_, i64>(7)? as usize,
|
indice_serie: row.get::<_, i64>(7)? as usize,
|
||||||
indice_valor: row
|
indice_valor: row.get::<_, Option<i64>>(8)?.map(|v| v as usize),
|
||||||
.get::<_, Option<i64>>(8)?
|
indice_data: row.get::<_, Option<i64>>(9)?.map(|v| v as usize),
|
||||||
.map(|v| v as usize),
|
indice_documento_tipo: row.get::<_, Option<i64>>(15)?.map(|v| v as usize),
|
||||||
indice_data: row
|
|
||||||
.get::<_, Option<i64>>(9)?
|
|
||||||
.map(|v| v as usize),
|
|
||||||
indice_documento_tipo: row
|
|
||||||
.get::<_, Option<i64>>(15)?
|
|
||||||
.map(|v| v as usize),
|
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
} else {
|
} else {
|
||||||
|
|||||||
@@ -14,11 +14,9 @@ pub fn aplicar_migrations(conn: &Connection) -> Result<()> {
|
|||||||
)?;
|
)?;
|
||||||
|
|
||||||
let versao_atual: i64 = conn
|
let versao_atual: i64 = conn
|
||||||
.query_row(
|
.query_row("SELECT versao FROM schema_version LIMIT 1;", [], |row| {
|
||||||
"SELECT versao FROM schema_version LIMIT 1;",
|
row.get(0)
|
||||||
[],
|
})
|
||||||
|row| row.get(0),
|
|
||||||
)
|
|
||||||
.unwrap_or(0);
|
.unwrap_or(0);
|
||||||
|
|
||||||
if versao_atual < 1 {
|
if versao_atual < 1 {
|
||||||
@@ -32,9 +30,15 @@ pub fn aplicar_migrations(conn: &Connection) -> Result<()> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if versao_atual == 0 {
|
if versao_atual == 0 {
|
||||||
conn.execute("INSERT INTO schema_version (versao) VALUES (?1);", [VERSAO_SCHEMA_ATUAL])?;
|
conn.execute(
|
||||||
|
"INSERT INTO schema_version (versao) VALUES (?1);",
|
||||||
|
[VERSAO_SCHEMA_ATUAL],
|
||||||
|
)?;
|
||||||
} else if versao_atual < VERSAO_SCHEMA_ATUAL {
|
} else if versao_atual < VERSAO_SCHEMA_ATUAL {
|
||||||
conn.execute("UPDATE schema_version SET versao = ?1;", [VERSAO_SCHEMA_ATUAL])?;
|
conn.execute(
|
||||||
|
"UPDATE schema_version SET versao = ?1;",
|
||||||
|
[VERSAO_SCHEMA_ATUAL],
|
||||||
|
)?;
|
||||||
}
|
}
|
||||||
|
|
||||||
Ok(())
|
Ok(())
|
||||||
|
|||||||
@@ -24,8 +24,8 @@ pub struct ResultadoXlsx {
|
|||||||
pub fn listar_abas(caminho: &Path) -> Result<Vec<String>, ErroArquivo> {
|
pub fn listar_abas(caminho: &Path) -> Result<Vec<String>, ErroArquivo> {
|
||||||
verificar_tamanho(caminho)?;
|
verificar_tamanho(caminho)?;
|
||||||
|
|
||||||
let workbook = open_workbook_auto(caminho)
|
let workbook =
|
||||||
.map_err(|e| ErroArquivo::Corrompido(e.to_string()))?;
|
open_workbook_auto(caminho).map_err(|e| ErroArquivo::Corrompido(e.to_string()))?;
|
||||||
|
|
||||||
Ok(workbook.sheet_names().to_vec())
|
Ok(workbook.sheet_names().to_vec())
|
||||||
}
|
}
|
||||||
@@ -45,8 +45,8 @@ pub fn ler_xlsx(
|
|||||||
) -> Result<ResultadoXlsx, ErroArquivo> {
|
) -> Result<ResultadoXlsx, ErroArquivo> {
|
||||||
verificar_tamanho(caminho)?;
|
verificar_tamanho(caminho)?;
|
||||||
|
|
||||||
let mut workbook = open_workbook_auto(caminho)
|
let mut workbook =
|
||||||
.map_err(|e| ErroArquivo::Corrompido(e.to_string()))?;
|
open_workbook_auto(caminho).map_err(|e| ErroArquivo::Corrompido(e.to_string()))?;
|
||||||
|
|
||||||
let range: calamine::Range<calamine::Data> = workbook
|
let range: calamine::Range<calamine::Data> = workbook
|
||||||
.worksheet_range(nome_aba)
|
.worksheet_range(nome_aba)
|
||||||
@@ -99,14 +99,11 @@ pub fn ler_xlsx(
|
|||||||
|
|
||||||
/// Retorna as primeiras 5 linhas de uma aba XLSX, a partir da linha 1.
|
/// Retorna as primeiras 5 linhas de uma aba XLSX, a partir da linha 1.
|
||||||
/// Usado exclusivamente para pré-visualização na UI.
|
/// Usado exclusivamente para pré-visualização na UI.
|
||||||
pub fn preview_xlsx(
|
pub fn preview_xlsx(caminho: &Path, nome_aba: &str) -> Result<Vec<Vec<String>>, ErroArquivo> {
|
||||||
caminho: &Path,
|
|
||||||
nome_aba: &str,
|
|
||||||
) -> Result<Vec<Vec<String>>, ErroArquivo> {
|
|
||||||
verificar_tamanho(caminho)?;
|
verificar_tamanho(caminho)?;
|
||||||
|
|
||||||
let mut workbook = open_workbook_auto(caminho)
|
let mut workbook =
|
||||||
.map_err(|e| ErroArquivo::Corrompido(e.to_string()))?;
|
open_workbook_auto(caminho).map_err(|e| ErroArquivo::Corrompido(e.to_string()))?;
|
||||||
|
|
||||||
let range: calamine::Range<calamine::Data> = workbook
|
let range: calamine::Range<calamine::Data> = workbook
|
||||||
.worksheet_range(nome_aba)
|
.worksheet_range(nome_aba)
|
||||||
@@ -182,8 +179,8 @@ pub fn parsear_letra_linha(s: &str) -> Option<Coordenada> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
fn verificar_tamanho(caminho: &Path) -> Result<(), ErroArquivo> {
|
fn verificar_tamanho(caminho: &Path) -> Result<(), ErroArquivo> {
|
||||||
let metadata = std::fs::metadata(caminho)
|
let metadata =
|
||||||
.map_err(|e| ErroArquivo::ErroLeitura(e.to_string()))?;
|
std::fs::metadata(caminho).map_err(|e| ErroArquivo::ErroLeitura(e.to_string()))?;
|
||||||
if metadata.len() > LIMITE_BYTES {
|
if metadata.len() > LIMITE_BYTES {
|
||||||
return Err(ErroArquivo::TamanhoExcedido(metadata.len()));
|
return Err(ErroArquivo::TamanhoExcedido(metadata.len()));
|
||||||
}
|
}
|
||||||
|
|||||||
+10
-17
@@ -1,3 +1,5 @@
|
|||||||
|
#![cfg_attr(not(debug_assertions), windows_subsystem = "windows")]
|
||||||
|
|
||||||
mod application;
|
mod application;
|
||||||
mod domain;
|
mod domain;
|
||||||
mod infrastructure;
|
mod infrastructure;
|
||||||
@@ -5,22 +7,13 @@ mod ui;
|
|||||||
|
|
||||||
use ui::app::App;
|
use ui::app::App;
|
||||||
|
|
||||||
fn main() -> eframe::Result {
|
fn main() -> iced::Result {
|
||||||
let native_options = eframe::NativeOptions {
|
iced::application("Comparador de Notas", App::update, App::view)
|
||||||
viewport: egui::ViewportBuilder::default()
|
.theme(|_app| ui::theme::tema_dark())
|
||||||
.with_title("Comparador de Notas")
|
.window(iced::window::Settings {
|
||||||
.with_inner_size([1024.0, 768.0])
|
size: iced::Size::new(1024.0, 768.0),
|
||||||
.with_min_inner_size([800.0, 600.0]),
|
min_size: Some(iced::Size::new(800.0, 600.0)),
|
||||||
..Default::default()
|
..Default::default()
|
||||||
};
|
})
|
||||||
|
.run_with(App::new)
|
||||||
eframe::run_native(
|
|
||||||
"Comparador de Notas",
|
|
||||||
native_options,
|
|
||||||
Box::new(|_cc| {
|
|
||||||
let mut app = App::default();
|
|
||||||
app.inicializar();
|
|
||||||
Ok(Box::new(app))
|
|
||||||
}),
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|||||||
+981
-455
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,3 @@
|
|||||||
|
pub mod modal;
|
||||||
|
pub mod paginacao;
|
||||||
|
pub mod tabela_preview;
|
||||||
@@ -0,0 +1,145 @@
|
|||||||
|
use crate::ui::app::EstadoModal;
|
||||||
|
use crate::ui::message::Message;
|
||||||
|
use crate::ui::theme as t;
|
||||||
|
use iced::widget::{button, column, container, mouse_area, row, stack, text, text_input};
|
||||||
|
use iced::{Alignment, Color, Element, Length};
|
||||||
|
|
||||||
|
/// Envolve o conteúdo principal com uma camada de modal por cima.
|
||||||
|
/// O overlay escuro bloqueia cliques no conteúdo de baixo.
|
||||||
|
pub fn view_com_modal<'a>(
|
||||||
|
conteudo: Element<'a, Message>,
|
||||||
|
modal: &'a EstadoModal,
|
||||||
|
) -> Element<'a, Message> {
|
||||||
|
let overlay = mouse_area(
|
||||||
|
container(view_modal(modal))
|
||||||
|
.width(Length::Fill)
|
||||||
|
.height(Length::Fill)
|
||||||
|
.style(|_theme| container::Style {
|
||||||
|
background: Some(Color::from_rgba(0.0, 0.0, 0.0, 0.6).into()),
|
||||||
|
..Default::default()
|
||||||
|
})
|
||||||
|
.center_x(Length::Fill)
|
||||||
|
.center_y(Length::Fill),
|
||||||
|
)
|
||||||
|
.on_press(Message::ModalCancelado);
|
||||||
|
|
||||||
|
stack![conteudo, overlay].into()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn view_modal(modal: &EstadoModal) -> Element<'_, Message> {
|
||||||
|
match modal {
|
||||||
|
EstadoModal::Informacao { titulo, mensagem } => {
|
||||||
|
caixa_modal(titulo, mensagem, TipoModal::Info, None, false)
|
||||||
|
}
|
||||||
|
EstadoModal::Aviso { titulo, mensagem } => {
|
||||||
|
caixa_modal(titulo, mensagem, TipoModal::Aviso, None, false)
|
||||||
|
}
|
||||||
|
EstadoModal::Erro { titulo, mensagem } => {
|
||||||
|
caixa_modal(titulo, mensagem, TipoModal::Erro, None, false)
|
||||||
|
}
|
||||||
|
EstadoModal::Confirmacao {
|
||||||
|
titulo, mensagem, ..
|
||||||
|
} => caixa_modal(titulo, mensagem, TipoModal::Confirmacao, None, true),
|
||||||
|
EstadoModal::InputTexto {
|
||||||
|
titulo,
|
||||||
|
mensagem,
|
||||||
|
texto,
|
||||||
|
..
|
||||||
|
} => caixa_modal(
|
||||||
|
titulo,
|
||||||
|
mensagem,
|
||||||
|
TipoModal::Info,
|
||||||
|
Some(texto.as_str()),
|
||||||
|
true,
|
||||||
|
),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
enum TipoModal {
|
||||||
|
Info,
|
||||||
|
Aviso,
|
||||||
|
Erro,
|
||||||
|
Confirmacao,
|
||||||
|
}
|
||||||
|
|
||||||
|
fn caixa_modal<'a>(
|
||||||
|
titulo: &'a str,
|
||||||
|
mensagem: &'a str,
|
||||||
|
tipo: TipoModal,
|
||||||
|
input: Option<&'a str>,
|
||||||
|
com_confirmar: bool,
|
||||||
|
) -> Element<'a, Message> {
|
||||||
|
// Cor e label de ícone por tipo
|
||||||
|
let (cor_titulo, icone) = match tipo {
|
||||||
|
TipoModal::Info => (t::PRIMARY, "i"),
|
||||||
|
TipoModal::Aviso => (t::WARNING, "!"),
|
||||||
|
TipoModal::Erro => (t::DANGER, "x"),
|
||||||
|
TipoModal::Confirmacao => (t::WARNING, "?"),
|
||||||
|
};
|
||||||
|
|
||||||
|
let badge_icone = container(text(icone).size(13).color(cor_titulo))
|
||||||
|
.padding([2, 8])
|
||||||
|
.style(move |_theme| iced::widget::container::Style {
|
||||||
|
background: Some(
|
||||||
|
Color {
|
||||||
|
a: 0.15,
|
||||||
|
..cor_titulo
|
||||||
|
}
|
||||||
|
.into(),
|
||||||
|
),
|
||||||
|
border: iced::Border {
|
||||||
|
color: Color {
|
||||||
|
a: 0.3,
|
||||||
|
..cor_titulo
|
||||||
|
},
|
||||||
|
width: 1.0,
|
||||||
|
radius: 4.0.into(),
|
||||||
|
},
|
||||||
|
..Default::default()
|
||||||
|
});
|
||||||
|
|
||||||
|
let titulo_row = row![badge_icone, text(titulo).size(17).color(t::TEXT),]
|
||||||
|
.spacing(8)
|
||||||
|
.align_y(Alignment::Center);
|
||||||
|
|
||||||
|
let mut col =
|
||||||
|
column![titulo_row, text(mensagem).size(14).color(t::TEXT_SECONDARY),].spacing(12);
|
||||||
|
|
||||||
|
if let Some(valor) = input {
|
||||||
|
col = col.push(
|
||||||
|
text_input("Nome...", valor)
|
||||||
|
.on_input(Message::ModalTextoAlterado)
|
||||||
|
.padding(8)
|
||||||
|
.style(t::input_dark),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Separador
|
||||||
|
col = col.push(
|
||||||
|
container(iced::widget::horizontal_rule(1))
|
||||||
|
.width(Length::Fill)
|
||||||
|
.padding([4, 0]),
|
||||||
|
);
|
||||||
|
|
||||||
|
// Botões
|
||||||
|
let btn_fechar = button("Fechar")
|
||||||
|
.on_press(Message::ModalCancelado)
|
||||||
|
.style(t::btn_ghost);
|
||||||
|
|
||||||
|
let mut botoes = row![btn_fechar].spacing(8);
|
||||||
|
|
||||||
|
if com_confirmar {
|
||||||
|
let btn_confirmar = button("Confirmar")
|
||||||
|
.on_press(Message::ModalConfirmado)
|
||||||
|
.style(t::btn_primary);
|
||||||
|
botoes = botoes.push(btn_confirmar);
|
||||||
|
}
|
||||||
|
|
||||||
|
col = col.push(botoes);
|
||||||
|
|
||||||
|
container(col.align_x(Alignment::Start))
|
||||||
|
.width(Length::Fixed(420.0))
|
||||||
|
.padding(24)
|
||||||
|
.style(t::card)
|
||||||
|
.into()
|
||||||
|
}
|
||||||
@@ -0,0 +1,31 @@
|
|||||||
|
use crate::ui::message::Message;
|
||||||
|
use crate::ui::theme as t;
|
||||||
|
use iced::widget::{button, row, text};
|
||||||
|
use iced::{Alignment, Element};
|
||||||
|
|
||||||
|
/// Renderiza controles de paginação reutilizáveis.
|
||||||
|
pub fn controles_paginacao(
|
||||||
|
pagina_atual: usize,
|
||||||
|
total_paginas: usize,
|
||||||
|
msg_anterior: Message,
|
||||||
|
msg_proximo: Message,
|
||||||
|
) -> Element<'static, Message> {
|
||||||
|
let btn_anterior = button("◀")
|
||||||
|
.on_press_maybe((pagina_atual > 0).then_some(msg_anterior))
|
||||||
|
.style(t::btn_ghost);
|
||||||
|
|
||||||
|
let btn_proximo = button("▶")
|
||||||
|
.on_press_maybe((pagina_atual + 1 < total_paginas).then_some(msg_proximo))
|
||||||
|
.style(t::btn_ghost);
|
||||||
|
|
||||||
|
row![
|
||||||
|
btn_anterior,
|
||||||
|
text(format!("Página {} / {}", pagina_atual + 1, total_paginas))
|
||||||
|
.size(13)
|
||||||
|
.color(t::TEXT_SECONDARY),
|
||||||
|
btn_proximo,
|
||||||
|
]
|
||||||
|
.spacing(8)
|
||||||
|
.align_y(Alignment::Center)
|
||||||
|
.into()
|
||||||
|
}
|
||||||
@@ -0,0 +1,105 @@
|
|||||||
|
use crate::ui::message::Message;
|
||||||
|
use crate::ui::screens::indice_para_letra;
|
||||||
|
use crate::ui::theme as t;
|
||||||
|
use iced::widget::{column, container, row, scrollable, text};
|
||||||
|
use iced::{Border, Color, Element, Font, Length};
|
||||||
|
|
||||||
|
/// Renderiza uma tabela de pré-visualização das primeiras linhas do arquivo.
|
||||||
|
/// Mostra uma linha de cabeçalho com letras estilo Excel (A, B, C, ...) seguida pelos dados.
|
||||||
|
pub fn tabela_preview(linhas: &[Vec<String>]) -> Element<'_, Message> {
|
||||||
|
let num_colunas = linhas.iter().map(|l| l.len()).max().unwrap_or(0);
|
||||||
|
if num_colunas == 0 {
|
||||||
|
return text("(vazio)").size(12).color(t::TEXT_MUTED).into();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Cabeçalho estilo Excel — fundo SURFACE_2
|
||||||
|
let cabecalho = container(
|
||||||
|
row((0..num_colunas)
|
||||||
|
.map(|i| {
|
||||||
|
container(
|
||||||
|
text(format!("{} ({})", indice_para_letra(i), i))
|
||||||
|
.font(Font::MONOSPACE)
|
||||||
|
.size(12)
|
||||||
|
.color(t::TEXT_SECONDARY),
|
||||||
|
)
|
||||||
|
.width(Length::Fixed(120.0))
|
||||||
|
.padding([4, 6])
|
||||||
|
.into()
|
||||||
|
})
|
||||||
|
.collect::<Vec<_>>())
|
||||||
|
.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()
|
||||||
|
});
|
||||||
|
|
||||||
|
let linhas_view = linhas.iter().enumerate().map(|(idx, linha)| {
|
||||||
|
// Linhas alternadas: SURFACE e SURFACE_2 levemente
|
||||||
|
let bg = if idx % 2 == 0 {
|
||||||
|
t::SURFACE
|
||||||
|
} else {
|
||||||
|
Color {
|
||||||
|
r: t::SURFACE.r + 0.01,
|
||||||
|
g: t::SURFACE.g + 0.01,
|
||||||
|
b: t::SURFACE.b + 0.015,
|
||||||
|
a: 1.0,
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
container(
|
||||||
|
row((0..num_colunas)
|
||||||
|
.map(|col| {
|
||||||
|
let celula = linha.get(col).map(|s| s.as_str()).unwrap_or("");
|
||||||
|
let truncado: String = if celula.chars().count() > 30 {
|
||||||
|
format!("{}...", celula.chars().take(30).collect::<String>())
|
||||||
|
} else {
|
||||||
|
celula.to_string()
|
||||||
|
};
|
||||||
|
container(text(truncado).font(Font::MONOSPACE).size(11).color(t::TEXT))
|
||||||
|
.width(Length::Fixed(120.0))
|
||||||
|
.padding([3, 6])
|
||||||
|
.into()
|
||||||
|
})
|
||||||
|
.collect::<Vec<_>>())
|
||||||
|
.spacing(0),
|
||||||
|
)
|
||||||
|
.width(Length::Shrink)
|
||||||
|
.style(move |_theme| iced::widget::container::Style {
|
||||||
|
background: Some(bg.into()),
|
||||||
|
..Default::default()
|
||||||
|
})
|
||||||
|
.into()
|
||||||
|
});
|
||||||
|
|
||||||
|
let todas_linhas = column(
|
||||||
|
std::iter::once(cabecalho.into())
|
||||||
|
.chain(linhas_view)
|
||||||
|
.collect::<Vec<_>>(),
|
||||||
|
)
|
||||||
|
.spacing(0);
|
||||||
|
|
||||||
|
container(
|
||||||
|
scrollable(todas_linhas)
|
||||||
|
.direction(scrollable::Direction::Horizontal(
|
||||||
|
scrollable::Scrollbar::default(),
|
||||||
|
))
|
||||||
|
.height(Length::Fixed(160.0)),
|
||||||
|
)
|
||||||
|
.style(|_theme| iced::widget::container::Style {
|
||||||
|
background: Some(t::SURFACE.into()),
|
||||||
|
border: Border {
|
||||||
|
color: t::BORDER,
|
||||||
|
width: 1.0,
|
||||||
|
radius: 6.0.into(),
|
||||||
|
},
|
||||||
|
..Default::default()
|
||||||
|
})
|
||||||
|
.into()
|
||||||
|
}
|
||||||
@@ -0,0 +1,122 @@
|
|||||||
|
use crate::domain::entities::nota::Nota;
|
||||||
|
use crate::domain::entities::{
|
||||||
|
chave_serie::ChaveSerie,
|
||||||
|
layout::{Layout, LayoutXlsx},
|
||||||
|
resultado_analise::{ResultadoAnalise, ResultadoPreAnalise},
|
||||||
|
};
|
||||||
|
use crate::domain::errors::ResumoAvisos;
|
||||||
|
use rusqlite::Connection;
|
||||||
|
use std::path::PathBuf;
|
||||||
|
use std::sync::{Arc, Mutex};
|
||||||
|
|
||||||
|
/// Todos os eventos/interações da UI.
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
pub enum Message {
|
||||||
|
// --- Inicialização ---
|
||||||
|
BancoInicializado(Result<(Arc<Mutex<Connection>>, bool, Vec<Layout>), String>),
|
||||||
|
LayoutsRecarregados(Vec<Layout>),
|
||||||
|
|
||||||
|
// --- Navegação ---
|
||||||
|
IrParaImportacao,
|
||||||
|
IrParaConfiguracaoColunas,
|
||||||
|
IrParaLayouts,
|
||||||
|
Voltar,
|
||||||
|
|
||||||
|
// --- Arquivo ---
|
||||||
|
SelecionarArquivo,
|
||||||
|
ArquivoSelecionado(PathBuf),
|
||||||
|
AbaSelecionada(String),
|
||||||
|
|
||||||
|
// --- XLSX: abas carregadas em background ---
|
||||||
|
AbaxlsxCarregadas {
|
||||||
|
caminho: PathBuf,
|
||||||
|
abas: Vec<String>,
|
||||||
|
layout_xlsx: LayoutXlsx,
|
||||||
|
nome_layout: String,
|
||||||
|
},
|
||||||
|
XlsxErroAoCarregar(String),
|
||||||
|
|
||||||
|
// --- Background tasks ---
|
||||||
|
AnaliseCompleta(ResultadoPendente),
|
||||||
|
|
||||||
|
// --- Configuração CSV ---
|
||||||
|
DelimitadorAlterado(char),
|
||||||
|
EncodingAlterado(String),
|
||||||
|
LinhaCabecalhoAlterada(usize),
|
||||||
|
IndiceNumeroAlterado(usize),
|
||||||
|
IndiceSerieAlterado(usize),
|
||||||
|
IndiceValorToggle(bool),
|
||||||
|
IndiceValorAlterado(usize),
|
||||||
|
IndiceDataToggle(bool),
|
||||||
|
IndiceDataAlterado(usize),
|
||||||
|
IndiceDocTipoToggle(bool),
|
||||||
|
IndiceDocTipoAlterado(usize),
|
||||||
|
|
||||||
|
// --- Configuração XLSX ---
|
||||||
|
AbaXlsxAlterada(String),
|
||||||
|
PosNumeroAlterada(String),
|
||||||
|
PosSerieAlterada(String),
|
||||||
|
PosValorToggle(bool),
|
||||||
|
PosValorAlterada(String),
|
||||||
|
PosDataToggle(bool),
|
||||||
|
PosDataAlterada(String),
|
||||||
|
PosDocTipoToggle(bool),
|
||||||
|
PosDocTipoAlterada(String),
|
||||||
|
|
||||||
|
// --- Análise ---
|
||||||
|
ExecutarImportacao,
|
||||||
|
ReanalisarArquivo,
|
||||||
|
ConfirmarExpansaoFaltantes,
|
||||||
|
CancelarExpansao,
|
||||||
|
NovaAnalise,
|
||||||
|
|
||||||
|
// --- Resultado ---
|
||||||
|
PaginaFaltantesAlterada(usize),
|
||||||
|
PaginaDuplicatasAlterada(usize),
|
||||||
|
ItensPorPaginaAlterado(usize),
|
||||||
|
CopiarFaltantes(ChaveSerie),
|
||||||
|
CopiarDuplicatas(ChaveSerie),
|
||||||
|
ExportarPdf,
|
||||||
|
PdfExportado(Result<PathBuf, String>),
|
||||||
|
|
||||||
|
// --- Layouts ---
|
||||||
|
LayoutSelecionado(i64),
|
||||||
|
SalvarLayout,
|
||||||
|
NomeLayoutAlterado(String),
|
||||||
|
ExcluirLayout(i64),
|
||||||
|
ExclusaoConfirmada(i64),
|
||||||
|
ExportarLayoutJson(i64),
|
||||||
|
ImportarLayoutJson,
|
||||||
|
LayoutJsonImportado(String),
|
||||||
|
SobrescreverLayout(Layout),
|
||||||
|
|
||||||
|
// --- Modal ---
|
||||||
|
ModalTextoAlterado(String),
|
||||||
|
ModalConfirmado,
|
||||||
|
ModalCancelado,
|
||||||
|
|
||||||
|
// --- Sem operação (used as fallback) ---
|
||||||
|
Noop,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Resultado enviado pela task de análise em background para a UI.
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
pub enum ResultadoPendente {
|
||||||
|
/// Análise concluída com sucesso.
|
||||||
|
Concluido {
|
||||||
|
resultado: ResultadoAnalise,
|
||||||
|
avisos: Option<ResumoAvisos>,
|
||||||
|
notas: Option<Vec<Nota>>,
|
||||||
|
},
|
||||||
|
/// Pré-análise concluída mas precisa de confirmação do usuário.
|
||||||
|
AguardandoConfirmacao {
|
||||||
|
pre: ResultadoPreAnalise,
|
||||||
|
series_excessivas: Vec<(ChaveSerie, u64)>,
|
||||||
|
avisos: ResumoAvisos,
|
||||||
|
notas: Vec<Nota>,
|
||||||
|
},
|
||||||
|
/// Arquivo importado não continha notas válidas.
|
||||||
|
Vazio,
|
||||||
|
/// Erro durante importação ou análise.
|
||||||
|
Erro(String),
|
||||||
|
}
|
||||||
@@ -1,2 +1,5 @@
|
|||||||
pub mod app;
|
pub mod app;
|
||||||
|
pub mod components;
|
||||||
|
pub mod message;
|
||||||
pub mod screens;
|
pub mod screens;
|
||||||
|
pub mod theme;
|
||||||
|
|||||||
@@ -1,345 +1,506 @@
|
|||||||
use crate::application::usecases::importar_arquivo::{importar_csv, importar_xlsx};
|
use crate::domain::entities::layout::TipoArquivo;
|
||||||
use crate::application::usecases::executar_analise::{
|
use crate::ui::app::App;
|
||||||
expandir_analise, pre_analisar, series_com_intervalo_excessivo,
|
use crate::ui::message::Message;
|
||||||
|
use crate::ui::theme as t;
|
||||||
|
use iced::widget::{
|
||||||
|
button, checkbox, column, container, pick_list, row, scrollable, text, text_input, Column,
|
||||||
};
|
};
|
||||||
use crate::domain::entities::layout::{Layout, TipoArquivo};
|
use iced::{Alignment, Element, Length};
|
||||||
use crate::ui::app::{App, EstadoApp, ResultadoPendente};
|
|
||||||
use egui::{Context, Ui};
|
|
||||||
|
|
||||||
/// Renderiza a tela de configuração de colunas.
|
const OPCOES_DELIMITADOR: &[(&str, char)] = &[
|
||||||
pub fn renderizar(ui: &mut Ui, ctx: &Context, app: &mut App) {
|
("Vírgula (,)", ','),
|
||||||
ui.heading("Configuração de Colunas");
|
("Ponto e vírgula (;)", ';'),
|
||||||
ui.add_space(8.0);
|
("Tabulação (Tab)", '\t'),
|
||||||
|
];
|
||||||
|
|
||||||
if let Some(caminho) = &app.caminho_arquivo.clone() {
|
const OPCOES_ENCODING: &[&str] = &["utf-8", "windows-1252"];
|
||||||
ui.label(format!("Arquivo: {}", caminho.display()));
|
|
||||||
}
|
|
||||||
|
|
||||||
ui.add_space(8.0);
|
/// Tela de configuração de colunas.
|
||||||
|
pub fn view(app: &App) -> Element<'_, Message> {
|
||||||
|
// ── Cabeçalho ─────────────────────────────────────────────────────────────
|
||||||
|
let header = column![
|
||||||
|
text("Configuração de Colunas").size(20).color(t::TEXT),
|
||||||
|
if let Some(caminho) = &app.caminho_arquivo {
|
||||||
|
row![
|
||||||
|
text("Arquivo:").size(12).color(t::TEXT_MUTED),
|
||||||
|
text(
|
||||||
|
caminho
|
||||||
|
.file_name()
|
||||||
|
.map(|n| n.to_string_lossy().to_string())
|
||||||
|
.unwrap_or_default()
|
||||||
|
)
|
||||||
|
.size(12)
|
||||||
|
.color(t::TEXT_SECONDARY),
|
||||||
|
]
|
||||||
|
.spacing(4)
|
||||||
|
.align_y(Alignment::Center)
|
||||||
|
} else {
|
||||||
|
row![text("").size(12)]
|
||||||
|
},
|
||||||
|
]
|
||||||
|
.spacing(4);
|
||||||
|
|
||||||
// Seletor de layout
|
// ── Seletor de layout ──────────────────────────────────────────────────────
|
||||||
let tipo_atual = app.tipo_arquivo_atual.clone();
|
let tipo_atual = app.tipo_arquivo_atual.clone();
|
||||||
let opcoes_layout: Vec<(i64, String)> = app
|
let opcoes_layout: Vec<String> = app
|
||||||
.layouts_salvos
|
.layouts_salvos
|
||||||
.iter()
|
.iter()
|
||||||
.filter(|l| l.tipo() == tipo_atual)
|
.filter(|l| l.tipo() == tipo_atual)
|
||||||
.filter_map(|l| l.id().map(|id| (id, l.nome().to_string())))
|
.map(|l| l.nome().to_string())
|
||||||
.collect();
|
.collect();
|
||||||
let nome_layout_atual = app.nome_layout_atual.clone();
|
let nome_layout_sel: Option<String> = if app.nome_layout_atual.is_empty() {
|
||||||
|
None
|
||||||
ui.horizontal(|ui| {
|
|
||||||
ui.label("Layout:");
|
|
||||||
egui::ComboBox::from_id_salt("combo_layouts_config")
|
|
||||||
.selected_text(if nome_layout_atual.is_empty() {
|
|
||||||
"— Selecionar layout —"
|
|
||||||
} else {
|
} else {
|
||||||
&nome_layout_atual
|
Some(app.nome_layout_atual.clone())
|
||||||
|
};
|
||||||
|
|
||||||
|
let secao_layout = container(
|
||||||
|
row![
|
||||||
|
text("Layout:")
|
||||||
|
.size(13)
|
||||||
|
.color(t::TEXT_SECONDARY)
|
||||||
|
.width(Length::Fixed(80.0)),
|
||||||
|
pick_list(opcoes_layout, nome_layout_sel, {
|
||||||
|
let layouts = app.layouts_salvos.clone();
|
||||||
|
move |nome_selecionado: String| {
|
||||||
|
if let Some(id) = layouts
|
||||||
|
.iter()
|
||||||
|
.find(|l| l.nome() == nome_selecionado)
|
||||||
|
.and_then(|l| l.id())
|
||||||
|
{
|
||||||
|
Message::LayoutSelecionado(id)
|
||||||
|
} else {
|
||||||
|
Message::NomeLayoutAlterado(nome_selecionado)
|
||||||
|
}
|
||||||
|
}
|
||||||
})
|
})
|
||||||
.show_ui(ui, |ui| {
|
.placeholder("— Selecionar layout —")
|
||||||
for (id, nome) in &opcoes_layout {
|
.style(input_style_pick)
|
||||||
if ui
|
.width(Length::Fixed(260.0)),
|
||||||
.selectable_label(nome_layout_atual == *nome, nome.as_str())
|
]
|
||||||
.clicked()
|
.spacing(8)
|
||||||
{
|
.align_y(Alignment::Center),
|
||||||
app.nome_layout_atual = nome.clone();
|
)
|
||||||
if let Some(layout) =
|
.style(t::card_secondary)
|
||||||
app.layouts_salvos.iter().find(|l| l.id() == Some(*id))
|
.padding([10, 14])
|
||||||
{
|
.width(Length::Fill);
|
||||||
let layout = layout.clone();
|
|
||||||
match &layout {
|
|
||||||
Layout::Csv { config, .. } => {
|
|
||||||
app.layout_csv_atual = config.clone();
|
|
||||||
}
|
|
||||||
Layout::Xlsx { config, .. } => {
|
|
||||||
app.layout_xlsx_atual = config.clone();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
ui.add_space(12.0);
|
// ── Configuração específica ───────────────────────────────────────────────
|
||||||
|
let config_section = match app.tipo_arquivo_atual {
|
||||||
|
TipoArquivo::Csv => view_csv(app),
|
||||||
|
TipoArquivo::Xlsx => view_xlsx(app),
|
||||||
|
};
|
||||||
|
|
||||||
match app.tipo_arquivo_atual.clone() {
|
// ── Pré-visualização ──────────────────────────────────────────────────────
|
||||||
TipoArquivo::Csv => renderizar_csv(ui, app),
|
let preview_section: Element<Message> = if let Some(linhas) = &app.preview_arquivo {
|
||||||
TipoArquivo::Xlsx => renderizar_xlsx(ui, app),
|
container(
|
||||||
}
|
column![
|
||||||
|
text("Pré-visualização (5 primeiras linhas)")
|
||||||
|
.size(13)
|
||||||
|
.color(t::TEXT_SECONDARY),
|
||||||
|
crate::ui::components::tabela_preview::tabela_preview(linhas),
|
||||||
|
]
|
||||||
|
.spacing(8),
|
||||||
|
)
|
||||||
|
.style(t::card)
|
||||||
|
.padding(14)
|
||||||
|
.width(Length::Fill)
|
||||||
|
.into()
|
||||||
|
} else {
|
||||||
|
text("").size(1).into()
|
||||||
|
};
|
||||||
|
|
||||||
// Pré-visualização do arquivo
|
// ── Validação ─────────────────────────────────────────────────────────────
|
||||||
if let Some(preview) = &app.preview_arquivo.clone() {
|
|
||||||
ui.add_space(8.0);
|
|
||||||
ui.separator();
|
|
||||||
ui.add_space(4.0);
|
|
||||||
crate::ui::screens::renderizar_tabela_preview(ui, preview);
|
|
||||||
}
|
|
||||||
|
|
||||||
ui.add_space(16.0);
|
|
||||||
ui.separator();
|
|
||||||
ui.add_space(8.0);
|
|
||||||
|
|
||||||
// Validação e botões de ação
|
|
||||||
let (valido, erros) = validar_config(app);
|
let (valido, erros) = validar_config(app);
|
||||||
|
|
||||||
if !erros.is_empty() {
|
let erros_section: Element<Message> = if erros.is_empty() {
|
||||||
for erro in &erros {
|
text("").size(1).into()
|
||||||
ui.colored_label(egui::Color32::RED, format!("⚠ {}", erro));
|
} else {
|
||||||
}
|
container(
|
||||||
ui.add_space(8.0);
|
Column::with_children(
|
||||||
}
|
erros
|
||||||
|
.iter()
|
||||||
|
.map(|e| text(format!("⚠ {}", e)).size(13).color(t::DANGER).into())
|
||||||
|
.collect::<Vec<_>>(),
|
||||||
|
)
|
||||||
|
.spacing(4),
|
||||||
|
)
|
||||||
|
.style(t::area_erro)
|
||||||
|
.padding([10, 14])
|
||||||
|
.width(Length::Fill)
|
||||||
|
.into()
|
||||||
|
};
|
||||||
|
|
||||||
ui.horizontal(|ui| {
|
// ── Botões ────────────────────────────────────────────────────────────────
|
||||||
if ui.button("< Voltar").clicked() {
|
let tem_arquivo = app.caminho_arquivo.is_some();
|
||||||
app.estado = EstadoApp::Importando;
|
let tem_notas = !app.notas_importadas.is_empty();
|
||||||
}
|
|
||||||
|
|
||||||
ui.add_enabled_ui(valido && app.caminho_arquivo.is_some(), |ui| {
|
let botoes = container(
|
||||||
if ui.button("▶ Importar e Analisar").clicked() {
|
row![
|
||||||
executar_importacao(app, ctx);
|
button(text("◀ Voltar").size(13))
|
||||||
}
|
.on_press(Message::Voltar)
|
||||||
});
|
.style(t::btn_ghost)
|
||||||
|
.padding([9, 14]),
|
||||||
|
button(text("▶ Importar e Analisar").size(13))
|
||||||
|
.on_press_maybe((valido && tem_arquivo).then_some(Message::ExecutarImportacao))
|
||||||
|
.style(t::btn_primary)
|
||||||
|
.padding([9, 14]),
|
||||||
|
button(text("🔄 Reanalisar").size(13))
|
||||||
|
.on_press_maybe((valido && tem_notas).then_some(Message::ReanalisarArquivo))
|
||||||
|
.style(t::btn_secondary)
|
||||||
|
.padding([9, 14]),
|
||||||
|
button(text("💾 Salvar layout").size(13))
|
||||||
|
.on_press(Message::SalvarLayout)
|
||||||
|
.style(t::btn_ghost)
|
||||||
|
.padding([9, 14]),
|
||||||
|
]
|
||||||
|
.spacing(8)
|
||||||
|
.align_y(Alignment::Center),
|
||||||
|
)
|
||||||
|
.style(t::card_secondary)
|
||||||
|
.padding([12, 16])
|
||||||
|
.width(Length::Fill);
|
||||||
|
|
||||||
if valido && !app.notas_importadas.is_empty() {
|
// ── Layout geral ──────────────────────────────────────────────────────────
|
||||||
if ui
|
let content = column![
|
||||||
.button("🔄 Reanalisar")
|
header,
|
||||||
.on_hover_text("Reanalisa as notas já importadas sem reimportar o arquivo")
|
secao_layout,
|
||||||
.clicked()
|
config_section,
|
||||||
{
|
preview_section,
|
||||||
app.executar_analise();
|
erros_section,
|
||||||
}
|
botoes,
|
||||||
}
|
]
|
||||||
|
.spacing(14)
|
||||||
|
.padding([20, 24])
|
||||||
|
.width(Length::Fill);
|
||||||
|
|
||||||
if ui.button("💾 Salvar como layout...").clicked() {
|
container(scrollable(content))
|
||||||
app.exibir_modal_salvar_layout();
|
.style(t::fundo)
|
||||||
}
|
.width(Length::Fill)
|
||||||
});
|
.height(Length::Fill)
|
||||||
|
.into()
|
||||||
}
|
}
|
||||||
|
|
||||||
fn renderizar_csv(ui: &mut Ui, app: &mut App) {
|
// ─── Estilo do pick_list ──────────────────────────────────────────────────────
|
||||||
ui.group(|ui| {
|
|
||||||
ui.label("Configurações CSV");
|
|
||||||
ui.add_space(4.0);
|
|
||||||
|
|
||||||
// Delimitador
|
fn input_style_pick(
|
||||||
ui.horizontal(|ui| {
|
theme: &iced::Theme,
|
||||||
ui.label("Delimitador:");
|
status: iced::widget::pick_list::Status,
|
||||||
let delim_str = match app.layout_csv_atual.delimitador {
|
) -> iced::widget::pick_list::Style {
|
||||||
|
let base = iced::widget::pick_list::Style {
|
||||||
|
text_color: t::TEXT,
|
||||||
|
placeholder_color: t::TEXT_MUTED,
|
||||||
|
handle_color: t::TEXT_SECONDARY,
|
||||||
|
background: t::BG.into(),
|
||||||
|
border: iced::Border {
|
||||||
|
color: t::BORDER,
|
||||||
|
width: 1.0,
|
||||||
|
radius: 6.0.into(),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
match status {
|
||||||
|
iced::widget::pick_list::Status::Opened | iced::widget::pick_list::Status::Hovered => {
|
||||||
|
iced::widget::pick_list::Style {
|
||||||
|
border: iced::Border {
|
||||||
|
color: t::PRIMARY,
|
||||||
|
..base.border
|
||||||
|
},
|
||||||
|
..base
|
||||||
|
}
|
||||||
|
}
|
||||||
|
_ => {
|
||||||
|
let _ = theme;
|
||||||
|
base
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Configuração CSV ────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
fn view_csv(app: &App) -> Element<'_, Message> {
|
||||||
|
let c = &app.layout_csv_atual;
|
||||||
|
|
||||||
|
let delim_str = match c.delimitador {
|
||||||
',' => "Vírgula (,)",
|
',' => "Vírgula (,)",
|
||||||
';' => "Ponto e vírgula (;)",
|
';' => "Ponto e vírgula (;)",
|
||||||
'\t' => "Tabulação (Tab)",
|
'\t' => "Tabulação (Tab)",
|
||||||
_ => "Outro",
|
_ => "Outro",
|
||||||
};
|
|
||||||
let mut delim_mudou = false;
|
|
||||||
egui::ComboBox::from_id_salt("combo_delimitador")
|
|
||||||
.selected_text(delim_str)
|
|
||||||
.show_ui(ui, |ui| {
|
|
||||||
if ui
|
|
||||||
.selectable_label(app.layout_csv_atual.delimitador == ',', "Vírgula (,)")
|
|
||||||
.clicked()
|
|
||||||
{
|
|
||||||
app.layout_csv_atual.delimitador = ',';
|
|
||||||
delim_mudou = true;
|
|
||||||
}
|
}
|
||||||
if ui
|
.to_string();
|
||||||
.selectable_label(
|
|
||||||
app.layout_csv_atual.delimitador == ';',
|
let opcoes_delim: Vec<String> = OPCOES_DELIMITADOR
|
||||||
"Ponto e vírgula (;)",
|
.iter()
|
||||||
|
.map(|(s, _)| s.to_string())
|
||||||
|
.collect();
|
||||||
|
let opcoes_enc: Vec<String> = OPCOES_ENCODING.iter().map(|s| s.to_string()).collect();
|
||||||
|
|
||||||
|
let linha_cabecalho_str = c.linha_cabecalho.to_string();
|
||||||
|
let indice_numero_str = c.indice_numero.to_string();
|
||||||
|
let indice_serie_str = c.indice_serie.to_string();
|
||||||
|
|
||||||
|
let inner = column![
|
||||||
|
secao_titulo("Importação CSV"),
|
||||||
|
campo_row(
|
||||||
|
"Delimitador",
|
||||||
|
pick_list(opcoes_delim, Some(delim_str), |selecionado| {
|
||||||
|
let c = OPCOES_DELIMITADOR
|
||||||
|
.iter()
|
||||||
|
.find(|(s, _)| *s == selecionado)
|
||||||
|
.map(|(_, c)| *c)
|
||||||
|
.unwrap_or(',');
|
||||||
|
Message::DelimitadorAlterado(c)
|
||||||
|
})
|
||||||
|
.style(input_style_pick)
|
||||||
|
.width(Length::Fixed(200.0))
|
||||||
|
.into(),
|
||||||
|
),
|
||||||
|
campo_row(
|
||||||
|
"Encoding",
|
||||||
|
pick_list(
|
||||||
|
opcoes_enc,
|
||||||
|
Some(c.encoding.clone()),
|
||||||
|
Message::EncodingAlterado
|
||||||
)
|
)
|
||||||
.clicked()
|
.style(input_style_pick)
|
||||||
{
|
.width(Length::Fixed(200.0))
|
||||||
app.layout_csv_atual.delimitador = ';';
|
.into(),
|
||||||
delim_mudou = true;
|
),
|
||||||
}
|
campo_row(
|
||||||
if ui
|
"Linha cabeçalho",
|
||||||
.selectable_label(
|
text_input("0", &linha_cabecalho_str)
|
||||||
app.layout_csv_atual.delimitador == '\t',
|
.on_input(|s| {
|
||||||
"Tabulação (Tab)",
|
s.parse::<usize>()
|
||||||
)
|
.map(Message::LinhaCabecalhoAlterada)
|
||||||
.clicked()
|
.unwrap_or(Message::Noop)
|
||||||
{
|
})
|
||||||
app.layout_csv_atual.delimitador = '\t';
|
.style(t::input_dark)
|
||||||
delim_mudou = true;
|
.width(Length::Fixed(80.0))
|
||||||
}
|
.into(),
|
||||||
});
|
),
|
||||||
if delim_mudou {
|
secao_subtitulo("Mapeamento de colunas (índice base 0)"),
|
||||||
if let Some(caminho) = &app.caminho_arquivo.clone() {
|
campo_row(
|
||||||
app.preview_arquivo = crate::infrastructure::csv_reader::preview_csv(
|
"Número (obrigatório)",
|
||||||
caminho,
|
text_input("0", &indice_numero_str)
|
||||||
app.layout_csv_atual.delimitador as u8,
|
.on_input(|s| {
|
||||||
&app.layout_csv_atual.encoding.clone(),
|
s.parse::<usize>()
|
||||||
5,
|
.map(Message::IndiceNumeroAlterado)
|
||||||
).ok();
|
.unwrap_or(Message::Noop)
|
||||||
}
|
})
|
||||||
}
|
.style(t::input_dark)
|
||||||
});
|
.width(Length::Fixed(80.0))
|
||||||
|
.into(),
|
||||||
|
),
|
||||||
|
campo_row(
|
||||||
|
"Série (obrigatório)",
|
||||||
|
text_input("0", &indice_serie_str)
|
||||||
|
.on_input(|s| {
|
||||||
|
s.parse::<usize>()
|
||||||
|
.map(Message::IndiceSerieAlterado)
|
||||||
|
.unwrap_or(Message::Noop)
|
||||||
|
})
|
||||||
|
.style(t::input_dark)
|
||||||
|
.width(Length::Fixed(80.0))
|
||||||
|
.into(),
|
||||||
|
),
|
||||||
|
campo_indice_opcional_csv(
|
||||||
|
"Valor (opcional)",
|
||||||
|
c.indice_valor,
|
||||||
|
Message::IndiceValorToggle,
|
||||||
|
Message::IndiceValorAlterado,
|
||||||
|
),
|
||||||
|
campo_indice_opcional_csv(
|
||||||
|
"Data (opcional)",
|
||||||
|
c.indice_data,
|
||||||
|
Message::IndiceDataToggle,
|
||||||
|
Message::IndiceDataAlterado,
|
||||||
|
),
|
||||||
|
campo_indice_opcional_csv(
|
||||||
|
"Tipo Documento (opcional)",
|
||||||
|
c.indice_documento_tipo,
|
||||||
|
Message::IndiceDocTipoToggle,
|
||||||
|
Message::IndiceDocTipoAlterado,
|
||||||
|
),
|
||||||
|
]
|
||||||
|
.spacing(10);
|
||||||
|
|
||||||
// Encoding
|
container(inner)
|
||||||
ui.horizontal(|ui| {
|
.style(t::card)
|
||||||
ui.label("Encoding:");
|
.padding([14, 18])
|
||||||
egui::ComboBox::from_id_salt("combo_encoding")
|
.width(Length::Fill)
|
||||||
.selected_text(&app.layout_csv_atual.encoding)
|
.into()
|
||||||
.show_ui(ui, |ui| {
|
|
||||||
if ui
|
|
||||||
.selectable_label(app.layout_csv_atual.encoding == "utf-8", "UTF-8")
|
|
||||||
.clicked()
|
|
||||||
{
|
|
||||||
app.layout_csv_atual.encoding = "utf-8".to_string();
|
|
||||||
}
|
|
||||||
if ui
|
|
||||||
.selectable_label(
|
|
||||||
app.layout_csv_atual.encoding == "windows-1252",
|
|
||||||
"Windows-1252 (Latin-1)",
|
|
||||||
)
|
|
||||||
.clicked()
|
|
||||||
{
|
|
||||||
app.layout_csv_atual.encoding = "windows-1252".to_string();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
// Linha cabeçalho
|
|
||||||
ui.horizontal(|ui| {
|
|
||||||
ui.label("Linha do cabeçalho (0 = sem cabeçalho):");
|
|
||||||
let mut val = app.layout_csv_atual.linha_cabecalho;
|
|
||||||
ui.add(egui::DragValue::new(&mut val).range(0..=100));
|
|
||||||
app.layout_csv_atual.linha_cabecalho = val;
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
ui.add_space(8.0);
|
|
||||||
ui.group(|ui| {
|
|
||||||
ui.label("Mapeamento de Colunas (índice base 0)");
|
|
||||||
ui.add_space(4.0);
|
|
||||||
|
|
||||||
campo_indice(
|
|
||||||
ui,
|
|
||||||
"Número (obrigatório):",
|
|
||||||
&mut app.layout_csv_atual.indice_numero,
|
|
||||||
);
|
|
||||||
campo_indice(
|
|
||||||
ui,
|
|
||||||
"Série (obrigatório):",
|
|
||||||
&mut app.layout_csv_atual.indice_serie,
|
|
||||||
);
|
|
||||||
|
|
||||||
campo_indice_opcional(
|
|
||||||
ui,
|
|
||||||
"Valor (opcional):",
|
|
||||||
&mut app.layout_csv_atual.indice_valor,
|
|
||||||
);
|
|
||||||
campo_indice_opcional(
|
|
||||||
ui,
|
|
||||||
"Data (opcional):",
|
|
||||||
&mut app.layout_csv_atual.indice_data,
|
|
||||||
);
|
|
||||||
campo_indice_opcional(
|
|
||||||
ui,
|
|
||||||
"Tipo Documento (opcional):",
|
|
||||||
&mut app.layout_csv_atual.indice_documento_tipo,
|
|
||||||
);
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
fn renderizar_xlsx(ui: &mut Ui, app: &mut App) {
|
// ─── Configuração XLSX ────────────────────────────────────────────────────────
|
||||||
ui.group(|ui| {
|
|
||||||
ui.label("Configurações XLSX");
|
|
||||||
ui.add_space(4.0);
|
|
||||||
|
|
||||||
ui.horizontal(|ui| {
|
fn view_xlsx(app: &App) -> Element<'_, Message> {
|
||||||
ui.label("Aba:");
|
let c = &app.layout_xlsx_atual;
|
||||||
if app.abas_xlsx.is_empty() {
|
|
||||||
ui.text_edit_singleline(&mut app.layout_xlsx_atual.aba);
|
let secao_aba: Element<Message> = if app.abas_xlsx.is_empty() {
|
||||||
|
campo_row(
|
||||||
|
"Aba",
|
||||||
|
text_input("Nome da aba", &c.aba)
|
||||||
|
.on_input(Message::AbaXlsxAlterada)
|
||||||
|
.style(t::input_dark)
|
||||||
|
.width(Length::Fixed(200.0))
|
||||||
|
.into(),
|
||||||
|
)
|
||||||
} else {
|
} else {
|
||||||
let aba_atual = app.layout_xlsx_atual.aba.clone();
|
let aba_sel = if c.aba.is_empty() {
|
||||||
egui::ComboBox::from_id_salt("combo_aba")
|
None
|
||||||
.selected_text(&aba_atual)
|
} else {
|
||||||
.show_ui(ui, |ui| {
|
Some(c.aba.clone())
|
||||||
for aba in &app.abas_xlsx.clone() {
|
};
|
||||||
if ui.selectable_label(aba_atual == *aba, aba).clicked() {
|
campo_row(
|
||||||
app.layout_xlsx_atual.aba = aba.clone();
|
"Aba",
|
||||||
}
|
pick_list(app.abas_xlsx.clone(), aba_sel, Message::AbaXlsxAlterada)
|
||||||
}
|
.style(input_style_pick)
|
||||||
});
|
.width(Length::Fixed(200.0))
|
||||||
}
|
.into(),
|
||||||
});
|
)
|
||||||
});
|
};
|
||||||
|
|
||||||
ui.add_space(8.0);
|
|
||||||
ui.group(|ui| {
|
|
||||||
ui.label("Mapeamento de Colunas (formato LetraLinha, ex: B3)");
|
|
||||||
ui.add_space(4.0);
|
|
||||||
|
|
||||||
|
let inner = column![
|
||||||
|
secao_titulo("Importação XLSX"),
|
||||||
|
secao_aba,
|
||||||
|
secao_subtitulo("Mapeamento de colunas (formato LetraLinha, ex: B3)"),
|
||||||
campo_letra_linha(
|
campo_letra_linha(
|
||||||
ui,
|
"Número (obrigatório)",
|
||||||
"Número (obrigatório):",
|
&c.pos_numero,
|
||||||
&mut app.layout_xlsx_atual.pos_numero,
|
Message::PosNumeroAlterada
|
||||||
);
|
),
|
||||||
campo_letra_linha(
|
campo_letra_linha(
|
||||||
ui,
|
"Série (obrigatório)",
|
||||||
"Série (obrigatório):",
|
&c.pos_serie,
|
||||||
&mut app.layout_xlsx_atual.pos_serie,
|
Message::PosSerieAlterada
|
||||||
);
|
),
|
||||||
campo_letra_linha_opcional(
|
campo_letra_linha_opcional(
|
||||||
ui,
|
"Valor (opcional)",
|
||||||
"Valor (opcional):",
|
c.pos_valor.as_deref(),
|
||||||
&mut app.layout_xlsx_atual.pos_valor,
|
Message::PosValorToggle,
|
||||||
);
|
Message::PosValorAlterada,
|
||||||
campo_letra_linha_opcional(ui, "Data (opcional):", &mut app.layout_xlsx_atual.pos_data);
|
),
|
||||||
campo_letra_linha_opcional(
|
campo_letra_linha_opcional(
|
||||||
ui,
|
"Data (opcional)",
|
||||||
"Tipo Documento (opcional):",
|
c.pos_data.as_deref(),
|
||||||
&mut app.layout_xlsx_atual.pos_documento_tipo,
|
Message::PosDataToggle,
|
||||||
);
|
Message::PosDataAlterada,
|
||||||
});
|
),
|
||||||
|
campo_letra_linha_opcional(
|
||||||
|
"Tipo Documento (opcional)",
|
||||||
|
c.pos_documento_tipo.as_deref(),
|
||||||
|
Message::PosDocTipoToggle,
|
||||||
|
Message::PosDocTipoAlterada,
|
||||||
|
),
|
||||||
|
]
|
||||||
|
.spacing(10);
|
||||||
|
|
||||||
|
container(inner)
|
||||||
|
.style(t::card)
|
||||||
|
.padding([14, 18])
|
||||||
|
.width(Length::Fill)
|
||||||
|
.into()
|
||||||
}
|
}
|
||||||
|
|
||||||
fn campo_indice(ui: &mut Ui, label: &str, valor: &mut usize) {
|
// ─── Helpers visuais ─────────────────────────────────────────────────────────
|
||||||
ui.horizontal(|ui| {
|
|
||||||
ui.label(label);
|
fn secao_titulo(label: &str) -> Element<'_, Message> {
|
||||||
ui.add(egui::DragValue::new(valor).range(0..=999usize));
|
text(label).size(14).color(t::TEXT).into()
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
fn campo_indice_opcional(ui: &mut Ui, label: &str, valor: &mut Option<usize>) {
|
fn secao_subtitulo(label: &str) -> Element<'_, Message> {
|
||||||
ui.horizontal(|ui| {
|
text(label).size(12).color(t::TEXT_SECONDARY).into()
|
||||||
let mut ativo = valor.is_some();
|
}
|
||||||
if ui.checkbox(&mut ativo, label).changed() {
|
|
||||||
*valor = if ativo { Some(0) } else { None };
|
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::Fixed(220.0)),
|
||||||
|
input,
|
||||||
|
]
|
||||||
|
.spacing(10)
|
||||||
|
.align_y(Alignment::Center)
|
||||||
|
.into()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn campo_indice_opcional_csv(
|
||||||
|
label: &str,
|
||||||
|
valor: Option<usize>,
|
||||||
|
msg_toggle: impl Fn(bool) -> Message + 'static,
|
||||||
|
msg_valor: impl Fn(usize) -> Message + 'static,
|
||||||
|
) -> Element<'static, Message> {
|
||||||
|
let ativo = valor.is_some();
|
||||||
|
let val_str = valor.map(|v| v.to_string()).unwrap_or_default();
|
||||||
|
|
||||||
|
let cb = checkbox(label, ativo).on_toggle(msg_toggle).text_size(13);
|
||||||
|
|
||||||
|
if ativo {
|
||||||
|
row![
|
||||||
|
cb.width(Length::Fixed(220.0)),
|
||||||
|
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)),
|
||||||
|
]
|
||||||
|
.spacing(10)
|
||||||
|
.align_y(Alignment::Center)
|
||||||
|
.into()
|
||||||
|
} else {
|
||||||
|
row![cb].into()
|
||||||
}
|
}
|
||||||
if let Some(v) = valor {
|
|
||||||
ui.add(egui::DragValue::new(v).range(0..=999usize));
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
fn campo_letra_linha(ui: &mut Ui, label: &str, valor: &mut String) {
|
fn campo_letra_linha<'a>(
|
||||||
ui.horizontal(|ui| {
|
label: &'a str,
|
||||||
ui.label(label);
|
valor: &'a str,
|
||||||
ui.text_edit_singleline(valor);
|
msg: impl Fn(String) -> Message + 'a,
|
||||||
});
|
) -> Element<'a, Message> {
|
||||||
|
campo_row(
|
||||||
|
label,
|
||||||
|
text_input("ex: B3", valor)
|
||||||
|
.on_input(msg)
|
||||||
|
.style(t::input_dark)
|
||||||
|
.width(Length::Fixed(100.0))
|
||||||
|
.into(),
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
fn campo_letra_linha_opcional(ui: &mut Ui, label: &str, valor: &mut Option<String>) {
|
fn campo_letra_linha_opcional<'a>(
|
||||||
ui.horizontal(|ui| {
|
label: &'a str,
|
||||||
let mut ativo = valor.is_some();
|
valor: Option<&'a str>,
|
||||||
if ui.checkbox(&mut ativo, label).changed() {
|
msg_toggle: impl Fn(bool) -> Message + 'a,
|
||||||
*valor = if ativo { Some(String::new()) } else { None };
|
msg_valor: impl Fn(String) -> Message + 'a,
|
||||||
|
) -> Element<'a, Message> {
|
||||||
|
let ativo = valor.is_some();
|
||||||
|
let val_str = valor.unwrap_or("").to_string();
|
||||||
|
|
||||||
|
let cb = checkbox(label, ativo).on_toggle(msg_toggle).text_size(13);
|
||||||
|
|
||||||
|
if ativo {
|
||||||
|
row![
|
||||||
|
cb.width(Length::Fixed(220.0)),
|
||||||
|
text_input("ex: B3", &val_str)
|
||||||
|
.on_input(msg_valor)
|
||||||
|
.style(t::input_dark)
|
||||||
|
.width(Length::Fixed(100.0)),
|
||||||
|
]
|
||||||
|
.spacing(10)
|
||||||
|
.align_y(Alignment::Center)
|
||||||
|
.into()
|
||||||
|
} else {
|
||||||
|
row![cb].into()
|
||||||
}
|
}
|
||||||
if let Some(v) = valor {
|
|
||||||
ui.text_edit_singleline(v);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Valida a configuração atual. Retorna (é_válido, lista_de_erros).
|
// ─── Validação ────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
fn validar_config(app: &App) -> (bool, Vec<String>) {
|
fn validar_config(app: &App) -> (bool, Vec<String>) {
|
||||||
let mut erros = Vec::new();
|
let mut erros = Vec::new();
|
||||||
|
|
||||||
match &app.tipo_arquivo_atual {
|
match &app.tipo_arquivo_atual {
|
||||||
TipoArquivo::Csv => {
|
TipoArquivo::Csv => {
|
||||||
let c = &app.layout_csv_atual;
|
let c = &app.layout_csv_atual;
|
||||||
// Verificar índices duplicados
|
|
||||||
let mut indices: Vec<(String, usize)> = vec![
|
let mut indices: Vec<(String, usize)> = vec![
|
||||||
("Numero".to_string(), c.indice_numero),
|
("Numero".to_string(), c.indice_numero),
|
||||||
("Serie".to_string(), c.indice_serie),
|
("Serie".to_string(), c.indice_serie),
|
||||||
@@ -393,62 +554,3 @@ fn verificar_duplicados(indices: &[(String, usize)], erros: &mut Vec<String>) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fn executar_importacao(app: &mut App, ctx: &egui::Context) {
|
|
||||||
let caminho = match &app.caminho_arquivo {
|
|
||||||
Some(p) => p.clone(),
|
|
||||||
None => return,
|
|
||||||
};
|
|
||||||
|
|
||||||
let tipo = app.tipo_arquivo_atual.clone();
|
|
||||||
let layout_csv = app.layout_csv_atual.clone();
|
|
||||||
let layout_xlsx = app.layout_xlsx_atual.clone();
|
|
||||||
|
|
||||||
let (tx, rx) = std::sync::mpsc::channel();
|
|
||||||
app.resultado_pendente = Some(rx);
|
|
||||||
app.estado = EstadoApp::Analisando;
|
|
||||||
ctx.request_repaint();
|
|
||||||
|
|
||||||
std::thread::spawn(move || {
|
|
||||||
// 1. Importar arquivo
|
|
||||||
let res_importacao = match tipo {
|
|
||||||
TipoArquivo::Csv => importar_csv(&caminho, &layout_csv).map_err(|e| e.to_string()),
|
|
||||||
TipoArquivo::Xlsx => importar_xlsx(&caminho, &layout_xlsx).map_err(|e| e.to_string()),
|
|
||||||
};
|
|
||||||
|
|
||||||
let res = match res_importacao {
|
|
||||||
Err(e) => ResultadoPendente::Erro(e),
|
|
||||||
Ok(importado) => {
|
|
||||||
if importado.notas.is_empty() {
|
|
||||||
ResultadoPendente::Vazio
|
|
||||||
} else {
|
|
||||||
let avisos = importado.avisos.clone();
|
|
||||||
let notas = importado.notas;
|
|
||||||
|
|
||||||
// 2. Pré-análise
|
|
||||||
let pre = pre_analisar(¬as);
|
|
||||||
let excessivos = series_com_intervalo_excessivo(&pre);
|
|
||||||
|
|
||||||
if !excessivos.is_empty() {
|
|
||||||
ResultadoPendente::AguardandoConfirmacao {
|
|
||||||
pre,
|
|
||||||
series_excessivas: excessivos,
|
|
||||||
avisos,
|
|
||||||
notas,
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
// 3. Expandir faltantes
|
|
||||||
let resultado = expandir_analise(pre, ¬as);
|
|
||||||
ResultadoPendente::Concluido {
|
|
||||||
resultado,
|
|
||||||
avisos: Some(avisos),
|
|
||||||
notas: Some(notas),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
let _ = tx.send(res);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|||||||
+167
-301
@@ -1,328 +1,194 @@
|
|||||||
use crate::application::usecases::executar_analise::{
|
use crate::ui::app::App;
|
||||||
expandir_analise, pre_analisar, series_com_intervalo_excessivo,
|
use crate::ui::message::Message;
|
||||||
};
|
use crate::ui::theme as t;
|
||||||
use crate::application::usecases::importar_arquivo::{importar_xlsx, listar_abas_xlsx};
|
use iced::widget::{button, column, container, pick_list, row, text};
|
||||||
use crate::domain::entities::layout::{Layout, TipoArquivo};
|
use iced::{Alignment, Element, Length};
|
||||||
use crate::ui::app::{App, EstadoApp, ResultadoPendente};
|
|
||||||
use egui::{Context, Ui};
|
|
||||||
use std::path::PathBuf;
|
|
||||||
|
|
||||||
/// Renderiza a tela de importação de arquivos.
|
/// Tela de importação de arquivos.
|
||||||
pub fn renderizar(ui: &mut Ui, ctx: &Context, app: &mut App) {
|
pub fn view(app: &App) -> Element<'_, Message> {
|
||||||
ui.heading("Comparador de Notas — Importar Arquivo");
|
// ── Área de seleção de arquivo ────────────────────────────────────────────
|
||||||
ui.add_space(16.0);
|
let (nome_arquivo, tem_arquivo) = if app.nome_arquivo.is_empty() {
|
||||||
|
("Nenhum arquivo selecionado".to_string(), false)
|
||||||
// --- Seleção de arquivo ---
|
|
||||||
ui.group(|ui| {
|
|
||||||
ui.label("Arquivo:");
|
|
||||||
ui.horizontal(|ui| {
|
|
||||||
let nome = if app.nome_arquivo.is_empty() {
|
|
||||||
"Nenhum arquivo selecionado".to_string()
|
|
||||||
} else {
|
} else {
|
||||||
app.nome_arquivo.clone()
|
(app.nome_arquivo.clone(), true)
|
||||||
};
|
};
|
||||||
ui.label(nome);
|
|
||||||
|
|
||||||
if ui.button("📂 Selecionar arquivo...").clicked() {
|
let icone_arquivo: Element<Message> = text(if tem_arquivo { "📄" } else { "📂" })
|
||||||
if let Some(caminho) = rfd::FileDialog::new()
|
.size(32)
|
||||||
.add_filter("Planilhas", &["csv", "xlsx", "xls"])
|
.color(if tem_arquivo {
|
||||||
.pick_file()
|
t::PRIMARY
|
||||||
{
|
} else {
|
||||||
on_arquivo_selecionado(app, ctx, caminho);
|
t::TEXT_MUTED
|
||||||
|
})
|
||||||
|
.into();
|
||||||
|
|
||||||
|
let texto_arquivo: Element<Message> = if tem_arquivo {
|
||||||
|
let nome_str = nome_arquivo.clone();
|
||||||
|
column![
|
||||||
|
text(nome_str).size(14).color(t::TEXT),
|
||||||
|
text("Arquivo pronto para configuração")
|
||||||
|
.size(12)
|
||||||
|
.color(t::TEXT_SECONDARY),
|
||||||
|
]
|
||||||
|
.spacing(4)
|
||||||
|
.align_x(Alignment::Center)
|
||||||
|
.into()
|
||||||
|
} else {
|
||||||
|
column![
|
||||||
|
text("Selecione um arquivo CSV ou XLSX")
|
||||||
|
.size(14)
|
||||||
|
.color(t::TEXT_SECONDARY),
|
||||||
|
text("Suportado: .csv, .xlsx, .xls")
|
||||||
|
.size(12)
|
||||||
|
.color(t::TEXT_MUTED),
|
||||||
|
]
|
||||||
|
.spacing(4)
|
||||||
|
.align_x(Alignment::Center)
|
||||||
|
.into()
|
||||||
|
};
|
||||||
|
|
||||||
|
let drop_zone = container(
|
||||||
|
column![icone_arquivo, texto_arquivo]
|
||||||
|
.spacing(12)
|
||||||
|
.align_x(Alignment::Center)
|
||||||
|
.width(Length::Fill),
|
||||||
|
)
|
||||||
|
.style(move |_theme| iced::widget::container::Style {
|
||||||
|
background: Some(
|
||||||
|
iced::Color {
|
||||||
|
a: 0.05,
|
||||||
|
..t::PRIMARY
|
||||||
}
|
}
|
||||||
|
.into(),
|
||||||
|
),
|
||||||
|
border: iced::Border {
|
||||||
|
color: if tem_arquivo {
|
||||||
|
iced::Color {
|
||||||
|
a: 0.5,
|
||||||
|
..t::PRIMARY
|
||||||
}
|
}
|
||||||
});
|
} else {
|
||||||
});
|
iced::Color {
|
||||||
|
a: 0.3,
|
||||||
|
..t::BORDER
|
||||||
|
}
|
||||||
|
},
|
||||||
|
width: 1.5,
|
||||||
|
radius: 8.0.into(),
|
||||||
|
},
|
||||||
|
..Default::default()
|
||||||
|
})
|
||||||
|
.padding([28, 20])
|
||||||
|
.width(Length::Fill);
|
||||||
|
|
||||||
ui.add_space(8.0);
|
let btn_selecionar = button(
|
||||||
|
row![text("Selecionar arquivo...").size(14)]
|
||||||
|
.align_y(Alignment::Center)
|
||||||
|
.spacing(6),
|
||||||
|
)
|
||||||
|
.on_press(Message::SelecionarArquivo)
|
||||||
|
.style(t::btn_primary)
|
||||||
|
.width(Length::Fill)
|
||||||
|
.padding([10, 16]);
|
||||||
|
|
||||||
// Coletar infos dos layouts antecipadamente para evitar borrow duplo
|
// ── Seção de layout ───────────────────────────────────────────────────────
|
||||||
let tipo_atual = app.tipo_arquivo_atual.clone();
|
let tipo_atual = app.tipo_arquivo_atual.clone();
|
||||||
let opcoes_layout: Vec<(i64, String)> = app
|
let opcoes_layout: Vec<String> = app
|
||||||
.layouts_salvos
|
.layouts_salvos
|
||||||
.iter()
|
.iter()
|
||||||
.filter(|l| l.tipo() == tipo_atual)
|
.filter(|l| l.tipo() == tipo_atual)
|
||||||
.filter_map(|l| l.id().map(|id| (id, l.nome().to_string())))
|
.map(|l| l.nome().to_string())
|
||||||
.collect();
|
.collect();
|
||||||
let nome_layout_atual = app.nome_layout_atual.clone();
|
|
||||||
|
|
||||||
ui.horizontal(|ui| {
|
let nome_layout_sel: Option<String> = if app.nome_layout_atual.is_empty() {
|
||||||
ui.label("Layout:");
|
None
|
||||||
egui::ComboBox::from_id_salt("combo_layouts_import")
|
|
||||||
.selected_text(if nome_layout_atual.is_empty() {
|
|
||||||
"— Selecionar layout —"
|
|
||||||
} else {
|
} else {
|
||||||
&nome_layout_atual
|
Some(app.nome_layout_atual.clone())
|
||||||
})
|
|
||||||
.show_ui(ui, |ui| {
|
|
||||||
for (id, nome) in &opcoes_layout {
|
|
||||||
if ui
|
|
||||||
.selectable_label(nome_layout_atual == *nome, nome.as_str())
|
|
||||||
.clicked()
|
|
||||||
{
|
|
||||||
app.nome_layout_atual = nome.clone();
|
|
||||||
if let Some(layout) =
|
|
||||||
app.layouts_salvos.iter().find(|l| l.id() == Some(*id))
|
|
||||||
{
|
|
||||||
let layout = layout.clone();
|
|
||||||
aplicar_layout(app, &layout);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
if ui.button("⚙ Gerenciar Layouts").clicked() {
|
|
||||||
app.estado = EstadoApp::GerenciandoLayouts;
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
ui.add_space(16.0);
|
|
||||||
|
|
||||||
if app.caminho_arquivo.is_some() {
|
|
||||||
if ui.button("▶ Configurar Colunas").clicked() {
|
|
||||||
app.estado = EstadoApp::ConfigurandoColunas;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Renderiza a tela de seleção de aba (XLSX).
|
|
||||||
pub fn renderizar_selecao_aba(ui: &mut Ui, ctx: &Context, app: &mut App) {
|
|
||||||
ui.heading("Selecionar Aba da Planilha");
|
|
||||||
ui.add_space(16.0);
|
|
||||||
|
|
||||||
let (abas, caminho) = match &app.estado {
|
|
||||||
EstadoApp::SelecionandoAba { abas, caminho } => (abas.clone(), caminho.clone()),
|
|
||||||
_ => return,
|
|
||||||
};
|
};
|
||||||
|
|
||||||
ui.label(format!("Arquivo: {}", caminho.display()));
|
let secao_layout = row![
|
||||||
ui.add_space(8.0);
|
text("Layout salvo:")
|
||||||
|
.size(13)
|
||||||
// --- Seleção de preset ---
|
.color(t::TEXT_SECONDARY)
|
||||||
let opcoes_layout: Vec<(i64, String)> = app
|
.width(Length::Fixed(110.0)),
|
||||||
.layouts_salvos
|
pick_list(opcoes_layout, nome_layout_sel, {
|
||||||
|
let layouts = app.layouts_salvos.clone();
|
||||||
|
move |nome_selecionado: String| {
|
||||||
|
if let Some(id) = layouts
|
||||||
.iter()
|
.iter()
|
||||||
.filter(|l| l.tipo() == TipoArquivo::Xlsx)
|
.find(|l| l.nome() == nome_selecionado)
|
||||||
.filter_map(|l| l.id().map(|id| (id, l.nome().to_string())))
|
.and_then(|l| l.id())
|
||||||
.collect();
|
{
|
||||||
let nome_layout_atual = app.nome_layout_atual.clone();
|
Message::LayoutSelecionado(id)
|
||||||
|
|
||||||
if !opcoes_layout.is_empty() {
|
|
||||||
ui.horizontal(|ui| {
|
|
||||||
ui.label("Layout:");
|
|
||||||
egui::ComboBox::from_id_salt("combo_layouts_aba")
|
|
||||||
.selected_text(if nome_layout_atual.is_empty() {
|
|
||||||
"— Selecionar layout —"
|
|
||||||
} else {
|
} else {
|
||||||
&nome_layout_atual
|
Message::NomeLayoutAlterado(nome_selecionado)
|
||||||
|
}
|
||||||
|
}
|
||||||
})
|
})
|
||||||
.show_ui(ui, |ui| {
|
.placeholder("— Selecionar layout —")
|
||||||
for (id, nome) in &opcoes_layout {
|
.width(Length::Fill),
|
||||||
if ui
|
button(text("Gerenciar").size(13))
|
||||||
.selectable_label(nome_layout_atual == *nome, nome.as_str())
|
.on_press(Message::IrParaLayouts)
|
||||||
.clicked()
|
.style(t::btn_ghost)
|
||||||
{
|
.padding([8, 12]),
|
||||||
app.nome_layout_atual = nome.clone();
|
]
|
||||||
if let Some(layout) =
|
.spacing(8)
|
||||||
app.layouts_salvos.iter().find(|l| l.id() == Some(*id))
|
.align_y(Alignment::Center);
|
||||||
{
|
|
||||||
let layout = layout.clone();
|
|
||||||
aplicar_layout(app, &layout);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
|
||||||
});
|
|
||||||
ui.add_space(8.0);
|
|
||||||
}
|
|
||||||
|
|
||||||
ui.label("Selecione a aba a processar:");
|
// ── Botão avançar ─────────────────────────────────────────────────────────
|
||||||
|
let botao_avancar: Element<Message> = if tem_arquivo {
|
||||||
|
button(row![text("Configurar Colunas ▶").size(14)].align_y(Alignment::Center))
|
||||||
|
.on_press(Message::IrParaConfiguracaoColunas)
|
||||||
|
.style(t::btn_primary)
|
||||||
|
.width(Length::Fill)
|
||||||
|
.padding([11, 16])
|
||||||
|
.into()
|
||||||
|
} else {
|
||||||
|
container(text("").size(1))
|
||||||
|
.height(Length::Fixed(42.0))
|
||||||
|
.into()
|
||||||
|
};
|
||||||
|
|
||||||
let aba_atual = app.layout_xlsx_atual.aba.clone();
|
// ── Separador visual ──────────────────────────────────────────────────────
|
||||||
for aba in &abas {
|
let separador = container(text(""))
|
||||||
if ui.selectable_label(aba_atual == *aba, aba).clicked() {
|
.height(Length::Fixed(1.0))
|
||||||
app.layout_xlsx_atual.aba = aba.clone();
|
.width(Length::Fill)
|
||||||
// Gerar pré-visualização da aba selecionada
|
.style(t::separador);
|
||||||
app.preview_arquivo =
|
|
||||||
crate::infrastructure::xlsx_reader::preview_xlsx(&caminho, aba).ok();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Pré-visualização da aba selecionada
|
// ── Card central ──────────────────────────────────────────────────────────
|
||||||
if !app.layout_xlsx_atual.aba.is_empty() {
|
let card_inner = column![
|
||||||
if let Some(preview) = &app.preview_arquivo {
|
drop_zone,
|
||||||
ui.add_space(8.0);
|
btn_selecionar,
|
||||||
crate::ui::screens::renderizar_tabela_preview(ui, preview);
|
separador,
|
||||||
}
|
secao_layout,
|
||||||
}
|
botao_avancar,
|
||||||
|
]
|
||||||
|
.spacing(14)
|
||||||
|
.padding(24)
|
||||||
|
.width(Length::Fill);
|
||||||
|
|
||||||
ui.add_space(12.0);
|
let card = container(card_inner)
|
||||||
|
.style(t::card)
|
||||||
|
.max_width(560)
|
||||||
|
.width(Length::Fill);
|
||||||
|
|
||||||
if !app.layout_xlsx_atual.aba.is_empty() {
|
// ── Layout geral ──────────────────────────────────────────────────────────
|
||||||
ui.horizontal(|ui| {
|
container(
|
||||||
// Se há preset selecionado, oferecer processamento direto
|
column![
|
||||||
let tem_preset = !app.nome_layout_atual.is_empty();
|
text("Importar Arquivo").size(20).color(t::TEXT),
|
||||||
if tem_preset {
|
text("Selecione e configure sua planilha para análise")
|
||||||
let caminho_clone = caminho.clone();
|
.size(13)
|
||||||
if ui.button("▶ Processar").clicked() {
|
.color(t::TEXT_SECONDARY),
|
||||||
app.nome_arquivo = caminho_clone
|
card,
|
||||||
.file_name()
|
]
|
||||||
.map(|n| n.to_string_lossy().to_string())
|
.spacing(16)
|
||||||
.unwrap_or_default();
|
.align_x(Alignment::Center)
|
||||||
app.caminho_arquivo = Some(caminho_clone);
|
.padding([32, 20])
|
||||||
disparar_analise(app, ctx);
|
.width(Length::Fill),
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if ui.button("⚙ Configurar Colunas").clicked() {
|
|
||||||
app.nome_arquivo = caminho
|
|
||||||
.file_name()
|
|
||||||
.map(|n| n.to_string_lossy().to_string())
|
|
||||||
.unwrap_or_default();
|
|
||||||
app.caminho_arquivo = Some(caminho);
|
|
||||||
app.estado = EstadoApp::ConfigurandoColunas;
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
if ui.button("< Voltar").clicked() {
|
|
||||||
app.estado = EstadoApp::Importando;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn on_arquivo_selecionado(app: &mut App, ctx: &Context, caminho: PathBuf) {
|
|
||||||
let extensao = caminho
|
|
||||||
.extension()
|
|
||||||
.and_then(|e| e.to_str())
|
|
||||||
.unwrap_or("")
|
|
||||||
.to_lowercase();
|
|
||||||
|
|
||||||
app.nome_arquivo = caminho
|
|
||||||
.file_name()
|
|
||||||
.map(|n| n.to_string_lossy().to_string())
|
|
||||||
.unwrap_or_default();
|
|
||||||
|
|
||||||
match extensao.as_str() {
|
|
||||||
"csv" => {
|
|
||||||
app.tipo_arquivo_atual = TipoArquivo::Csv;
|
|
||||||
app.caminho_arquivo = Some(caminho.clone());
|
|
||||||
app.notas_importadas.clear();
|
|
||||||
// Gerar pré-visualização com o delimitador atual
|
|
||||||
app.preview_arquivo = crate::infrastructure::csv_reader::preview_csv(
|
|
||||||
&caminho,
|
|
||||||
app.layout_csv_atual.delimitador as u8,
|
|
||||||
&app.layout_csv_atual.encoding.clone(),
|
|
||||||
5,
|
|
||||||
)
|
)
|
||||||
.ok();
|
.style(t::fundo)
|
||||||
}
|
.width(Length::Fill)
|
||||||
"xlsx" | "xls" => {
|
.height(Length::Fill)
|
||||||
app.tipo_arquivo_atual = TipoArquivo::Xlsx;
|
.center_x(Length::Fill)
|
||||||
match listar_abas_xlsx(&caminho) {
|
.into()
|
||||||
Ok(info) => {
|
|
||||||
app.notas_importadas.clear();
|
|
||||||
|
|
||||||
// Verificar se há preset XLSX ativo com aba compatível
|
|
||||||
let preset_aba = if !app.nome_layout_atual.is_empty() {
|
|
||||||
let aba = app.layout_xlsx_atual.aba.clone();
|
|
||||||
if !aba.is_empty() && info.abas.contains(&aba) {
|
|
||||||
Some(aba)
|
|
||||||
} else {
|
|
||||||
None
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
None
|
|
||||||
};
|
|
||||||
|
|
||||||
if let Some(aba) = preset_aba {
|
|
||||||
// Fluxo rápido: aba do preset existe → disparar análise direto
|
|
||||||
app.layout_xlsx_atual.aba = aba.clone();
|
|
||||||
app.caminho_arquivo = Some(caminho.clone());
|
|
||||||
app.abas_xlsx = info.abas;
|
|
||||||
app.preview_arquivo =
|
|
||||||
crate::infrastructure::xlsx_reader::preview_xlsx(&caminho, &aba).ok();
|
|
||||||
disparar_analise(app, ctx);
|
|
||||||
} else {
|
|
||||||
// Fluxo normal: exibir tela de seleção de aba
|
|
||||||
app.abas_xlsx = info.abas.clone();
|
|
||||||
app.estado = EstadoApp::SelecionandoAba {
|
|
||||||
abas: info.abas,
|
|
||||||
caminho: caminho.clone(),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Err(e) => {
|
|
||||||
app.exibir_erro(format!("Erro ao ler abas do arquivo: {}", e));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
_ => {
|
|
||||||
app.exibir_erro("Formato de arquivo não suportado. Use CSV, XLSX ou XLS.");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Dispara a análise assíncrona com o layout XLSX atual.
|
|
||||||
/// Usado tanto no fluxo rápido (preset com aba compatível) quanto no botão "Processar" da tela de aba.
|
|
||||||
fn disparar_analise(app: &mut App, ctx: &Context) {
|
|
||||||
let caminho = match &app.caminho_arquivo {
|
|
||||||
Some(p) => p.clone(),
|
|
||||||
None => return,
|
|
||||||
};
|
|
||||||
|
|
||||||
let layout_xlsx = app.layout_xlsx_atual.clone();
|
|
||||||
let (tx, rx) = std::sync::mpsc::channel();
|
|
||||||
app.resultado_pendente = Some(rx);
|
|
||||||
app.estado = EstadoApp::Analisando;
|
|
||||||
ctx.request_repaint();
|
|
||||||
|
|
||||||
std::thread::spawn(move || {
|
|
||||||
let res_importacao = importar_xlsx(&caminho, &layout_xlsx).map_err(|e| e.to_string());
|
|
||||||
|
|
||||||
let res = match res_importacao {
|
|
||||||
Err(e) => ResultadoPendente::Erro(e),
|
|
||||||
Ok(importado) => {
|
|
||||||
if importado.notas.is_empty() {
|
|
||||||
ResultadoPendente::Vazio
|
|
||||||
} else {
|
|
||||||
let avisos = importado.avisos.clone();
|
|
||||||
let notas = importado.notas;
|
|
||||||
|
|
||||||
let pre = pre_analisar(¬as);
|
|
||||||
let excessivos = series_com_intervalo_excessivo(&pre);
|
|
||||||
|
|
||||||
if !excessivos.is_empty() {
|
|
||||||
ResultadoPendente::AguardandoConfirmacao {
|
|
||||||
pre,
|
|
||||||
series_excessivas: excessivos,
|
|
||||||
avisos,
|
|
||||||
notas,
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
let resultado = expandir_analise(pre, ¬as);
|
|
||||||
ResultadoPendente::Concluido {
|
|
||||||
resultado,
|
|
||||||
avisos: Some(avisos),
|
|
||||||
notas: Some(notas),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
let _ = tx.send(res);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
fn aplicar_layout(app: &mut App, layout: &Layout) {
|
|
||||||
match layout {
|
|
||||||
Layout::Csv { config, .. } => {
|
|
||||||
app.layout_csv_atual = config.clone();
|
|
||||||
app.tipo_arquivo_atual = TipoArquivo::Csv;
|
|
||||||
}
|
|
||||||
Layout::Xlsx { config, .. } => {
|
|
||||||
app.layout_xlsx_atual = config.clone();
|
|
||||||
app.tipo_arquivo_atual = TipoArquivo::Xlsx;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
+81
-251
@@ -1,267 +1,97 @@
|
|||||||
use crate::application::usecases::layouts::{
|
use crate::domain::entities::layout::{Layout, TipoArquivo};
|
||||||
exportar_layout_json, importar_layout_json, salvar_layout,
|
use crate::ui::app::App;
|
||||||
};
|
use crate::ui::message::Message;
|
||||||
use crate::domain::entities::layout::{Layout, LayoutJson, TipoArquivo};
|
use crate::ui::theme as t;
|
||||||
use crate::domain::errors::ErroLayout;
|
use iced::widget::{button, column, container, horizontal_space, row, scrollable, text};
|
||||||
use crate::ui::app::{AcaoModal, App, EstadoApp};
|
use iced::{Alignment, Element, Length};
|
||||||
use egui::{Context, Ui};
|
|
||||||
|
|
||||||
/// Renderiza a tela de gerenciamento de layouts.
|
/// Tela de gerenciamento de layouts.
|
||||||
pub fn renderizar(ui: &mut Ui, ctx: &Context, app: &mut App) {
|
pub fn view(app: &App) -> Element<'_, Message> {
|
||||||
ui.heading("Gerenciar Layouts");
|
let cabecalho = row![
|
||||||
ui.add_space(8.0);
|
button("< Voltar")
|
||||||
|
.on_press(Message::IrParaImportacao)
|
||||||
|
.style(t::btn_secondary),
|
||||||
|
horizontal_space(),
|
||||||
|
button("Importar JSON")
|
||||||
|
.on_press(Message::ImportarLayoutJson)
|
||||||
|
.style(t::btn_ghost),
|
||||||
|
]
|
||||||
|
.spacing(8)
|
||||||
|
.align_y(Alignment::Center)
|
||||||
|
.width(Length::Fill);
|
||||||
|
|
||||||
ui.horizontal(|ui| {
|
let titulo = text("Gerenciar Layouts").size(22).color(t::TEXT);
|
||||||
if ui.button("< Voltar").clicked() {
|
|
||||||
app.estado = EstadoApp::Importando;
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
ui.add_space(12.0);
|
let secao_csv = view_secao_layouts("Layouts CSV", &app.layouts_salvos, TipoArquivo::Csv);
|
||||||
ui.separator();
|
let secao_xlsx = view_secao_layouts("Layouts XLSX", &app.layouts_salvos, TipoArquivo::Xlsx);
|
||||||
|
|
||||||
// Salvar layout atual
|
let content = column![titulo, cabecalho, secao_csv, secao_xlsx,]
|
||||||
ui.add_space(8.0);
|
.spacing(16)
|
||||||
ui.group(|ui| {
|
.padding(20)
|
||||||
ui.label("Salvar Layout Atual");
|
.width(Length::Fill);
|
||||||
ui.horizontal(|ui| {
|
|
||||||
ui.label("Nome:");
|
|
||||||
ui.text_edit_singleline(&mut app.nome_layout_atual);
|
|
||||||
if ui.button("💾 Salvar").clicked() {
|
|
||||||
salvar_layout_atual(app);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
ui.add_space(12.0);
|
container(scrollable(content))
|
||||||
ui.separator();
|
.width(Length::Fill)
|
||||||
ui.add_space(8.0);
|
.height(Length::Fill)
|
||||||
|
.style(t::fundo)
|
||||||
// Layouts CSV
|
.into()
|
||||||
let layouts_csv: Vec<_> = app
|
|
||||||
.layouts_salvos
|
|
||||||
.iter()
|
|
||||||
.filter(|l| l.tipo() == TipoArquivo::Csv)
|
|
||||||
.cloned()
|
|
||||||
.collect();
|
|
||||||
|
|
||||||
let layouts_xlsx: Vec<_> = app
|
|
||||||
.layouts_salvos
|
|
||||||
.iter()
|
|
||||||
.filter(|l| l.tipo() == TipoArquivo::Xlsx)
|
|
||||||
.cloned()
|
|
||||||
.collect();
|
|
||||||
|
|
||||||
egui::ScrollArea::vertical().show(ui, |ui| {
|
|
||||||
renderizar_secao_layouts(ui, ctx, app, "Layouts CSV", &layouts_csv);
|
|
||||||
ui.add_space(12.0);
|
|
||||||
renderizar_secao_layouts(ui, ctx, app, "Layouts XLSX", &layouts_xlsx);
|
|
||||||
|
|
||||||
ui.add_space(16.0);
|
|
||||||
ui.separator();
|
|
||||||
ui.add_space(8.0);
|
|
||||||
|
|
||||||
// Importar de JSON
|
|
||||||
ui.horizontal(|ui| {
|
|
||||||
ui.label("Importar layout de arquivo JSON:");
|
|
||||||
if ui.button("📥 Importar JSON").clicked() {
|
|
||||||
if let Some(caminho) = rfd::FileDialog::new()
|
|
||||||
.add_filter("JSON", &["json"])
|
|
||||||
.pick_file()
|
|
||||||
{
|
|
||||||
match std::fs::read_to_string(&caminho) {
|
|
||||||
Ok(conteudo) => importar_json(app, &conteudo),
|
|
||||||
Err(e) => {
|
|
||||||
app.exibir_erro(format!("Erro ao ler arquivo JSON: {}", e));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
fn renderizar_secao_layouts(
|
fn view_secao_layouts<'a>(
|
||||||
ui: &mut Ui,
|
titulo: &'a str,
|
||||||
_ctx: &Context,
|
layouts: &'a [Layout],
|
||||||
app: &mut App,
|
tipo: TipoArquivo,
|
||||||
titulo: &str,
|
) -> Element<'a, Message> {
|
||||||
layouts: &[Layout],
|
let titulo_widget = text(titulo).size(16).color(t::TEXT_SECONDARY);
|
||||||
) {
|
|
||||||
ui.label(egui::RichText::new(titulo).strong());
|
|
||||||
ui.add_space(4.0);
|
|
||||||
|
|
||||||
if layouts.is_empty() {
|
let filtrados: Vec<&Layout> = layouts.iter().filter(|l| l.tipo() == tipo).collect();
|
||||||
ui.label("(nenhum layout salvo)");
|
|
||||||
return;
|
let mut col = column![titulo_widget].spacing(4);
|
||||||
|
|
||||||
|
if filtrados.is_empty() {
|
||||||
|
col = col.push(text("(nenhum layout salvo)").size(13).color(t::TEXT_MUTED));
|
||||||
|
return container(col)
|
||||||
|
.padding([12, 16])
|
||||||
|
.width(Length::Fill)
|
||||||
|
.style(t::card)
|
||||||
|
.into();
|
||||||
}
|
}
|
||||||
|
|
||||||
for layout in layouts {
|
for layout in filtrados {
|
||||||
ui.horizontal(|ui| {
|
|
||||||
ui.label(layout.nome());
|
|
||||||
ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
|
|
||||||
// Excluir
|
|
||||||
if let Some(id) = layout.id() {
|
if let Some(id) = layout.id() {
|
||||||
if ui.button("🗑 Excluir").clicked() {
|
let linha = container(
|
||||||
app.exibir_confirmacao(
|
row![
|
||||||
"Confirmar exclusão",
|
text(layout.nome())
|
||||||
format!("Deseja excluir o layout '{}'?", layout.nome()),
|
.size(14)
|
||||||
AcaoModal::ConfirmarExclusaoLayout(id),
|
.color(t::TEXT)
|
||||||
);
|
.width(Length::Fill),
|
||||||
}
|
horizontal_space(),
|
||||||
|
button("Carregar")
|
||||||
|
.on_press(Message::LayoutSelecionado(id))
|
||||||
|
.style(t::btn_primary),
|
||||||
|
button("Exportar JSON")
|
||||||
|
.on_press(Message::ExportarLayoutJson(id))
|
||||||
|
.style(t::btn_ghost),
|
||||||
|
button("Excluir")
|
||||||
|
.on_press(Message::ExcluirLayout(id))
|
||||||
|
.style(t::btn_danger),
|
||||||
|
]
|
||||||
|
.spacing(8)
|
||||||
|
.align_y(Alignment::Center)
|
||||||
|
.padding([10, 0]),
|
||||||
|
)
|
||||||
|
.width(Length::Fill);
|
||||||
|
|
||||||
// Exportar
|
col = col.push(linha);
|
||||||
if ui.button("📤 Exportar JSON").clicked() {
|
|
||||||
match exportar_layout_json(layout) {
|
// Separador entre linhas
|
||||||
Ok((conteudo, nome_sugerido)) => {
|
col = col.push(container(iced::widget::horizontal_rule(1)).width(Length::Fill));
|
||||||
if let Some(caminho) = rfd::FileDialog::new()
|
|
||||||
.set_file_name(&nome_sugerido)
|
|
||||||
.add_filter("JSON", &["json"])
|
|
||||||
.save_file()
|
|
||||||
{
|
|
||||||
if let Err(e) = std::fs::write(&caminho, &conteudo) {
|
|
||||||
app.exibir_erro(format!("Erro ao salvar JSON: {}", e));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Err(e) => {
|
|
||||||
app.exibir_erro(format!("Erro ao exportar layout: {}", e));
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Carregar
|
container(col)
|
||||||
if ui.button("📂 Carregar").clicked() {
|
.padding([12, 16])
|
||||||
aplicar_layout(app, layout);
|
.width(Length::Fill)
|
||||||
app.nome_layout_atual = layout.nome().to_string();
|
.style(t::card)
|
||||||
app.estado = EstadoApp::ConfigurandoColunas;
|
.into()
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn salvar_layout_atual(app: &mut App) {
|
|
||||||
if app.nome_layout_atual.trim().is_empty() {
|
|
||||||
app.exibir_aviso("Nome inválido", "Informe um nome para o layout.");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
let layout = match app.tipo_arquivo_atual.clone() {
|
|
||||||
TipoArquivo::Csv => Layout::Csv {
|
|
||||||
id: None,
|
|
||||||
nome: app.nome_layout_atual.trim().to_string(),
|
|
||||||
config: app.layout_csv_atual.clone(),
|
|
||||||
},
|
|
||||||
TipoArquivo::Xlsx => Layout::Xlsx {
|
|
||||||
id: None,
|
|
||||||
nome: app.nome_layout_atual.trim().to_string(),
|
|
||||||
config: app.layout_xlsx_atual.clone(),
|
|
||||||
},
|
|
||||||
};
|
|
||||||
|
|
||||||
if let Some(conn) = &app.conn {
|
|
||||||
match salvar_layout(conn, &layout) {
|
|
||||||
Ok(_) => {
|
|
||||||
app.recarregar_layouts();
|
|
||||||
app.exibir_aviso("Sucesso", "Layout salvo com sucesso.");
|
|
||||||
}
|
|
||||||
Err(ErroLayout::NomeConflitante(nome)) => {
|
|
||||||
let id_existente = app
|
|
||||||
.layouts_salvos
|
|
||||||
.iter()
|
|
||||||
.find(|l| l.nome() == nome)
|
|
||||||
.and_then(|l| l.id());
|
|
||||||
if let Some(id) = id_existente {
|
|
||||||
let layout_com_id = match app.tipo_arquivo_atual.clone() {
|
|
||||||
TipoArquivo::Csv => Layout::Csv {
|
|
||||||
id: Some(id),
|
|
||||||
nome: nome.clone(),
|
|
||||||
config: app.layout_csv_atual.clone(),
|
|
||||||
},
|
|
||||||
TipoArquivo::Xlsx => Layout::Xlsx {
|
|
||||||
id: Some(id),
|
|
||||||
nome: nome.clone(),
|
|
||||||
config: app.layout_xlsx_atual.clone(),
|
|
||||||
},
|
|
||||||
};
|
|
||||||
app.exibir_confirmacao(
|
|
||||||
"Conflito de nome",
|
|
||||||
format!(
|
|
||||||
"Já existe um layout com o nome '{}'. Deseja sobrescrever?",
|
|
||||||
nome
|
|
||||||
),
|
|
||||||
AcaoModal::SobrescreverLayout(layout_com_id),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Err(e) => {
|
|
||||||
app.exibir_erro(format!("Erro ao salvar layout: {}", e));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn importar_json(app: &mut App, conteudo: &str) {
|
|
||||||
if let Some(conn) = &app.conn {
|
|
||||||
match importar_layout_json(conn, conteudo, false, None) {
|
|
||||||
Ok(_) => {
|
|
||||||
app.recarregar_layouts();
|
|
||||||
app.exibir_aviso("Sucesso", "Layout importado com sucesso.");
|
|
||||||
}
|
|
||||||
Err(ErroLayout::NomeConflitante(nome)) => {
|
|
||||||
// Recriar o layout parseado para passá-lo no modal de confirmação.
|
|
||||||
// O JSON já foi validado pela chamada acima, então o parse aqui não falha.
|
|
||||||
let parsed = serde_json::from_str::<LayoutJson>(conteudo)
|
|
||||||
.ok()
|
|
||||||
.and_then(|json_repr| Layout::try_from(json_repr).ok());
|
|
||||||
|
|
||||||
let id_existente = app
|
|
||||||
.layouts_salvos
|
|
||||||
.iter()
|
|
||||||
.find(|l| l.nome() == nome)
|
|
||||||
.and_then(|l| l.id());
|
|
||||||
|
|
||||||
match (parsed, id_existente) {
|
|
||||||
(Some(mut layout), Some(id)) => {
|
|
||||||
match &mut layout {
|
|
||||||
Layout::Csv { id: i, .. } => *i = Some(id),
|
|
||||||
Layout::Xlsx { id: i, .. } => *i = Some(id),
|
|
||||||
}
|
|
||||||
app.exibir_confirmacao(
|
|
||||||
"Conflito de nome",
|
|
||||||
format!(
|
|
||||||
"Já existe um layout com o nome '{}'. Deseja sobrescrever?",
|
|
||||||
nome
|
|
||||||
),
|
|
||||||
AcaoModal::SobrescreverLayout(layout),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
_ => {
|
|
||||||
app.exibir_erro(format!(
|
|
||||||
"Conflito de nome: layout '{}' já existe.",
|
|
||||||
nome
|
|
||||||
));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Err(e) => {
|
|
||||||
app.exibir_erro(format!("Erro ao importar layout: {}", e));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn aplicar_layout(app: &mut App, layout: &Layout) {
|
|
||||||
match layout {
|
|
||||||
Layout::Csv { config, .. } => {
|
|
||||||
app.layout_csv_atual = config.clone();
|
|
||||||
app.tipo_arquivo_atual = TipoArquivo::Csv;
|
|
||||||
}
|
|
||||||
Layout::Xlsx { config, .. } => {
|
|
||||||
app.layout_xlsx_atual = config.clone();
|
|
||||||
app.tipo_arquivo_atual = TipoArquivo::Xlsx;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
+2
-56
@@ -2,9 +2,10 @@ pub mod configuracao_colunas;
|
|||||||
pub mod import;
|
pub mod import;
|
||||||
pub mod layouts;
|
pub mod layouts;
|
||||||
pub mod resultado;
|
pub mod resultado;
|
||||||
|
pub mod selecionar_aba;
|
||||||
|
|
||||||
/// Converte um índice de coluna base-0 para a notação de letras do Excel (A, B, ..., Z, AA, ...).
|
/// Converte um índice de coluna base-0 para a notação de letras do Excel (A, B, ..., Z, AA, ...).
|
||||||
fn indice_para_letra(mut idx: usize) -> String {
|
pub fn indice_para_letra(mut idx: usize) -> String {
|
||||||
let mut resultado = String::new();
|
let mut resultado = String::new();
|
||||||
loop {
|
loop {
|
||||||
resultado.insert(0, (b'A' + (idx % 26) as u8) as char);
|
resultado.insert(0, (b'A' + (idx % 26) as u8) as char);
|
||||||
@@ -15,58 +16,3 @@ fn indice_para_letra(mut idx: usize) -> String {
|
|||||||
}
|
}
|
||||||
resultado
|
resultado
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Renderiza uma tabela simples de pré-visualização do arquivo.
|
|
||||||
/// Exibe uma linha de cabeçalho com letras no estilo Excel (A, B, C, ...)
|
|
||||||
/// seguida pelas linhas de dados.
|
|
||||||
pub fn renderizar_tabela_preview(ui: &mut egui::Ui, linhas: &[Vec<String>]) {
|
|
||||||
let num_colunas = linhas.iter().map(|l| l.len()).max().unwrap_or(0);
|
|
||||||
if num_colunas == 0 {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
ui.label(
|
|
||||||
egui::RichText::new(format!(
|
|
||||||
"Pré-visualização ({} linha(s))",
|
|
||||||
linhas.len()
|
|
||||||
))
|
|
||||||
.small()
|
|
||||||
.weak(),
|
|
||||||
);
|
|
||||||
ui.add_space(2.0);
|
|
||||||
|
|
||||||
egui::ScrollArea::horizontal()
|
|
||||||
.id_salt("scroll_preview")
|
|
||||||
.max_height(160.0)
|
|
||||||
.show(ui, |ui| {
|
|
||||||
egui::Grid::new("tabela_preview")
|
|
||||||
.striped(true)
|
|
||||||
.spacing([8.0, 2.0])
|
|
||||||
.show(ui, |ui| {
|
|
||||||
// Linha de cabeçalho: letras A, B, C, ... com índice base-0 entre parênteses
|
|
||||||
for i in 0..num_colunas {
|
|
||||||
ui.label(
|
|
||||||
egui::RichText::new(format!("{} ({})", indice_para_letra(i), i))
|
|
||||||
.strong()
|
|
||||||
.monospace(),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
ui.end_row();
|
|
||||||
|
|
||||||
// Linhas de dados
|
|
||||||
for linha in linhas {
|
|
||||||
for col in 0..num_colunas {
|
|
||||||
let celula = linha.get(col).map(|s| s.as_str()).unwrap_or("");
|
|
||||||
let texto = if celula.chars().count() > 30 {
|
|
||||||
let truncado: String = celula.chars().take(30).collect();
|
|
||||||
format!("{}...", truncado)
|
|
||||||
} else {
|
|
||||||
celula.to_string()
|
|
||||||
};
|
|
||||||
ui.label(egui::RichText::new(texto).monospace().small());
|
|
||||||
}
|
|
||||||
ui.end_row();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
});
|
|
||||||
}
|
|
||||||
+309
-242
@@ -1,127 +1,138 @@
|
|||||||
use crate::application::usecases::exportar_pdf::exportar_pdf;
|
|
||||||
use crate::domain::{
|
use crate::domain::{
|
||||||
entities::{chave_serie::ChaveSerie, resultado_analise::ResultadoAnalise},
|
entities::{chave_serie::ChaveSerie, resultado_analise::ResultadoAnalise},
|
||||||
services::{detector_sequencia::agrupar_contiguos, parser_monetario::formatar_valor_br},
|
services::{detector_sequencia::agrupar_contiguos, parser_monetario::formatar_valor_br},
|
||||||
};
|
};
|
||||||
use crate::infrastructure::pdf_generator::GenpdfGenerator;
|
use crate::ui::app::App;
|
||||||
use crate::ui::app::{AcaoModal, App, EstadoApp};
|
use crate::ui::message::Message;
|
||||||
use egui::{Context, Ui};
|
use crate::ui::theme as t;
|
||||||
|
use iced::widget::{button, column, container, progress_bar, row, scrollable, text};
|
||||||
|
use iced::{Alignment, Element, Length};
|
||||||
|
|
||||||
const OPCOES_PAGINA: &[usize] = &[50, 100, 200, 1000];
|
const OPCOES_PAGINA: &[usize] = &[50, 100, 200, 1000];
|
||||||
|
|
||||||
/// Renderiza a tela de resultados.
|
/// Tela de resultados da análise.
|
||||||
pub fn renderizar(ui: &mut Ui, ctx: &Context, app: &mut App) {
|
pub fn view<'a>(app: &'a App, resultado: &'a ResultadoAnalise) -> Element<'a, Message> {
|
||||||
// Extrair resultado do estado (sem mover)
|
// ── Stat cards no topo ────────────────────────────────────────────────────
|
||||||
let resultado = match &app.estado {
|
|
||||||
EstadoApp::ExibindoResultado(r) => r.clone(),
|
|
||||||
_ => return,
|
|
||||||
};
|
|
||||||
|
|
||||||
ui.heading("Resultado da Análise");
|
|
||||||
ui.add_space(8.0);
|
|
||||||
|
|
||||||
ui.horizontal(|ui| {
|
|
||||||
if ui.button("< Nova Análise").clicked() {
|
|
||||||
app.exibir_confirmacao(
|
|
||||||
"Nova Análise",
|
|
||||||
"Deseja iniciar uma nova análise? O resultado atual será descartado.",
|
|
||||||
AcaoModal::ConfirmarNovaAnalise,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
if ui.button("⚙ Reconfigurar Colunas").clicked() {
|
|
||||||
app.estado = EstadoApp::ConfigurandoColunas;
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
let pode_reanalisar = app.caminho_arquivo.is_some();
|
|
||||||
if ui
|
|
||||||
.add_enabled(pode_reanalisar, egui::Button::new("🔄 Reanalisar Arquivo"))
|
|
||||||
.on_hover_text("Reimporta o arquivo do disco com o layout atual e reanalisa")
|
|
||||||
.clicked()
|
|
||||||
{
|
|
||||||
app.reimportar_e_analisar(ctx);
|
|
||||||
}
|
|
||||||
|
|
||||||
if ui.button("📄 Exportar PDF").clicked() {
|
|
||||||
exportar_para_pdf(app, &resultado);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
ui.add_space(8.0);
|
|
||||||
|
|
||||||
// Controle de itens por página
|
|
||||||
ui.horizontal(|ui| {
|
|
||||||
ui.label("Itens por página:");
|
|
||||||
for &opcao in OPCOES_PAGINA {
|
|
||||||
if ui
|
|
||||||
.selectable_label(app.itens_por_pagina == opcao, opcao.to_string())
|
|
||||||
.clicked()
|
|
||||||
{
|
|
||||||
app.itens_por_pagina = opcao;
|
|
||||||
app.pagina_faltantes = 0;
|
|
||||||
app.pagina_duplicatas = 0;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
ui.add_space(12.0);
|
|
||||||
ui.separator();
|
|
||||||
|
|
||||||
egui::ScrollArea::vertical().show(ui, |ui| {
|
|
||||||
// Faltantes
|
|
||||||
renderizar_faltantes(ui, app, &resultado);
|
|
||||||
|
|
||||||
ui.add_space(12.0);
|
|
||||||
ui.separator();
|
|
||||||
|
|
||||||
// Duplicatas
|
|
||||||
renderizar_duplicatas(ui, app, &resultado);
|
|
||||||
|
|
||||||
ui.add_space(12.0);
|
|
||||||
ui.separator();
|
|
||||||
|
|
||||||
// Totais
|
|
||||||
renderizar_totais(ui, &resultado);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
fn renderizar_totais(ui: &mut Ui, resultado: &ResultadoAnalise) {
|
|
||||||
ui.label(egui::RichText::new("Totais").heading().strong());
|
|
||||||
ui.add_space(4.0);
|
|
||||||
|
|
||||||
ui.label(format!(
|
|
||||||
"Total Geral: R$ {}",
|
|
||||||
formatar_valor_br(&resultado.soma_total)
|
|
||||||
));
|
|
||||||
|
|
||||||
let mut chaves: Vec<&ChaveSerie> = resultado.soma_por_serie.keys().collect();
|
|
||||||
chaves.sort();
|
|
||||||
|
|
||||||
for chave in chaves {
|
|
||||||
let soma = &resultado.soma_por_serie[chave];
|
|
||||||
let total_notas = resultado.total_por_serie.get(chave).copied().unwrap_or(0);
|
|
||||||
ui.label(format!(
|
|
||||||
" Série {}: {} nota(s) — R$ {}",
|
|
||||||
chave.label(),
|
|
||||||
total_notas,
|
|
||||||
formatar_valor_br(soma)
|
|
||||||
));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn renderizar_faltantes(ui: &mut Ui, app: &mut App, resultado: &ResultadoAnalise) {
|
|
||||||
let total_faltantes = resultado.total_faltantes();
|
let total_faltantes = resultado.total_faltantes();
|
||||||
ui.label(
|
let total_duplicatas = resultado.total_duplicatas();
|
||||||
egui::RichText::new(format!("Notas Faltantes ({} total)", total_faltantes))
|
let total_notas: usize = resultado.total_por_serie.values().sum();
|
||||||
.heading()
|
let valor_total_str = format!("R$ {}", formatar_valor_br(&resultado.soma_total));
|
||||||
.strong(),
|
|
||||||
);
|
|
||||||
ui.add_space(4.0);
|
|
||||||
|
|
||||||
if total_faltantes == 0 {
|
let stat_faltantes = stat_card_widget("Faltantes", total_faltantes.to_string(), t::WARNING);
|
||||||
ui.label("✔ Nenhuma nota faltante.");
|
let stat_duplicatas = stat_card_widget("Duplicatas", total_duplicatas.to_string(), t::DANGER);
|
||||||
return;
|
let stat_total = stat_card_widget("Total de Notas", total_notas.to_string(), t::PRIMARY);
|
||||||
|
let stat_valor = stat_card_widget("Valor Total", valor_total_str, t::SUCCESS);
|
||||||
|
|
||||||
|
let stat_row = row![stat_faltantes, stat_duplicatas, stat_total, stat_valor]
|
||||||
|
.spacing(12)
|
||||||
|
.width(Length::Fill);
|
||||||
|
|
||||||
|
// ── Botões de ação ────────────────────────────────────────────────────────
|
||||||
|
let botoes_topo = row![
|
||||||
|
button("< Nova Análise")
|
||||||
|
.on_press(Message::NovaAnalise)
|
||||||
|
.style(t::btn_secondary),
|
||||||
|
button("Reconfigurar Colunas")
|
||||||
|
.on_press(Message::IrParaConfiguracaoColunas)
|
||||||
|
.style(t::btn_ghost),
|
||||||
|
button("Reanalisar Arquivo")
|
||||||
|
.on_press_maybe(
|
||||||
|
app.caminho_arquivo
|
||||||
|
.as_ref()
|
||||||
|
.map(|_| Message::ReanalisarArquivo)
|
||||||
|
)
|
||||||
|
.style(t::btn_ghost),
|
||||||
|
button("Exportar PDF")
|
||||||
|
.on_press(Message::ExportarPdf)
|
||||||
|
.style(t::btn_primary),
|
||||||
|
]
|
||||||
|
.spacing(8);
|
||||||
|
|
||||||
|
// ── Controle de itens por página ──────────────────────────────────────────
|
||||||
|
let opcoes_por_pagina = row(OPCOES_PAGINA
|
||||||
|
.iter()
|
||||||
|
.map(|&n| {
|
||||||
|
let ativo = n == app.itens_por_pagina;
|
||||||
|
button(text(n.to_string()).size(13))
|
||||||
|
.on_press(Message::ItensPorPaginaAlterado(n))
|
||||||
|
.style(if ativo {
|
||||||
|
t::btn_pagina_ativo
|
||||||
|
} else {
|
||||||
|
t::btn_pagina_inativo
|
||||||
|
})
|
||||||
|
.into()
|
||||||
|
})
|
||||||
|
.collect::<Vec<_>>())
|
||||||
|
.spacing(4);
|
||||||
|
|
||||||
|
let controle_pagina = row![
|
||||||
|
text("Itens por página:").size(13).color(t::TEXT_SECONDARY),
|
||||||
|
opcoes_por_pagina,
|
||||||
|
]
|
||||||
|
.spacing(8)
|
||||||
|
.align_y(Alignment::Center);
|
||||||
|
|
||||||
|
// ── Seções principais ─────────────────────────────────────────────────────
|
||||||
|
let secao_faltantes = view_faltantes(app, resultado);
|
||||||
|
let secao_duplicatas = view_duplicatas(app, resultado);
|
||||||
|
let secao_totais = view_totais(resultado);
|
||||||
|
|
||||||
|
let conteudo = 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))
|
||||||
|
.width(Length::Fill)
|
||||||
|
.height(Length::Fill)
|
||||||
|
.style(t::fundo)
|
||||||
|
.into()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Cria um stat card com label, valor e cor de destaque.
|
||||||
|
fn stat_card_widget<'a>(label: &'a str, valor: String, cor: iced::Color) -> Element<'a, Message> {
|
||||||
|
let conteudo = column![
|
||||||
|
text(label).size(12).color(t::TEXT_SECONDARY),
|
||||||
|
text(valor).size(24).color(cor),
|
||||||
|
]
|
||||||
|
.spacing(4);
|
||||||
|
|
||||||
|
container(conteudo)
|
||||||
|
.padding([14, 18])
|
||||||
|
.width(Length::Fill)
|
||||||
|
.style(t::stat_card)
|
||||||
|
.into()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn view_faltantes<'a>(app: &'a App, resultado: &'a ResultadoAnalise) -> Element<'a, Message> {
|
||||||
|
let total = resultado.total_faltantes();
|
||||||
|
|
||||||
|
let titulo_row = row![
|
||||||
|
text("Notas Faltantes").size(18).color(t::TEXT),
|
||||||
|
container(text(format!(" {} ", total)).size(12).color(t::WARNING))
|
||||||
|
.padding([2, 8])
|
||||||
|
.style(t::badge_aviso),
|
||||||
|
]
|
||||||
|
.spacing(8)
|
||||||
|
.align_y(Alignment::Center);
|
||||||
|
|
||||||
|
let mut col = column![titulo_row].spacing(12);
|
||||||
|
|
||||||
|
if total == 0 {
|
||||||
|
col = col.push(text("Nenhuma nota faltante.").size(14).color(t::SUCCESS));
|
||||||
|
return container(col)
|
||||||
|
.padding(16)
|
||||||
|
.width(Length::Fill)
|
||||||
|
.style(t::card)
|
||||||
|
.into();
|
||||||
}
|
}
|
||||||
|
|
||||||
let mut chaves: Vec<&ChaveSerie> = resultado.faltantes_por_serie.keys().collect();
|
let mut chaves: Vec<&ChaveSerie> = resultado.faltantes_por_serie.keys().collect();
|
||||||
@@ -133,82 +144,108 @@ fn renderizar_faltantes(ui: &mut Ui, app: &mut App, resultado: &ResultadoAnalise
|
|||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Estatística de completude por série
|
|
||||||
let total_notas = resultado.total_por_serie.get(chave).copied().unwrap_or(0);
|
let total_notas = resultado.total_por_serie.get(chave).copied().unwrap_or(0);
|
||||||
let total_esperado = total_notas + faltantes.len();
|
let total_esperado = total_notas + faltantes.len();
|
||||||
let percentual = total_notas as f64 / total_esperado as f64 * 100.0;
|
let percentual = total_notas as f64 / total_esperado as f64;
|
||||||
|
let percentual_f32 = percentual as f32;
|
||||||
|
|
||||||
ui.horizontal(|ui| {
|
// Cabeçalho da série
|
||||||
ui.label(format!(
|
let cabecalho_serie = row![
|
||||||
"Série {} — {} faltante(s) — {}/{} notas ({:.1}% completo):",
|
column![
|
||||||
chave.label(),
|
text(format!("Série {}", chave.label()))
|
||||||
|
.size(14)
|
||||||
|
.color(t::TEXT),
|
||||||
|
text(format!(
|
||||||
|
"{} faltante(s) — {}/{} notas ({:.1}% completo)",
|
||||||
faltantes.len(),
|
faltantes.len(),
|
||||||
total_notas,
|
total_notas,
|
||||||
total_esperado,
|
total_esperado,
|
||||||
percentual,
|
percentual * 100.0,
|
||||||
));
|
))
|
||||||
if ui
|
.size(12)
|
||||||
.button("📋 Copiar")
|
.color(t::TEXT_SECONDARY),
|
||||||
.on_hover_text("Copiar todos os números faltantes")
|
]
|
||||||
.clicked()
|
.spacing(2)
|
||||||
{
|
.width(Length::Fill),
|
||||||
let texto = faltantes
|
button("Copiar")
|
||||||
.iter()
|
.on_press(Message::CopiarFaltantes(chave.clone()))
|
||||||
.map(|n| n.to_string())
|
.style(t::btn_ghost),
|
||||||
.collect::<Vec<_>>()
|
]
|
||||||
.join(", ");
|
.spacing(8)
|
||||||
ui.ctx().copy_text(texto);
|
.align_y(Alignment::Center);
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
// Paginação (por faltante individual)
|
// Progress bar de completude
|
||||||
|
let barra = progress_bar(0.0..=1.0, percentual_f32)
|
||||||
|
.height(6)
|
||||||
|
.style(t::progress_bar_por_percentual(percentual_f32));
|
||||||
|
|
||||||
|
col = col.push(cabecalho_serie);
|
||||||
|
col = col.push(barra);
|
||||||
|
|
||||||
|
// Paginação e lista
|
||||||
let total_paginas = (faltantes.len() + app.itens_por_pagina - 1) / app.itens_por_pagina;
|
let total_paginas = (faltantes.len() + app.itens_por_pagina - 1) / app.itens_por_pagina;
|
||||||
if app.pagina_faltantes >= total_paginas {
|
let pagina = app.pagina_faltantes.min(total_paginas.saturating_sub(1));
|
||||||
app.pagina_faltantes = 0;
|
let inicio = pagina * app.itens_por_pagina;
|
||||||
}
|
|
||||||
|
|
||||||
let inicio = app.pagina_faltantes * app.itens_por_pagina;
|
|
||||||
let fim = (inicio + app.itens_por_pagina).min(faltantes.len());
|
let fim = (inicio + app.itens_por_pagina).min(faltantes.len());
|
||||||
|
|
||||||
// Exibir grupos contíguos da página atual
|
let mut lista = column![].spacing(2);
|
||||||
for (a, b) in agrupar_contiguos(&faltantes[inicio..fim]) {
|
for (a, b) in agrupar_contiguos(&faltantes[inicio..fim]) {
|
||||||
if a == b {
|
let txt = if a == b {
|
||||||
ui.label(format!(" • {}", a));
|
text(format!(" {}", a)).size(13).color(t::TEXT_SECONDARY)
|
||||||
} else {
|
} else {
|
||||||
ui.label(format!(" • {}–{} ({} notas)", a, b, b - a + 1));
|
text(format!(" {}–{} ({} notas)", a, b, b - a + 1))
|
||||||
}
|
.size(13)
|
||||||
|
.color(t::TEXT_SECONDARY)
|
||||||
|
};
|
||||||
|
lista = lista.push(txt);
|
||||||
}
|
}
|
||||||
|
col = col.push(lista);
|
||||||
|
|
||||||
if total_paginas > 1 {
|
if total_paginas > 1 {
|
||||||
ui.horizontal(|ui| {
|
col = col.push(crate::ui::components::paginacao::controles_paginacao(
|
||||||
if ui.button("◀").clicked() && app.pagina_faltantes > 0 {
|
pagina,
|
||||||
app.pagina_faltantes -= 1;
|
total_paginas,
|
||||||
}
|
Message::PaginaFaltantesAlterada(pagina.saturating_sub(1)),
|
||||||
ui.label(format!(
|
Message::PaginaFaltantesAlterada(pagina + 1),
|
||||||
"Página {} / {}",
|
|
||||||
app.pagina_faltantes + 1,
|
|
||||||
total_paginas
|
|
||||||
));
|
));
|
||||||
if ui.button("▶").clicked() && app.pagina_faltantes + 1 < total_paginas {
|
|
||||||
app.pagina_faltantes += 1;
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Separador
|
||||||
|
col = col.push(
|
||||||
|
container(iced::widget::horizontal_rule(1))
|
||||||
|
.width(Length::Fill)
|
||||||
|
.padding([4, 0]),
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
container(col)
|
||||||
|
.padding(16)
|
||||||
|
.width(Length::Fill)
|
||||||
|
.style(t::card)
|
||||||
|
.into()
|
||||||
}
|
}
|
||||||
|
|
||||||
fn renderizar_duplicatas(ui: &mut Ui, app: &mut App, resultado: &ResultadoAnalise) {
|
fn view_duplicatas<'a>(app: &'a App, resultado: &'a ResultadoAnalise) -> Element<'a, Message> {
|
||||||
let total_dup = resultado.total_duplicatas();
|
let total = resultado.total_duplicatas();
|
||||||
ui.label(
|
|
||||||
egui::RichText::new(format!("Notas Duplicadas ({} grupo(s))", total_dup))
|
|
||||||
.heading()
|
|
||||||
.strong(),
|
|
||||||
);
|
|
||||||
ui.add_space(4.0);
|
|
||||||
|
|
||||||
if total_dup == 0 {
|
let titulo_row = row![
|
||||||
ui.label("✔ Nenhuma nota duplicada.");
|
text("Notas Duplicadas").size(18).color(t::TEXT),
|
||||||
return;
|
container(text(format!(" {} ", total)).size(12).color(t::DANGER))
|
||||||
|
.padding([2, 8])
|
||||||
|
.style(t::badge_perigo),
|
||||||
|
]
|
||||||
|
.spacing(8)
|
||||||
|
.align_y(Alignment::Center);
|
||||||
|
|
||||||
|
let mut col = column![titulo_row].spacing(12);
|
||||||
|
|
||||||
|
if total == 0 {
|
||||||
|
col = col.push(text("Nenhuma nota duplicada.").size(14).color(t::SUCCESS));
|
||||||
|
return container(col)
|
||||||
|
.padding(16)
|
||||||
|
.width(Length::Fill)
|
||||||
|
.style(t::card)
|
||||||
|
.into();
|
||||||
}
|
}
|
||||||
|
|
||||||
let mut chaves: Vec<&ChaveSerie> = resultado.duplicadas_por_serie.keys().collect();
|
let mut chaves: Vec<&ChaveSerie> = resultado.duplicadas_por_serie.keys().collect();
|
||||||
@@ -220,90 +257,120 @@ fn renderizar_duplicatas(ui: &mut Ui, app: &mut App, resultado: &ResultadoAnalis
|
|||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
ui.horizontal(|ui| {
|
let cabecalho_serie = row![
|
||||||
ui.label(format!(
|
text(format!(
|
||||||
"Série {} — {} grupo(s) duplicado(s):",
|
"Série {} — {} grupo(s) duplicado(s)",
|
||||||
chave.label(),
|
chave.label(),
|
||||||
duplicatas.len()
|
duplicatas.len()
|
||||||
));
|
))
|
||||||
if ui
|
.size(14)
|
||||||
.button("📋 Copiar")
|
.color(t::TEXT)
|
||||||
.on_hover_text("Copiar números duplicados")
|
.width(Length::Fill),
|
||||||
.clicked()
|
button("Copiar")
|
||||||
{
|
.on_press(Message::CopiarDuplicatas(chave.clone()))
|
||||||
let texto = duplicatas
|
.style(t::btn_ghost),
|
||||||
.iter()
|
]
|
||||||
.map(|(n, c)| format!("{} ({}x)", n, c))
|
.spacing(8)
|
||||||
.collect::<Vec<_>>()
|
.align_y(Alignment::Center);
|
||||||
.join(", ");
|
|
||||||
ui.ctx().copy_text(texto);
|
col = col.push(cabecalho_serie);
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
let total_paginas = (duplicatas.len() + app.itens_por_pagina - 1) / app.itens_por_pagina;
|
let total_paginas = (duplicatas.len() + app.itens_por_pagina - 1) / app.itens_por_pagina;
|
||||||
if app.pagina_duplicatas >= total_paginas {
|
let pagina = app.pagina_duplicatas.min(total_paginas.saturating_sub(1));
|
||||||
app.pagina_duplicatas = 0;
|
let inicio = pagina * app.itens_por_pagina;
|
||||||
}
|
|
||||||
|
|
||||||
let inicio = app.pagina_duplicatas * app.itens_por_pagina;
|
|
||||||
let fim = (inicio + app.itens_por_pagina).min(duplicatas.len());
|
let fim = (inicio + app.itens_por_pagina).min(duplicatas.len());
|
||||||
|
|
||||||
|
let mut lista = column![].spacing(2);
|
||||||
for (numero, count) in &duplicatas[inicio..fim] {
|
for (numero, count) in &duplicatas[inicio..fim] {
|
||||||
ui.label(format!(
|
lista = lista.push(
|
||||||
" • NF {} / Série {} — {} ocorrências",
|
text(format!(
|
||||||
|
" NF {} / Série {} — {} ocorrências",
|
||||||
numero,
|
numero,
|
||||||
chave.label(),
|
chave.label(),
|
||||||
count
|
count
|
||||||
));
|
))
|
||||||
}
|
.size(13)
|
||||||
|
.color(t::TEXT_SECONDARY),
|
||||||
if total_paginas > 1 {
|
|
||||||
ui.horizontal(|ui| {
|
|
||||||
if ui.button("◀").clicked() && app.pagina_duplicatas > 0 {
|
|
||||||
app.pagina_duplicatas -= 1;
|
|
||||||
}
|
|
||||||
ui.label(format!(
|
|
||||||
"Página {} / {}",
|
|
||||||
app.pagina_duplicatas + 1,
|
|
||||||
total_paginas
|
|
||||||
));
|
|
||||||
if ui.button("▶").clicked() && app.pagina_duplicatas + 1 < total_paginas {
|
|
||||||
app.pagina_duplicatas += 1;
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn exportar_para_pdf(app: &mut App, resultado: &ResultadoAnalise) {
|
|
||||||
if let Some(caminho) = rfd::FileDialog::new()
|
|
||||||
.set_file_name("relatorio.pdf")
|
|
||||||
.add_filter("PDF", &["pdf"])
|
|
||||||
.save_file()
|
|
||||||
{
|
|
||||||
let gerador = GenpdfGenerator;
|
|
||||||
let nome_layout = if app.nome_layout_atual.is_empty() {
|
|
||||||
None
|
|
||||||
} else {
|
|
||||||
Some(app.nome_layout_atual.as_str())
|
|
||||||
};
|
|
||||||
|
|
||||||
match exportar_pdf(
|
|
||||||
&gerador,
|
|
||||||
resultado,
|
|
||||||
&app.nome_arquivo,
|
|
||||||
nome_layout,
|
|
||||||
&caminho,
|
|
||||||
) {
|
|
||||||
Ok(_) => {
|
|
||||||
app.exibir_aviso(
|
|
||||||
"Sucesso",
|
|
||||||
format!("PDF exportado para: {}", caminho.display()),
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
Err(e) => {
|
col = col.push(lista);
|
||||||
app.exibir_erro(format!("Erro ao exportar PDF: {}", e));
|
|
||||||
}
|
if total_paginas > 1 {
|
||||||
|
col = col.push(crate::ui::components::paginacao::controles_paginacao(
|
||||||
|
pagina,
|
||||||
|
total_paginas,
|
||||||
|
Message::PaginaDuplicatasAlterada(pagina.saturating_sub(1)),
|
||||||
|
Message::PaginaDuplicatasAlterada(pagina + 1),
|
||||||
|
));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
col = col.push(
|
||||||
|
container(iced::widget::horizontal_rule(1))
|
||||||
|
.width(Length::Fill)
|
||||||
|
.padding([4, 0]),
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
container(col)
|
||||||
|
.padding(16)
|
||||||
|
.width(Length::Fill)
|
||||||
|
.style(t::card)
|
||||||
|
.into()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn view_totais(resultado: &ResultadoAnalise) -> Element<'_, Message> {
|
||||||
|
let titulo = text("Totais por Série").size(18).color(t::TEXT);
|
||||||
|
|
||||||
|
let mut col = column![titulo].spacing(8);
|
||||||
|
|
||||||
|
let mut chaves: Vec<&ChaveSerie> = resultado.soma_por_serie.keys().collect();
|
||||||
|
chaves.sort();
|
||||||
|
|
||||||
|
for chave in chaves {
|
||||||
|
let soma = &resultado.soma_por_serie[chave];
|
||||||
|
let total_notas = resultado.total_por_serie.get(chave).copied().unwrap_or(0);
|
||||||
|
|
||||||
|
let linha = row![
|
||||||
|
text(format!("Série {}", chave.label()))
|
||||||
|
.size(14)
|
||||||
|
.color(t::TEXT)
|
||||||
|
.width(Length::Fill),
|
||||||
|
text(format!("{} nota(s)", total_notas))
|
||||||
|
.size(13)
|
||||||
|
.color(t::TEXT_SECONDARY),
|
||||||
|
text(format!("R$ {}", formatar_valor_br(soma)))
|
||||||
|
.size(13)
|
||||||
|
.color(t::SUCCESS),
|
||||||
|
]
|
||||||
|
.spacing(12)
|
||||||
|
.align_y(Alignment::Center);
|
||||||
|
|
||||||
|
col = col.push(linha);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Total geral
|
||||||
|
col = col.push(
|
||||||
|
container(iced::widget::horizontal_rule(1))
|
||||||
|
.width(Length::Fill)
|
||||||
|
.padding([4, 0]),
|
||||||
|
);
|
||||||
|
col = col.push(
|
||||||
|
row![
|
||||||
|
text("Total Geral")
|
||||||
|
.size(15)
|
||||||
|
.color(t::TEXT)
|
||||||
|
.width(Length::Fill),
|
||||||
|
text(format!("R$ {}", formatar_valor_br(&resultado.soma_total)))
|
||||||
|
.size(15)
|
||||||
|
.color(t::SUCCESS),
|
||||||
|
]
|
||||||
|
.spacing(12)
|
||||||
|
.align_y(Alignment::Center),
|
||||||
|
);
|
||||||
|
|
||||||
|
container(col)
|
||||||
|
.padding(16)
|
||||||
|
.width(Length::Fill)
|
||||||
|
.style(t::card)
|
||||||
|
.into()
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,156 @@
|
|||||||
|
use crate::ui::app::App;
|
||||||
|
use crate::ui::message::Message;
|
||||||
|
use crate::ui::theme as t;
|
||||||
|
use iced::widget::{button, column, container, row, scrollable, text};
|
||||||
|
use iced::{Alignment, Element, Length};
|
||||||
|
use std::path::PathBuf;
|
||||||
|
|
||||||
|
/// Tela de seleção de aba de arquivo XLSX.
|
||||||
|
pub fn view<'a>(app: &'a App, abas: &'a [String], caminho: &'a PathBuf) -> Element<'a, Message> {
|
||||||
|
let nome_arquivo = caminho
|
||||||
|
.file_name()
|
||||||
|
.map(|n| n.to_string_lossy().to_string())
|
||||||
|
.unwrap_or_else(|| caminho.display().to_string());
|
||||||
|
|
||||||
|
let nome_arquivo_owned = nome_arquivo.clone();
|
||||||
|
|
||||||
|
// ── Cabeçalho ─────────────────────────────────────────────────────────────
|
||||||
|
let header = column![
|
||||||
|
text("Selecionar Aba").size(20).color(t::TEXT),
|
||||||
|
row![
|
||||||
|
text("Arquivo:").size(12).color(t::TEXT_MUTED),
|
||||||
|
text(nome_arquivo_owned).size(12).color(t::TEXT_SECONDARY),
|
||||||
|
]
|
||||||
|
.spacing(4)
|
||||||
|
.align_y(Alignment::Center),
|
||||||
|
]
|
||||||
|
.spacing(4);
|
||||||
|
|
||||||
|
// ── Lista de abas ─────────────────────────────────────────────────────────
|
||||||
|
let aba_atual = &app.layout_xlsx_atual.aba;
|
||||||
|
let lista_abas = column(
|
||||||
|
abas.iter()
|
||||||
|
.map(|aba| {
|
||||||
|
let selecionada = aba == aba_atual;
|
||||||
|
let label = row![
|
||||||
|
text(if selecionada { "●" } else { "○" })
|
||||||
|
.size(12)
|
||||||
|
.color(if selecionada {
|
||||||
|
t::PRIMARY
|
||||||
|
} else {
|
||||||
|
t::TEXT_MUTED
|
||||||
|
}),
|
||||||
|
text(aba)
|
||||||
|
.size(14)
|
||||||
|
.color(if selecionada { t::PRIMARY } else { t::TEXT }),
|
||||||
|
]
|
||||||
|
.spacing(8)
|
||||||
|
.align_y(Alignment::Center);
|
||||||
|
|
||||||
|
button(label)
|
||||||
|
.on_press(Message::AbaSelecionada(aba.clone()))
|
||||||
|
.style(if selecionada {
|
||||||
|
t::btn_aba_ativa
|
||||||
|
} else {
|
||||||
|
t::btn_aba_inativa
|
||||||
|
})
|
||||||
|
.width(Length::Fill)
|
||||||
|
.padding([8, 12])
|
||||||
|
.into()
|
||||||
|
})
|
||||||
|
.collect::<Vec<_>>(),
|
||||||
|
)
|
||||||
|
.spacing(4)
|
||||||
|
.width(Length::Fill);
|
||||||
|
|
||||||
|
let card_abas = container(
|
||||||
|
column![
|
||||||
|
text("Abas disponíveis").size(13).color(t::TEXT_SECONDARY),
|
||||||
|
scrollable(lista_abas).height(Length::Fixed(220.0)),
|
||||||
|
]
|
||||||
|
.spacing(10),
|
||||||
|
)
|
||||||
|
.style(t::card)
|
||||||
|
.padding(16)
|
||||||
|
.width(Length::Fill);
|
||||||
|
|
||||||
|
// ── Preview ───────────────────────────────────────────────────────────────
|
||||||
|
let preview_section: Element<Message> = if !aba_atual.is_empty() {
|
||||||
|
if let Some(linhas) = &app.preview_arquivo {
|
||||||
|
container(
|
||||||
|
column![
|
||||||
|
text(format!("Pré-visualização: {}", aba_atual))
|
||||||
|
.size(13)
|
||||||
|
.color(t::TEXT_SECONDARY),
|
||||||
|
crate::ui::components::tabela_preview::tabela_preview(linhas),
|
||||||
|
]
|
||||||
|
.spacing(8),
|
||||||
|
)
|
||||||
|
.style(t::card)
|
||||||
|
.padding(16)
|
||||||
|
.width(Length::Fill)
|
||||||
|
.into()
|
||||||
|
} else {
|
||||||
|
container(
|
||||||
|
text("(sem pré-visualização disponível)")
|
||||||
|
.size(12)
|
||||||
|
.color(t::TEXT_MUTED),
|
||||||
|
)
|
||||||
|
.style(t::card)
|
||||||
|
.padding(16)
|
||||||
|
.width(Length::Fill)
|
||||||
|
.into()
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
container(
|
||||||
|
text("Selecione uma aba para pré-visualizar o conteúdo.")
|
||||||
|
.size(13)
|
||||||
|
.color(t::TEXT_MUTED),
|
||||||
|
)
|
||||||
|
.style(t::card)
|
||||||
|
.padding(16)
|
||||||
|
.width(Length::Fill)
|
||||||
|
.into()
|
||||||
|
};
|
||||||
|
|
||||||
|
// ── Botões de ação ────────────────────────────────────────────────────────
|
||||||
|
let tem_preset = !app.nome_layout_atual.is_empty();
|
||||||
|
let aba_selecionada = !aba_atual.is_empty();
|
||||||
|
|
||||||
|
let mut botoes = row![button(text("◀ Voltar").size(13))
|
||||||
|
.on_press(Message::Voltar)
|
||||||
|
.style(t::btn_ghost)
|
||||||
|
.padding([9, 14]),]
|
||||||
|
.spacing(8)
|
||||||
|
.align_y(Alignment::Center);
|
||||||
|
|
||||||
|
if aba_selecionada && tem_preset {
|
||||||
|
botoes = botoes.push(
|
||||||
|
button(text("Processar ▶").size(13))
|
||||||
|
.on_press(Message::ExecutarImportacao)
|
||||||
|
.style(t::btn_primary)
|
||||||
|
.padding([9, 14]),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if aba_selecionada {
|
||||||
|
botoes = botoes.push(
|
||||||
|
button(text("⚙ Configurar Colunas").size(13))
|
||||||
|
.on_press(Message::IrParaConfiguracaoColunas)
|
||||||
|
.style(t::btn_secondary)
|
||||||
|
.padding([9, 14]),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Layout geral ──────────────────────────────────────────────────────────
|
||||||
|
let content = column![header, card_abas, preview_section, botoes]
|
||||||
|
.spacing(16)
|
||||||
|
.padding([20, 24])
|
||||||
|
.width(Length::Fill);
|
||||||
|
|
||||||
|
container(scrollable(content))
|
||||||
|
.style(t::fundo)
|
||||||
|
.width(Length::Fill)
|
||||||
|
.height(Length::Fill)
|
||||||
|
.into()
|
||||||
|
}
|
||||||
+481
@@ -0,0 +1,481 @@
|
|||||||
|
use iced::widget::{button, container, progress_bar, text_input};
|
||||||
|
use iced::{Border, Color, Theme};
|
||||||
|
|
||||||
|
// ─── Paleta de cores ──────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
pub const BG: Color = Color {
|
||||||
|
r: 0.059,
|
||||||
|
g: 0.090,
|
||||||
|
b: 0.165,
|
||||||
|
a: 1.0,
|
||||||
|
}; // #0F172A
|
||||||
|
pub const SURFACE: Color = Color {
|
||||||
|
r: 0.118,
|
||||||
|
g: 0.161,
|
||||||
|
b: 0.231,
|
||||||
|
a: 1.0,
|
||||||
|
}; // #1E293B
|
||||||
|
pub const SURFACE_2: Color = Color {
|
||||||
|
r: 0.200,
|
||||||
|
g: 0.255,
|
||||||
|
b: 0.333,
|
||||||
|
a: 1.0,
|
||||||
|
}; // #334155
|
||||||
|
pub const BORDER: Color = Color {
|
||||||
|
r: 0.200,
|
||||||
|
g: 0.255,
|
||||||
|
b: 0.333,
|
||||||
|
a: 1.0,
|
||||||
|
}; // #334155
|
||||||
|
pub const TEXT: Color = Color {
|
||||||
|
r: 0.945,
|
||||||
|
g: 0.961,
|
||||||
|
b: 0.980,
|
||||||
|
a: 1.0,
|
||||||
|
}; // #F1F5F9
|
||||||
|
pub const TEXT_SECONDARY: Color = Color {
|
||||||
|
r: 0.580,
|
||||||
|
g: 0.639,
|
||||||
|
b: 0.722,
|
||||||
|
a: 1.0,
|
||||||
|
}; // #94A3B8
|
||||||
|
pub const TEXT_MUTED: Color = Color {
|
||||||
|
r: 0.392,
|
||||||
|
g: 0.455,
|
||||||
|
b: 0.545,
|
||||||
|
a: 1.0,
|
||||||
|
}; // #64748B
|
||||||
|
pub const PRIMARY: Color = Color {
|
||||||
|
r: 0.231,
|
||||||
|
g: 0.510,
|
||||||
|
b: 0.965,
|
||||||
|
a: 1.0,
|
||||||
|
}; // #3B82F6
|
||||||
|
pub const PRIMARY_HOVER: Color = Color {
|
||||||
|
r: 0.145,
|
||||||
|
g: 0.388,
|
||||||
|
b: 0.922,
|
||||||
|
a: 1.0,
|
||||||
|
}; // #2563EB
|
||||||
|
pub const SUCCESS: Color = Color {
|
||||||
|
r: 0.133,
|
||||||
|
g: 0.773,
|
||||||
|
b: 0.369,
|
||||||
|
a: 1.0,
|
||||||
|
}; // #22C55E
|
||||||
|
pub const WARNING: Color = Color {
|
||||||
|
r: 0.961,
|
||||||
|
g: 0.620,
|
||||||
|
b: 0.043,
|
||||||
|
a: 1.0,
|
||||||
|
}; // #F59E0B
|
||||||
|
pub const DANGER: Color = Color {
|
||||||
|
r: 0.937,
|
||||||
|
g: 0.267,
|
||||||
|
b: 0.267,
|
||||||
|
a: 1.0,
|
||||||
|
}; // #EF4444
|
||||||
|
pub const TRACK_BG: Color = Color {
|
||||||
|
r: 0.067,
|
||||||
|
g: 0.094,
|
||||||
|
b: 0.153,
|
||||||
|
a: 1.0,
|
||||||
|
}; // #111827
|
||||||
|
|
||||||
|
// ─── Tema global ─────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
pub fn tema_dark() -> Theme {
|
||||||
|
Theme::custom(
|
||||||
|
"dark".to_string(),
|
||||||
|
iced::theme::Palette {
|
||||||
|
background: BG,
|
||||||
|
text: TEXT,
|
||||||
|
primary: PRIMARY,
|
||||||
|
success: SUCCESS,
|
||||||
|
danger: DANGER,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Container styles ────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/// Card principal: surface + borda + radius 8
|
||||||
|
pub fn card(_theme: &Theme) -> container::Style {
|
||||||
|
container::Style {
|
||||||
|
background: Some(SURFACE.into()),
|
||||||
|
border: Border {
|
||||||
|
color: BORDER,
|
||||||
|
width: 1.0,
|
||||||
|
radius: 8.0.into(),
|
||||||
|
},
|
||||||
|
text_color: Some(TEXT),
|
||||||
|
..Default::default()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Card secundário: surface_2 + borda + radius 6
|
||||||
|
pub fn card_secondary(_theme: &Theme) -> container::Style {
|
||||||
|
container::Style {
|
||||||
|
background: Some(SURFACE_2.into()),
|
||||||
|
border: Border {
|
||||||
|
color: BORDER,
|
||||||
|
width: 1.0,
|
||||||
|
radius: 6.0.into(),
|
||||||
|
},
|
||||||
|
text_color: Some(TEXT),
|
||||||
|
..Default::default()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Fundo geral da tela
|
||||||
|
pub fn fundo(_theme: &Theme) -> container::Style {
|
||||||
|
container::Style {
|
||||||
|
background: Some(BG.into()),
|
||||||
|
text_color: Some(TEXT),
|
||||||
|
..Default::default()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Cabeçalho de tabela (surface_2 sem borda)
|
||||||
|
pub fn cabecalho_tabela(_theme: &Theme) -> container::Style {
|
||||||
|
container::Style {
|
||||||
|
background: Some(SURFACE_2.into()),
|
||||||
|
text_color: Some(TEXT_SECONDARY),
|
||||||
|
..Default::default()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Badge de sucesso (OK)
|
||||||
|
pub fn badge_sucesso(_theme: &Theme) -> container::Style {
|
||||||
|
container::Style {
|
||||||
|
background: Some(Color { a: 0.15, ..SUCCESS }.into()),
|
||||||
|
border: Border {
|
||||||
|
color: Color { a: 0.3, ..SUCCESS },
|
||||||
|
width: 1.0,
|
||||||
|
radius: 4.0.into(),
|
||||||
|
},
|
||||||
|
text_color: Some(SUCCESS),
|
||||||
|
..Default::default()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Badge de aviso (faltante)
|
||||||
|
pub fn badge_aviso(_theme: &Theme) -> container::Style {
|
||||||
|
container::Style {
|
||||||
|
background: Some(Color { a: 0.15, ..WARNING }.into()),
|
||||||
|
border: Border {
|
||||||
|
color: Color { a: 0.3, ..WARNING },
|
||||||
|
width: 1.0,
|
||||||
|
radius: 4.0.into(),
|
||||||
|
},
|
||||||
|
text_color: Some(WARNING),
|
||||||
|
..Default::default()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Badge de perigo (duplicada)
|
||||||
|
pub fn badge_perigo(_theme: &Theme) -> container::Style {
|
||||||
|
container::Style {
|
||||||
|
background: Some(Color { a: 0.15, ..DANGER }.into()),
|
||||||
|
border: Border {
|
||||||
|
color: Color { a: 0.3, ..DANGER },
|
||||||
|
width: 1.0,
|
||||||
|
radius: 4.0.into(),
|
||||||
|
},
|
||||||
|
text_color: Some(DANGER),
|
||||||
|
..Default::default()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Área de erro/validação
|
||||||
|
pub fn area_erro(_theme: &Theme) -> container::Style {
|
||||||
|
container::Style {
|
||||||
|
background: Some(Color { a: 0.10, ..DANGER }.into()),
|
||||||
|
border: Border {
|
||||||
|
color: Color { a: 0.4, ..DANGER },
|
||||||
|
width: 1.0,
|
||||||
|
radius: 6.0.into(),
|
||||||
|
},
|
||||||
|
text_color: Some(DANGER),
|
||||||
|
..Default::default()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Stat card com borda colorida no topo
|
||||||
|
pub fn stat_card(_theme: &Theme) -> container::Style {
|
||||||
|
container::Style {
|
||||||
|
background: Some(SURFACE.into()),
|
||||||
|
border: Border {
|
||||||
|
color: BORDER,
|
||||||
|
width: 1.0,
|
||||||
|
radius: 8.0.into(),
|
||||||
|
},
|
||||||
|
text_color: Some(TEXT),
|
||||||
|
..Default::default()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Separador horizontal (linha fina)
|
||||||
|
pub fn separador(_theme: &Theme) -> container::Style {
|
||||||
|
container::Style {
|
||||||
|
background: Some(BORDER.into()),
|
||||||
|
..Default::default()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Fundo do breadcrumb
|
||||||
|
pub fn breadcrumb_bg(_theme: &Theme) -> container::Style {
|
||||||
|
container::Style {
|
||||||
|
background: Some(SURFACE.into()),
|
||||||
|
border: Border {
|
||||||
|
color: BORDER,
|
||||||
|
width: 0.0,
|
||||||
|
radius: 0.0.into(),
|
||||||
|
},
|
||||||
|
text_color: Some(TEXT_SECONDARY),
|
||||||
|
..Default::default()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Button styles ───────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/// Botão primário (azul sólido)
|
||||||
|
pub fn btn_primary(theme: &Theme, status: button::Status) -> button::Style {
|
||||||
|
let base = button::Style {
|
||||||
|
background: Some(PRIMARY.into()),
|
||||||
|
text_color: Color::WHITE,
|
||||||
|
border: Border {
|
||||||
|
radius: 6.0.into(),
|
||||||
|
..Default::default()
|
||||||
|
},
|
||||||
|
..Default::default()
|
||||||
|
};
|
||||||
|
match status {
|
||||||
|
button::Status::Hovered => button::Style {
|
||||||
|
background: Some(PRIMARY_HOVER.into()),
|
||||||
|
..base
|
||||||
|
},
|
||||||
|
button::Status::Pressed => button::Style {
|
||||||
|
background: Some(
|
||||||
|
Color {
|
||||||
|
r: 0.114,
|
||||||
|
g: 0.306,
|
||||||
|
b: 0.847,
|
||||||
|
a: 1.0,
|
||||||
|
}
|
||||||
|
.into(),
|
||||||
|
), // #1D4ED8
|
||||||
|
..base
|
||||||
|
},
|
||||||
|
button::Status::Disabled => button::Style {
|
||||||
|
background: Some(Color { a: 0.4, ..PRIMARY }.into()),
|
||||||
|
text_color: Color {
|
||||||
|
a: 0.5,
|
||||||
|
..Color::WHITE
|
||||||
|
},
|
||||||
|
..base
|
||||||
|
},
|
||||||
|
_ => {
|
||||||
|
let _ = theme;
|
||||||
|
base
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Botão secundário (surface_2)
|
||||||
|
pub fn btn_secondary(theme: &Theme, status: button::Status) -> button::Style {
|
||||||
|
let base = button::Style {
|
||||||
|
background: Some(SURFACE_2.into()),
|
||||||
|
text_color: TEXT,
|
||||||
|
border: Border {
|
||||||
|
radius: 6.0.into(),
|
||||||
|
..Default::default()
|
||||||
|
},
|
||||||
|
..Default::default()
|
||||||
|
};
|
||||||
|
match status {
|
||||||
|
button::Status::Hovered => button::Style {
|
||||||
|
background: Some(
|
||||||
|
Color {
|
||||||
|
r: 0.25,
|
||||||
|
g: 0.31,
|
||||||
|
b: 0.40,
|
||||||
|
a: 1.0,
|
||||||
|
}
|
||||||
|
.into(),
|
||||||
|
),
|
||||||
|
..base
|
||||||
|
},
|
||||||
|
button::Status::Disabled => button::Style {
|
||||||
|
background: Some(
|
||||||
|
Color {
|
||||||
|
a: 0.5,
|
||||||
|
..SURFACE_2
|
||||||
|
}
|
||||||
|
.into(),
|
||||||
|
),
|
||||||
|
text_color: Color { a: 0.4, ..TEXT },
|
||||||
|
..base
|
||||||
|
},
|
||||||
|
_ => {
|
||||||
|
let _ = theme;
|
||||||
|
base
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Botão ghost (sem fundo, borda sutil)
|
||||||
|
pub fn btn_ghost(theme: &Theme, status: button::Status) -> button::Style {
|
||||||
|
let base = button::Style {
|
||||||
|
background: Some(Color::TRANSPARENT.into()),
|
||||||
|
text_color: TEXT_SECONDARY,
|
||||||
|
border: Border {
|
||||||
|
color: BORDER,
|
||||||
|
width: 1.0,
|
||||||
|
radius: 6.0.into(),
|
||||||
|
},
|
||||||
|
..Default::default()
|
||||||
|
};
|
||||||
|
match status {
|
||||||
|
button::Status::Hovered => button::Style {
|
||||||
|
background: Some(Color { a: 0.08, ..PRIMARY }.into()),
|
||||||
|
text_color: TEXT,
|
||||||
|
..base
|
||||||
|
},
|
||||||
|
button::Status::Disabled => button::Style {
|
||||||
|
text_color: TEXT_MUTED,
|
||||||
|
border: Border {
|
||||||
|
color: Color { a: 0.3, ..BORDER },
|
||||||
|
..base.border
|
||||||
|
},
|
||||||
|
..base
|
||||||
|
},
|
||||||
|
_ => {
|
||||||
|
let _ = theme;
|
||||||
|
base
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Botão de perigo (exclusão)
|
||||||
|
pub fn btn_danger(theme: &Theme, status: button::Status) -> button::Style {
|
||||||
|
let base = button::Style {
|
||||||
|
background: Some(Color { a: 0.15, ..DANGER }.into()),
|
||||||
|
text_color: DANGER,
|
||||||
|
border: Border {
|
||||||
|
color: Color { a: 0.3, ..DANGER },
|
||||||
|
width: 1.0,
|
||||||
|
radius: 6.0.into(),
|
||||||
|
},
|
||||||
|
..Default::default()
|
||||||
|
};
|
||||||
|
match status {
|
||||||
|
button::Status::Hovered => button::Style {
|
||||||
|
background: Some(Color { a: 0.25, ..DANGER }.into()),
|
||||||
|
..base
|
||||||
|
},
|
||||||
|
_ => {
|
||||||
|
let _ = theme;
|
||||||
|
base
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Botão de aba selecionada (tela selecionar_aba)
|
||||||
|
pub fn btn_aba_ativa(theme: &Theme, status: button::Status) -> button::Style {
|
||||||
|
let base = button::Style {
|
||||||
|
background: Some(Color { a: 0.20, ..PRIMARY }.into()),
|
||||||
|
text_color: PRIMARY,
|
||||||
|
border: Border {
|
||||||
|
color: PRIMARY,
|
||||||
|
width: 1.0,
|
||||||
|
radius: 6.0.into(),
|
||||||
|
},
|
||||||
|
..Default::default()
|
||||||
|
};
|
||||||
|
match status {
|
||||||
|
button::Status::Hovered => button::Style {
|
||||||
|
background: Some(Color { a: 0.30, ..PRIMARY }.into()),
|
||||||
|
..base
|
||||||
|
},
|
||||||
|
_ => {
|
||||||
|
let _ = theme;
|
||||||
|
base
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Botão de aba inativa (tela selecionar_aba)
|
||||||
|
pub fn btn_aba_inativa(theme: &Theme, status: button::Status) -> button::Style {
|
||||||
|
btn_secondary(theme, status)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Botão de itens por página (ativo)
|
||||||
|
pub fn btn_pagina_ativo(theme: &Theme, status: button::Status) -> button::Style {
|
||||||
|
btn_primary(theme, status)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Botão de itens por página (inativo)
|
||||||
|
pub fn btn_pagina_inativo(theme: &Theme, status: button::Status) -> button::Style {
|
||||||
|
btn_ghost(theme, status)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Text input styles ────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
pub fn input_dark(theme: &Theme, status: text_input::Status) -> text_input::Style {
|
||||||
|
let base = text_input::Style {
|
||||||
|
background: BG.into(),
|
||||||
|
border: Border {
|
||||||
|
color: BORDER,
|
||||||
|
width: 1.0,
|
||||||
|
radius: 6.0.into(),
|
||||||
|
},
|
||||||
|
icon: TEXT_MUTED,
|
||||||
|
placeholder: TEXT_MUTED,
|
||||||
|
value: TEXT,
|
||||||
|
selection: Color { a: 0.3, ..PRIMARY },
|
||||||
|
};
|
||||||
|
match status {
|
||||||
|
text_input::Status::Focused => text_input::Style {
|
||||||
|
border: Border {
|
||||||
|
color: PRIMARY,
|
||||||
|
..base.border
|
||||||
|
},
|
||||||
|
..base
|
||||||
|
},
|
||||||
|
text_input::Status::Hovered => text_input::Style {
|
||||||
|
border: Border {
|
||||||
|
color: TEXT_SECONDARY,
|
||||||
|
..base.border
|
||||||
|
},
|
||||||
|
..base
|
||||||
|
},
|
||||||
|
_ => {
|
||||||
|
let _ = theme;
|
||||||
|
base
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Progress bar styles ─────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/// Progress bar colorida por percentual (0.0 – 1.0)
|
||||||
|
pub fn progress_bar_por_percentual(percentual: f32) -> impl Fn(&Theme) -> progress_bar::Style {
|
||||||
|
move |_theme| {
|
||||||
|
let cor = if percentual >= 0.90 {
|
||||||
|
SUCCESS
|
||||||
|
} else if percentual >= 0.60 {
|
||||||
|
WARNING
|
||||||
|
} else {
|
||||||
|
DANGER
|
||||||
|
};
|
||||||
|
progress_bar::Style {
|
||||||
|
background: TRACK_BG.into(),
|
||||||
|
bar: cor.into(),
|
||||||
|
border: Border {
|
||||||
|
radius: 10.0.into(),
|
||||||
|
..Default::default()
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
Binary file not shown.
|
After Width: | Height: | Size: 1.1 MiB |
@@ -0,0 +1,109 @@
|
|||||||
|
{
|
||||||
|
"color": {
|
||||||
|
"primary": {
|
||||||
|
"base": "#3B82F6",
|
||||||
|
"hover": "#2563EB",
|
||||||
|
"active": "#1D4ED8"
|
||||||
|
},
|
||||||
|
|
||||||
|
"semantic": {
|
||||||
|
"success": "#22C55E",
|
||||||
|
"warning": "#F59E0B",
|
||||||
|
"error": "#EF4444",
|
||||||
|
"info": "#0EA5E9"
|
||||||
|
},
|
||||||
|
|
||||||
|
"dark": {
|
||||||
|
"background": "#0F172A",
|
||||||
|
"surface": "#1E293B",
|
||||||
|
"surfaceSecondary": "#334155",
|
||||||
|
|
||||||
|
"text": {
|
||||||
|
"primary": "#F1F5F9",
|
||||||
|
"secondary": "#94A3B8",
|
||||||
|
"muted": "#64748B"
|
||||||
|
},
|
||||||
|
|
||||||
|
"border": "#334155",
|
||||||
|
|
||||||
|
"interaction": {
|
||||||
|
"hover": "#3B82F622",
|
||||||
|
"selection": "#3B82F633",
|
||||||
|
"focus": "#3B82F6"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
"light": {
|
||||||
|
"background": "#F8FAFC",
|
||||||
|
"surface": "#FFFFFF",
|
||||||
|
"surfaceSecondary": "#F1F5F9",
|
||||||
|
|
||||||
|
"text": {
|
||||||
|
"primary": "#0F172A",
|
||||||
|
"secondary": "#64748B",
|
||||||
|
"muted": "#94A3B8"
|
||||||
|
},
|
||||||
|
|
||||||
|
"border": "#E2E8F0",
|
||||||
|
|
||||||
|
"interaction": {
|
||||||
|
"hover": "#3B82F611",
|
||||||
|
"selection": "#3B82F622",
|
||||||
|
"focus": "#3B82F6"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
"table": {
|
||||||
|
"row": {
|
||||||
|
"hover": "#3B82F611",
|
||||||
|
"selected": "#3B82F622"
|
||||||
|
},
|
||||||
|
|
||||||
|
"status": {
|
||||||
|
"ok": "#22C55E",
|
||||||
|
"missing": "#F59E0B",
|
||||||
|
"duplicate": "#EF4444",
|
||||||
|
"invalid": "#EF4444"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
"spacing": {
|
||||||
|
"xs": 4,
|
||||||
|
"sm": 8,
|
||||||
|
"md": 12,
|
||||||
|
"lg": 16,
|
||||||
|
"xl": 24,
|
||||||
|
"xxl": 32
|
||||||
|
},
|
||||||
|
|
||||||
|
"radius": {
|
||||||
|
"sm": 4,
|
||||||
|
"md": 6,
|
||||||
|
"lg": 8
|
||||||
|
},
|
||||||
|
|
||||||
|
"font": {
|
||||||
|
"family": "Inter, system-ui, sans-serif",
|
||||||
|
|
||||||
|
"size": {
|
||||||
|
"xs": 11,
|
||||||
|
"sm": 12,
|
||||||
|
"md": 14,
|
||||||
|
"lg": 16,
|
||||||
|
"xl": 20
|
||||||
|
},
|
||||||
|
|
||||||
|
"weight": {
|
||||||
|
"normal": 400,
|
||||||
|
"medium": 500,
|
||||||
|
"bold": 600
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
"shadow": {
|
||||||
|
"sm": "0 1px 2px rgba(0,0,0,0.05)",
|
||||||
|
"md": "0 4px 8px rgba(0,0,0,0.08)",
|
||||||
|
"lg": "0 10px 20px rgba(0,0,0,0.12)"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,287 @@
|
|||||||
|
```html
|
||||||
|
<!doctype html>
|
||||||
|
<html lang="pt-BR">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8" />
|
||||||
|
<title>Comparador de Notas - Mockup</title>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
:root {
|
||||||
|
--bg: #0f172a;
|
||||||
|
--surface: #1e293b;
|
||||||
|
--surface-2: #334155;
|
||||||
|
|
||||||
|
--text: #f1f5f9;
|
||||||
|
--text-secondary: #94a3b8;
|
||||||
|
|
||||||
|
--border: #334155;
|
||||||
|
|
||||||
|
--primary: #3b82f6;
|
||||||
|
--success: #22c55e;
|
||||||
|
--warning: #f59e0b;
|
||||||
|
--error: #ef4444;
|
||||||
|
}
|
||||||
|
|
||||||
|
* {
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
|
||||||
|
body {
|
||||||
|
margin: 0;
|
||||||
|
background: var(--bg);
|
||||||
|
color: var(--text);
|
||||||
|
font-family: system-ui, sans-serif;
|
||||||
|
}
|
||||||
|
|
||||||
|
.container {
|
||||||
|
width: 1100px;
|
||||||
|
margin: 40px auto;
|
||||||
|
background: var(--surface);
|
||||||
|
border-radius: 8px;
|
||||||
|
padding: 24px;
|
||||||
|
box-shadow: 0 10px 25px rgba(0, 0, 0, 0.4);
|
||||||
|
}
|
||||||
|
|
||||||
|
.header {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
margin-bottom: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.title {
|
||||||
|
font-size: 20px;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.actions button {
|
||||||
|
background: var(--primary);
|
||||||
|
border: none;
|
||||||
|
padding: 10px 16px;
|
||||||
|
border-radius: 6px;
|
||||||
|
color: white;
|
||||||
|
margin-left: 8px;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.actions button.secondary {
|
||||||
|
background: var(--surface-2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.stats {
|
||||||
|
display: flex;
|
||||||
|
gap: 40px;
|
||||||
|
margin: 20px 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stat {
|
||||||
|
font-size: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stat strong {
|
||||||
|
font-size: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.series-box {
|
||||||
|
background: var(--surface-2);
|
||||||
|
padding: 16px;
|
||||||
|
border-radius: 6px;
|
||||||
|
margin-bottom: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.series {
|
||||||
|
margin-bottom: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.progress {
|
||||||
|
height: 8px;
|
||||||
|
background: #111827;
|
||||||
|
border-radius: 10px;
|
||||||
|
overflow: hidden;
|
||||||
|
margin-top: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bar {
|
||||||
|
height: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.green {
|
||||||
|
background: var(--success);
|
||||||
|
}
|
||||||
|
.orange {
|
||||||
|
background: var(--warning);
|
||||||
|
}
|
||||||
|
.red {
|
||||||
|
background: var(--error);
|
||||||
|
}
|
||||||
|
|
||||||
|
table {
|
||||||
|
width: 100%;
|
||||||
|
border-collapse: collapse;
|
||||||
|
margin-top: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
thead {
|
||||||
|
background: var(--surface-2);
|
||||||
|
}
|
||||||
|
|
||||||
|
th,
|
||||||
|
td {
|
||||||
|
padding: 10px;
|
||||||
|
text-align: left;
|
||||||
|
border-bottom: 1px solid var(--border);
|
||||||
|
}
|
||||||
|
|
||||||
|
.status {
|
||||||
|
padding: 4px 8px;
|
||||||
|
border-radius: 4px;
|
||||||
|
font-size: 12px;
|
||||||
|
display: inline-block;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ok {
|
||||||
|
background: rgba(34, 197, 94, 0.2);
|
||||||
|
color: var(--success);
|
||||||
|
}
|
||||||
|
|
||||||
|
.dup {
|
||||||
|
background: rgba(239, 68, 68, 0.2);
|
||||||
|
color: var(--error);
|
||||||
|
}
|
||||||
|
|
||||||
|
.missing {
|
||||||
|
background: rgba(245, 158, 11, 0.2);
|
||||||
|
color: var(--warning);
|
||||||
|
}
|
||||||
|
|
||||||
|
.footer {
|
||||||
|
margin-top: 20px;
|
||||||
|
display: flex;
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.footer button {
|
||||||
|
background: var(--surface-2);
|
||||||
|
border: none;
|
||||||
|
padding: 10px 14px;
|
||||||
|
border-radius: 6px;
|
||||||
|
color: var(--text);
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.footer button.primary {
|
||||||
|
background: var(--primary);
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
|
||||||
|
<body>
|
||||||
|
<div class="container">
|
||||||
|
<div class="header">
|
||||||
|
<div class="title">Comparador de Notas</div>
|
||||||
|
|
||||||
|
<div class="actions">
|
||||||
|
<button>Importar Planilha</button>
|
||||||
|
<button class="secondary">Configurar Campos</button>
|
||||||
|
<button class="secondary">Exportar Relatório</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="stats">
|
||||||
|
<div class="stat">
|
||||||
|
<strong style="color: #3b82f6">48</strong> Notas Faltantes
|
||||||
|
</div>
|
||||||
|
<div class="stat">
|
||||||
|
<strong style="color: #ef4444">6</strong> Notas Duplicadas
|
||||||
|
</div>
|
||||||
|
<div class="stat"><strong>R$ 125.600,00</strong> Total</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="series-box">
|
||||||
|
<div class="series">
|
||||||
|
Série 1 – NFE — 48 / 50 notas — 96% completo
|
||||||
|
<div class="progress">
|
||||||
|
<div class="bar green" style="width: 96%"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="series">
|
||||||
|
Série 2 – NFCE — 20 / 25 notas — 80% completo
|
||||||
|
<div class="progress">
|
||||||
|
<div class="bar orange" style="width: 80%"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="series">
|
||||||
|
Série 3 – NFE — 12 / 20 notas — 60% completo
|
||||||
|
<div class="progress">
|
||||||
|
<div class="bar red" style="width: 60%"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<table>
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Número</th>
|
||||||
|
<th>Série</th>
|
||||||
|
<th>Tipo</th>
|
||||||
|
<th>Valor</th>
|
||||||
|
<th>Status</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
|
||||||
|
<tbody>
|
||||||
|
<tr>
|
||||||
|
<td>1005</td>
|
||||||
|
<td>1</td>
|
||||||
|
<td>NFE</td>
|
||||||
|
<td>R$ 2.500,00</td>
|
||||||
|
<td><span class="status dup">Duplicada</span></td>
|
||||||
|
</tr>
|
||||||
|
|
||||||
|
<tr>
|
||||||
|
<td>1006</td>
|
||||||
|
<td>1</td>
|
||||||
|
<td>NFE</td>
|
||||||
|
<td>R$ 3.200,00</td>
|
||||||
|
<td><span class="status ok">OK</span></td>
|
||||||
|
</tr>
|
||||||
|
|
||||||
|
<tr>
|
||||||
|
<td>1010</td>
|
||||||
|
<td>1</td>
|
||||||
|
<td>NFE</td>
|
||||||
|
<td>R$ 4.000,00</td>
|
||||||
|
<td>
|
||||||
|
<span class="status missing">Falta: 1010–1050</span>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
|
||||||
|
<tr>
|
||||||
|
<td>1051</td>
|
||||||
|
<td>1</td>
|
||||||
|
<td>NFE</td>
|
||||||
|
<td>R$ 2.800,00</td>
|
||||||
|
<td><span class="status ok">OK</span></td>
|
||||||
|
</tr>
|
||||||
|
|
||||||
|
<tr>
|
||||||
|
<td>1075</td>
|
||||||
|
<td>2</td>
|
||||||
|
<td>NFCE</td>
|
||||||
|
<td>R$ 1.200,00</td>
|
||||||
|
<td><span class="status dup">Duplicada</span></td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
|
||||||
|
<div class="footer">
|
||||||
|
<button>Copiar Faltantes</button>
|
||||||
|
<button>Copiar Duplicadas</button>
|
||||||
|
<button class="primary">Exportar PDF</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
|
```
|
||||||
Reference in New Issue
Block a user