diff --git a/.claude/settings.local.json b/.claude/settings.local.json new file mode 100644 index 0000000..8a6e3d0 --- /dev/null +++ b/.claude/settings.local.json @@ -0,0 +1,7 @@ +{ + "permissions": { + "allow": [ + "Bash(cargo build)" + ] + } +} diff --git a/.vscode/settings.json b/.vscode/settings.json new file mode 100644 index 0000000..34419fa --- /dev/null +++ b/.vscode/settings.json @@ -0,0 +1,10 @@ +{ + "sqltools.connections": [ + { + "previewLimit": 50, + "driver": "SQLite", + "database": "C:\\Users\\felip\\AppData\\Roaming\\comparador-notas\\config.db", + "name": "comparador-notas" + } + ] +} \ No newline at end of file diff --git a/src/application/usecases/layouts.rs b/src/application/usecases/layouts.rs index db231c1..dcac9c3 100644 --- a/src/application/usecases/layouts.rs +++ b/src/application/usecases/layouts.rs @@ -7,17 +7,26 @@ use rusqlite::Connection; /// Salva um layout no banco de dados. /// Se o layout já tem um id, atualiza. Caso contrário, insere. -pub fn salvar_layout(conn: &Connection, layout: &Layout) -> Result { +/// Retorna `ErroLayout::NomeConflitante` se já existir um layout com o mesmo nome. +pub fn salvar_layout(conn: &Connection, layout: &Layout) -> Result { // Validar campos obrigatórios if layout.nome().trim().is_empty() { - return Err("Nome do layout não pode ser vazio".to_string()); + return Err(ErroLayout::CampoObrigatorioAusente("nome".to_string())); } if let Some(id) = layout.id() { - layout_repository::atualizar(conn, layout).map_err(|e| e.to_string())?; + layout_repository::atualizar(conn, layout) + .map_err(|e| ErroLayout::JsonMalformado(e.to_string()))?; Ok(id) } else { - layout_repository::salvar(conn, layout).map_err(|e| e.to_string()) + let nome = layout.nome().to_string(); + let existe = layout_repository::existe_nome(conn, &nome) + .map_err(|e| ErroLayout::JsonMalformado(e.to_string()))?; + if existe { + return Err(ErroLayout::NomeConflitante(nome)); + } + layout_repository::salvar(conn, layout) + .map_err(|e| ErroLayout::JsonMalformado(e.to_string())) } } diff --git a/src/infrastructure/sqlite/migrations.rs b/src/infrastructure/sqlite/migrations.rs index 4ac6eec..7b4446f 100644 --- a/src/infrastructure/sqlite/migrations.rs +++ b/src/infrastructure/sqlite/migrations.rs @@ -1,7 +1,7 @@ use rusqlite::{Connection, Result}; /// Versão atual do schema do banco de dados. -const VERSAO_SCHEMA_ATUAL: i64 = 1; +const VERSAO_SCHEMA_ATUAL: i64 = 2; /// Aplica todas as migrations necessárias para atualizar o banco /// para a versão mais recente. @@ -21,15 +21,34 @@ pub fn aplicar_migrations(conn: &Connection) -> Result<()> { ) .unwrap_or(0); - if versao_atual < VERSAO_SCHEMA_ATUAL { + if versao_atual < 1 { migration_v1(conn)?; - if versao_atual == 0 { - conn.execute("INSERT INTO schema_version (versao) VALUES (?1);", [VERSAO_SCHEMA_ATUAL])?; - } else { - conn.execute("UPDATE schema_version SET versao = ?1;", [VERSAO_SCHEMA_ATUAL])?; - } + } + if versao_atual < 2 { + migration_v2(conn)?; } + if versao_atual == 0 { + conn.execute("INSERT INTO schema_version (versao) VALUES (?1);", [VERSAO_SCHEMA_ATUAL])?; + } else if versao_atual < VERSAO_SCHEMA_ATUAL { + conn.execute("UPDATE schema_version SET versao = ?1;", [VERSAO_SCHEMA_ATUAL])?; + } + + Ok(()) +} + +/// Migration v2: adicionar índice único em layouts.nome. +/// Renomeia duplicatas (sufixo com id) antes de criar o índice para não falhar +/// em bancos que já possuem nomes repetidos. +fn migration_v2(conn: &Connection) -> Result<()> { + conn.execute_batch( + "UPDATE layouts + SET nome = nome || ' (' || id || ')' + WHERE id NOT IN ( + SELECT MIN(id) FROM layouts GROUP BY nome + ); + CREATE UNIQUE INDEX IF NOT EXISTS idx_layouts_nome ON layouts (nome);", + )?; Ok(()) } diff --git a/src/main.rs b/src/main.rs index 2c9c44a..d7aa8b5 100644 --- a/src/main.rs +++ b/src/main.rs @@ -13,7 +13,7 @@ fn main() -> eframe::Result { .with_min_inner_size([800.0, 600.0]), ..Default::default() }; - + eframe::run_native( "Comparador de Notas", native_options, diff --git a/src/ui/app.rs b/src/ui/app.rs index e9a127c..f7ec5d4 100644 --- a/src/ui/app.rs +++ b/src/ui/app.rs @@ -8,7 +8,7 @@ use crate::domain::{ nota::Nota, resultado_analise::{ResultadoAnalise, ResultadoPreAnalise}, }, - errors::ResumoAvisos, + errors::{ErroLayout, ResumoAvisos}, }; use crate::infrastructure::sqlite::{connection::abrir_banco, migrations::aplicar_migrations}; use egui::Context; @@ -62,8 +62,8 @@ pub enum TipoModal { pub enum AcaoModal { ConfirmarExpansaoFaltantes, ConfirmarExclusaoLayout(i64), - #[allow(dead_code)] - SobrescreverLayout, + /// Sobrescrever layout existente — layout já carrega o id correto. + SobrescreverLayout(Layout), ConfirmarNovaAnalise, /// Salvar a configuração atual como novo layout, usando modal.input_texto como nome. SalvarLayoutConfig, @@ -341,7 +341,18 @@ impl App { } } } - AcaoModal::SobrescreverLayout => {} // Handled inline in layouts screen + AcaoModal::SobrescreverLayout(layout) => { + if let Some(conn) = &self.conn { + match salvar_layout(conn, &layout) { + Ok(_) => { + self.recarregar_layouts(); + } + Err(e) => { + self.exibir_erro(e.to_string()); + } + } + } + } AcaoModal::ConfirmarNovaAnalise => { self.notas_importadas.clear(); self.preview_arquivo = None; @@ -377,7 +388,36 @@ impl App { self.recarregar_layouts(); self.exibir_aviso("Layout salvo", "Layout salvo com sucesso."); } - Some(Err(e)) => self.exibir_erro(e), + Some(Err(ErroLayout::NomeConflitante(nome_conflito))) => { + let id_existente = self + .layouts_salvos + .iter() + .find(|l| l.nome() == nome_conflito) + .and_then(|l| l.id()); + if let Some(id) = id_existente { + let layout_com_id = match &self.tipo_arquivo_atual { + TipoArquivo::Csv => Layout::Csv { + id: Some(id), + nome: nome_conflito.clone(), + config: self.layout_csv_atual.clone(), + }, + TipoArquivo::Xlsx => Layout::Xlsx { + id: Some(id), + nome: nome_conflito.clone(), + config: self.layout_xlsx_atual.clone(), + }, + }; + self.exibir_confirmacao( + "Conflito de nome", + format!( + "Já existe um layout com o nome '{}'. Deseja sobrescrever?", + nome_conflito + ), + AcaoModal::SobrescreverLayout(layout_com_id), + ); + } + } + Some(Err(e)) => self.exibir_erro(e.to_string()), None => {} } } diff --git a/src/ui/screens/layouts.rs b/src/ui/screens/layouts.rs index 916bde8..a8cbc88 100644 --- a/src/ui/screens/layouts.rs +++ b/src/ui/screens/layouts.rs @@ -1,7 +1,7 @@ use crate::application::usecases::layouts::{ exportar_layout_json, importar_layout_json, salvar_layout, }; -use crate::domain::entities::layout::{Layout, TipoArquivo}; +use crate::domain::entities::layout::{Layout, LayoutJson, TipoArquivo}; use crate::domain::errors::ErroLayout; use crate::ui::app::{AcaoModal, App, EstadoApp}; use egui::{Context, Ui}; @@ -167,6 +167,35 @@ fn salvar_layout_atual(app: &mut App) { 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)); } @@ -182,15 +211,40 @@ fn importar_json(app: &mut App, conteudo: &str) { 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 + // 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::(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));