feat: implement main application structure with UI and layout management
- Added main application logic in `src/ui/app.rs` to handle state and modal dialogs. - Created module structure for UI components in `src/ui/mod.rs`. - Implemented column configuration screen in `src/ui/screens/configuracao_colunas.rs`. - Developed file import screen in `src/ui/screens/import.rs` for CSV and XLSX files. - Added layout management screen in `src/ui/screens/layouts.rs` for saving and importing layouts. - Created result display screen in `src/ui/screens/resultado.rs` to show analysis results. - Introduced modular organization for screens in `src/ui/screens/mod.rs`.
This commit is contained in:
@@ -0,0 +1,295 @@
|
||||
use crate::application::usecases::importar_arquivo::{importar_csv, importar_xlsx};
|
||||
use crate::domain::entities::layout::TipoArquivo;
|
||||
use crate::ui::app::{App, EstadoApp};
|
||||
use egui::{Context, Ui};
|
||||
|
||||
/// 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);
|
||||
|
||||
if let Some(caminho) = &app.caminho_arquivo.clone() {
|
||||
ui.label(format!("Arquivo: {}", caminho.display()));
|
||||
}
|
||||
|
||||
ui.add_space(12.0);
|
||||
|
||||
match app.tipo_arquivo_atual.clone() {
|
||||
TipoArquivo::Csv => renderizar_csv(ui, app),
|
||||
TipoArquivo::Xlsx => renderizar_xlsx(ui, app),
|
||||
}
|
||||
|
||||
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);
|
||||
|
||||
if !erros.is_empty() {
|
||||
for erro in &erros {
|
||||
ui.colored_label(egui::Color32::RED, format!("⚠ {}", erro));
|
||||
}
|
||||
ui.add_space(8.0);
|
||||
}
|
||||
|
||||
ui.horizontal(|ui| {
|
||||
if ui.button("← Voltar").clicked() {
|
||||
app.estado = EstadoApp::Importando;
|
||||
}
|
||||
|
||||
ui.add_enabled_ui(valido && app.caminho_arquivo.is_some(), |ui| {
|
||||
if ui.button("▶ Importar e Analisar").clicked() {
|
||||
executar_importacao(app);
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
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 {
|
||||
',' => "Vírgula (,)",
|
||||
';' => "Ponto e vírgula (;)",
|
||||
'\t' => "Tabulação (Tab)",
|
||||
_ => "Outro",
|
||||
};
|
||||
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 = ',';
|
||||
}
|
||||
if ui.selectable_label(app.layout_csv_atual.delimitador == ';', "Ponto e vírgula (;)").clicked() {
|
||||
app.layout_csv_atual.delimitador = ';';
|
||||
}
|
||||
if ui.selectable_label(app.layout_csv_atual.delimitador == '\t', "Tabulação (Tab)").clicked() {
|
||||
app.layout_csv_atual.delimitador = '\t';
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// Encoding
|
||||
ui.horizontal(|ui| {
|
||||
ui.label("Encoding:");
|
||||
egui::ComboBox::from_id_salt("combo_encoding")
|
||||
.selected_text(&app.layout_csv_atual.encoding)
|
||||
.show_ui(ui, |ui| {
|
||||
if ui.selectable_label(app.layout_csv_atual.encoding == "utf-8", "UTF-8").clicked() {
|
||||
app.layout_csv_atual.encoding = "utf-8".to_string();
|
||||
}
|
||||
if ui.selectable_label(app.layout_csv_atual.encoding == "windows-1252", "Windows-1252 (Latin-1)").clicked() {
|
||||
app.layout_csv_atual.encoding = "windows-1252".to_string();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// Linha cabeçalho
|
||||
ui.horizontal(|ui| {
|
||||
ui.label("Linha do cabeçalho (0 = sem cabeçalho):");
|
||||
let mut val = app.layout_csv_atual.linha_cabecalho;
|
||||
ui.add(egui::DragValue::new(&mut val).range(0..=100));
|
||||
app.layout_csv_atual.linha_cabecalho = val;
|
||||
});
|
||||
});
|
||||
|
||||
ui.add_space(8.0);
|
||||
ui.group(|ui| {
|
||||
ui.label("Mapeamento de Colunas (índice base 0)");
|
||||
ui.add_space(4.0);
|
||||
|
||||
campo_indice(ui, "Número (obrigatório):", &mut app.layout_csv_atual.indice_numero);
|
||||
campo_indice(ui, "Série (obrigatório):", &mut app.layout_csv_atual.indice_serie);
|
||||
|
||||
campo_indice_opcional(ui, "Valor (opcional):", &mut app.layout_csv_atual.indice_valor);
|
||||
campo_indice_opcional(ui, "Data (opcional):", &mut app.layout_csv_atual.indice_data);
|
||||
});
|
||||
}
|
||||
|
||||
fn renderizar_xlsx(ui: &mut Ui, app: &mut App) {
|
||||
ui.group(|ui| {
|
||||
ui.label("Configurações XLSX");
|
||||
ui.add_space(4.0);
|
||||
|
||||
ui.horizontal(|ui| {
|
||||
ui.label("Aba:");
|
||||
if app.abas_xlsx.is_empty() {
|
||||
ui.text_edit_singleline(&mut app.layout_xlsx_atual.aba);
|
||||
} else {
|
||||
let aba_atual = app.layout_xlsx_atual.aba.clone();
|
||||
egui::ComboBox::from_id_salt("combo_aba")
|
||||
.selected_text(&aba_atual)
|
||||
.show_ui(ui, |ui| {
|
||||
for aba in &app.abas_xlsx.clone() {
|
||||
if ui.selectable_label(aba_atual == *aba, aba).clicked() {
|
||||
app.layout_xlsx_atual.aba = aba.clone();
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
ui.add_space(8.0);
|
||||
ui.group(|ui| {
|
||||
ui.label("Mapeamento de Colunas (formato LetraLinha, ex: B3)");
|
||||
ui.add_space(4.0);
|
||||
|
||||
campo_letra_linha(ui, "Número (obrigatório):", &mut app.layout_xlsx_atual.pos_numero);
|
||||
campo_letra_linha(ui, "Série (obrigatório):", &mut app.layout_xlsx_atual.pos_serie);
|
||||
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);
|
||||
});
|
||||
}
|
||||
|
||||
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_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(ui: &mut Ui, label: &str, valor: &mut String) {
|
||||
ui.horizontal(|ui| {
|
||||
ui.label(label);
|
||||
ui.text_edit_singleline(valor);
|
||||
});
|
||||
}
|
||||
|
||||
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 let Some(v) = valor {
|
||||
ui.text_edit_singleline(v);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/// Valida a configuração atual. Retorna (é_válido, lista_de_erros).
|
||||
fn validar_config(app: &App) -> (bool, Vec<String>) {
|
||||
let mut erros = Vec::new();
|
||||
|
||||
match &app.tipo_arquivo_atual {
|
||||
TipoArquivo::Csv => {
|
||||
let c = &app.layout_csv_atual;
|
||||
// Verificar índices duplicados
|
||||
let mut indices: Vec<(String, usize)> = vec![
|
||||
("Numero".to_string(), c.indice_numero),
|
||||
("Serie".to_string(), c.indice_serie),
|
||||
];
|
||||
if let Some(v) = c.indice_valor {
|
||||
indices.push(("Valor".to_string(), v));
|
||||
}
|
||||
if let Some(d) = c.indice_data {
|
||||
indices.push(("Data".to_string(), d));
|
||||
}
|
||||
verificar_duplicados(&indices, &mut erros);
|
||||
}
|
||||
TipoArquivo::Xlsx => {
|
||||
let c = &app.layout_xlsx_atual;
|
||||
if c.aba.trim().is_empty() {
|
||||
erros.push("Selecione uma aba".to_string());
|
||||
}
|
||||
if c.pos_numero.trim().is_empty() {
|
||||
erros.push("Posição do campo Numero é obrigatória".to_string());
|
||||
} else if crate::infrastructure::xlsx_reader::parsear_letra_linha(&c.pos_numero)
|
||||
.is_none()
|
||||
{
|
||||
erros.push(format!("Posição Numero inválida: '{}'", c.pos_numero));
|
||||
}
|
||||
if c.pos_serie.trim().is_empty() {
|
||||
erros.push("Posição do campo Serie é obrigatória".to_string());
|
||||
} else if crate::infrastructure::xlsx_reader::parsear_letra_linha(&c.pos_serie)
|
||||
.is_none()
|
||||
{
|
||||
erros.push(format!("Posição Serie inválida: '{}'", c.pos_serie));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let valido = erros.is_empty();
|
||||
(valido, erros)
|
||||
}
|
||||
|
||||
fn verificar_duplicados(indices: &[(String, usize)], erros: &mut Vec<String>) {
|
||||
for i in 0..indices.len() {
|
||||
for j in (i + 1)..indices.len() {
|
||||
if indices[i].1 == indices[j].1 {
|
||||
erros.push(format!(
|
||||
"Campos '{}' e '{}' mapeados para o mesmo índice {}",
|
||||
indices[i].0, indices[j].0, indices[i].1
|
||||
));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn executar_importacao(app: &mut App) {
|
||||
let caminho = match &app.caminho_arquivo {
|
||||
Some(p) => p.clone(),
|
||||
None => return,
|
||||
};
|
||||
|
||||
let resultado = match app.tipo_arquivo_atual.clone() {
|
||||
TipoArquivo::Csv => {
|
||||
importar_csv(&caminho, &app.layout_csv_atual)
|
||||
.map_err(|e| e.to_string())
|
||||
}
|
||||
TipoArquivo::Xlsx => {
|
||||
importar_xlsx(&caminho, &app.layout_xlsx_atual)
|
||||
.map_err(|e| e.to_string())
|
||||
}
|
||||
};
|
||||
|
||||
match resultado {
|
||||
Ok(res) => {
|
||||
if res.notas.is_empty() {
|
||||
app.exibir_aviso("Aviso", "Nenhuma nota válida encontrada no arquivo.");
|
||||
return;
|
||||
}
|
||||
|
||||
let avisos = res.avisos.clone();
|
||||
app.notas_importadas = res.notas;
|
||||
app.avisos_importacao = if avisos.tem_avisos() {
|
||||
Some(avisos.clone())
|
||||
} else {
|
||||
None
|
||||
};
|
||||
|
||||
// Executar análise
|
||||
app.executar_analise();
|
||||
|
||||
// Exibir avisos consolidados após análise
|
||||
if let Some(av) = &app.avisos_importacao {
|
||||
if av.tem_avisos() {
|
||||
let linhas = av.linhas_para_exibir().join("\n");
|
||||
app.exibir_aviso("Avisos de Importação", linhas);
|
||||
}
|
||||
}
|
||||
}
|
||||
Err(e) => {
|
||||
app.exibir_erro(format!("Erro ao importar arquivo: {}", e));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,169 @@
|
||||
use crate::application::usecases::importar_arquivo::{importar_csv, importar_xlsx, listar_abas_xlsx};
|
||||
use crate::domain::entities::layout::TipoArquivo;
|
||||
use crate::ui::app::{App, EstadoApp};
|
||||
use egui::{Context, Ui};
|
||||
use std::path::PathBuf;
|
||||
|
||||
/// Renderiza a tela de importação de arquivos.
|
||||
pub fn renderizar(ui: &mut Ui, _ctx: &Context, app: &mut App) {
|
||||
ui.heading("Comparador de Notas — Importar Arquivo");
|
||||
ui.add_space(16.0);
|
||||
|
||||
// --- Seleção de arquivo ---
|
||||
ui.group(|ui| {
|
||||
ui.label("Arquivo:");
|
||||
ui.horizontal(|ui| {
|
||||
let nome = if app.nome_arquivo.is_empty() {
|
||||
"Nenhum arquivo selecionado".to_string()
|
||||
} 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"])
|
||||
.pick_file()
|
||||
{
|
||||
on_arquivo_selecionado(app, caminho);
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
ui.add_space(8.0);
|
||||
|
||||
// Coletar infos dos layouts antecipadamente para evitar borrow duplo
|
||||
let tipo_atual = app.tipo_arquivo_atual.clone();
|
||||
let opcoes_layout: Vec<(i64, String)> = app
|
||||
.layouts_salvos
|
||||
.iter()
|
||||
.filter(|l| l.tipo() == tipo_atual)
|
||||
.filter_map(|l| l.id().map(|id| (id, 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 —"
|
||||
} 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,
|
||||
};
|
||||
|
||||
ui.label(format!("Arquivo: {}", caminho.display()));
|
||||
ui.add_space(8.0);
|
||||
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();
|
||||
}
|
||||
}
|
||||
|
||||
ui.add_space(12.0);
|
||||
if !app.layout_xlsx_atual.aba.is_empty() {
|
||||
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, 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);
|
||||
app.notas_importadas.clear();
|
||||
}
|
||||
"xlsx" => {
|
||||
app.tipo_arquivo_atual = TipoArquivo::Xlsx;
|
||||
match listar_abas_xlsx(&caminho) {
|
||||
Ok(info) => {
|
||||
app.abas_xlsx = info.abas.clone();
|
||||
app.estado = EstadoApp::SelecionandoAba {
|
||||
abas: info.abas,
|
||||
caminho: caminho.clone(),
|
||||
};
|
||||
app.notas_importadas.clear();
|
||||
}
|
||||
Err(e) => {
|
||||
app.exibir_erro(format!("Erro ao ler abas do arquivo: {}", e));
|
||||
}
|
||||
}
|
||||
}
|
||||
_ => {
|
||||
app.exibir_erro("Formato de arquivo não suportado. Use CSV ou XLSX.");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn aplicar_layout(app: &mut App, layout: &crate::domain::entities::layout::Layout) {
|
||||
match layout {
|
||||
crate::domain::entities::layout::Layout::Csv { config, .. } => {
|
||||
app.layout_csv_atual = config.clone();
|
||||
app.tipo_arquivo_atual = TipoArquivo::Csv;
|
||||
}
|
||||
crate::domain::entities::layout::Layout::Xlsx { config, .. } => {
|
||||
app.layout_xlsx_atual = config.clone();
|
||||
app.tipo_arquivo_atual = TipoArquivo::Xlsx;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,213 @@
|
||||
use crate::application::usecases::layouts::{
|
||||
exportar_layout_json, importar_layout_json, salvar_layout,
|
||||
};
|
||||
use crate::domain::entities::layout::{Layout, LayoutCsv, LayoutXlsx, TipoArquivo};
|
||||
use crate::domain::errors::ErroLayout;
|
||||
use crate::ui::app::{AcaoModal, App, EstadoApp};
|
||||
use egui::{Context, Ui};
|
||||
|
||||
/// 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);
|
||||
|
||||
ui.horizontal(|ui| {
|
||||
if ui.button("← Voltar").clicked() {
|
||||
app.estado = EstadoApp::Importando;
|
||||
}
|
||||
});
|
||||
|
||||
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);
|
||||
|
||||
// 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(
|
||||
ui: &mut Ui,
|
||||
ctx: &Context,
|
||||
app: &mut App,
|
||||
titulo: &str,
|
||||
layouts: &[Layout],
|
||||
) {
|
||||
ui.label(egui::RichText::new(titulo).strong());
|
||||
ui.add_space(4.0);
|
||||
|
||||
if layouts.is_empty() {
|
||||
ui.label("(nenhum layout salvo)");
|
||||
return;
|
||||
}
|
||||
|
||||
for layout in layouts {
|
||||
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 ui.button("🗑 Excluir").clicked() {
|
||||
app.exibir_confirmacao(
|
||||
"Confirmar exclusão",
|
||||
format!("Deseja excluir o layout '{}'?", layout.nome()),
|
||||
AcaoModal::ConfirmarExclusaoLayout(id),
|
||||
);
|
||||
}
|
||||
|
||||
// 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));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 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(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)) => {
|
||||
// Exibir opções: sobrescrever ou cancelar
|
||||
app.exibir_aviso(
|
||||
"Conflito de nome",
|
||||
format!(
|
||||
"Já existe um layout com o nome '{}'. Use 'Salvar com novo nome' ou cancele a importação.",
|
||||
nome
|
||||
),
|
||||
);
|
||||
// TODO: implementar fluxo completo de sobrescrever com entrada de novo 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
pub mod configuracao_colunas;
|
||||
pub mod import;
|
||||
pub mod layouts;
|
||||
pub mod resultado;
|
||||
@@ -0,0 +1,259 @@
|
||||
use crate::application::usecases::exportar_pdf::exportar_pdf;
|
||||
use crate::domain::{
|
||||
entities::resultado_analise::ResultadoAnalise,
|
||||
services::parser_monetario::formatar_valor_br,
|
||||
};
|
||||
use crate::infrastructure::pdf_generator::GenpdfGenerator;
|
||||
use crate::ui::app::{App, EstadoApp};
|
||||
use egui::{Context, Ui};
|
||||
|
||||
const OPCOES_PAGINA: &[usize] = &[50, 100, 200, 1000];
|
||||
|
||||
/// Renderiza a tela de resultados.
|
||||
pub fn renderizar(ui: &mut Ui, ctx: &Context, app: &mut App) {
|
||||
// Extrair resultado do estado (sem mover)
|
||||
let resultado = match &app.estado {
|
||||
EstadoApp::ExibindoResultado(r) => r.clone(),
|
||||
_ => return,
|
||||
};
|
||||
|
||||
ui.heading("Resultado da Análise");
|
||||
ui.add_space(8.0);
|
||||
|
||||
ui.horizontal(|ui| {
|
||||
if ui.button("← Nova Análise").clicked() {
|
||||
app.estado = EstadoApp::Importando;
|
||||
app.notas_importadas.clear();
|
||||
return;
|
||||
}
|
||||
|
||||
if ui.button("⚙ Reconfigurar Colunas").clicked() {
|
||||
app.estado = EstadoApp::ConfigurandoColunas;
|
||||
return;
|
||||
}
|
||||
|
||||
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| {
|
||||
// Totais
|
||||
renderizar_totais(ui, &resultado);
|
||||
|
||||
ui.add_space(12.0);
|
||||
ui.separator();
|
||||
|
||||
// Faltantes
|
||||
renderizar_faltantes(ui, app, &resultado);
|
||||
|
||||
ui.add_space(12.0);
|
||||
ui.separator();
|
||||
|
||||
// Duplicatas
|
||||
renderizar_duplicatas(ui, app, &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 series: Vec<&String> = resultado.soma_por_serie.keys().collect();
|
||||
series.sort();
|
||||
|
||||
for serie in series {
|
||||
let soma = &resultado.soma_por_serie[serie];
|
||||
let total_notas = resultado.total_por_serie.get(serie).copied().unwrap_or(0);
|
||||
ui.label(format!(
|
||||
" Série {}: {} nota(s) — R$ {}",
|
||||
serie,
|
||||
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;
|
||||
}
|
||||
|
||||
let mut series: Vec<&String> = resultado.faltantes_por_serie.keys().collect();
|
||||
series.sort();
|
||||
|
||||
for serie in series {
|
||||
let faltantes = &resultado.faltantes_por_serie[serie];
|
||||
if faltantes.is_empty() {
|
||||
continue;
|
||||
}
|
||||
|
||||
ui.label(format!(
|
||||
"Série {} — {} faltante(s):",
|
||||
serie,
|
||||
faltantes.len()
|
||||
));
|
||||
|
||||
// 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 fim = (inicio + app.itens_por_pagina).min(faltantes.len());
|
||||
|
||||
for numero in &faltantes[inicio..fim] {
|
||||
ui.label(format!(" • {}", numero));
|
||||
}
|
||||
|
||||
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
|
||||
));
|
||||
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);
|
||||
|
||||
if total_dup == 0 {
|
||||
ui.label("✔ Nenhuma nota duplicada.");
|
||||
return;
|
||||
}
|
||||
|
||||
let mut series: Vec<&String> = resultado.duplicadas_por_serie.keys().collect();
|
||||
series.sort();
|
||||
|
||||
for serie in series {
|
||||
let duplicatas = &resultado.duplicadas_por_serie[serie];
|
||||
if duplicatas.is_empty() {
|
||||
continue;
|
||||
}
|
||||
|
||||
ui.label(format!(
|
||||
"Série {} — {} grupo(s) duplicado(s):",
|
||||
serie,
|
||||
duplicatas.len()
|
||||
));
|
||||
|
||||
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 fim = (inicio + app.itens_por_pagina).min(duplicatas.len());
|
||||
|
||||
for (numero, count) in &duplicatas[inicio..fim] {
|
||||
ui.label(format!(
|
||||
" • NF {} / Série {} — {} ocorrências",
|
||||
numero, serie, count
|
||||
));
|
||||
}
|
||||
|
||||
if total_paginas > 1 {
|
||||
ui.horizontal(|ui| {
|
||||
if ui.button("◀").clicked() && app.pagina_duplicatas > 0 {
|
||||
app.pagina_duplicatas -= 1;
|
||||
}
|
||||
ui.label(format!(
|
||||
"Página {} / {}",
|
||||
app.pagina_duplicatas + 1,
|
||||
total_paginas
|
||||
));
|
||||
if ui.button("▶").clicked() && app.pagina_duplicatas + 1 < total_paginas {
|
||||
app.pagina_duplicatas += 1;
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn exportar_para_pdf(app: &mut App, resultado: &ResultadoAnalise) {
|
||||
if let Some(caminho) = rfd::FileDialog::new()
|
||||
.set_file_name("relatorio.pdf")
|
||||
.add_filter("PDF", &["pdf"])
|
||||
.save_file()
|
||||
{
|
||||
let gerador = GenpdfGenerator;
|
||||
let nome_layout = if app.nome_layout_atual.is_empty() {
|
||||
None
|
||||
} else {
|
||||
Some(app.nome_layout_atual.as_str())
|
||||
};
|
||||
|
||||
match exportar_pdf(
|
||||
&gerador,
|
||||
resultado,
|
||||
&app.nome_arquivo,
|
||||
nome_layout,
|
||||
&caminho,
|
||||
) {
|
||||
Ok(_) => {
|
||||
app.exibir_aviso("Sucesso", format!("PDF exportado para: {}", caminho.display()));
|
||||
}
|
||||
Err(e) => {
|
||||
app.exibir_erro(format!("Erro ao exportar PDF: {}", e));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user