Refactor UI components for layout management and results display
- Replaced the `renderizar` function in `layouts.rs` with a new `view` function using Iced for a more modern UI approach. - Introduced a new `view_secao_layouts` function to handle the display of saved layouts. - Updated the `resultado.rs` file to use Iced for rendering the results screen, including buttons for actions and pagination controls. - Created a new `selecionar_aba.rs` file for the selection of XLSX sheet tabs, implementing a preview feature. - Removed old rendering functions and replaced them with Iced components for better performance and maintainability. - Added design tokens in `design_tokens.json` for consistent styling across the application. - Created a mockup HTML file to visualize the UI design.
This commit is contained in:
Generated
+1217
-695
File diff suppressed because it is too large
Load Diff
+2
-3
@@ -4,8 +4,8 @@ version = "0.1.0"
|
||||
edition = "2024"
|
||||
|
||||
[dependencies]
|
||||
eframe = "0.31"
|
||||
egui = "0.31"
|
||||
iced = { version = "0.13", features = ["tokio", "image"] }
|
||||
tokio = { version = "1", features = ["full"] }
|
||||
rusqlite = { version = "0.32", features = ["bundled"] }
|
||||
csv = "1.3"
|
||||
calamine = "0.26"
|
||||
@@ -20,7 +20,6 @@ dirs = "5"
|
||||
thiserror = "2"
|
||||
regex = "1"
|
||||
rfd = "0.15"
|
||||
image = { version = "0.25", default-features = false, features = ["ico"] }
|
||||
|
||||
[build-dependencies]
|
||||
winres = "0.1"
|
||||
|
||||
+396
-567
File diff suppressed because it is too large
Load Diff
+7
-34
@@ -7,39 +7,12 @@ mod ui;
|
||||
|
||||
use ui::app::App;
|
||||
|
||||
fn load_icon() -> Option<egui::viewport::IconData> {
|
||||
let bytes = include_bytes!("../icon.ico");
|
||||
let img = image::load_from_memory(bytes).ok()?.into_rgba8();
|
||||
let (width, height) = img.dimensions();
|
||||
Some(egui::viewport::IconData {
|
||||
rgba: img.into_raw(),
|
||||
width,
|
||||
height,
|
||||
})
|
||||
}
|
||||
|
||||
fn main() -> eframe::Result {
|
||||
let mut viewport = egui::ViewportBuilder::default()
|
||||
.with_title("Comparador de Notas")
|
||||
.with_inner_size([1024.0, 768.0])
|
||||
.with_min_inner_size([800.0, 600.0]);
|
||||
|
||||
if let Some(icon) = load_icon() {
|
||||
viewport = viewport.with_icon(std::sync::Arc::new(icon));
|
||||
}
|
||||
|
||||
let native_options = eframe::NativeOptions {
|
||||
viewport,
|
||||
fn main() -> iced::Result {
|
||||
iced::application("Comparador de Notas", App::update, App::view)
|
||||
.window(iced::window::Settings {
|
||||
size: iced::Size::new(1024.0, 768.0),
|
||||
min_size: Some(iced::Size::new(800.0, 600.0)),
|
||||
..Default::default()
|
||||
};
|
||||
|
||||
eframe::run_native(
|
||||
"Comparador de Notas",
|
||||
native_options,
|
||||
Box::new(|_cc| {
|
||||
let mut app = App::default();
|
||||
app.inicializar();
|
||||
Ok(Box::new(app))
|
||||
}),
|
||||
)
|
||||
})
|
||||
.run_with(App::new)
|
||||
}
|
||||
|
||||
+969
-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,85 @@
|
||||
use crate::ui::app::EstadoModal;
|
||||
use crate::ui::message::Message;
|
||||
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.5).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, None, false),
|
||||
EstadoModal::Aviso { titulo, mensagem } => caixa_modal(titulo, mensagem, None, false),
|
||||
EstadoModal::Erro { titulo, mensagem } => caixa_modal(titulo, mensagem, None, false),
|
||||
EstadoModal::Confirmacao {
|
||||
titulo, mensagem, ..
|
||||
} => caixa_modal(titulo, mensagem, None, true),
|
||||
EstadoModal::InputTexto {
|
||||
titulo,
|
||||
mensagem,
|
||||
texto,
|
||||
..
|
||||
} => caixa_modal(titulo, mensagem, Some(texto.as_str()), true),
|
||||
}
|
||||
}
|
||||
|
||||
fn caixa_modal<'a>(
|
||||
titulo: &'a str,
|
||||
mensagem: &'a str,
|
||||
input: Option<&'a str>,
|
||||
com_confirmar: bool,
|
||||
) -> Element<'a, Message> {
|
||||
let mut col = column![text(titulo).size(18), text(mensagem).size(14),].spacing(8);
|
||||
|
||||
if let Some(valor) = input {
|
||||
col = col.push(
|
||||
text_input("Nome...", valor)
|
||||
.on_input(Message::ModalTextoAlterado)
|
||||
.padding(6),
|
||||
);
|
||||
}
|
||||
|
||||
let mut botoes = row![button("Fechar").on_press(Message::ModalCancelado),].spacing(8);
|
||||
|
||||
if com_confirmar {
|
||||
botoes = botoes.push(button("Confirmar").on_press(Message::ModalConfirmado));
|
||||
}
|
||||
|
||||
col = col.push(botoes);
|
||||
|
||||
container(col.align_x(Alignment::Start))
|
||||
.width(Length::Fixed(400.0))
|
||||
.padding(24)
|
||||
.style(|theme: &iced::Theme| {
|
||||
let palette = theme.extended_palette();
|
||||
container::Style {
|
||||
background: Some(palette.background.base.color.into()),
|
||||
border: iced::Border {
|
||||
color: palette.background.strong.color,
|
||||
width: 1.0,
|
||||
radius: 8.0.into(),
|
||||
},
|
||||
..Default::default()
|
||||
}
|
||||
})
|
||||
.into()
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
use crate::ui::message::Message;
|
||||
use iced::widget::{button, row, text};
|
||||
use iced::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));
|
||||
let btn_proximo =
|
||||
button("▶").on_press_maybe((pagina_atual + 1 < total_paginas).then_some(msg_proximo));
|
||||
|
||||
row![
|
||||
btn_anterior,
|
||||
text(format!("Página {} / {}", pagina_atual + 1, total_paginas)).size(13),
|
||||
btn_proximo,
|
||||
]
|
||||
.spacing(8)
|
||||
.into()
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
use crate::ui::message::Message;
|
||||
use crate::ui::screens::indice_para_letra;
|
||||
use iced::widget::{column, row, scrollable, text};
|
||||
use iced::{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).into();
|
||||
}
|
||||
|
||||
let cabecalho = row((0..num_colunas)
|
||||
.map(|i| {
|
||||
text(format!("{} ({})", indice_para_letra(i), i))
|
||||
.font(Font::MONOSPACE)
|
||||
.size(12)
|
||||
.width(Length::Fixed(120.0))
|
||||
.into()
|
||||
})
|
||||
.collect::<Vec<_>>())
|
||||
.spacing(4);
|
||||
|
||||
let linhas_view = linhas.iter().map(|linha| {
|
||||
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()
|
||||
};
|
||||
text(truncado)
|
||||
.font(Font::MONOSPACE)
|
||||
.size(11)
|
||||
.width(Length::Fixed(120.0))
|
||||
.into()
|
||||
})
|
||||
.collect::<Vec<_>>())
|
||||
.spacing(4)
|
||||
.into()
|
||||
});
|
||||
|
||||
let todas_linhas = column(
|
||||
std::iter::once(cabecalho.into())
|
||||
.chain(linhas_view)
|
||||
.collect::<Vec<_>>(),
|
||||
)
|
||||
.spacing(2);
|
||||
|
||||
scrollable(todas_linhas)
|
||||
.direction(scrollable::Direction::Horizontal(
|
||||
scrollable::Scrollbar::default(),
|
||||
))
|
||||
.height(Length::Fixed(160.0))
|
||||
.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,4 @@
|
||||
pub mod app;
|
||||
pub mod components;
|
||||
pub mod message;
|
||||
pub mod screens;
|
||||
|
||||
@@ -1,336 +1,379 @@
|
||||
use crate::application::usecases::executar_analise::{
|
||||
expandir_analise, pre_analisar, series_com_intervalo_excessivo,
|
||||
use crate::domain::entities::layout::TipoArquivo;
|
||||
use crate::ui::app::App;
|
||||
use crate::ui::message::Message;
|
||||
use iced::widget::{
|
||||
button, checkbox, column, container, pick_list, row, scrollable, text, text_input, Column,
|
||||
};
|
||||
use crate::application::usecases::importar_arquivo::{importar_csv, importar_xlsx};
|
||||
use crate::domain::entities::layout::{Layout, TipoArquivo};
|
||||
use crate::ui::app::{App, EstadoApp, ResultadoPendente};
|
||||
use egui::{Context, Ui};
|
||||
use iced::{Alignment, Element, Length};
|
||||
|
||||
/// Renderiza a tela de configuração de colunas.
|
||||
pub fn renderizar(ui: &mut Ui, ctx: &Context, app: &mut App) {
|
||||
ui.heading("Configuração de Colunas");
|
||||
ui.add_space(8.0);
|
||||
const OPCOES_DELIMITADOR: &[(&str, char)] = &[
|
||||
("Vírgula (,)", ','),
|
||||
("Ponto e vírgula (;)", ';'),
|
||||
("Tabulação (Tab)", '\t'),
|
||||
];
|
||||
|
||||
if let Some(caminho) = &app.caminho_arquivo.clone() {
|
||||
ui.label(format!("Arquivo: {}", caminho.display()));
|
||||
}
|
||||
const OPCOES_ENCODING: &[&str] = &["utf-8", "windows-1252"];
|
||||
|
||||
ui.add_space(8.0);
|
||||
/// Tela de configuração de colunas.
|
||||
pub fn view(app: &App) -> Element<'_, Message> {
|
||||
let titulo = text("Configuração de Colunas").size(22);
|
||||
|
||||
let arquivo_label = if let Some(caminho) = &app.caminho_arquivo {
|
||||
text(format!("Arquivo: {}", caminho.display())).size(13)
|
||||
} else {
|
||||
text("").size(13)
|
||||
};
|
||||
|
||||
// Seletor de layout
|
||||
let tipo_atual = app.tipo_arquivo_atual.clone();
|
||||
let opcoes_layout: Vec<(i64, String)> = app
|
||||
let opcoes_layout: Vec<String> = app
|
||||
.layouts_salvos
|
||||
.iter()
|
||||
.filter(|l| l.tipo() == tipo_atual)
|
||||
.filter_map(|l| l.id().map(|id| (id, l.nome().to_string())))
|
||||
.map(|l| l.nome().to_string())
|
||||
.collect();
|
||||
let nome_layout_atual = app.nome_layout_atual.clone();
|
||||
|
||||
ui.horizontal(|ui| {
|
||||
ui.label("Layout:");
|
||||
egui::ComboBox::from_id_salt("combo_layouts_config")
|
||||
.selected_text(if nome_layout_atual.is_empty() {
|
||||
"— Selecionar layout —"
|
||||
let nome_layout_sel: Option<String> = if app.nome_layout_atual.is_empty() {
|
||||
None
|
||||
} else {
|
||||
&nome_layout_atual
|
||||
Some(app.nome_layout_atual.clone())
|
||||
};
|
||||
|
||||
let secao_layout = row![
|
||||
text("Layout:").size(14),
|
||||
pick_list(opcoes_layout, nome_layout_sel, {
|
||||
let layouts = app.layouts_salvos.clone();
|
||||
move |nome_selecionado: String| {
|
||||
// Busca o id do layout pelo nome e dispara LayoutSelecionado
|
||||
if let Some(id) = layouts
|
||||
.iter()
|
||||
.find(|l| l.nome() == nome_selecionado)
|
||||
.and_then(|l| l.id())
|
||||
{
|
||||
Message::LayoutSelecionado(id)
|
||||
} else {
|
||||
Message::NomeLayoutAlterado(nome_selecionado)
|
||||
}
|
||||
}
|
||||
})
|
||||
.show_ui(ui, |ui| {
|
||||
for (id, nome) in &opcoes_layout {
|
||||
if ui
|
||||
.selectable_label(nome_layout_atual == *nome, nome.as_str())
|
||||
.clicked()
|
||||
{
|
||||
app.nome_layout_atual = nome.clone();
|
||||
if let Some(layout) =
|
||||
app.layouts_salvos.iter().find(|l| l.id() == Some(*id))
|
||||
{
|
||||
let layout = layout.clone();
|
||||
match &layout {
|
||||
Layout::Csv { config, .. } => {
|
||||
app.layout_csv_atual = config.clone();
|
||||
}
|
||||
Layout::Xlsx { config, .. } => {
|
||||
app.layout_xlsx_atual = config.clone();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
.placeholder("— Selecionar layout —")
|
||||
.width(Length::Fixed(240.0)),
|
||||
]
|
||||
.spacing(8)
|
||||
.align_y(Alignment::Center);
|
||||
|
||||
ui.add_space(12.0);
|
||||
// Configuração específica por tipo
|
||||
let config_section = match app.tipo_arquivo_atual {
|
||||
TipoArquivo::Csv => view_csv(app),
|
||||
TipoArquivo::Xlsx => view_xlsx(app),
|
||||
};
|
||||
|
||||
match app.tipo_arquivo_atual.clone() {
|
||||
TipoArquivo::Csv => renderizar_csv(ui, app),
|
||||
TipoArquivo::Xlsx => renderizar_xlsx(ui, app),
|
||||
}
|
||||
// Pré-visualização
|
||||
let preview_section: Element<Message> = if let Some(linhas) = &app.preview_arquivo {
|
||||
column![
|
||||
text("Pré-visualização:").size(13),
|
||||
crate::ui::components::tabela_preview::tabela_preview(linhas),
|
||||
]
|
||||
.spacing(4)
|
||||
.into()
|
||||
} else {
|
||||
text("").into()
|
||||
};
|
||||
|
||||
// Pré-visualização do arquivo
|
||||
if let Some(preview) = &app.preview_arquivo.clone() {
|
||||
ui.add_space(8.0);
|
||||
ui.separator();
|
||||
ui.add_space(4.0);
|
||||
crate::ui::screens::renderizar_tabela_preview(ui, preview);
|
||||
}
|
||||
|
||||
ui.add_space(16.0);
|
||||
ui.separator();
|
||||
ui.add_space(8.0);
|
||||
|
||||
// Validação e botões de ação
|
||||
// Validação
|
||||
let (valido, erros) = validar_config(app);
|
||||
|
||||
if !erros.is_empty() {
|
||||
for erro in &erros {
|
||||
ui.colored_label(egui::Color32::RED, format!("⚠ {}", erro));
|
||||
}
|
||||
ui.add_space(8.0);
|
||||
let erros_section: Element<Message> = if erros.is_empty() {
|
||||
text("").into()
|
||||
} else {
|
||||
Column::with_children(
|
||||
erros
|
||||
.iter()
|
||||
.map(|e| text(format!("⚠ {}", e)).size(13).into())
|
||||
.collect::<Vec<_>>(),
|
||||
)
|
||||
.spacing(2)
|
||||
.into()
|
||||
};
|
||||
|
||||
let tem_arquivo = app.caminho_arquivo.is_some();
|
||||
let tem_notas = !app.notas_importadas.is_empty();
|
||||
|
||||
let botoes = row![
|
||||
button("< Voltar").on_press(Message::Voltar),
|
||||
button("▶ Importar e Analisar")
|
||||
.on_press_maybe((valido && tem_arquivo).then_some(Message::ExecutarImportacao)),
|
||||
button("🔄 Reanalisar")
|
||||
.on_press_maybe((valido && tem_notas).then_some(Message::ReanalisarArquivo)),
|
||||
button("💾 Salvar como layout...").on_press(Message::SalvarLayout),
|
||||
]
|
||||
.spacing(8);
|
||||
|
||||
let content = column![
|
||||
titulo,
|
||||
arquivo_label,
|
||||
secao_layout,
|
||||
config_section,
|
||||
preview_section,
|
||||
erros_section,
|
||||
botoes,
|
||||
]
|
||||
.spacing(12)
|
||||
.padding(16)
|
||||
.width(Length::Fill);
|
||||
|
||||
container(scrollable(content))
|
||||
.width(Length::Fill)
|
||||
.height(Length::Fill)
|
||||
.into()
|
||||
}
|
||||
|
||||
ui.horizontal(|ui| {
|
||||
if ui.button("< Voltar").clicked() {
|
||||
app.estado = EstadoApp::Importando;
|
||||
}
|
||||
fn view_csv(app: &App) -> Element<'_, Message> {
|
||||
let c = &app.layout_csv_atual;
|
||||
|
||||
ui.add_enabled_ui(valido && app.caminho_arquivo.is_some(), |ui| {
|
||||
if ui.button("▶ Importar e Analisar").clicked() {
|
||||
executar_importacao(app, ctx);
|
||||
}
|
||||
});
|
||||
|
||||
if valido && !app.notas_importadas.is_empty() {
|
||||
if ui
|
||||
.button("🔄 Reanalisar")
|
||||
.on_hover_text("Reanalisa as notas já importadas sem reimportar o arquivo")
|
||||
.clicked()
|
||||
{
|
||||
app.executar_analise();
|
||||
}
|
||||
}
|
||||
|
||||
if ui.button("💾 Salvar como layout...").clicked() {
|
||||
app.exibir_modal_salvar_layout();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
fn renderizar_csv(ui: &mut Ui, app: &mut App) {
|
||||
ui.group(|ui| {
|
||||
ui.label("Configurações CSV");
|
||||
ui.add_space(4.0);
|
||||
|
||||
// Delimitador
|
||||
ui.horizontal(|ui| {
|
||||
ui.label("Delimitador:");
|
||||
let delim_str = match app.layout_csv_atual.delimitador {
|
||||
let delim_str = match c.delimitador {
|
||||
',' => "Vírgula (,)",
|
||||
';' => "Ponto e vírgula (;)",
|
||||
'\t' => "Tabulação (Tab)",
|
||||
_ => "Outro",
|
||||
};
|
||||
let mut delim_mudou = false;
|
||||
egui::ComboBox::from_id_salt("combo_delimitador")
|
||||
.selected_text(delim_str)
|
||||
.show_ui(ui, |ui| {
|
||||
if ui
|
||||
.selectable_label(app.layout_csv_atual.delimitador == ',', "Vírgula (,)")
|
||||
.clicked()
|
||||
{
|
||||
app.layout_csv_atual.delimitador = ',';
|
||||
delim_mudou = true;
|
||||
}
|
||||
if ui
|
||||
.selectable_label(
|
||||
app.layout_csv_atual.delimitador == ';',
|
||||
"Ponto e vírgula (;)",
|
||||
.to_string();
|
||||
|
||||
let opcoes_delim: Vec<String> = OPCOES_DELIMITADOR
|
||||
.iter()
|
||||
.map(|(s, _)| s.to_string())
|
||||
.collect();
|
||||
|
||||
let opcoes_enc: Vec<String> = OPCOES_ENCODING.iter().map(|s| s.to_string()).collect();
|
||||
|
||||
let linha_cabecalho_str = c.linha_cabecalho.to_string();
|
||||
let indice_numero_str = c.indice_numero.to_string();
|
||||
let indice_serie_str = c.indice_serie.to_string();
|
||||
|
||||
column![
|
||||
text("Configurações CSV").size(16),
|
||||
row![
|
||||
text("Delimitador:").size(14).width(Length::Fixed(220.0)),
|
||||
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)
|
||||
})
|
||||
.width(Length::Fixed(200.0)),
|
||||
]
|
||||
.spacing(8)
|
||||
.align_y(Alignment::Center),
|
||||
row![
|
||||
text("Encoding:").size(14).width(Length::Fixed(220.0)),
|
||||
pick_list(
|
||||
opcoes_enc,
|
||||
Some(c.encoding.clone()),
|
||||
Message::EncodingAlterado
|
||||
)
|
||||
.clicked()
|
||||
{
|
||||
app.layout_csv_atual.delimitador = ';';
|
||||
delim_mudou = true;
|
||||
}
|
||||
if ui
|
||||
.selectable_label(
|
||||
app.layout_csv_atual.delimitador == '\t',
|
||||
"Tabulação (Tab)",
|
||||
)
|
||||
.clicked()
|
||||
{
|
||||
app.layout_csv_atual.delimitador = '\t';
|
||||
delim_mudou = true;
|
||||
}
|
||||
});
|
||||
if delim_mudou {
|
||||
if let Some(caminho) = &app.caminho_arquivo.clone() {
|
||||
app.preview_arquivo = crate::infrastructure::csv_reader::preview_csv(
|
||||
caminho,
|
||||
app.layout_csv_atual.delimitador as u8,
|
||||
&app.layout_csv_atual.encoding.clone(),
|
||||
5,
|
||||
)
|
||||
.ok();
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// Encoding
|
||||
ui.horizontal(|ui| {
|
||||
ui.label("Encoding:");
|
||||
egui::ComboBox::from_id_salt("combo_encoding")
|
||||
.selected_text(&app.layout_csv_atual.encoding)
|
||||
.show_ui(ui, |ui| {
|
||||
if ui
|
||||
.selectable_label(app.layout_csv_atual.encoding == "utf-8", "UTF-8")
|
||||
.clicked()
|
||||
{
|
||||
app.layout_csv_atual.encoding = "utf-8".to_string();
|
||||
}
|
||||
if ui
|
||||
.selectable_label(
|
||||
app.layout_csv_atual.encoding == "windows-1252",
|
||||
"Windows-1252 (Latin-1)",
|
||||
)
|
||||
.clicked()
|
||||
{
|
||||
app.layout_csv_atual.encoding = "windows-1252".to_string();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// Linha cabeçalho
|
||||
ui.horizontal(|ui| {
|
||||
ui.label("Linha do cabeçalho (0 = sem cabeçalho):");
|
||||
let mut val = app.layout_csv_atual.linha_cabecalho;
|
||||
ui.add(egui::DragValue::new(&mut val).range(0..=100));
|
||||
app.layout_csv_atual.linha_cabecalho = val;
|
||||
});
|
||||
});
|
||||
|
||||
ui.add_space(8.0);
|
||||
ui.group(|ui| {
|
||||
ui.label("Mapeamento de Colunas (índice base 0)");
|
||||
ui.add_space(4.0);
|
||||
|
||||
campo_indice(
|
||||
ui,
|
||||
"Número (obrigatório):",
|
||||
&mut app.layout_csv_atual.indice_numero,
|
||||
);
|
||||
campo_indice(
|
||||
ui,
|
||||
"Série (obrigatório):",
|
||||
&mut app.layout_csv_atual.indice_serie,
|
||||
);
|
||||
|
||||
campo_indice_opcional(
|
||||
ui,
|
||||
.width(Length::Fixed(200.0)),
|
||||
]
|
||||
.spacing(8)
|
||||
.align_y(Alignment::Center),
|
||||
row![
|
||||
text("Linha do cabeçalho:")
|
||||
.size(14)
|
||||
.width(Length::Fixed(220.0)),
|
||||
text_input("0", &linha_cabecalho_str)
|
||||
.on_input(|s| {
|
||||
s.parse::<usize>()
|
||||
.map(Message::LinhaCabecalhoAlterada)
|
||||
.unwrap_or(Message::Noop)
|
||||
})
|
||||
.width(Length::Fixed(80.0)),
|
||||
]
|
||||
.spacing(8)
|
||||
.align_y(Alignment::Center),
|
||||
text("Mapeamento de Colunas (índice base 0)").size(14),
|
||||
row![
|
||||
text("Número (obrigatório):")
|
||||
.size(14)
|
||||
.width(Length::Fixed(220.0)),
|
||||
text_input("0", &indice_numero_str)
|
||||
.on_input(|s| {
|
||||
s.parse::<usize>()
|
||||
.map(Message::IndiceNumeroAlterado)
|
||||
.unwrap_or(Message::Noop)
|
||||
})
|
||||
.width(Length::Fixed(80.0)),
|
||||
]
|
||||
.spacing(8)
|
||||
.align_y(Alignment::Center),
|
||||
row![
|
||||
text("Série (obrigatório):")
|
||||
.size(14)
|
||||
.width(Length::Fixed(220.0)),
|
||||
text_input("0", &indice_serie_str)
|
||||
.on_input(|s| {
|
||||
s.parse::<usize>()
|
||||
.map(Message::IndiceSerieAlterado)
|
||||
.unwrap_or(Message::Noop)
|
||||
})
|
||||
.width(Length::Fixed(80.0)),
|
||||
]
|
||||
.spacing(8)
|
||||
.align_y(Alignment::Center),
|
||||
campo_indice_opcional_csv(
|
||||
"Valor (opcional):",
|
||||
&mut app.layout_csv_atual.indice_valor,
|
||||
);
|
||||
campo_indice_opcional(
|
||||
ui,
|
||||
c.indice_valor,
|
||||
Message::IndiceValorToggle,
|
||||
Message::IndiceValorAlterado,
|
||||
),
|
||||
campo_indice_opcional_csv(
|
||||
"Data (opcional):",
|
||||
&mut app.layout_csv_atual.indice_data,
|
||||
);
|
||||
campo_indice_opcional(
|
||||
ui,
|
||||
c.indice_data,
|
||||
Message::IndiceDataToggle,
|
||||
Message::IndiceDataAlterado,
|
||||
),
|
||||
campo_indice_opcional_csv(
|
||||
"Tipo Documento (opcional):",
|
||||
&mut app.layout_csv_atual.indice_documento_tipo,
|
||||
);
|
||||
});
|
||||
c.indice_documento_tipo,
|
||||
Message::IndiceDocTipoToggle,
|
||||
Message::IndiceDocTipoAlterado,
|
||||
),
|
||||
]
|
||||
.spacing(8)
|
||||
.into()
|
||||
}
|
||||
|
||||
fn renderizar_xlsx(ui: &mut Ui, app: &mut App) {
|
||||
ui.group(|ui| {
|
||||
ui.label("Configurações XLSX");
|
||||
ui.add_space(4.0);
|
||||
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();
|
||||
|
||||
ui.horizontal(|ui| {
|
||||
ui.label("Aba:");
|
||||
if app.abas_xlsx.is_empty() {
|
||||
ui.text_edit_singleline(&mut app.layout_xlsx_atual.aba);
|
||||
let cb = checkbox(label, ativo).on_toggle(msg_toggle);
|
||||
|
||||
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) })
|
||||
.width(Length::Fixed(80.0)),
|
||||
]
|
||||
.spacing(8)
|
||||
.align_y(Alignment::Center)
|
||||
.into()
|
||||
} else {
|
||||
let aba_atual = app.layout_xlsx_atual.aba.clone();
|
||||
egui::ComboBox::from_id_salt("combo_aba")
|
||||
.selected_text(&aba_atual)
|
||||
.show_ui(ui, |ui| {
|
||||
for aba in &app.abas_xlsx.clone() {
|
||||
if ui.selectable_label(aba_atual == *aba, aba).clicked() {
|
||||
app.layout_xlsx_atual.aba = aba.clone();
|
||||
row![cb].into()
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
ui.add_space(8.0);
|
||||
ui.group(|ui| {
|
||||
ui.label("Mapeamento de Colunas (formato LetraLinha, ex: B3)");
|
||||
ui.add_space(4.0);
|
||||
fn view_xlsx(app: &App) -> Element<'_, Message> {
|
||||
let c = &app.layout_xlsx_atual;
|
||||
|
||||
let secao_aba: Element<Message> = if app.abas_xlsx.is_empty() {
|
||||
row![
|
||||
text("Aba:").size(14).width(Length::Fixed(200.0)),
|
||||
text_input("Nome da aba", &c.aba)
|
||||
.on_input(Message::AbaXlsxAlterada)
|
||||
.width(Length::Fixed(200.0)),
|
||||
]
|
||||
.spacing(8)
|
||||
.align_y(Alignment::Center)
|
||||
.into()
|
||||
} else {
|
||||
let aba_sel = if c.aba.is_empty() {
|
||||
None
|
||||
} else {
|
||||
Some(c.aba.clone())
|
||||
};
|
||||
row![
|
||||
text("Aba:").size(14).width(Length::Fixed(200.0)),
|
||||
pick_list(app.abas_xlsx.clone(), aba_sel, Message::AbaXlsxAlterada)
|
||||
.width(Length::Fixed(200.0)),
|
||||
]
|
||||
.spacing(8)
|
||||
.align_y(Alignment::Center)
|
||||
.into()
|
||||
};
|
||||
|
||||
column![
|
||||
text("Configurações XLSX").size(16),
|
||||
secao_aba,
|
||||
text("Mapeamento de Colunas (formato LetraLinha, ex: B3)").size(14),
|
||||
campo_letra_linha(
|
||||
ui,
|
||||
"Número (obrigatório):",
|
||||
&mut app.layout_xlsx_atual.pos_numero,
|
||||
);
|
||||
&c.pos_numero,
|
||||
Message::PosNumeroAlterada
|
||||
),
|
||||
campo_letra_linha(
|
||||
ui,
|
||||
"Série (obrigatório):",
|
||||
&mut app.layout_xlsx_atual.pos_serie,
|
||||
);
|
||||
&c.pos_serie,
|
||||
Message::PosSerieAlterada
|
||||
),
|
||||
campo_letra_linha_opcional(
|
||||
ui,
|
||||
"Valor (opcional):",
|
||||
&mut app.layout_xlsx_atual.pos_valor,
|
||||
);
|
||||
campo_letra_linha_opcional(ui, "Data (opcional):", &mut app.layout_xlsx_atual.pos_data);
|
||||
c.pos_valor.as_deref(),
|
||||
Message::PosValorToggle,
|
||||
Message::PosValorAlterada,
|
||||
),
|
||||
campo_letra_linha_opcional(
|
||||
"Data (opcional):",
|
||||
c.pos_data.as_deref(),
|
||||
Message::PosDataToggle,
|
||||
Message::PosDataAlterada,
|
||||
),
|
||||
campo_letra_linha_opcional(
|
||||
ui,
|
||||
"Tipo Documento (opcional):",
|
||||
&mut app.layout_xlsx_atual.pos_documento_tipo,
|
||||
);
|
||||
});
|
||||
c.pos_documento_tipo.as_deref(),
|
||||
Message::PosDocTipoToggle,
|
||||
Message::PosDocTipoAlterada,
|
||||
),
|
||||
]
|
||||
.spacing(8)
|
||||
.into()
|
||||
}
|
||||
|
||||
fn campo_indice(ui: &mut Ui, label: &str, valor: &mut usize) {
|
||||
ui.horizontal(|ui| {
|
||||
ui.label(label);
|
||||
ui.add(egui::DragValue::new(valor).range(0..=999usize));
|
||||
});
|
||||
fn campo_letra_linha<'a>(
|
||||
label: &'a str,
|
||||
valor: &'a str,
|
||||
msg: impl Fn(String) -> Message + 'a,
|
||||
) -> Element<'a, Message> {
|
||||
row![
|
||||
text(label).size(14).width(Length::Fixed(200.0)),
|
||||
text_input("ex: B3", valor)
|
||||
.on_input(msg)
|
||||
.width(Length::Fixed(100.0)),
|
||||
]
|
||||
.spacing(8)
|
||||
.align_y(Alignment::Center)
|
||||
.into()
|
||||
}
|
||||
|
||||
fn campo_indice_opcional(ui: &mut Ui, label: &str, valor: &mut Option<usize>) {
|
||||
ui.horizontal(|ui| {
|
||||
let mut ativo = valor.is_some();
|
||||
if ui.checkbox(&mut ativo, label).changed() {
|
||||
*valor = if ativo { Some(0) } else { None };
|
||||
}
|
||||
if let Some(v) = valor {
|
||||
ui.add(egui::DragValue::new(v).range(0..=999usize));
|
||||
}
|
||||
});
|
||||
}
|
||||
fn campo_letra_linha_opcional<'a>(
|
||||
label: &'a str,
|
||||
valor: Option<&'a str>,
|
||||
msg_toggle: impl Fn(bool) -> Message + 'a,
|
||||
msg_valor: impl Fn(String) -> Message + 'a,
|
||||
) -> Element<'a, Message> {
|
||||
let ativo = valor.is_some();
|
||||
let val_str = valor.unwrap_or("").to_string();
|
||||
|
||||
fn campo_letra_linha(ui: &mut Ui, label: &str, valor: &mut String) {
|
||||
ui.horizontal(|ui| {
|
||||
ui.label(label);
|
||||
ui.text_edit_singleline(valor);
|
||||
});
|
||||
}
|
||||
let cb = checkbox(label, ativo).on_toggle(msg_toggle);
|
||||
|
||||
fn campo_letra_linha_opcional(ui: &mut Ui, label: &str, valor: &mut Option<String>) {
|
||||
ui.horizontal(|ui| {
|
||||
let mut ativo = valor.is_some();
|
||||
if ui.checkbox(&mut ativo, label).changed() {
|
||||
*valor = if ativo { Some(String::new()) } else { None };
|
||||
if ativo {
|
||||
row![
|
||||
cb.width(Length::Fixed(200.0)),
|
||||
text_input("ex: B3", &val_str)
|
||||
.on_input(msg_valor)
|
||||
.width(Length::Fixed(100.0)),
|
||||
]
|
||||
.spacing(8)
|
||||
.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).
|
||||
@@ -340,7 +383,6 @@ fn validar_config(app: &App) -> (bool, Vec<String>) {
|
||||
match &app.tipo_arquivo_atual {
|
||||
TipoArquivo::Csv => {
|
||||
let c = &app.layout_csv_atual;
|
||||
// Verificar índices duplicados
|
||||
let mut indices: Vec<(String, usize)> = vec![
|
||||
("Numero".to_string(), c.indice_numero),
|
||||
("Serie".to_string(), c.indice_serie),
|
||||
@@ -394,62 +436,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);
|
||||
});
|
||||
}
|
||||
|
||||
+53
-301
@@ -1,328 +1,80 @@
|
||||
use crate::application::usecases::executar_analise::{
|
||||
expandir_analise, pre_analisar, series_com_intervalo_excessivo,
|
||||
};
|
||||
use crate::application::usecases::importar_arquivo::{importar_xlsx, listar_abas_xlsx};
|
||||
use crate::domain::entities::layout::{Layout, TipoArquivo};
|
||||
use crate::ui::app::{App, EstadoApp, ResultadoPendente};
|
||||
use egui::{Context, Ui};
|
||||
use std::path::PathBuf;
|
||||
use crate::ui::app::App;
|
||||
use crate::ui::message::Message;
|
||||
use iced::widget::{button, column, container, pick_list, row, text};
|
||||
use iced::{Alignment, Element, Length};
|
||||
|
||||
/// Renderiza a tela de importação de arquivos.
|
||||
pub fn renderizar(ui: &mut Ui, ctx: &Context, app: &mut App) {
|
||||
ui.heading("Comparador de Notas — Importar Arquivo");
|
||||
ui.add_space(16.0);
|
||||
/// Tela de importação de arquivos.
|
||||
pub fn view(app: &App) -> Element<'_, Message> {
|
||||
let titulo = text("Comparador de Notas — Importar Arquivo").size(22);
|
||||
|
||||
// --- Seleção de arquivo ---
|
||||
ui.group(|ui| {
|
||||
ui.label("Arquivo:");
|
||||
ui.horizontal(|ui| {
|
||||
// Nome do arquivo selecionado
|
||||
let nome = if app.nome_arquivo.is_empty() {
|
||||
"Nenhum arquivo selecionado".to_string()
|
||||
} else {
|
||||
app.nome_arquivo.clone()
|
||||
};
|
||||
ui.label(nome);
|
||||
|
||||
if ui.button("📂 Selecionar arquivo...").clicked() {
|
||||
if let Some(caminho) = rfd::FileDialog::new()
|
||||
.add_filter("Planilhas", &["csv", "xlsx", "xls"])
|
||||
.pick_file()
|
||||
{
|
||||
on_arquivo_selecionado(app, ctx, caminho);
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
let secao_arquivo = row![
|
||||
text(nome).size(14).width(Length::Fill),
|
||||
button("Selecionar arquivo...").on_press(Message::SelecionarArquivo),
|
||||
]
|
||||
.spacing(8)
|
||||
.align_y(Alignment::Center);
|
||||
|
||||
ui.add_space(8.0);
|
||||
|
||||
// Coletar infos dos layouts antecipadamente para evitar borrow duplo
|
||||
// Seletor de layout compatível com o tipo atual
|
||||
let tipo_atual = app.tipo_arquivo_atual.clone();
|
||||
let opcoes_layout: Vec<(i64, String)> = app
|
||||
let opcoes_layout: Vec<String> = app
|
||||
.layouts_salvos
|
||||
.iter()
|
||||
.filter(|l| l.tipo() == tipo_atual)
|
||||
.filter_map(|l| l.id().map(|id| (id, l.nome().to_string())))
|
||||
.map(|l| l.nome().to_string())
|
||||
.collect();
|
||||
let nome_layout_atual = app.nome_layout_atual.clone();
|
||||
|
||||
ui.horizontal(|ui| {
|
||||
ui.label("Layout:");
|
||||
egui::ComboBox::from_id_salt("combo_layouts_import")
|
||||
.selected_text(if nome_layout_atual.is_empty() {
|
||||
"— Selecionar layout —"
|
||||
let nome_layout_sel: Option<String> = if app.nome_layout_atual.is_empty() {
|
||||
None
|
||||
} else {
|
||||
&nome_layout_atual
|
||||
})
|
||||
.show_ui(ui, |ui| {
|
||||
for (id, nome) in &opcoes_layout {
|
||||
if ui
|
||||
.selectable_label(nome_layout_atual == *nome, nome.as_str())
|
||||
.clicked()
|
||||
{
|
||||
app.nome_layout_atual = nome.clone();
|
||||
if let Some(layout) =
|
||||
app.layouts_salvos.iter().find(|l| l.id() == Some(*id))
|
||||
{
|
||||
let layout = layout.clone();
|
||||
aplicar_layout(app, &layout);
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
if ui.button("⚙ Gerenciar Layouts").clicked() {
|
||||
app.estado = EstadoApp::GerenciandoLayouts;
|
||||
}
|
||||
});
|
||||
|
||||
ui.add_space(16.0);
|
||||
|
||||
if app.caminho_arquivo.is_some() {
|
||||
if ui.button("▶ Configurar Colunas").clicked() {
|
||||
app.estado = EstadoApp::ConfigurandoColunas;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Renderiza a tela de seleção de aba (XLSX).
|
||||
pub fn renderizar_selecao_aba(ui: &mut Ui, ctx: &Context, app: &mut App) {
|
||||
ui.heading("Selecionar Aba da Planilha");
|
||||
ui.add_space(16.0);
|
||||
|
||||
let (abas, caminho) = match &app.estado {
|
||||
EstadoApp::SelecionandoAba { abas, caminho } => (abas.clone(), caminho.clone()),
|
||||
_ => return,
|
||||
Some(app.nome_layout_atual.clone())
|
||||
};
|
||||
|
||||
ui.label(format!("Arquivo: {}", caminho.display()));
|
||||
ui.add_space(8.0);
|
||||
|
||||
// --- Seleção de preset ---
|
||||
let opcoes_layout: Vec<(i64, String)> = app
|
||||
.layouts_salvos
|
||||
let secao_layout = row![
|
||||
text("Layout:").size(14),
|
||||
pick_list(opcoes_layout, nome_layout_sel, {
|
||||
let layouts = app.layouts_salvos.clone();
|
||||
move |nome_selecionado: String| {
|
||||
if let Some(id) = layouts
|
||||
.iter()
|
||||
.filter(|l| l.tipo() == TipoArquivo::Xlsx)
|
||||
.filter_map(|l| l.id().map(|id| (id, l.nome().to_string())))
|
||||
.collect();
|
||||
let nome_layout_atual = app.nome_layout_atual.clone();
|
||||
|
||||
if !opcoes_layout.is_empty() {
|
||||
ui.horizontal(|ui| {
|
||||
ui.label("Layout:");
|
||||
egui::ComboBox::from_id_salt("combo_layouts_aba")
|
||||
.selected_text(if nome_layout_atual.is_empty() {
|
||||
"— Selecionar layout —"
|
||||
.find(|l| l.nome() == nome_selecionado)
|
||||
.and_then(|l| l.id())
|
||||
{
|
||||
Message::LayoutSelecionado(id)
|
||||
} else {
|
||||
&nome_layout_atual
|
||||
Message::NomeLayoutAlterado(nome_selecionado)
|
||||
}
|
||||
}
|
||||
})
|
||||
.show_ui(ui, |ui| {
|
||||
for (id, nome) in &opcoes_layout {
|
||||
if ui
|
||||
.selectable_label(nome_layout_atual == *nome, nome.as_str())
|
||||
.clicked()
|
||||
{
|
||||
app.nome_layout_atual = nome.clone();
|
||||
if let Some(layout) =
|
||||
app.layouts_salvos.iter().find(|l| l.id() == Some(*id))
|
||||
{
|
||||
let layout = layout.clone();
|
||||
aplicar_layout(app, &layout);
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
ui.add_space(8.0);
|
||||
}
|
||||
.placeholder("— Selecionar layout —")
|
||||
.width(Length::Fixed(260.0)),
|
||||
button("⚙ Gerenciar Layouts").on_press(Message::IrParaLayouts),
|
||||
]
|
||||
.spacing(8)
|
||||
.align_y(Alignment::Center);
|
||||
|
||||
ui.label("Selecione a aba a processar:");
|
||||
|
||||
let aba_atual = app.layout_xlsx_atual.aba.clone();
|
||||
for aba in &abas {
|
||||
if ui.selectable_label(aba_atual == *aba, aba).clicked() {
|
||||
app.layout_xlsx_atual.aba = aba.clone();
|
||||
// Gerar pré-visualização da aba selecionada
|
||||
app.preview_arquivo =
|
||||
crate::infrastructure::xlsx_reader::preview_xlsx(&caminho, aba).ok();
|
||||
}
|
||||
}
|
||||
|
||||
// Pré-visualização da aba selecionada
|
||||
if !app.layout_xlsx_atual.aba.is_empty() {
|
||||
if let Some(preview) = &app.preview_arquivo {
|
||||
ui.add_space(8.0);
|
||||
crate::ui::screens::renderizar_tabela_preview(ui, preview);
|
||||
}
|
||||
}
|
||||
|
||||
ui.add_space(12.0);
|
||||
|
||||
if !app.layout_xlsx_atual.aba.is_empty() {
|
||||
ui.horizontal(|ui| {
|
||||
// Se há preset selecionado, oferecer processamento direto
|
||||
let tem_preset = !app.nome_layout_atual.is_empty();
|
||||
if tem_preset {
|
||||
let caminho_clone = caminho.clone();
|
||||
if ui.button("▶ Processar").clicked() {
|
||||
app.nome_arquivo = caminho_clone
|
||||
.file_name()
|
||||
.map(|n| n.to_string_lossy().to_string())
|
||||
.unwrap_or_default();
|
||||
app.caminho_arquivo = Some(caminho_clone);
|
||||
disparar_analise(app, ctx);
|
||||
}
|
||||
}
|
||||
|
||||
if ui.button("⚙ Configurar Colunas").clicked() {
|
||||
app.nome_arquivo = caminho
|
||||
.file_name()
|
||||
.map(|n| n.to_string_lossy().to_string())
|
||||
.unwrap_or_default();
|
||||
app.caminho_arquivo = Some(caminho);
|
||||
app.estado = EstadoApp::ConfigurandoColunas;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
if ui.button("< Voltar").clicked() {
|
||||
app.estado = EstadoApp::Importando;
|
||||
}
|
||||
}
|
||||
|
||||
fn on_arquivo_selecionado(app: &mut App, ctx: &Context, caminho: PathBuf) {
|
||||
let extensao = caminho
|
||||
.extension()
|
||||
.and_then(|e| e.to_str())
|
||||
.unwrap_or("")
|
||||
.to_lowercase();
|
||||
|
||||
app.nome_arquivo = caminho
|
||||
.file_name()
|
||||
.map(|n| n.to_string_lossy().to_string())
|
||||
.unwrap_or_default();
|
||||
|
||||
match extensao.as_str() {
|
||||
"csv" => {
|
||||
app.tipo_arquivo_atual = TipoArquivo::Csv;
|
||||
app.caminho_arquivo = Some(caminho.clone());
|
||||
app.notas_importadas.clear();
|
||||
// Gerar pré-visualização com o delimitador atual
|
||||
app.preview_arquivo = crate::infrastructure::csv_reader::preview_csv(
|
||||
&caminho,
|
||||
app.layout_csv_atual.delimitador as u8,
|
||||
&app.layout_csv_atual.encoding.clone(),
|
||||
5,
|
||||
)
|
||||
.ok();
|
||||
}
|
||||
"xlsx" | "xls" => {
|
||||
app.tipo_arquivo_atual = TipoArquivo::Xlsx;
|
||||
match listar_abas_xlsx(&caminho) {
|
||||
Ok(info) => {
|
||||
app.notas_importadas.clear();
|
||||
|
||||
// Verificar se há preset XLSX ativo com aba compatível
|
||||
let preset_aba = if !app.nome_layout_atual.is_empty() {
|
||||
let aba = app.layout_xlsx_atual.aba.clone();
|
||||
if !aba.is_empty() && info.abas.contains(&aba) {
|
||||
Some(aba)
|
||||
// Botão de avançar só aparece quando um arquivo foi selecionado
|
||||
let botao_avancar: Element<Message> = if app.caminho_arquivo.is_some() {
|
||||
button("▶ Configurar Colunas")
|
||||
.on_press(Message::IrParaConfiguracaoColunas)
|
||||
.into()
|
||||
} else {
|
||||
None
|
||||
}
|
||||
} else {
|
||||
None
|
||||
text("").into()
|
||||
};
|
||||
|
||||
if let Some(aba) = preset_aba {
|
||||
// Fluxo rápido: aba do preset existe → disparar análise direto
|
||||
app.layout_xlsx_atual.aba = aba.clone();
|
||||
app.caminho_arquivo = Some(caminho.clone());
|
||||
app.abas_xlsx = info.abas;
|
||||
app.preview_arquivo =
|
||||
crate::infrastructure::xlsx_reader::preview_xlsx(&caminho, &aba).ok();
|
||||
disparar_analise(app, ctx);
|
||||
} else {
|
||||
// Fluxo normal: exibir tela de seleção de aba
|
||||
app.abas_xlsx = info.abas.clone();
|
||||
app.estado = EstadoApp::SelecionandoAba {
|
||||
abas: info.abas,
|
||||
caminho: caminho.clone(),
|
||||
};
|
||||
}
|
||||
}
|
||||
Err(e) => {
|
||||
app.exibir_erro(format!("Erro ao ler abas do arquivo: {}", e));
|
||||
}
|
||||
}
|
||||
}
|
||||
_ => {
|
||||
app.exibir_erro("Formato de arquivo não suportado. Use CSV, XLSX ou XLS.");
|
||||
}
|
||||
}
|
||||
}
|
||||
let content = column![titulo, secao_arquivo, secao_layout, botao_avancar]
|
||||
.spacing(16)
|
||||
.padding(24)
|
||||
.width(Length::Fill);
|
||||
|
||||
/// 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;
|
||||
}
|
||||
}
|
||||
container(content)
|
||||
.width(Length::Fill)
|
||||
.height(Length::Fill)
|
||||
.into()
|
||||
}
|
||||
|
||||
+50
-248
@@ -1,264 +1,66 @@
|
||||
use crate::application::usecases::layouts::{
|
||||
exportar_layout_json, importar_layout_json, salvar_layout,
|
||||
};
|
||||
use crate::domain::entities::layout::{Layout, LayoutJson, TipoArquivo};
|
||||
use crate::domain::errors::ErroLayout;
|
||||
use crate::ui::app::{AcaoModal, App, EstadoApp};
|
||||
use egui::{Context, Ui};
|
||||
use crate::domain::entities::layout::{Layout, TipoArquivo};
|
||||
use crate::ui::app::App;
|
||||
use crate::ui::message::Message;
|
||||
use iced::widget::{button, column, container, horizontal_space, row, scrollable, text};
|
||||
use iced::{Alignment, Element, Length};
|
||||
|
||||
/// Renderiza a tela de gerenciamento de layouts.
|
||||
pub fn renderizar(ui: &mut Ui, ctx: &Context, app: &mut App) {
|
||||
ui.heading("Gerenciar Layouts");
|
||||
ui.add_space(8.0);
|
||||
/// Tela de gerenciamento de layouts.
|
||||
pub fn view(app: &App) -> Element<'_, Message> {
|
||||
let titulo = text("Gerenciar Layouts").size(22);
|
||||
|
||||
ui.horizontal(|ui| {
|
||||
if ui.button("< Voltar").clicked() {
|
||||
app.estado = EstadoApp::Importando;
|
||||
}
|
||||
});
|
||||
let botao_voltar = row![button("< Voltar").on_press(Message::IrParaImportacao)].spacing(8);
|
||||
|
||||
ui.add_space(12.0);
|
||||
ui.separator();
|
||||
|
||||
// Salvar layout atual
|
||||
ui.add_space(8.0);
|
||||
ui.group(|ui| {
|
||||
ui.label("Salvar Layout Atual");
|
||||
ui.horizontal(|ui| {
|
||||
ui.label("Nome:");
|
||||
ui.text_edit_singleline(&mut app.nome_layout_atual);
|
||||
if ui.button("💾 Salvar").clicked() {
|
||||
salvar_layout_atual(app);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
ui.add_space(12.0);
|
||||
ui.separator();
|
||||
ui.add_space(8.0);
|
||||
|
||||
// Layouts CSV
|
||||
let layouts_csv: Vec<_> = app
|
||||
.layouts_salvos
|
||||
.iter()
|
||||
.filter(|l| l.tipo() == TipoArquivo::Csv)
|
||||
.cloned()
|
||||
.collect();
|
||||
|
||||
let layouts_xlsx: Vec<_> = app
|
||||
.layouts_salvos
|
||||
.iter()
|
||||
.filter(|l| l.tipo() == TipoArquivo::Xlsx)
|
||||
.cloned()
|
||||
.collect();
|
||||
|
||||
egui::ScrollArea::vertical().show(ui, |ui| {
|
||||
renderizar_secao_layouts(ui, ctx, app, "Layouts CSV", &layouts_csv);
|
||||
ui.add_space(12.0);
|
||||
renderizar_secao_layouts(ui, ctx, app, "Layouts XLSX", &layouts_xlsx);
|
||||
|
||||
ui.add_space(16.0);
|
||||
ui.separator();
|
||||
ui.add_space(8.0);
|
||||
let secao_csv = view_secao_layouts("Layouts CSV", &app.layouts_salvos, TipoArquivo::Csv);
|
||||
let secao_xlsx = view_secao_layouts("Layouts XLSX", &app.layouts_salvos, TipoArquivo::Xlsx);
|
||||
|
||||
// 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));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
let importar_row = row![
|
||||
text("Importar layout de arquivo JSON:").size(14),
|
||||
button("📥 Importar JSON").on_press(Message::ImportarLayoutJson),
|
||||
]
|
||||
.spacing(8)
|
||||
.align_y(Alignment::Center);
|
||||
|
||||
let content = column![titulo, botao_voltar, secao_csv, secao_xlsx, importar_row,]
|
||||
.spacing(16)
|
||||
.padding(16)
|
||||
.width(Length::Fill);
|
||||
|
||||
container(scrollable(content))
|
||||
.width(Length::Fill)
|
||||
.height(Length::Fill)
|
||||
.into()
|
||||
}
|
||||
|
||||
fn renderizar_secao_layouts(
|
||||
ui: &mut Ui,
|
||||
_ctx: &Context,
|
||||
app: &mut App,
|
||||
titulo: &str,
|
||||
layouts: &[Layout],
|
||||
) {
|
||||
ui.label(egui::RichText::new(titulo).strong());
|
||||
ui.add_space(4.0);
|
||||
fn view_secao_layouts<'a>(
|
||||
titulo: &'a str,
|
||||
layouts: &'a [Layout],
|
||||
tipo: TipoArquivo,
|
||||
) -> Element<'a, Message> {
|
||||
let mut col = column![text(titulo).size(16)].spacing(4);
|
||||
|
||||
if layouts.is_empty() {
|
||||
ui.label("(nenhum layout salvo)");
|
||||
return;
|
||||
let filtrados: Vec<&Layout> = layouts.iter().filter(|l| l.tipo() == tipo).collect();
|
||||
|
||||
if filtrados.is_empty() {
|
||||
col = col.push(text("(nenhum layout salvo)").size(13));
|
||||
return col.into();
|
||||
}
|
||||
|
||||
for layout in layouts {
|
||||
ui.horizontal(|ui| {
|
||||
ui.label(layout.nome());
|
||||
ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
|
||||
// Excluir
|
||||
for layout in filtrados {
|
||||
if let Some(id) = layout.id() {
|
||||
if ui.button("🗑 Excluir").clicked() {
|
||||
app.exibir_confirmacao(
|
||||
"Confirmar exclusão",
|
||||
format!("Deseja excluir o layout '{}'?", layout.nome()),
|
||||
AcaoModal::ConfirmarExclusaoLayout(id),
|
||||
);
|
||||
}
|
||||
let linha = row![
|
||||
text(layout.nome()).size(14).width(Length::Fill),
|
||||
horizontal_space(),
|
||||
button("📂 Carregar").on_press(Message::LayoutSelecionado(id)),
|
||||
button("📤 Exportar JSON").on_press(Message::ExportarLayoutJson(id)),
|
||||
button("🗑 Excluir").on_press(Message::ExcluirLayout(id)),
|
||||
]
|
||||
.spacing(8)
|
||||
.align_y(Alignment::Center);
|
||||
|
||||
// Exportar
|
||||
if ui.button("📤 Exportar JSON").clicked() {
|
||||
match exportar_layout_json(layout) {
|
||||
Ok((conteudo, nome_sugerido)) => {
|
||||
if let Some(caminho) = rfd::FileDialog::new()
|
||||
.set_file_name(&nome_sugerido)
|
||||
.add_filter("JSON", &["json"])
|
||||
.save_file()
|
||||
{
|
||||
if let Err(e) = std::fs::write(&caminho, &conteudo) {
|
||||
app.exibir_erro(format!("Erro ao salvar JSON: {}", e));
|
||||
}
|
||||
}
|
||||
}
|
||||
Err(e) => {
|
||||
app.exibir_erro(format!("Erro ao exportar layout: {}", e));
|
||||
}
|
||||
col = col.push(linha);
|
||||
}
|
||||
}
|
||||
|
||||
// Carregar
|
||||
if ui.button("📂 Carregar").clicked() {
|
||||
aplicar_layout(app, layout);
|
||||
app.nome_layout_atual = layout.nome().to_string();
|
||||
app.estado = EstadoApp::ConfigurandoColunas;
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
fn salvar_layout_atual(app: &mut App) {
|
||||
if app.nome_layout_atual.trim().is_empty() {
|
||||
app.exibir_aviso("Nome inválido", "Informe um nome para o layout.");
|
||||
return;
|
||||
}
|
||||
|
||||
let layout = match app.tipo_arquivo_atual.clone() {
|
||||
TipoArquivo::Csv => Layout::Csv {
|
||||
id: None,
|
||||
nome: app.nome_layout_atual.trim().to_string(),
|
||||
config: app.layout_csv_atual.clone(),
|
||||
},
|
||||
TipoArquivo::Xlsx => Layout::Xlsx {
|
||||
id: None,
|
||||
nome: app.nome_layout_atual.trim().to_string(),
|
||||
config: app.layout_xlsx_atual.clone(),
|
||||
},
|
||||
};
|
||||
|
||||
if let Some(conn) = &app.conn {
|
||||
match salvar_layout(conn, &layout) {
|
||||
Ok(_) => {
|
||||
app.recarregar_layouts();
|
||||
app.exibir_aviso("Sucesso", "Layout salvo com sucesso.");
|
||||
}
|
||||
Err(ErroLayout::NomeConflitante(nome)) => {
|
||||
let id_existente = app
|
||||
.layouts_salvos
|
||||
.iter()
|
||||
.find(|l| l.nome() == nome)
|
||||
.and_then(|l| l.id());
|
||||
if let Some(id) = id_existente {
|
||||
let layout_com_id = match app.tipo_arquivo_atual.clone() {
|
||||
TipoArquivo::Csv => Layout::Csv {
|
||||
id: Some(id),
|
||||
nome: nome.clone(),
|
||||
config: app.layout_csv_atual.clone(),
|
||||
},
|
||||
TipoArquivo::Xlsx => Layout::Xlsx {
|
||||
id: Some(id),
|
||||
nome: nome.clone(),
|
||||
config: app.layout_xlsx_atual.clone(),
|
||||
},
|
||||
};
|
||||
app.exibir_confirmacao(
|
||||
"Conflito de nome",
|
||||
format!(
|
||||
"Já existe um layout com o nome '{}'. Deseja sobrescrever?",
|
||||
nome
|
||||
),
|
||||
AcaoModal::SobrescreverLayout(layout_com_id),
|
||||
);
|
||||
}
|
||||
}
|
||||
Err(e) => {
|
||||
app.exibir_erro(format!("Erro ao salvar layout: {}", e));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn importar_json(app: &mut App, conteudo: &str) {
|
||||
if let Some(conn) = &app.conn {
|
||||
match importar_layout_json(conn, conteudo, false, None) {
|
||||
Ok(_) => {
|
||||
app.recarregar_layouts();
|
||||
app.exibir_aviso("Sucesso", "Layout importado com sucesso.");
|
||||
}
|
||||
Err(ErroLayout::NomeConflitante(nome)) => {
|
||||
// Recriar o layout parseado para passá-lo no modal de confirmação.
|
||||
// O JSON já foi validado pela chamada acima, então o parse aqui não falha.
|
||||
let parsed = serde_json::from_str::<LayoutJson>(conteudo)
|
||||
.ok()
|
||||
.and_then(|json_repr| Layout::try_from(json_repr).ok());
|
||||
|
||||
let id_existente = app
|
||||
.layouts_salvos
|
||||
.iter()
|
||||
.find(|l| l.nome() == nome)
|
||||
.and_then(|l| l.id());
|
||||
|
||||
match (parsed, id_existente) {
|
||||
(Some(mut layout), Some(id)) => {
|
||||
match &mut layout {
|
||||
Layout::Csv { id: i, .. } => *i = Some(id),
|
||||
Layout::Xlsx { id: i, .. } => *i = Some(id),
|
||||
}
|
||||
app.exibir_confirmacao(
|
||||
"Conflito de nome",
|
||||
format!(
|
||||
"Já existe um layout com o nome '{}'. Deseja sobrescrever?",
|
||||
nome
|
||||
),
|
||||
AcaoModal::SobrescreverLayout(layout),
|
||||
);
|
||||
}
|
||||
_ => {
|
||||
app.exibir_erro(format!("Conflito de nome: layout '{}' já existe.", nome));
|
||||
}
|
||||
}
|
||||
}
|
||||
Err(e) => {
|
||||
app.exibir_erro(format!("Erro ao importar layout: {}", e));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn aplicar_layout(app: &mut App, layout: &Layout) {
|
||||
match layout {
|
||||
Layout::Csv { config, .. } => {
|
||||
app.layout_csv_atual = config.clone();
|
||||
app.tipo_arquivo_atual = TipoArquivo::Csv;
|
||||
}
|
||||
Layout::Xlsx { config, .. } => {
|
||||
app.layout_xlsx_atual = config.clone();
|
||||
app.tipo_arquivo_atual = TipoArquivo::Xlsx;
|
||||
}
|
||||
}
|
||||
col.into()
|
||||
}
|
||||
|
||||
+2
-53
@@ -2,9 +2,10 @@ pub mod configuracao_colunas;
|
||||
pub mod import;
|
||||
pub mod layouts;
|
||||
pub mod resultado;
|
||||
pub mod selecionar_aba;
|
||||
|
||||
/// Converte um índice de coluna base-0 para a notação de letras do Excel (A, B, ..., Z, AA, ...).
|
||||
fn indice_para_letra(mut idx: usize) -> String {
|
||||
pub fn indice_para_letra(mut idx: usize) -> String {
|
||||
let mut resultado = String::new();
|
||||
loop {
|
||||
resultado.insert(0, (b'A' + (idx % 26) as u8) as char);
|
||||
@@ -15,55 +16,3 @@ fn indice_para_letra(mut idx: usize) -> String {
|
||||
}
|
||||
resultado
|
||||
}
|
||||
|
||||
/// Renderiza uma tabela simples de pré-visualização do arquivo.
|
||||
/// Exibe uma linha de cabeçalho com letras no estilo Excel (A, B, C, ...)
|
||||
/// seguida pelas linhas de dados.
|
||||
pub fn renderizar_tabela_preview(ui: &mut egui::Ui, linhas: &[Vec<String>]) {
|
||||
let num_colunas = linhas.iter().map(|l| l.len()).max().unwrap_or(0);
|
||||
if num_colunas == 0 {
|
||||
return;
|
||||
}
|
||||
|
||||
ui.label(
|
||||
egui::RichText::new(format!("Pré-visualização ({} linha(s))", linhas.len()))
|
||||
.small()
|
||||
.weak(),
|
||||
);
|
||||
ui.add_space(2.0);
|
||||
|
||||
egui::ScrollArea::horizontal()
|
||||
.id_salt("scroll_preview")
|
||||
.max_height(160.0)
|
||||
.show(ui, |ui| {
|
||||
egui::Grid::new("tabela_preview")
|
||||
.striped(true)
|
||||
.spacing([8.0, 2.0])
|
||||
.show(ui, |ui| {
|
||||
// Linha de cabeçalho: letras A, B, C, ... com índice base-0 entre parênteses
|
||||
for i in 0..num_colunas {
|
||||
ui.label(
|
||||
egui::RichText::new(format!("{} ({})", indice_para_letra(i), i))
|
||||
.strong()
|
||||
.monospace(),
|
||||
);
|
||||
}
|
||||
ui.end_row();
|
||||
|
||||
// Linhas de dados
|
||||
for linha in linhas {
|
||||
for col in 0..num_colunas {
|
||||
let celula = linha.get(col).map(|s| s.as_str()).unwrap_or("");
|
||||
let texto = if celula.chars().count() > 30 {
|
||||
let truncado: String = celula.chars().take(30).collect();
|
||||
format!("{}...", truncado)
|
||||
} else {
|
||||
celula.to_string()
|
||||
};
|
||||
ui.label(egui::RichText::new(texto).monospace().small());
|
||||
}
|
||||
ui.end_row();
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
+139
-221
@@ -1,127 +1,76 @@
|
||||
use crate::application::usecases::exportar_pdf::exportar_pdf;
|
||||
use crate::domain::{
|
||||
entities::{chave_serie::ChaveSerie, resultado_analise::ResultadoAnalise},
|
||||
services::{detector_sequencia::agrupar_contiguos, parser_monetario::formatar_valor_br},
|
||||
};
|
||||
use crate::infrastructure::pdf_generator::GenpdfGenerator;
|
||||
use crate::ui::app::{AcaoModal, App, EstadoApp};
|
||||
use egui::{Context, Ui};
|
||||
use crate::ui::app::App;
|
||||
use crate::ui::message::Message;
|
||||
use iced::widget::{button, column, container, row, scrollable, text};
|
||||
use iced::{Alignment, Element, Length};
|
||||
|
||||
const OPCOES_PAGINA: &[usize] = &[50, 100, 200, 1000];
|
||||
|
||||
/// Renderiza a tela de resultados.
|
||||
pub fn renderizar(ui: &mut Ui, ctx: &Context, app: &mut App) {
|
||||
// Extrair resultado do estado (sem mover)
|
||||
let resultado = match &app.estado {
|
||||
EstadoApp::ExibindoResultado(r) => r.clone(),
|
||||
_ => return,
|
||||
};
|
||||
/// Tela de resultados da análise.
|
||||
pub fn view<'a>(app: &'a App, resultado: &'a ResultadoAnalise) -> Element<'a, Message> {
|
||||
let titulo = text("Resultado da Análise").size(22);
|
||||
|
||||
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);
|
||||
// Botões de ação superiores
|
||||
let botoes_topo = row![
|
||||
button("< Nova Análise").on_press(Message::NovaAnalise), // abre modal de confirmação
|
||||
button("⚙ Reconfigurar Colunas").on_press(Message::IrParaConfiguracaoColunas),
|
||||
button("🔄 Reanalisar Arquivo").on_press_maybe(
|
||||
app.caminho_arquivo
|
||||
.as_ref()
|
||||
.map(|_| Message::ReanalisarArquivo)
|
||||
),
|
||||
button("📄 Exportar PDF").on_press(Message::ExportarPdf),
|
||||
]
|
||||
.spacing(8);
|
||||
|
||||
// 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;
|
||||
}
|
||||
}
|
||||
});
|
||||
let opcoes_por_pagina = row(OPCOES_PAGINA
|
||||
.iter()
|
||||
.map(|&n| {
|
||||
button(text(n.to_string()).size(13))
|
||||
.on_press(Message::ItensPorPaginaAlterado(n))
|
||||
.into()
|
||||
})
|
||||
.collect::<Vec<_>>())
|
||||
.spacing(4);
|
||||
|
||||
ui.add_space(12.0);
|
||||
ui.separator();
|
||||
let controle_pagina = row![text("Itens por página:").size(13), opcoes_por_pagina,]
|
||||
.spacing(8)
|
||||
.align_y(Alignment::Center);
|
||||
|
||||
egui::ScrollArea::vertical().show(ui, |ui| {
|
||||
// Faltantes
|
||||
renderizar_faltantes(ui, app, &resultado);
|
||||
// Seções principais
|
||||
let secao_faltantes = view_faltantes(app, resultado);
|
||||
let secao_duplicatas = view_duplicatas(app, resultado);
|
||||
let secao_totais = view_totais(resultado);
|
||||
|
||||
ui.add_space(12.0);
|
||||
ui.separator();
|
||||
let conteudo = column![
|
||||
titulo,
|
||||
botoes_topo,
|
||||
controle_pagina,
|
||||
secao_faltantes,
|
||||
secao_duplicatas,
|
||||
secao_totais,
|
||||
]
|
||||
.spacing(16)
|
||||
.padding(16)
|
||||
.width(Length::Fill);
|
||||
|
||||
// Duplicatas
|
||||
renderizar_duplicatas(ui, app, &resultado);
|
||||
|
||||
ui.add_space(12.0);
|
||||
ui.separator();
|
||||
|
||||
// Totais
|
||||
renderizar_totais(ui, &resultado);
|
||||
});
|
||||
container(scrollable(conteudo))
|
||||
.width(Length::Fill)
|
||||
.height(Length::Fill)
|
||||
.into()
|
||||
}
|
||||
|
||||
fn renderizar_totais(ui: &mut Ui, resultado: &ResultadoAnalise) {
|
||||
ui.label(egui::RichText::new("Totais").heading().strong());
|
||||
ui.add_space(4.0);
|
||||
fn view_faltantes<'a>(app: &'a App, resultado: &'a ResultadoAnalise) -> Element<'a, Message> {
|
||||
let total = resultado.total_faltantes();
|
||||
let mut col = column![text(format!("Notas Faltantes ({} total)", total)).size(18),].spacing(8);
|
||||
|
||||
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();
|
||||
ui.label(
|
||||
egui::RichText::new(format!("Notas Faltantes ({} total)", total_faltantes))
|
||||
.heading()
|
||||
.strong(),
|
||||
);
|
||||
ui.add_space(4.0);
|
||||
|
||||
if total_faltantes == 0 {
|
||||
ui.label("✔ Nenhuma nota faltante.");
|
||||
return;
|
||||
if total == 0 {
|
||||
col = col.push(text("✔ Nenhuma nota faltante.").size(14));
|
||||
return col.into();
|
||||
}
|
||||
|
||||
let mut chaves: Vec<&ChaveSerie> = resultado.faltantes_por_serie.keys().collect();
|
||||
@@ -133,82 +82,63 @@ fn renderizar_faltantes(ui: &mut Ui, app: &mut App, resultado: &ResultadoAnalise
|
||||
continue;
|
||||
}
|
||||
|
||||
// Estatística de completude por série
|
||||
let total_notas = resultado.total_por_serie.get(chave).copied().unwrap_or(0);
|
||||
let total_esperado = total_notas + faltantes.len();
|
||||
let percentual = total_notas as f64 / total_esperado as f64 * 100.0;
|
||||
|
||||
ui.horizontal(|ui| {
|
||||
ui.label(format!(
|
||||
let cabecalho_serie = row![
|
||||
text(format!(
|
||||
"Série {} — {} faltante(s) — {}/{} notas ({:.1}% completo):",
|
||||
chave.label(),
|
||||
faltantes.len(),
|
||||
total_notas,
|
||||
total_esperado,
|
||||
percentual,
|
||||
));
|
||||
if ui
|
||||
.button("📋 Copiar")
|
||||
.on_hover_text("Copiar todos os números faltantes")
|
||||
.clicked()
|
||||
{
|
||||
let texto = faltantes
|
||||
.iter()
|
||||
.map(|n| n.to_string())
|
||||
.collect::<Vec<_>>()
|
||||
.join(", ");
|
||||
ui.ctx().copy_text(texto);
|
||||
}
|
||||
});
|
||||
))
|
||||
.size(14)
|
||||
.width(Length::Fill),
|
||||
button("📋 Copiar").on_press(Message::CopiarFaltantes(chave.clone())),
|
||||
]
|
||||
.spacing(8)
|
||||
.align_y(Alignment::Center);
|
||||
|
||||
// Paginação (por faltante individual)
|
||||
col = col.push(cabecalho_serie);
|
||||
|
||||
// Paginação
|
||||
let total_paginas = (faltantes.len() + app.itens_por_pagina - 1) / app.itens_por_pagina;
|
||||
if app.pagina_faltantes >= total_paginas {
|
||||
app.pagina_faltantes = 0;
|
||||
}
|
||||
|
||||
let inicio = app.pagina_faltantes * app.itens_por_pagina;
|
||||
let pagina = app.pagina_faltantes.min(total_paginas.saturating_sub(1));
|
||||
let inicio = pagina * app.itens_por_pagina;
|
||||
let fim = (inicio + app.itens_por_pagina).min(faltantes.len());
|
||||
|
||||
// Exibir grupos contíguos da página atual
|
||||
for (a, b) in agrupar_contiguos(&faltantes[inicio..fim]) {
|
||||
if a == b {
|
||||
ui.label(format!(" • {}", a));
|
||||
col = col.push(text(format!(" • {}", a)).size(13));
|
||||
} else {
|
||||
ui.label(format!(" • {}–{} ({} notas)", a, b, b - a + 1));
|
||||
col = col.push(text(format!(" • {}–{} ({} notas)", a, b, b - a + 1)).size(13));
|
||||
}
|
||||
}
|
||||
|
||||
if total_paginas > 1 {
|
||||
ui.horizontal(|ui| {
|
||||
if ui.button("◀").clicked() && app.pagina_faltantes > 0 {
|
||||
app.pagina_faltantes -= 1;
|
||||
}
|
||||
ui.label(format!(
|
||||
"Página {} / {}",
|
||||
app.pagina_faltantes + 1,
|
||||
total_paginas
|
||||
col = col.push(crate::ui::components::paginacao::controles_paginacao(
|
||||
pagina,
|
||||
total_paginas,
|
||||
Message::PaginaFaltantesAlterada(pagina.saturating_sub(1)),
|
||||
Message::PaginaFaltantesAlterada(pagina + 1),
|
||||
));
|
||||
if ui.button("▶").clicked() && app.pagina_faltantes + 1 < total_paginas {
|
||||
app.pagina_faltantes += 1;
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn renderizar_duplicatas(ui: &mut Ui, app: &mut App, resultado: &ResultadoAnalise) {
|
||||
let total_dup = resultado.total_duplicatas();
|
||||
ui.label(
|
||||
egui::RichText::new(format!("Notas Duplicadas ({} grupo(s))", total_dup))
|
||||
.heading()
|
||||
.strong(),
|
||||
);
|
||||
ui.add_space(4.0);
|
||||
col.into()
|
||||
}
|
||||
|
||||
if total_dup == 0 {
|
||||
ui.label("✔ Nenhuma nota duplicada.");
|
||||
return;
|
||||
fn view_duplicatas<'a>(app: &'a App, resultado: &'a ResultadoAnalise) -> Element<'a, Message> {
|
||||
let total = resultado.total_duplicatas();
|
||||
let mut col =
|
||||
column![text(format!("Notas Duplicadas ({} grupo(s))", total)).size(18),].spacing(8);
|
||||
|
||||
if total == 0 {
|
||||
col = col.push(text("✔ Nenhuma nota duplicada.").size(14));
|
||||
return col.into();
|
||||
}
|
||||
|
||||
let mut chaves: Vec<&ChaveSerie> = resultado.duplicadas_por_serie.keys().collect();
|
||||
@@ -220,90 +150,78 @@ fn renderizar_duplicatas(ui: &mut Ui, app: &mut App, resultado: &ResultadoAnalis
|
||||
continue;
|
||||
}
|
||||
|
||||
ui.horizontal(|ui| {
|
||||
ui.label(format!(
|
||||
let cabecalho_serie = row![
|
||||
text(format!(
|
||||
"Série {} — {} grupo(s) duplicado(s):",
|
||||
chave.label(),
|
||||
duplicatas.len()
|
||||
));
|
||||
if ui
|
||||
.button("📋 Copiar")
|
||||
.on_hover_text("Copiar números duplicados")
|
||||
.clicked()
|
||||
{
|
||||
let texto = duplicatas
|
||||
.iter()
|
||||
.map(|(n, c)| format!("{} ({}x)", n, c))
|
||||
.collect::<Vec<_>>()
|
||||
.join(", ");
|
||||
ui.ctx().copy_text(texto);
|
||||
}
|
||||
});
|
||||
))
|
||||
.size(14)
|
||||
.width(Length::Fill),
|
||||
button("📋 Copiar").on_press(Message::CopiarDuplicatas(chave.clone())),
|
||||
]
|
||||
.spacing(8)
|
||||
.align_y(Alignment::Center);
|
||||
|
||||
col = col.push(cabecalho_serie);
|
||||
|
||||
let total_paginas = (duplicatas.len() + app.itens_por_pagina - 1) / app.itens_por_pagina;
|
||||
if app.pagina_duplicatas >= total_paginas {
|
||||
app.pagina_duplicatas = 0;
|
||||
}
|
||||
|
||||
let inicio = app.pagina_duplicatas * app.itens_por_pagina;
|
||||
let pagina = app.pagina_duplicatas.min(total_paginas.saturating_sub(1));
|
||||
let inicio = pagina * app.itens_por_pagina;
|
||||
let fim = (inicio + app.itens_por_pagina).min(duplicatas.len());
|
||||
|
||||
for (numero, count) in &duplicatas[inicio..fim] {
|
||||
ui.label(format!(
|
||||
col = col.push(
|
||||
text(format!(
|
||||
" • NF {} / Série {} — {} ocorrências",
|
||||
numero,
|
||||
chave.label(),
|
||||
count
|
||||
));
|
||||
))
|
||||
.size(13),
|
||||
);
|
||||
}
|
||||
|
||||
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
|
||||
col = col.push(crate::ui::components::paginacao::controles_paginacao(
|
||||
pagina,
|
||||
total_paginas,
|
||||
Message::PaginaDuplicatasAlterada(pagina.saturating_sub(1)),
|
||||
Message::PaginaDuplicatasAlterada(pagina + 1),
|
||||
));
|
||||
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())
|
||||
};
|
||||
col.into()
|
||||
}
|
||||
|
||||
match exportar_pdf(
|
||||
&gerador,
|
||||
resultado,
|
||||
&app.nome_arquivo,
|
||||
nome_layout,
|
||||
&caminho,
|
||||
) {
|
||||
Ok(_) => {
|
||||
app.exibir_aviso(
|
||||
"Sucesso",
|
||||
format!("PDF exportado para: {}", caminho.display()),
|
||||
fn view_totais(resultado: &ResultadoAnalise) -> Element<'_, Message> {
|
||||
let mut col = column![text("Totais").size(18)].spacing(4);
|
||||
|
||||
col = col.push(
|
||||
text(format!(
|
||||
"Total Geral: R$ {}",
|
||||
formatar_valor_br(&resultado.soma_total)
|
||||
))
|
||||
.size(14),
|
||||
);
|
||||
|
||||
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);
|
||||
col = col.push(
|
||||
text(format!(
|
||||
" Série {}: {} nota(s) — R$ {}",
|
||||
chave.label(),
|
||||
total_notas,
|
||||
formatar_valor_br(soma)
|
||||
))
|
||||
.size(13),
|
||||
);
|
||||
}
|
||||
Err(e) => {
|
||||
app.exibir_erro(format!("Erro ao exportar PDF: {}", e));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
col.into()
|
||||
}
|
||||
|
||||
@@ -0,0 +1,79 @@
|
||||
use crate::ui::app::App;
|
||||
use crate::ui::message::Message;
|
||||
use iced::widget::{button, column, container, row, scrollable, text};
|
||||
use iced::{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 titulo = text("Selecionar Aba da Planilha").size(22);
|
||||
|
||||
let arquivo_label = text(format!("Arquivo: {}", caminho.display())).size(13);
|
||||
|
||||
// Lista de abas como botões seleccionáveis
|
||||
let aba_atual = &app.layout_xlsx_atual.aba;
|
||||
let lista_abas = column(
|
||||
abas.iter()
|
||||
.map(|aba| {
|
||||
let selecionada = aba == aba_atual;
|
||||
let btn = if selecionada {
|
||||
button(text(aba).size(14))
|
||||
.on_press(Message::AbaSelecionada(aba.clone()))
|
||||
.width(Length::Fill)
|
||||
} else {
|
||||
button(text(aba).size(14))
|
||||
.on_press(Message::AbaSelecionada(aba.clone()))
|
||||
.width(Length::Fill)
|
||||
};
|
||||
btn.into()
|
||||
})
|
||||
.collect::<Vec<_>>(),
|
||||
)
|
||||
.spacing(4)
|
||||
.width(Length::Fill);
|
||||
|
||||
// Pré-visualização da aba selecionada
|
||||
let preview_section: Element<Message> = if !aba_atual.is_empty() {
|
||||
if let Some(linhas) = &app.preview_arquivo {
|
||||
crate::ui::components::tabela_preview::tabela_preview(linhas)
|
||||
} else {
|
||||
text("(sem pré-visualização)").size(12).into()
|
||||
}
|
||||
} else {
|
||||
text("Selecione uma aba para pré-visualizar.")
|
||||
.size(12)
|
||||
.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("< Voltar").on_press(Message::Voltar)].spacing(8);
|
||||
|
||||
if aba_selecionada && tem_preset {
|
||||
botoes = botoes.push(button("▶ Processar").on_press(Message::ExecutarImportacao));
|
||||
}
|
||||
|
||||
if aba_selecionada {
|
||||
botoes = botoes
|
||||
.push(button("⚙ Configurar Colunas").on_press(Message::IrParaConfiguracaoColunas));
|
||||
}
|
||||
|
||||
let content = column![
|
||||
titulo,
|
||||
arquivo_label,
|
||||
text("Selecione a aba a processar:").size(14),
|
||||
scrollable(lista_abas).height(Length::Fixed(200.0)),
|
||||
preview_section,
|
||||
botoes,
|
||||
]
|
||||
.spacing(12)
|
||||
.padding(16)
|
||||
.width(Length::Fill);
|
||||
|
||||
container(content)
|
||||
.width(Length::Fill)
|
||||
.height(Length::Fill)
|
||||
.into()
|
||||
}
|
||||
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