Compare commits
5
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2c08f237e9 | ||
|
|
dedb6a72c2 | ||
|
|
41c75e5ed1 | ||
|
|
0f728b9684 | ||
|
|
b2b91cda55 |
@@ -88,3 +88,39 @@ pub fn ler_csv(
|
|||||||
avisos,
|
avisos,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Retorna as primeiras `n` linhas brutas do CSV (sem pular cabeçalho).
|
||||||
|
/// Usado exclusivamente para pré-visualização na UI.
|
||||||
|
pub fn preview_csv(
|
||||||
|
caminho: &Path,
|
||||||
|
delimitador: u8,
|
||||||
|
encoding: &str,
|
||||||
|
n: usize,
|
||||||
|
) -> Result<Vec<Vec<String>>, ErroArquivo> {
|
||||||
|
let bytes = std::fs::read(caminho)
|
||||||
|
.map_err(|e| ErroArquivo::ErroLeitura(e.to_string()))?;
|
||||||
|
|
||||||
|
let conteudo = match encoding.to_lowercase().as_str() {
|
||||||
|
"windows-1252" | "latin-1" | "iso-8859-1" => {
|
||||||
|
let (decoded, _, _) = WINDOWS_1252.decode(&bytes);
|
||||||
|
decoded.into_owned()
|
||||||
|
}
|
||||||
|
_ => String::from_utf8(bytes)
|
||||||
|
.map_err(|e| ErroArquivo::ErroLeitura(format!("Encoding inválido: {}", e)))?,
|
||||||
|
};
|
||||||
|
|
||||||
|
let mut reader = csv::ReaderBuilder::new()
|
||||||
|
.delimiter(delimitador)
|
||||||
|
.has_headers(false)
|
||||||
|
.flexible(true)
|
||||||
|
.from_reader(conteudo.as_bytes());
|
||||||
|
|
||||||
|
let linhas = reader
|
||||||
|
.records()
|
||||||
|
.take(n)
|
||||||
|
.filter_map(|r| r.ok())
|
||||||
|
.map(|r| r.iter().map(|s| s.to_string()).collect())
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
Ok(linhas)
|
||||||
|
}
|
||||||
|
|||||||
@@ -97,6 +97,53 @@ pub fn ler_xlsx(
|
|||||||
Ok(ResultadoXlsx { linhas, avisos })
|
Ok(ResultadoXlsx { linhas, avisos })
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Retorna as primeiras 5 linhas de uma aba XLSX, a partir da linha 1.
|
||||||
|
/// Usado exclusivamente para pré-visualização na UI.
|
||||||
|
pub fn preview_xlsx(
|
||||||
|
caminho: &Path,
|
||||||
|
nome_aba: &str,
|
||||||
|
) -> Result<Vec<Vec<String>>, ErroArquivo> {
|
||||||
|
verificar_tamanho(caminho)?;
|
||||||
|
|
||||||
|
let mut workbook = open_workbook_auto(caminho)
|
||||||
|
.map_err(|e| ErroArquivo::Corrompido(e.to_string()))?;
|
||||||
|
|
||||||
|
let range: calamine::Range<calamine::Data> = workbook
|
||||||
|
.worksheet_range(nome_aba)
|
||||||
|
.map_err(|e| ErroArquivo::Corrompido(e.to_string()))?;
|
||||||
|
|
||||||
|
let linhas = range
|
||||||
|
.rows()
|
||||||
|
.take(5)
|
||||||
|
.map(|row| {
|
||||||
|
row.iter()
|
||||||
|
.map(|cell| {
|
||||||
|
use calamine::Data;
|
||||||
|
match cell {
|
||||||
|
Data::Empty => String::new(),
|
||||||
|
Data::String(s) => s.clone(),
|
||||||
|
Data::Float(f) => {
|
||||||
|
if f.fract() == 0.0 {
|
||||||
|
format!("{}", *f as i64)
|
||||||
|
} else {
|
||||||
|
format!("{}", f)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Data::Int(i) => i.to_string(),
|
||||||
|
Data::Bool(b) => b.to_string(),
|
||||||
|
Data::DateTime(dt) => dt.to_string(),
|
||||||
|
Data::DateTimeIso(s) => s.clone(),
|
||||||
|
Data::DurationIso(s) => s.clone(),
|
||||||
|
Data::Error(_) => String::new(),
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.collect()
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
Ok(linhas)
|
||||||
|
}
|
||||||
|
|
||||||
/// Converte uma notação LetraLinha (ex: "B3") para (coluna_base0, linha_base1).
|
/// Converte uma notação LetraLinha (ex: "B3") para (coluna_base0, linha_base1).
|
||||||
///
|
///
|
||||||
/// Retorna `None` se a notação for inválida.
|
/// Retorna `None` se a notação for inválida.
|
||||||
|
|||||||
+241
-5
@@ -21,6 +21,7 @@ use crate::infrastructure::{
|
|||||||
use egui::Context;
|
use egui::Context;
|
||||||
use rusqlite::Connection;
|
use rusqlite::Connection;
|
||||||
use std::path::PathBuf;
|
use std::path::PathBuf;
|
||||||
|
use std::sync::mpsc;
|
||||||
|
|
||||||
/// Estado global da aplicação.
|
/// Estado global da aplicação.
|
||||||
pub enum EstadoApp {
|
pub enum EstadoApp {
|
||||||
@@ -39,6 +40,8 @@ pub enum EstadoApp {
|
|||||||
},
|
},
|
||||||
/// Gerenciamento de layouts.
|
/// Gerenciamento de layouts.
|
||||||
GerenciandoLayouts,
|
GerenciandoLayouts,
|
||||||
|
/// Análise em execução em background (thread separada).
|
||||||
|
Analisando,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Modal bloqueante a ser exibido sobre qualquer tela.
|
/// Modal bloqueante a ser exibido sobre qualquer tela.
|
||||||
@@ -50,6 +53,8 @@ pub struct Modal {
|
|||||||
pub tipo: TipoModal,
|
pub tipo: TipoModal,
|
||||||
/// Para modal de confirmação, a ação ao confirmar.
|
/// Para modal de confirmação, a ação ao confirmar.
|
||||||
pub acao_confirmacao: Option<AcaoModal>,
|
pub acao_confirmacao: Option<AcaoModal>,
|
||||||
|
/// Para modal com campo de texto (InputTexto).
|
||||||
|
pub input_texto: String,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Default, Clone, PartialEq)]
|
#[derive(Default, Clone, PartialEq)]
|
||||||
@@ -59,6 +64,8 @@ pub enum TipoModal {
|
|||||||
Aviso,
|
Aviso,
|
||||||
Erro,
|
Erro,
|
||||||
Confirmacao,
|
Confirmacao,
|
||||||
|
/// Modal com campo de texto para entrada do usuário.
|
||||||
|
InputTexto,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Clone)]
|
#[derive(Clone)]
|
||||||
@@ -66,6 +73,32 @@ pub enum AcaoModal {
|
|||||||
ConfirmarExpansaoFaltantes,
|
ConfirmarExpansaoFaltantes,
|
||||||
ConfirmarExclusaoLayout(i64),
|
ConfirmarExclusaoLayout(i64),
|
||||||
SobrescreverLayout,
|
SobrescreverLayout,
|
||||||
|
ConfirmarNovaAnalise,
|
||||||
|
/// Salvar a configuração atual como novo layout, usando modal.input_texto como nome.
|
||||||
|
SalvarLayoutConfig,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Resultado enviado pela thread de análise de volta para a UI.
|
||||||
|
pub enum ResultadoPendente {
|
||||||
|
/// Análise concluída com sucesso.
|
||||||
|
Concluido {
|
||||||
|
resultado: ResultadoAnalise,
|
||||||
|
/// Avisos de importação (None quando vier do path de expansão).
|
||||||
|
avisos: Option<ResumoAvisos>,
|
||||||
|
/// Notas importadas (None quando o App já as possui).
|
||||||
|
notas: Option<Vec<Nota>>,
|
||||||
|
},
|
||||||
|
/// Pré-análise concluída mas precisa de confirmação do usuário.
|
||||||
|
AguardandoConfirmacao {
|
||||||
|
pre: ResultadoPreAnalise,
|
||||||
|
series_excessivas: Vec<(String, u64)>,
|
||||||
|
avisos: ResumoAvisos,
|
||||||
|
notas: Vec<Nota>,
|
||||||
|
},
|
||||||
|
/// Arquivo importado não continha notas válidas.
|
||||||
|
Vazio,
|
||||||
|
/// Erro durante importação ou análise.
|
||||||
|
Erro(String),
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Struct principal da aplicação egui.
|
/// Struct principal da aplicação egui.
|
||||||
@@ -101,6 +134,11 @@ pub struct App {
|
|||||||
pub pagina_faltantes: usize,
|
pub pagina_faltantes: usize,
|
||||||
pub pagina_duplicatas: usize,
|
pub pagina_duplicatas: usize,
|
||||||
pub itens_por_pagina: usize,
|
pub itens_por_pagina: usize,
|
||||||
|
|
||||||
|
// Pré-visualização das primeiras linhas do arquivo
|
||||||
|
pub preview_arquivo: Option<Vec<Vec<String>>>,
|
||||||
|
// Canal para receber resultado da análise em background
|
||||||
|
pub resultado_pendente: Option<mpsc::Receiver<ResultadoPendente>>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Default for App {
|
impl Default for App {
|
||||||
@@ -123,6 +161,8 @@ impl Default for App {
|
|||||||
pagina_faltantes: 0,
|
pagina_faltantes: 0,
|
||||||
pagina_duplicatas: 0,
|
pagina_duplicatas: 0,
|
||||||
itens_por_pagina: 100,
|
itens_por_pagina: 100,
|
||||||
|
preview_arquivo: None,
|
||||||
|
resultado_pendente: None,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -137,6 +177,7 @@ impl App {
|
|||||||
EstadoApp::ExibindoResultado(_) => 3,
|
EstadoApp::ExibindoResultado(_) => 3,
|
||||||
EstadoApp::ConfirmandoIntervalo { .. } => 4,
|
EstadoApp::ConfirmandoIntervalo { .. } => 4,
|
||||||
EstadoApp::GerenciandoLayouts => 5,
|
EstadoApp::GerenciandoLayouts => 5,
|
||||||
|
EstadoApp::Analisando => 6,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -175,6 +216,7 @@ impl App {
|
|||||||
mensagem: msg.into(),
|
mensagem: msg.into(),
|
||||||
tipo: TipoModal::Erro,
|
tipo: TipoModal::Erro,
|
||||||
acao_confirmacao: None,
|
acao_confirmacao: None,
|
||||||
|
input_texto: String::new(),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -185,6 +227,7 @@ impl App {
|
|||||||
mensagem: msg.into(),
|
mensagem: msg.into(),
|
||||||
tipo: TipoModal::Aviso,
|
tipo: TipoModal::Aviso,
|
||||||
acao_confirmacao: None,
|
acao_confirmacao: None,
|
||||||
|
input_texto: String::new(),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -200,10 +243,23 @@ impl App {
|
|||||||
mensagem: msg.into(),
|
mensagem: msg.into(),
|
||||||
tipo: TipoModal::Confirmacao,
|
tipo: TipoModal::Confirmacao,
|
||||||
acao_confirmacao: Some(acao),
|
acao_confirmacao: Some(acao),
|
||||||
|
input_texto: String::new(),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Recarrega a lista de layouts do banco.
|
/// Recarrega a lista de layouts do banco.
|
||||||
|
/// Exibe modal com campo de texto para salvar o layout atual como novo preset.
|
||||||
|
pub fn exibir_modal_salvar_layout(&mut self) {
|
||||||
|
self.modal = Modal {
|
||||||
|
visivel: true,
|
||||||
|
titulo: "Salvar como layout...".to_string(),
|
||||||
|
mensagem: "Nome do layout:".to_string(),
|
||||||
|
tipo: TipoModal::InputTexto,
|
||||||
|
acao_confirmacao: Some(AcaoModal::SalvarLayoutConfig),
|
||||||
|
input_texto: self.nome_layout_atual.clone(),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
pub fn recarregar_layouts(&mut self) {
|
pub fn recarregar_layouts(&mut self) {
|
||||||
if let Some(conn) = &self.conn {
|
if let Some(conn) = &self.conn {
|
||||||
match listar_layouts(conn) {
|
match listar_layouts(conn) {
|
||||||
@@ -227,14 +283,26 @@ impl App {
|
|||||||
egui::Window::new(&titulo)
|
egui::Window::new(&titulo)
|
||||||
.collapsible(false)
|
.collapsible(false)
|
||||||
.resizable(false)
|
.resizable(false)
|
||||||
|
.min_width(320.0)
|
||||||
.anchor(egui::Align2::CENTER_CENTER, [0.0, 0.0])
|
.anchor(egui::Align2::CENTER_CENTER, [0.0, 0.0])
|
||||||
.show(ctx, |ui| {
|
.show(ctx, |ui| {
|
||||||
ui.add_space(8.0);
|
ui.add_space(8.0);
|
||||||
ui.label(&mensagem);
|
ui.label(&mensagem);
|
||||||
|
|
||||||
|
if tipo == TipoModal::InputTexto {
|
||||||
|
ui.add_space(4.0);
|
||||||
|
let resp = ui.add(
|
||||||
|
egui::TextEdit::singleline(&mut self.modal.input_texto)
|
||||||
|
.desired_width(280.0)
|
||||||
|
.hint_text("Nome do layout"),
|
||||||
|
);
|
||||||
|
resp.request_focus();
|
||||||
|
}
|
||||||
|
|
||||||
ui.add_space(12.0);
|
ui.add_space(12.0);
|
||||||
|
|
||||||
ui.horizontal(|ui| {
|
ui.horizontal(|ui| {
|
||||||
if tipo == TipoModal::Confirmacao {
|
if tipo == TipoModal::Confirmacao || tipo == TipoModal::InputTexto {
|
||||||
if ui.button("✔ Confirmar").clicked() {
|
if ui.button("✔ Confirmar").clicked() {
|
||||||
self.modal.visivel = false;
|
self.modal.visivel = false;
|
||||||
if let Some(acao) = acao.clone() {
|
if let Some(acao) = acao.clone() {
|
||||||
@@ -243,6 +311,7 @@ impl App {
|
|||||||
}
|
}
|
||||||
if ui.button("✖ Cancelar").clicked() {
|
if ui.button("✖ Cancelar").clicked() {
|
||||||
self.modal.visivel = false;
|
self.modal.visivel = false;
|
||||||
|
self.modal.input_texto.clear();
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
if ui.button("OK").clicked() {
|
if ui.button("OK").clicked() {
|
||||||
@@ -256,12 +325,20 @@ impl App {
|
|||||||
fn executar_acao_modal(&mut self, acao: AcaoModal) {
|
fn executar_acao_modal(&mut self, acao: AcaoModal) {
|
||||||
match acao {
|
match acao {
|
||||||
AcaoModal::ConfirmarExpansaoFaltantes => {
|
AcaoModal::ConfirmarExpansaoFaltantes => {
|
||||||
// Retirar pre-análise do estado e expandir
|
|
||||||
if let EstadoApp::ConfirmandoIntervalo { pre, .. } =
|
if let EstadoApp::ConfirmandoIntervalo { pre, .. } =
|
||||||
std::mem::replace(&mut self.estado, EstadoApp::Importando)
|
std::mem::replace(&mut self.estado, EstadoApp::Analisando)
|
||||||
{
|
{
|
||||||
let resultado = expandir_analise(pre, &self.notas_importadas);
|
let notas = self.notas_importadas.clone();
|
||||||
self.estado = EstadoApp::ExibindoResultado(resultado);
|
let (tx, rx) = mpsc::channel();
|
||||||
|
self.resultado_pendente = Some(rx);
|
||||||
|
std::thread::spawn(move || {
|
||||||
|
let resultado = expandir_analise(pre, ¬as);
|
||||||
|
let _ = tx.send(ResultadoPendente::Concluido {
|
||||||
|
resultado,
|
||||||
|
avisos: None,
|
||||||
|
notas: None,
|
||||||
|
});
|
||||||
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
AcaoModal::ConfirmarExclusaoLayout(id) => {
|
AcaoModal::ConfirmarExclusaoLayout(id) => {
|
||||||
@@ -274,6 +351,102 @@ impl App {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
AcaoModal::SobrescreverLayout => {} // Handled inline in layouts screen
|
AcaoModal::SobrescreverLayout => {} // Handled inline in layouts screen
|
||||||
|
AcaoModal::ConfirmarNovaAnalise => {
|
||||||
|
self.notas_importadas.clear();
|
||||||
|
self.preview_arquivo = None;
|
||||||
|
self.estado = EstadoApp::Importando;
|
||||||
|
}
|
||||||
|
AcaoModal::SalvarLayoutConfig => {
|
||||||
|
let nome = self.modal.input_texto.trim().to_string();
|
||||||
|
self.modal.input_texto.clear();
|
||||||
|
if nome.is_empty() {
|
||||||
|
self.exibir_erro("O nome do layout não pode ser vazio.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
let layout = match &self.tipo_arquivo_atual {
|
||||||
|
TipoArquivo::Csv => Layout::Csv {
|
||||||
|
id: None,
|
||||||
|
nome: nome.clone(),
|
||||||
|
config: self.layout_csv_atual.clone(),
|
||||||
|
},
|
||||||
|
TipoArquivo::Xlsx => Layout::Xlsx {
|
||||||
|
id: None,
|
||||||
|
nome: nome.clone(),
|
||||||
|
config: self.layout_xlsx_atual.clone(),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
let resultado = if let Some(conn) = &self.conn {
|
||||||
|
Some(salvar_layout(conn, &layout))
|
||||||
|
} else {
|
||||||
|
None
|
||||||
|
};
|
||||||
|
match resultado {
|
||||||
|
Some(Ok(_)) => {
|
||||||
|
self.nome_layout_atual = nome;
|
||||||
|
self.recarregar_layouts();
|
||||||
|
self.exibir_aviso("Layout salvo", "Layout salvo com sucesso.");
|
||||||
|
}
|
||||||
|
Some(Err(e)) => self.exibir_erro(e),
|
||||||
|
None => {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Processa o resultado recebido do canal de análise em background.
|
||||||
|
pub fn processar_resultado_pendente(&mut self, resultado: ResultadoPendente) {
|
||||||
|
match resultado {
|
||||||
|
ResultadoPendente::Concluido { resultado, avisos, notas } => {
|
||||||
|
if let Some(n) = notas {
|
||||||
|
self.notas_importadas = n;
|
||||||
|
}
|
||||||
|
if let Some(av) = &avisos {
|
||||||
|
self.avisos_importacao = if av.tem_avisos() { Some(av.clone()) } else { None };
|
||||||
|
}
|
||||||
|
self.estado = EstadoApp::ExibindoResultado(resultado);
|
||||||
|
if let Some(av) = &self.avisos_importacao.clone() {
|
||||||
|
if av.tem_avisos() {
|
||||||
|
let linhas = av.linhas_para_exibir().join("\n");
|
||||||
|
self.exibir_aviso("Avisos de Importação", linhas);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
ResultadoPendente::AguardandoConfirmacao {
|
||||||
|
pre,
|
||||||
|
series_excessivas,
|
||||||
|
avisos,
|
||||||
|
notas,
|
||||||
|
} => {
|
||||||
|
self.notas_importadas = notas;
|
||||||
|
self.avisos_importacao = if avisos.tem_avisos() { Some(avisos) } else { None };
|
||||||
|
let msg = series_excessivas
|
||||||
|
.iter()
|
||||||
|
.map(|(serie, count)| {
|
||||||
|
format!("Série {}: intervalo de {} faltantes detectado", serie, count)
|
||||||
|
})
|
||||||
|
.collect::<Vec<_>>()
|
||||||
|
.join("\n");
|
||||||
|
self.estado = EstadoApp::ConfirmandoIntervalo {
|
||||||
|
pre,
|
||||||
|
series_excessivas,
|
||||||
|
};
|
||||||
|
self.exibir_confirmacao(
|
||||||
|
"Intervalo muito grande",
|
||||||
|
format!(
|
||||||
|
"As seguintes séries têm um número muito alto de faltantes:\n\n{}\n\nDeseja continuar mesmo assim?",
|
||||||
|
msg
|
||||||
|
),
|
||||||
|
AcaoModal::ConfirmarExpansaoFaltantes,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
ResultadoPendente::Vazio => {
|
||||||
|
self.estado = EstadoApp::ConfigurandoColunas;
|
||||||
|
self.exibir_aviso("Aviso", "Nenhuma nota válida encontrada no arquivo.");
|
||||||
|
}
|
||||||
|
ResultadoPendente::Erro(e) => {
|
||||||
|
self.estado = EstadoApp::ConfigurandoColunas;
|
||||||
|
self.exibir_erro(format!("Erro ao importar arquivo: {}", e));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -316,11 +489,65 @@ impl App {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
impl App {
|
||||||
|
/// Renderiza o breadcrumb de etapas no topo (exceto na tela de layouts).
|
||||||
|
pub fn renderizar_breadcrumb(&self, ctx: &Context) {
|
||||||
|
let passo_ativo: usize = match self.tela_atual() {
|
||||||
|
0 | 1 => 1,
|
||||||
|
2 | 4 | 6 => 2,
|
||||||
|
3 => 3,
|
||||||
|
_ => return, // GerenciandoLayouts: sem breadcrumb
|
||||||
|
};
|
||||||
|
|
||||||
|
egui::TopBottomPanel::top("breadcrumb").show(ctx, |ui| {
|
||||||
|
ui.add_space(4.0);
|
||||||
|
ui.horizontal(|ui| {
|
||||||
|
for (i, label) in ["① Arquivo", "② Colunas", "③ Resultado"].iter().enumerate() {
|
||||||
|
let n = i + 1;
|
||||||
|
let texto = egui::RichText::new(*label);
|
||||||
|
if n == passo_ativo {
|
||||||
|
ui.label(texto.strong());
|
||||||
|
} else if n < passo_ativo {
|
||||||
|
ui.label(texto);
|
||||||
|
} else {
|
||||||
|
ui.label(texto.weak());
|
||||||
|
}
|
||||||
|
if n < 3 {
|
||||||
|
ui.label(egui::RichText::new(" ›").weak());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
ui.add_space(4.0);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
impl eframe::App for App {
|
impl eframe::App for App {
|
||||||
fn update(&mut self, ctx: &Context, _frame: &mut eframe::Frame) {
|
fn update(&mut self, ctx: &Context, _frame: &mut eframe::Frame) {
|
||||||
|
// Verificar resultado pendente da análise em background
|
||||||
|
if let Some(rx) = self.resultado_pendente.take() {
|
||||||
|
match rx.try_recv() {
|
||||||
|
Ok(resultado) => {
|
||||||
|
self.processar_resultado_pendente(resultado);
|
||||||
|
}
|
||||||
|
Err(mpsc::TryRecvError::Empty) => {
|
||||||
|
// Ainda processando: devolver receiver e solicitar repaint
|
||||||
|
self.resultado_pendente = Some(rx);
|
||||||
|
ctx.request_repaint();
|
||||||
|
}
|
||||||
|
Err(mpsc::TryRecvError::Disconnected) => {
|
||||||
|
self.estado = EstadoApp::ConfigurandoColunas;
|
||||||
|
self.exibir_erro("Erro interno: a análise foi interrompida inesperadamente.");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Sempre renderizar modal por cima de tudo
|
// Sempre renderizar modal por cima de tudo
|
||||||
self.renderizar_modal(ctx);
|
self.renderizar_modal(ctx);
|
||||||
|
|
||||||
|
// Breadcrumb de etapas no topo
|
||||||
|
self.renderizar_breadcrumb(ctx);
|
||||||
|
|
||||||
// Determinar qual tela exibir sem borrar self.estado
|
// Determinar qual tela exibir sem borrar self.estado
|
||||||
let tela_id = self.tela_atual();
|
let tela_id = self.tela_atual();
|
||||||
|
|
||||||
@@ -333,6 +560,15 @@ impl eframe::App for App {
|
|||||||
3 => crate::ui::screens::resultado::renderizar(ui, ctx, self),
|
3 => crate::ui::screens::resultado::renderizar(ui, ctx, self),
|
||||||
4 => crate::ui::screens::configuracao_colunas::renderizar(ui, ctx, self),
|
4 => crate::ui::screens::configuracao_colunas::renderizar(ui, ctx, self),
|
||||||
5 => crate::ui::screens::layouts::renderizar(ui, ctx, self),
|
5 => crate::ui::screens::layouts::renderizar(ui, ctx, self),
|
||||||
|
6 => {
|
||||||
|
ui.centered_and_justified(|ui| {
|
||||||
|
ui.label(
|
||||||
|
egui::RichText::new("⏳ Analisando... aguarde.")
|
||||||
|
.size(22.0)
|
||||||
|
.strong(),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
}
|
||||||
_ => {}
|
_ => {}
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,6 +1,9 @@
|
|||||||
use crate::application::usecases::importar_arquivo::{importar_csv, importar_xlsx};
|
use crate::application::usecases::importar_arquivo::{importar_csv, importar_xlsx};
|
||||||
use crate::domain::entities::layout::TipoArquivo;
|
use crate::application::usecases::executar_analise::{
|
||||||
use crate::ui::app::{App, EstadoApp};
|
expandir_analise, pre_analisar, series_com_intervalo_excessivo,
|
||||||
|
};
|
||||||
|
use crate::domain::entities::layout::{Layout, TipoArquivo};
|
||||||
|
use crate::ui::app::{App, EstadoApp, ResultadoPendente};
|
||||||
use egui::{Context, Ui};
|
use egui::{Context, Ui};
|
||||||
|
|
||||||
/// Renderiza a tela de configuração de colunas.
|
/// Renderiza a tela de configuração de colunas.
|
||||||
@@ -12,6 +15,51 @@ pub fn renderizar(ui: &mut Ui, ctx: &Context, app: &mut App) {
|
|||||||
ui.label(format!("Arquivo: {}", caminho.display()));
|
ui.label(format!("Arquivo: {}", caminho.display()));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
ui.add_space(8.0);
|
||||||
|
|
||||||
|
// Seletor de layout
|
||||||
|
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_config")
|
||||||
|
.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();
|
||||||
|
match &layout {
|
||||||
|
Layout::Csv { config, .. } => {
|
||||||
|
app.layout_csv_atual = config.clone();
|
||||||
|
}
|
||||||
|
Layout::Xlsx { config, .. } => {
|
||||||
|
app.layout_xlsx_atual = config.clone();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
ui.add_space(12.0);
|
ui.add_space(12.0);
|
||||||
|
|
||||||
match app.tipo_arquivo_atual.clone() {
|
match app.tipo_arquivo_atual.clone() {
|
||||||
@@ -19,6 +67,14 @@ pub fn renderizar(ui: &mut Ui, ctx: &Context, app: &mut App) {
|
|||||||
TipoArquivo::Xlsx => renderizar_xlsx(ui, app),
|
TipoArquivo::Xlsx => renderizar_xlsx(ui, app),
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 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.add_space(16.0);
|
||||||
ui.separator();
|
ui.separator();
|
||||||
ui.add_space(8.0);
|
ui.add_space(8.0);
|
||||||
@@ -40,9 +96,13 @@ pub fn renderizar(ui: &mut Ui, ctx: &Context, app: &mut App) {
|
|||||||
|
|
||||||
ui.add_enabled_ui(valido && app.caminho_arquivo.is_some(), |ui| {
|
ui.add_enabled_ui(valido && app.caminho_arquivo.is_some(), |ui| {
|
||||||
if ui.button("▶ Importar e Analisar").clicked() {
|
if ui.button("▶ Importar e Analisar").clicked() {
|
||||||
executar_importacao(app);
|
executar_importacao(app, ctx);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
if ui.button("💾 Salvar como layout...").clicked() {
|
||||||
|
app.exibir_modal_salvar_layout();
|
||||||
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -60,6 +120,7 @@ fn renderizar_csv(ui: &mut Ui, app: &mut App) {
|
|||||||
'\t' => "Tabulação (Tab)",
|
'\t' => "Tabulação (Tab)",
|
||||||
_ => "Outro",
|
_ => "Outro",
|
||||||
};
|
};
|
||||||
|
let mut delim_mudou = false;
|
||||||
egui::ComboBox::from_id_salt("combo_delimitador")
|
egui::ComboBox::from_id_salt("combo_delimitador")
|
||||||
.selected_text(delim_str)
|
.selected_text(delim_str)
|
||||||
.show_ui(ui, |ui| {
|
.show_ui(ui, |ui| {
|
||||||
@@ -68,6 +129,7 @@ fn renderizar_csv(ui: &mut Ui, app: &mut App) {
|
|||||||
.clicked()
|
.clicked()
|
||||||
{
|
{
|
||||||
app.layout_csv_atual.delimitador = ',';
|
app.layout_csv_atual.delimitador = ',';
|
||||||
|
delim_mudou = true;
|
||||||
}
|
}
|
||||||
if ui
|
if ui
|
||||||
.selectable_label(
|
.selectable_label(
|
||||||
@@ -77,6 +139,7 @@ fn renderizar_csv(ui: &mut Ui, app: &mut App) {
|
|||||||
.clicked()
|
.clicked()
|
||||||
{
|
{
|
||||||
app.layout_csv_atual.delimitador = ';';
|
app.layout_csv_atual.delimitador = ';';
|
||||||
|
delim_mudou = true;
|
||||||
}
|
}
|
||||||
if ui
|
if ui
|
||||||
.selectable_label(
|
.selectable_label(
|
||||||
@@ -86,8 +149,19 @@ fn renderizar_csv(ui: &mut Ui, app: &mut App) {
|
|||||||
.clicked()
|
.clicked()
|
||||||
{
|
{
|
||||||
app.layout_csv_atual.delimitador = '\t';
|
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
|
// Encoding
|
||||||
@@ -297,49 +371,61 @@ fn verificar_duplicados(indices: &[(String, usize)], erros: &mut Vec<String>) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fn executar_importacao(app: &mut App) {
|
fn executar_importacao(app: &mut App, ctx: &egui::Context) {
|
||||||
let caminho = match &app.caminho_arquivo {
|
let caminho = match &app.caminho_arquivo {
|
||||||
Some(p) => p.clone(),
|
Some(p) => p.clone(),
|
||||||
None => return,
|
None => return,
|
||||||
};
|
};
|
||||||
|
|
||||||
let resultado = match app.tipo_arquivo_atual.clone() {
|
let tipo = app.tipo_arquivo_atual.clone();
|
||||||
TipoArquivo::Csv => {
|
let layout_csv = app.layout_csv_atual.clone();
|
||||||
importar_csv(&caminho, &app.layout_csv_atual).map_err(|e| e.to_string())
|
let layout_xlsx = app.layout_xlsx_atual.clone();
|
||||||
}
|
|
||||||
TipoArquivo::Xlsx => {
|
|
||||||
importar_xlsx(&caminho, &app.layout_xlsx_atual).map_err(|e| e.to_string())
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
match resultado {
|
let (tx, rx) = std::sync::mpsc::channel();
|
||||||
Ok(res) => {
|
app.resultado_pendente = Some(rx);
|
||||||
if res.notas.is_empty() {
|
app.estado = EstadoApp::Analisando;
|
||||||
app.exibir_aviso("Aviso", "Nenhuma nota válida encontrada no arquivo.");
|
ctx.request_repaint();
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
let avisos = res.avisos.clone();
|
std::thread::spawn(move || {
|
||||||
app.notas_importadas = res.notas;
|
// 1. Importar arquivo
|
||||||
app.avisos_importacao = if avisos.tem_avisos() {
|
let res_importacao = match tipo {
|
||||||
Some(avisos.clone())
|
TipoArquivo::Csv => importar_csv(&caminho, &layout_csv).map_err(|e| e.to_string()),
|
||||||
} else {
|
TipoArquivo::Xlsx => importar_xlsx(&caminho, &layout_xlsx).map_err(|e| e.to_string()),
|
||||||
None
|
};
|
||||||
};
|
|
||||||
|
|
||||||
// Executar análise
|
let res = match res_importacao {
|
||||||
app.executar_analise();
|
Err(e) => ResultadoPendente::Erro(e),
|
||||||
|
Ok(importado) => {
|
||||||
|
if importado.notas.is_empty() {
|
||||||
|
ResultadoPendente::Vazio
|
||||||
|
} else {
|
||||||
|
let avisos = importado.avisos.clone();
|
||||||
|
let notas = importado.notas;
|
||||||
|
|
||||||
// Exibir avisos consolidados após análise
|
// 2. Pré-análise
|
||||||
if let Some(av) = &app.avisos_importacao {
|
let pre = pre_analisar(¬as);
|
||||||
if av.tem_avisos() {
|
let excessivos = series_com_intervalo_excessivo(&pre);
|
||||||
let linhas = av.linhas_para_exibir().join("\n");
|
|
||||||
app.exibir_aviso("Avisos de Importação", linhas);
|
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),
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
};
|
||||||
Err(e) => {
|
|
||||||
app.exibir_erro(format!("Erro ao importar arquivo: {}", e));
|
let _ = tx.send(res);
|
||||||
}
|
});
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -102,6 +102,19 @@ pub fn renderizar_selecao_aba(ui: &mut Ui, _ctx: &Context, app: &mut App) {
|
|||||||
for aba in &abas {
|
for aba in &abas {
|
||||||
if ui.selectable_label(aba_atual == *aba, aba).clicked() {
|
if ui.selectable_label(aba_atual == *aba, aba).clicked() {
|
||||||
app.layout_xlsx_atual.aba = aba.clone();
|
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);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -137,8 +150,15 @@ fn on_arquivo_selecionado(app: &mut App, caminho: PathBuf) {
|
|||||||
match extensao.as_str() {
|
match extensao.as_str() {
|
||||||
"csv" => {
|
"csv" => {
|
||||||
app.tipo_arquivo_atual = TipoArquivo::Csv;
|
app.tipo_arquivo_atual = TipoArquivo::Csv;
|
||||||
app.caminho_arquivo = Some(caminho);
|
app.caminho_arquivo = Some(caminho.clone());
|
||||||
app.notas_importadas.clear();
|
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" => {
|
"xlsx" | "xls" => {
|
||||||
app.tipo_arquivo_atual = TipoArquivo::Xlsx;
|
app.tipo_arquivo_atual = TipoArquivo::Xlsx;
|
||||||
|
|||||||
@@ -2,3 +2,71 @@ pub mod configuracao_colunas;
|
|||||||
pub mod import;
|
pub mod import;
|
||||||
pub mod layouts;
|
pub mod layouts;
|
||||||
pub mod resultado;
|
pub mod resultado;
|
||||||
|
|
||||||
|
/// 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 {
|
||||||
|
let mut resultado = String::new();
|
||||||
|
loop {
|
||||||
|
resultado.insert(0, (b'A' + (idx % 26) as u8) as char);
|
||||||
|
if idx < 26 {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
idx = idx / 26 - 1;
|
||||||
|
}
|
||||||
|
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();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
+12
-10
@@ -4,7 +4,7 @@ use crate::domain::{
|
|||||||
services::parser_monetario::formatar_valor_br,
|
services::parser_monetario::formatar_valor_br,
|
||||||
};
|
};
|
||||||
use crate::infrastructure::pdf_generator::GenpdfGenerator;
|
use crate::infrastructure::pdf_generator::GenpdfGenerator;
|
||||||
use crate::ui::app::{App, EstadoApp};
|
use crate::ui::app::{AcaoModal, App, EstadoApp};
|
||||||
use egui::{Context, Ui};
|
use egui::{Context, Ui};
|
||||||
|
|
||||||
const OPCOES_PAGINA: &[usize] = &[50, 100, 200, 1000];
|
const OPCOES_PAGINA: &[usize] = &[50, 100, 200, 1000];
|
||||||
@@ -22,9 +22,11 @@ pub fn renderizar(ui: &mut Ui, ctx: &Context, app: &mut App) {
|
|||||||
|
|
||||||
ui.horizontal(|ui| {
|
ui.horizontal(|ui| {
|
||||||
if ui.button("< Nova Análise").clicked() {
|
if ui.button("< Nova Análise").clicked() {
|
||||||
app.estado = EstadoApp::Importando;
|
app.exibir_confirmacao(
|
||||||
app.notas_importadas.clear();
|
"Nova Análise",
|
||||||
return;
|
"Deseja iniciar uma nova análise? O resultado atual será descartado.",
|
||||||
|
AcaoModal::ConfirmarNovaAnalise,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
if ui.button("⚙ Reconfigurar Colunas").clicked() {
|
if ui.button("⚙ Reconfigurar Colunas").clicked() {
|
||||||
@@ -58,12 +60,6 @@ pub fn renderizar(ui: &mut Ui, ctx: &Context, app: &mut App) {
|
|||||||
ui.separator();
|
ui.separator();
|
||||||
|
|
||||||
egui::ScrollArea::vertical().show(ui, |ui| {
|
egui::ScrollArea::vertical().show(ui, |ui| {
|
||||||
// Totais
|
|
||||||
renderizar_totais(ui, &resultado);
|
|
||||||
|
|
||||||
ui.add_space(12.0);
|
|
||||||
ui.separator();
|
|
||||||
|
|
||||||
// Faltantes
|
// Faltantes
|
||||||
renderizar_faltantes(ui, app, &resultado);
|
renderizar_faltantes(ui, app, &resultado);
|
||||||
|
|
||||||
@@ -72,6 +68,12 @@ pub fn renderizar(ui: &mut Ui, ctx: &Context, app: &mut App) {
|
|||||||
|
|
||||||
// Duplicatas
|
// Duplicatas
|
||||||
renderizar_duplicatas(ui, app, &resultado);
|
renderizar_duplicatas(ui, app, &resultado);
|
||||||
|
|
||||||
|
ui.add_space(12.0);
|
||||||
|
ui.separator();
|
||||||
|
|
||||||
|
// Totais
|
||||||
|
renderizar_totais(ui, &resultado);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user