Fechamento de Caixa - Tauri 2 initial commit
Build Fechamento de Caixa / build (macos-14, app) (push) Has been cancelled
Build Fechamento de Caixa / build (ubuntu-22.04, deb) (push) Has been cancelled
Build Fechamento de Caixa / build (windows-2022, msi) (push) Has been cancelled

- Storage: SQLite local via rusqlite
- Supabase: REST API via reqwest
- Printer: USB ESC/POS for TM-T20
- Frontend: vanilla HTML (form-v9.3 ported)
- Build: GitHub Actions for Linux/Windows/macOS
This commit is contained in:
root
2026-06-20 13:58:34 +00:00
parent b06462a92d
commit a5cff433a6
20 changed files with 8313 additions and 0 deletions
+5505
View File
File diff suppressed because it is too large Load Diff
+34
View File
@@ -0,0 +1,34 @@
[package]
name = "fechamento-caixa"
version = "0.1.0"
edition = "2021"
[build-dependencies]
tauri-build = { version = "2", features = [] }
[dependencies]
tauri = { version = "2", features = ["devtools"] }
tauri-plugin-shell = "2"
tauri-plugin-dialog = "2"
tauri-plugin-fs = "2"
serde = { version = "1", features = ["derive"] }
serde_json = "1"
rusqlite = { version = "0.32", features = ["bundled"] }
reqwest = { version = "0.12", features = ["json", "blocking"] }
tokio = { version = "1", features = ["full"] }
serde_yaml = "0.9"
uuid = { version = "1", features = ["v4"] }
chrono = { version = "0.4", features = ["serde"] }
log = "0.4"
env_logger = "0.11"
thiserror = "2"
dirs = "5"
[target.'cfg(windows)'.dependencies]
windows = { version = "0.58", features = ["Win32_Foundation", "Win32_Devices_Usb", "Win32_Storage_FileSystem"] }
[profile.release]
strip = true
lto = true
codegen-units = 1
panic = "abort"
+3
View File
@@ -0,0 +1,3 @@
fn main() {
tauri_build::build()
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 393 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 857 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 108 B

View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 2.0 KiB

+116
View File
@@ -0,0 +1,116 @@
//! Fechamento de Caixa — O Frangão
//! App desktop nativo (Tauri 2 + Rust)
//! Substitui: n8n webhooks + localStorage/IndexedDB + window.print()
#![cfg_attr(not(debug_assertions), windows_subsystem = "windows")]
mod plugins;
use log::LevelFilter;
use plugins::printer::{self};
use plugins::storage::{self};
use plugins::supabase::{self};
use std::fs;
use std::path::PathBuf;
fn data_dir_path() -> PathBuf {
dirs::data_local_dir()
.unwrap_or_else(|| PathBuf::from("."))
.join("FechamentoCaixa")
}
fn load_config() -> (String, String) {
let cfg_path = data_dir_path().join("config.json");
if let Ok(content) = fs::read_to_string(&cfg_path) {
if let Ok(cfg) = serde_json::from_str::<serde_json::Value>(&content) {
let anon = cfg.get("SUPABASE_ANON_KEY")
.and_then(|v| v.as_str())
.unwrap_or("")
.to_string();
let service = cfg.get("SUPABASE_SERVICE_KEY")
.and_then(|v| v.as_str())
.unwrap_or("")
.to_string();
if !anon.is_empty() && !service.is_empty() {
return (anon, service);
}
}
}
// Fallback: tenta variáveis de ambiente
(
std::env::var("SUPABASE_ANON_KEY").unwrap_or_default(),
std::env::var("SUPABASE_SERVICE_KEY").unwrap_or_default(),
)
}
fn main() {
env_logger::Builder::new()
.filter_level(LevelFilter::Info)
.format_timestamp_millis()
.init();
log::info!("=== Fechamento de Caixa (Tauri) ===");
let data_dir = data_dir_path();
fs::create_dir_all(&data_dir).expect("não foi possível criar diretório de dados");
log::info!("Data dir: {:?}", data_dir);
// Carrega chaves do Supabase
let (anon_key, service_key) = load_config();
if anon_key.is_empty() || service_key.is_empty() {
log::warn!("SUPABASE_ANON_KEY ou SUPABASE_SERVICE_KEY não encontradas!");
log::warn!("Cole-as em: {:?}", data_dir.join("config.json"));
log::warn!("Formato: {{\"SUPABASE_ANON_KEY\": \"...\", \"SUPABASE_SERVICE_KEY\": \"...\"}}");
} else {
log::info!("Supabase: keys carregadas (anon={}..., service={}...)",
&anon_key[..8.min(anon_key.len())],
&service_key[..8.min(service_key.len())]);
}
// Inicializa plugins
let storage = storage::init(data_dir.clone())
.expect("falha ao inicializar SQLite");
let supabase = supabase::init(anon_key, service_key);
let printer = printer::init();
let app_state = plugins::app::init(data_dir.clone());
log::info!("Plugins inicializados: storage, supabase, printer");
tauri::Builder::default()
.plugin(tauri_plugin_shell::init())
.plugin(tauri_plugin_dialog::init())
.plugin(tauri_plugin_fs::init())
.manage(storage)
.manage(supabase)
.manage(printer)
.manage(app_state)
.invoke_handler(tauri::generate_handler![
// Storage
plugins::storage::salvar_fechamento,
plugins::storage::carregar_rascunho,
plugins::storage::listar_fechamentos,
plugins::storage::buscar_fechamento_por_id,
plugins::storage::pendentes_sync,
// Supabase
plugins::supabase::sb_salvar_fechamento,
plugins::supabase::sb_carregar_rascunho,
plugins::supabase::sb_listar_recentes,
plugins::supabase::sb_buscar_por_id,
plugins::supabase::sb_salvar_listas,
plugins::supabase::sb_carregar_listas,
plugins::supabase::sb_online,
// Printer
plugins::printer::detectar_impressora,
plugins::printer::configurar_impressora,
plugins::printer::caminho_impressora,
plugins::printer::imprimir_recibo,
plugins::printer::teste_impressora,
// App
plugins::app::get_loja,
plugins::app::set_loja,
plugins::app::get_config,
plugins::app::set_config,
])
.run(tauri::generate_context!())
.expect("erro ao inicializar Tauri");
}
+87
View File
@@ -0,0 +1,87 @@
//! Plugin de configuração da aplicação (loja, preferências)
//! Persistido em config.json ao lado do banco SQLite.
use serde::{Deserialize, Serialize};
use std::fs;
use std::path::PathBuf;
use std::sync::Mutex;
use tauri::State;
#[derive(Debug, Serialize, Deserialize, Clone)]
pub struct AppConfig {
pub loja: String,
pub pdv_nome: String,
pub operador_default: String,
pub turno_default: String,
pub sync_automatico: bool,
pub auto_open_printer: bool,
}
impl Default for AppConfig {
fn default() -> Self {
Self {
loja: "Uniao".to_string(),
pdv_nome: "PDV-01".to_string(),
operador_default: String::new(),
turno_default: String::new(),
sync_automatico: true,
auto_open_printer: false,
}
}
}
pub struct AppState {
pub config: Mutex<AppConfig>,
config_path: PathBuf,
}
impl AppState {
pub fn new(data_dir: PathBuf) -> Self {
let config_path = data_dir.join("config.json");
let config = if let Ok(content) = fs::read_to_string(&config_path) {
serde_json::from_str(&content).unwrap_or_default()
} else {
AppConfig::default()
};
Self {
config: Mutex::new(config),
config_path,
}
}
pub fn save(&self) -> Result<(), String> {
let cfg = self.config.lock().map_err(|_| "lock error")?;
let json = serde_json::to_string_pretty(&*cfg)
.map_err(|e| e.to_string())?;
fs::write(&self.config_path, json).map_err(|e| e.to_string())?;
log::info!("Config salvo em {:?}", self.config_path);
Ok(())
}
}
#[tauri::command]
pub fn get_loja(state: State<'_, AppState>) -> String {
state.config.lock().unwrap().loja.clone()
}
#[tauri::command]
pub fn set_loja(state: State<'_, AppState>, loja: String) -> Result<(), String> {
state.config.lock().unwrap().loja = loja;
state.save()
}
#[tauri::command]
pub fn get_config(state: State<'_, AppState>) -> AppConfig {
state.config.lock().unwrap().clone()
}
#[tauri::command]
pub fn set_config(state: State<'_, AppState>, cfg: AppConfig) -> Result<(), String> {
*state.config.lock().unwrap() = cfg;
state.save()
}
pub fn init(data_dir: PathBuf) -> AppState {
AppState::new(data_dir)
}
+95
View File
@@ -0,0 +1,95 @@
//! Comandos ESC/POS para impressoras térmicas 80mm
//! Comandos Epson ESC/POS padrão — funciona com TM-T20 e compatíveis.
/// Inicializar impressora
pub const INIT: &[u8] = b"\x1B\x40";
/// Nova linha
pub const LF: &[u8] = b"\x0A";
/// Cortar papel (parcial)
pub const CUT: &[u8] = b"\x1D\x56\x01";
/// Cortar papel (total)
pub const CUT_FULL: &[u8] = b"\x1D\x56\x00";
/// Alimentar 3 linhas antes de cortar
pub const FEED_CUT: &[u8] = b"\x1B\x64\x03";
/// Negrito ON / OFF
pub const BOLD_ON: &[u8] = b"\x1B\x45\x01";
pub const BOLD_OFF: &[u8] = b"\x1B\x45\x00";
/// Alinhamento
pub const ALIGN_LEFT: &[u8] = b"\x1B\x61\x00";
pub const ALIGN_CENTER: &[u8] = b"\x1B\x61\x01";
pub const ALIGN_RIGHT: &[u8] = b"\x1B\x61\x02";
/// Fonte normal / dupla
pub const FONT_NORMAL: &[u8] = b"\x1B\x21\x00";
pub const FONT_DOUBLE: &[u8] = b"\x1B\x21\x30";
/// Linha divisória (48 colunas para 80mm)
pub const DIVIDER: &str = "--------------------------------";
/// Monta texto centralizado com negrito + fonte dupla
pub fn title(text: &str) -> Vec<u8> {
let mut out = Vec::new();
out.extend_from_slice(ALIGN_CENTER);
out.extend_from_slice(BOLD_ON);
out.extend_from_slice(FONT_DOUBLE);
out.extend_from_slice(text.as_bytes());
out.extend_from_slice(LF);
out.extend_from_slice(FONT_NORMAL);
out.extend_from_slice(BOLD_OFF);
out
}
/// Monta linha normal (alinhada à esquerda)
pub fn line(text: &str) -> Vec<u8> {
let mut out = Vec::new();
out.extend_from_slice(ALIGN_LEFT);
out.extend_from_slice(text.as_bytes());
out.extend_from_slice(LF);
out
}
/// Monta linha centralizada
pub fn line_center(text: &str) -> Vec<u8> {
let mut out = Vec::new();
out.extend_from_slice(ALIGN_CENTER);
out.extend_from_slice(text.as_bytes());
out.extend_from_slice(LF);
out
}
/// Monta linha em negrito
pub fn line_bold(text: &str) -> Vec<u8> {
let mut out = Vec::new();
out.extend_from_slice(BOLD_ON);
out.extend_from_slice(ALIGN_LEFT);
out.extend_from_slice(text.as_bytes());
out.extend_from_slice(LF);
out.extend_from_slice(BOLD_OFF);
out
}
/// Monta linha com chave-valor alinhado (label 14 chars + espaço + valor 14 chars)
pub fn kv(label: &str, value: &str) -> Vec<u8> {
let padded_label = format!("{:<14}", label);
let padded_value = format!("{:>14}", value);
let line = format!("{} {}", padded_label, padded_value);
let line = if line.len() > 48 { line[..48].to_string() } else { line };
let mut out = Vec::new();
out.extend_from_slice(ALIGN_LEFT);
out.extend_from_slice(line.as_bytes());
out.extend_from_slice(LF);
out
}
/// Monta linha divisória
pub fn divider() -> Vec<u8> {
let mut out = Vec::new();
out.extend_from_slice(ALIGN_LEFT);
out.extend_from_slice(DIVIDER.as_bytes());
out.extend_from_slice(LF);
out
}
/// Monta espaço em branco (N linhas)
pub fn blank_lines(n: u8) -> &'static [u8] {
const BLANK: &[u8] = b"\n\n\n\n\n\n\n\n";
&BLANK[..(n as usize).min(8)]
}
+5
View File
@@ -0,0 +1,5 @@
pub mod app;
pub mod escpos;
pub mod printer;
pub mod storage;
pub mod supabase;
+390
View File
@@ -0,0 +1,390 @@
//! Plugin de impressão ESC/POS via USB para TM-T20 e compatíveis
//! Substitui window.print() — impressão direta sem popup do SO.
use serde::{Deserialize, Serialize};
use std::sync::Mutex;
use tauri::State;
use thiserror::Error;
#[derive(Error, Debug)]
pub enum PrinterError {
#[error("Impressora não encontrada. Verifique se está conectada via USB e ligada.")]
NotFound,
#[error("Falha ao abrir impressora: {0}")]
OpenFailed(String),
#[error("Falha ao enviar dados: {0}")]
WriteFailed(String),
#[error("Configuração inválida: {0}")]
ConfigError(String),
}
impl serde::Serialize for PrinterError {
fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
where
S: serde::Serializer,
{
serializer.serialize_str(&self.to_string())
}
}
// ── Comandos ESC/POS via plugins::escpos ────────────────────────────────────
use crate::plugins::escpos;
// ── Struct de dados do recibo ────────────────────────────────────────────────
#[derive(Debug, Serialize, Deserialize, Clone)]
pub struct ReciboData {
pub loja: String,
pub data: String,
pub operador: String,
pub turno: String,
pub saldo_troco: f64,
pub fechamento: f64,
pub saldo_esperado: f64,
pub diferenca: f64,
pub sangrias: f64,
pub despesas: f64,
pub vales: f64,
pub areceber: f64,
pub credito: f64,
pub debito: f64,
pub alimentacao: f64,
pub voucher: f64,
pub cancelamentos: f64,
}
impl ReciboData {
pub fn fmt_money(&self, v: f64) -> String {
format!("R$ {:.2}", v).replace('.', ",")
}
/// Gera todos os bytes ESC/POS do recibo 80mm
pub fn to_escpos(&self) -> Vec<u8> {
let mut out = Vec::new();
// Inicializa
out.extend(escpos::INIT);
// Cabeçalho
out.extend(escpos::blank_lines(1));
out.extend(escpos::title(&self.loja));
out.extend(escpos::line_center("Fechamento de Caixa"));
out.extend(escpos::blank_lines(1));
out.extend(escpos::divider());
// Info básica
out.extend(escpos::kv("Data:", &self.data));
out.extend(escpos::kv("Operador:", &self.operador));
out.extend(escpos::kv("Turno:", &self.turno));
out.extend(escpos::divider());
// Valores de entrada
out.extend(escpos::line_bold("ENTRADAS"));
out.extend(escpos::kv("Saldo Troco:", &self.fmt_money(self.saldo_troco)));
out.extend(escpos::kv("Fechamento:", &self.fmt_money(self.fechamento)));
out.extend(escpos::kv("Saldo Esperado:", &self.fmt_money(self.saldo_esperado)));
out.extend(escpos::divider());
// Sangrias / Despesas
out.extend(escpos::line_bold("DÉBITOS"));
out.extend(escpos::kv("Sangrias:", &self.fmt_money(self.sangrias)));
out.extend(escpos::kv("Despesas:", &self.fmt_money(self.despesas)));
out.extend(escpos::kv("Vales:", &self.fmt_money(self.vales)));
out.extend(escpos::kv("A Receber:", &self.fmt_money(self.areceber)));
out.extend(escpos::divider());
// Cartões
out.extend(escpos::line_bold("CARTÕES"));
out.extend(escpos::kv("Crédito:", &self.fmt_money(self.credito)));
out.extend(escpos::kv("Débito:", &self.fmt_money(self.debito)));
out.extend(escpos::kv("Alimentação:", &self.fmt_money(self.alimentacao)));
out.extend(escpos::kv("Voucher:", &self.fmt_money(self.voucher)));
out.extend(escpos::kv("Cancelamentos:", &self.fmt_money(self.cancelamentos)));
out.extend(escpos::divider());
// Resultado final
let diff_str = if self.diferenca >= 0.0 {
format!("+{}", self.fmt_money(self.diferenca))
} else {
self.fmt_money(self.diferenca)
};
out.extend(escpos::line_bold("DIFERENÇA:"));
out.extend(escpos::title(&diff_str));
out.extend(escpos::blank_lines(2));
// Rodapé
out.extend(escpos::divider());
out.extend(escpos::line_center(&format!("{}", chrono::Local::now().format("%d/%m/%Y %H:%M"))));
out.extend(escpos::line_center("Obrigado!"));
out.extend(escpos::blank_lines(4));
// Corta papel
out.extend(escpos::FEED_CUT);
out.extend(escpos::CUT);
out
}
}
// ── USB Printer Manager ─────────────────────────────────────────────────────
#[cfg(windows)]
pub mod usb_windows {
use std::ffi::OsStr;
use std::os::windows::ffi::OsStrExt;
use std::ptr;
use windows::Win32::Devices::Usb::{
CM_Get_Child, CM_Get_Child_Req, CM_Get_Device_IDW, CM_Get_Parent,
CR_SUCCESS, DEVINST, GUID, HDEVINFO, DIGCF_PRESENT, DIGCF_DEVICEINTERFACE,
};
use windows::Win32::Foundation::{BOOL, CloseHandle, HANDLE};
use windows::Win32::Storage::FileSystem::{
CreateFileW, FILE_ACCESS_FLAGS, FILE_SHARE_READ, FILE_SHARE_WRITE,
OPEN_EXISTING, FILE_ATTRIBUTE_NORMAL,
};
pub fn wide_string(s: &str) -> Vec<u16> {
OsStr::new(s).encode_wide().chain(std::iter::once(0)).collect()
}
pub fn close_handle(h: HANDLE) {
unsafe { let _ = CloseHandle(h); }
}
pub fn find_printer_device() -> Option<String> {
// TMP: returns the first USB printer found
// In production you'd enumerate device interfaces
// For TM-T20, the USB device interface GUID is the printer GUID
let guid = GUID::from_values(
0x4d36e979, 0xe325, 0x11ce,
[0xbf, 0xc1, 0x08, 0x00, 0x2b, 0xe1, 0x03, 0x18],
);
let devices = unsafe {
windows::Win32::Devices::Enumeration::SetupDiGetClassDevsW(
Some(&guid),
None,
None,
DIGCF_PRESENT.0 | DIGCF_DEVICEINTERFACE.0,
)
};
if devices.is_err() {
return None;
}
let devices = devices.unwrap();
// Try opening as a file (USB bulk endpoint workaround)
// This works for many USB printers when accessed via a file path
// The TM-T20 often appears as \\.\USB001
let paths = [
r"\\.\USB001",
r"\\.\USB002",
r"\\.\USB003",
r"\\.\USB004",
r"\\.\LPT1",
r"\\.\COM1",
r"\\.\COM2",
];
for path in &paths {
let wide = wide_string(path);
let handle = unsafe {
CreateFileW(
windows::core::PCWSTR::from_raw(wide.as_ptr()),
FILE_ACCESS_FLAGS(0xC0000000), // GENERIC_READ | GENERIC_WRITE
FILE_SHARE_READ | FILE_SHARE_WRITE,
None,
OPEN_EXISTING,
FILE_ATTRIBUTE_NORMAL,
None,
)
};
if handle.is_ok() {
unsafe { let _ = CloseHandle(handle.unwrap()); }
return Some((*path).to_string());
}
}
unsafe {
let _ = windows::Win32::Devices::Enumeration::SetupDiDestroyDeviceInfoList(devices.ok()?);
}
None
}
pub fn open_printer(path: &str) -> Result<HANDLE, String> {
let wide = wide_string(path);
let handle = unsafe {
CreateFileW(
windows::core::PCWSTR::from_raw(wide.as_ptr()),
FILE_ACCESS_FLAGS(0xC0000000),
FILE_SHARE_READ | FILE_SHARE_WRITE,
None,
OPEN_EXISTING,
FILE_ATTRIBUTE_NORMAL,
None,
)
};
match handle {
Ok(h) => Ok(h),
Err(e) => Err(format!("CreateFileW failed: {:?}", e)),
}
}
pub fn write_to(handle: HANDLE, data: &[u8]) -> Result<usize, String> {
use windows::Win32::Storage::FileSystem::WriteFile;
use windows::Win32::Foundation::DWORD;
let mut written: DWORD = 0;
let result = unsafe {
WriteFile(
handle,
data.as_ptr() as *const _,
data.len() as DWORD,
Some(&mut written),
None,
)
};
if result.is_ok() {
Ok(written as usize)
} else {
Err(format!("WriteFile failed: {:?}", result))
}
}
}
#[cfg(not(windows))]
pub mod usb_windows {
pub fn find_printer_device() -> Option<String> {
// On non-Windows, we'd use libusb or serialport
None
}
pub fn open_printer(_path: &str) -> Result<(), String> {
Err("Not supported on this platform".to_string())
}
pub fn write_to(_handle: (), _data: &[u8]) -> Result<usize, String> {
Err("Not supported".to_string())
}
}
// ── Printer Manager ────────────────────────────────────────────────────────
pub struct PrinterManager {
device_path: Mutex<Option<String>>,
printer_type: Mutex<PrinterType>,
}
#[derive(Debug, Clone, Copy, PartialEq)]
pub enum PrinterType {
Usb,
Serial,
Network,
}
impl Default for PrinterManager {
fn default() -> Self {
Self {
device_path: Mutex::new(None),
printer_type: Mutex::new(PrinterType::Usb),
}
}
}
impl PrinterManager {
/// Detecta impressora USB conectada.
pub fn detectar(&self) -> Result<String, PrinterError> {
if let Some(path) = usb_windows::find_printer_device() {
*self.device_path.lock().unwrap() = Some(path.clone());
return Ok(path);
}
Err(PrinterError::NotFound)
}
/// Configura caminho manual da impressora.
pub fn configurar(&self, path: String) -> Result<(), PrinterError> {
if path.is_empty() {
return Err(PrinterError::ConfigError("Caminho não pode ser vazio".into()));
}
*self.device_path.lock().unwrap() = Some(path.clone());
Ok(())
}
/// Imprime bytes ESC/POS diretamente.
pub fn imprimir_bytes(&self, data: &[u8]) -> Result<usize, PrinterError> {
let path = self.device_path.lock().unwrap().clone()
.ok_or_else(|| PrinterError::ConfigError(
"Impressora não configurada. Use detectar() ou configurar().".into()
))?;
let handle = usb_windows::open_printer(&path)
.map_err(|e| PrinterError::OpenFailed(e))?;
let n = usb_windows::write_to(handle, data)
.map_err(|e| PrinterError::WriteFailed(e))?;
#[cfg(windows)]
{
use windows::Win32::Foundation::CloseHandle;
unsafe { let _ = CloseHandle(handle); }
}
#[cfg(not(windows))]
let _ = handle;
Ok(n)
}
/// Imprime um recibo a partir dos dados estruturados.
pub fn imprimir_recibo(&self, dados: &ReciboData) -> Result<usize, PrinterError> {
let bytes = dados.to_escpos();
self.imprimir_bytes(&bytes)
}
/// Retorna o caminho configurado.
pub fn caminho(&self) -> Option<String> {
self.device_path.lock().unwrap().clone()
}
}
// ── Tauri Commands ─────────────────────────────────────────────────────────
#[tauri::command]
pub fn detectar_impressora(pm: State<'_, PrinterManager>) -> Result<String, PrinterError> {
pm.detectar()
}
#[tauri::command]
pub fn configurar_impressora(pm: State<'_, PrinterManager>, caminho: String) -> Result<(), PrinterError> {
pm.configurar(caminho)
}
#[tauri::command]
pub fn caminho_impressora(pm: State<'_, PrinterManager>) -> Option<String> {
pm.caminho()
}
#[tauri::command]
pub fn imprimir_recibo(
pm: State<'_, PrinterManager>,
dados: ReciboData,
) -> Result<usize, PrinterError> {
pm.imprimir_recibo(&dados)
}
#[tauri::command]
pub fn teste_impressora(pm: State<'_, PrinterManager>) -> Result<usize, PrinterError> {
let mut out = Vec::new();
out.extend(escpos::INIT);
out.extend(escpos::blank_lines(2));
out.extend(escpos::title("TESTE DE IMPRESSAO"));
out.extend(escpos::line_center("O Frangao - Fechamento de Caixa"));
out.extend(escpos::line_center(&chrono::Local::now().format("%d/%m/%Y %H:%M").to_string()));
out.extend(escpos::blank_lines(3));
out.extend(escpos::FEED_CUT);
out.extend(escpos::CUT);
pm.imprimir_bytes(&out)
}
pub fn init() -> PrinterManager {
PrinterManager::default()
}
+409
View File
@@ -0,0 +1,409 @@
//! Plugin de armazenamento local via SQLite
//! Fonte primária de dados — funciona offline, sync com Supabase quando online.
use rusqlite::{Connection, params};
use serde::{Deserialize, Serialize};
use std::path::PathBuf;
use std::sync::Mutex;
use tauri::State;
use thiserror::Error;
use uuid::Uuid;
use chrono::Utc;
#[derive(Error, Debug)]
pub enum StorageError {
#[error("SQLite error: {0}")]
Sqlite(#[from] rusqlite::Error),
#[error("Lock error")]
Lock,
#[error("IO error: {0}")]
Io(#[from] std::io::Error),
#[error("JSON error: {0}")]
Json(#[from] serde_json::Error),
}
impl serde::Serialize for StorageError {
fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
where
S: serde::Serializer,
{
serializer.serialize_str(&self.to_string())
}
}
// ── Tipos ──────────────────────────────────────────────────────────────────
#[derive(Debug, Serialize, Deserialize, Clone)]
pub struct FechamentoEstado {
pub id: Option<i64>,
pub uuid: String,
pub loja: String,
pub data: String,
pub operador: String,
pub turno: String,
pub saldo_troco: f64,
pub saldo_esperado: f64,
pub fechamento: f64,
pub tabelas: TableData,
pub cartoes: CartaoData,
pub sync_status: SyncStatus,
pub criado_em: String,
pub atualizado_em: String,
}
#[derive(Debug, Serialize, Deserialize, Clone, Default)]
pub struct TableData {
pub sangrias: Vec<TableRow>,
pub despesas: Vec<TableRow>,
pub pixcnpj: Vec<TableRow>,
pub vales: Vec<TableRow>,
pub areceber: Vec<TableRow>,
pub cancelamentos: Vec<TableRow>,
}
#[derive(Debug, Serialize, Deserialize, Clone, Default)]
pub struct TableRow {
pub retirada: Option<f64>,
pub valor: Option<f64>,
pub descricao: Option<String>,
pub motivo: Option<String>,
pub cliente: Option<String>,
#[serde(flatten)]
pub extra: std::collections::HashMap<String, serde_json::Value>,
}
#[derive(Debug, Serialize, Deserialize, Clone, Default)]
pub struct CartaoData {
pub credito: Vec<TableRow>,
pub debito: Vec<TableRow>,
pub alimentacao: Vec<TableRow>,
pub voucher: Vec<TableRow>,
}
#[derive(Debug, Serialize, Deserialize, Clone, Copy, PartialEq)]
#[serde(rename_all = "lowercase")]
pub enum SyncStatus {
Local, // só local, não sincronizado
Syncing, // sync em progresso
Synced, // sincronizado com servidor
Error, // erro no sync
}
impl std::fmt::Display for SyncStatus {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
SyncStatus::Local => write!(f, "local"),
SyncStatus::Syncing => write!(f, "syncing"),
SyncStatus::Synced => write!(f, "synced"),
SyncStatus::Error => write!(f, "error"),
}
}
}
impl From<&str> for SyncStatus {
fn from(s: &str) -> Self {
match s {
"local" => SyncStatus::Local,
"syncing" => SyncStatus::Syncing,
"synced" => SyncStatus::Synced,
"error" => SyncStatus::Error,
_ => SyncStatus::Local,
}
}
}
#[derive(Debug, Serialize, Deserialize)]
pub struct SalvarResult {
pub id: i64,
pub uuid: String,
pub sync_status: String,
}
// ── DB Manager ──────────────────────────────────────────────────────────────
pub struct DbManager {
conn: Mutex<Connection>,
}
impl DbManager {
pub fn new(app_dir: PathBuf) -> Result<Self, StorageError> {
std::fs::create_dir_all(&app_dir)?;
let db_path = app_dir.join("fechamento.db");
let conn = Connection::open(&db_path)?;
conn.execute_batch(
"PRAGMA journal_mode=WAL;
PRAGMA synchronous=NORMAL;
PRAGMA foreign_keys=ON;
PRAGMA busy_timeout=5000;
CREATE TABLE IF NOT EXISTS fechamentos (
id INTEGER PRIMARY KEY AUTOINCREMENT,
uuid TEXT NOT NULL UNIQUE,
loja TEXT NOT NULL,
data TEXT NOT NULL,
operador TEXT NOT NULL DEFAULT '',
turno TEXT NOT NULL DEFAULT '',
saldo_troco REAL NOT NULL DEFAULT 0,
saldo_esperado REAL NOT NULL DEFAULT 0,
fechamento REAL NOT NULL DEFAULT 0,
tabelas_json TEXT NOT NULL DEFAULT '{}',
cartoes_json TEXT NOT NULL DEFAULT '{}',
sync_status TEXT NOT NULL DEFAULT 'local',
criado_em TEXT NOT NULL DEFAULT (datetime('now')),
atualizado_em TEXT NOT NULL DEFAULT (datetime('now')),
UNIQUE(loja, data, operador, turno)
);
CREATE INDEX IF NOT EXISTS idx_fechamentos_lookup
ON fechamentos(loja, data, operador, turno);
CREATE INDEX IF NOT EXISTS idx_fechamentos_data
ON fechamentos(data);"
)?;
log::info!("SQLite ready: {:?}", db_path);
Ok(Self { conn: Mutex::new(conn) })
}
/// Salva ou atualiza um estado de fechamento. Retorna (id, uuid).
pub fn salvar(&self, estado: &FechamentoEstado) -> Result<SalvarResult, StorageError> {
let conn = self.conn.lock().map_err(|_| StorageError::Lock)?;
let uuid = if estado.uuid.is_empty() {
Uuid::new_v4().to_string()
} else {
estado.uuid.clone()
};
let now = Utc::now().format("%Y-%m-%dT%H:%M:%S%.3fZ").to_string();
let tabelas_json = serde_json::to_string(&estado.tabelas)?;
let cartoes_json = serde_json::to_string(&estado.cartoes)?;
let status_str = estado.sync_status.to_string();
// Upsert
conn.execute(
r#"INSERT INTO fechamentos
(uuid, loja, data, operador, turno, saldo_troco, saldo_esperado,
fechamento, tabelas_json, cartoes_json, sync_status, atualizado_em)
VALUES (?1,?2,?3,?4,?5,?6,?7,?8,?9,?10,?11,?12)
ON CONFLICT(loja, data, operador, turno) DO UPDATE SET
uuid=excluded.uuid,
saldo_troco=excluded.saldo_troco,
saldo_esperado=excluded.saldo_esperado,
fechamento=excluded.fechamento,
tabelas_json=excluded.tabelas_json,
cartoes_json=excluded.cartoes_json,
sync_status=excluded.sync_status,
atualizado_em=excluded.atualizado_em
WHERE atualizado_em <= excluded.atualizado_em"#,
params![
uuid,
estado.loja,
estado.data,
estado.operador,
estado.turno,
estado.saldo_troco,
estado.saldo_esperado,
estado.fechamento,
tabelas_json,
cartoes_json,
status_str,
now,
],
)?;
let id = conn.query_row(
"SELECT id FROM fechamentos WHERE uuid = ?1",
params![uuid],
|r| r.get(0),
)?;
Ok(SalvarResult { id, uuid, sync_status: status_str })
}
/// Busca o rascunho mais recente para loja+data (independente de operador/turno).
pub fn carregar_rascunho(&self, loja: &str, data: &str) -> Result<Option<FechamentoEstado>, StorageError> {
let conn = self.conn.lock().map_err(|_| StorageError::Lock)?;
let mut stmt = conn.prepare(
"SELECT id, uuid, loja, data, operador, turno, saldo_troco, saldo_esperado,
fechamento, tabelas_json, cartoes_json, sync_status, criado_em, atualizado_em
FROM fechamentos
WHERE loja = ?1 AND data = ?2
ORDER BY atualizado_em DESC
LIMIT 1"
)?;
let result = stmt.query_row(params![loja, data], |r| {
let tabelas_json: String = r.get(9)?;
let cartoes_json: String = r.get(10)?;
let sync_str: String = r.get(11)?;
Ok(FechamentoEstado {
id: Some(r.get(0)?),
uuid: r.get(1)?,
loja: r.get(2)?,
data: r.get(3)?,
operador: r.get(4)?,
turno: r.get(5)?,
saldo_troco: r.get(6)?,
saldo_esperado: r.get(7)?,
fechamento: r.get(8)?,
tabelas: serde_json::from_str(&tabelas_json).unwrap_or_default(),
cartoes: serde_json::from_str(&cartoes_json).unwrap_or_default(),
sync_status: SyncStatus::from(sync_str.as_str()),
criado_em: r.get(12)?,
atualizado_em: r.get(13)?,
})
});
match result {
Ok(e) => Ok(Some(e)),
Err(rusqlite::Error::QueryReturnedNoRows) => Ok(None),
Err(e) => Err(StorageError::Sqlite(e)),
}
}
/// Lista todos os fechamentos de uma loja (para o histórico).
pub fn listar(&self, loja: &str, limite: i64) -> Result<Vec<FechamentoEstado>, StorageError> {
let conn = self.conn.lock().map_err(|_| StorageError::Lock)?;
let mut stmt = conn.prepare(
"SELECT id, uuid, loja, data, operador, turno, saldo_troco, saldo_esperado,
fechamento, tabelas_json, cartoes_json, sync_status, criado_em, atualizado_em
FROM fechamentos
WHERE loja = ?1
ORDER BY data DESC, id DESC
LIMIT ?2"
)?;
let rows = stmt.query_map(params![loja, limite], |r| {
let tabelas_json: String = r.get(9)?;
let cartoes_json: String = r.get(10)?;
let sync_str: String = r.get(11)?;
Ok(FechamentoEstado {
id: Some(r.get(0)?),
uuid: r.get(1)?,
loja: r.get(2)?,
data: r.get(3)?,
operador: r.get(4)?,
turno: r.get(5)?,
saldo_troco: r.get(6)?,
saldo_esperado: r.get(7)?,
fechamento: r.get(8)?,
tabelas: serde_json::from_str(&tabelas_json).unwrap_or_default(),
cartoes: serde_json::from_str(&cartoes_json).unwrap_or_default(),
sync_status: SyncStatus::from(sync_str.as_str()),
criado_em: r.get(12)?,
atualizado_em: r.get(13)?,
})
})?;
rows.collect::<Result<Vec<_>, _>>().map_err(StorageError::Sqlite)
}
/// Busca um fechamento específico por ID.
pub fn buscar_por_id(&self, id: i64) -> Result<Option<FechamentoEstado>, StorageError> {
let conn = self.conn.lock().map_err(|_| StorageError::Lock)?;
let mut stmt = conn.prepare(
"SELECT id, uuid, loja, data, operador, turno, saldo_troco, saldo_esperado,
fechamento, tabelas_json, cartoes_json, sync_status, criado_em, atualizado_em
FROM fechamentos WHERE id = ?1"
)?;
let result = stmt.query_row(params![id], |r| {
let tabelas_json: String = r.get(9)?;
let cartoes_json: String = r.get(10)?;
let sync_str: String = r.get(11)?;
Ok(FechamentoEstado {
id: Some(r.get(0)?),
uuid: r.get(1)?,
loja: r.get(2)?,
data: r.get(3)?,
operador: r.get(4)?,
turno: r.get(5)?,
saldo_troco: r.get(6)?,
saldo_esperado: r.get(7)?,
fechamento: r.get(8)?,
tabelas: serde_json::from_str(&tabelas_json).unwrap_or_default(),
cartoes: serde_json::from_str(&cartoes_json).unwrap_or_default(),
sync_status: SyncStatus::from(sync_str.as_str()),
criado_em: r.get(12)?,
atualizado_em: r.get(13)?,
})
});
match result {
Ok(e) => Ok(Some(e)),
Err(rusqlite::Error::QueryReturnedNoRows) => Ok(None),
Err(e) => Err(StorageError::Sqlite(e)),
}
}
/// Atualiza sync_status de um registro.
pub fn atualizar_sync(&self, uuid: &str, status: SyncStatus) -> Result<(), StorageError> {
let conn = self.conn.lock().map_err(|_| StorageError::Lock)?;
let now = Utc::now().format("%Y-%m-%dT%H:%M:%S%.3fZ").to_string();
conn.execute(
"UPDATE fechamentos SET sync_status = ?1, atualizado_em = ?2 WHERE uuid = ?3",
params![status.to_string(), now, uuid],
)?;
Ok(())
}
/// Remove um registro (para debugging).
pub fn remover(&self, uuid: &str) -> Result<bool, StorageError> {
let conn = self.conn.lock().map_err(|_| StorageError::Lock)?;
let n = conn.execute("DELETE FROM fechamentos WHERE uuid = ?1", params![uuid])?;
Ok(n > 0)
}
/// Retorna quantos registros estão pendentes de sync.
pub fn pendentes_sync(&self) -> Result<i64, StorageError> {
let conn = self.conn.lock().map_err(|_| StorageError::Lock)?;
let count: i64 = conn.query_row(
"SELECT COUNT(*) FROM fechamentos WHERE sync_status = 'local' OR sync_status = 'error'",
[],
|r| r.get(0),
)?;
Ok(count)
}
}
// ── Tauri Commands ─────────────────────────────────────────────────────────
#[tauri::command]
pub fn salvar_fechamento(
db: State<'_, DbManager>,
estado: FechamentoEstado,
) -> Result<SalvarResult, StorageError> {
db.salvar(&estado)
}
#[tauri::command]
pub fn carregar_rascunho(
db: State<'_, DbManager>,
loja: String,
data: String,
) -> Result<Option<FechamentoEstado>, StorageError> {
db.carregar_rascunho(&loja, &data)
}
#[tauri::command]
pub fn listar_fechamentos(
db: State<'_, DbManager>,
loja: String,
limite: Option<i64>,
) -> Result<Vec<FechamentoEstado>, StorageError> {
db.listar(&loja, limite.unwrap_or(50))
}
#[tauri::command]
pub fn buscar_fechamento_por_id(
db: State<'_, DbManager>,
id: i64,
) -> Result<Option<FechamentoEstado>, StorageError> {
db.buscar_por_id(id)
}
#[tauri::command]
pub fn pendentes_sync(db: State<'_, DbManager>) -> Result<i64, StorageError> {
db.pendentes_sync()
}
pub fn init(app_dir: PathBuf) -> Result<DbManager, StorageError> {
DbManager::new(app_dir)
}
+366
View File
@@ -0,0 +1,366 @@
//! Plugin de comunicação com Supabase via REST API
//! Substitui completamente o fetch() do JS + n8n como intermediário.
use reqwest::blocking::Client;
use serde_json::Value;
use std::time::Duration;
use tauri::State;
use thiserror::Error;
#[derive(Error, Debug)]
pub enum SupabaseError {
#[error("HTTP error: {0}")]
Http(#[from] reqwest::Error),
#[error("API error: {0}")]
Api(String),
#[error("Lock error")]
Lock,
}
impl serde::Serialize for SupabaseError {
fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
where
S: serde::Serializer,
{
serializer.serialize_str(&self.to_string())
}
}
pub struct SupabaseClient {
http: Client,
anon_key: String,
service_role_key: String,
base_url: String,
}
impl SupabaseClient {
pub fn new(anon_key: String, service_role_key: String) -> Self {
let http = Client::builder()
.timeout(Duration::from_secs(15))
.build()
.expect("reqwest client");
Self {
http,
anon_key,
service_role_key,
base_url: "https://supabase.ofrangao.com.br".to_string(),
}
}
fn headers(&self, use_service: bool) -> reqwest::header::HeaderMap {
let key = if use_service { &self.service_role_key } else { &self.anon_key };
let mut headers = reqwest::header::HeaderMap::new();
headers.insert(
reqwest::header::AUTHORIZATION,
format!("Bearer {}", key).parse().unwrap(),
);
headers.insert(
reqwest::header::HeaderName::from_static("apikey"),
key.parse().unwrap(),
);
headers.insert(
reqwest::header::CONTENT_TYPE,
"application/json".parse().unwrap(),
);
headers
}
/// Salva/atualiza um fechamento na tabela `fechamentos_web`.
pub fn salvar_fechamento(&self, estado: &serde_json::Value) -> Result<Value, SupabaseError> {
let uuid = estado
.get("uuid")
.and_then(|v| v.as_str())
.unwrap_or("");
let payload = serde_json::json!({
"uuid": uuid,
"loja": estado["loja"],
"data": estado["data"],
"operador": estado["operador"],
"turno": estado["turno"],
"saldo_troco": estado["saldo_troco"],
"saldo_esperado": estado["saldo_esperado"],
"fechamento": estado["fechamento"],
"dados": estado,
"observacoes": "rascunho",
});
let _url = format!(
"{}/rest/v1/fechamentos_web?uuid=eq.{}&select=id",
self.base_url, uuid
);
// Upsert via POST com Prefer: resolution=merge-duplicates
let resp = self
.http
.post(&format!("{}/rest/v1/rpc/upsert_fechamento", self.base_url))
.headers(self.headers(true))
.json(&payload)
.send()?;
if resp.status().is_success() {
Ok(serde_json::json!({ "ok": true, "uuid": uuid }))
} else {
let body = resp.text().unwrap_or_default();
// Fallback: tenta POST direto na tabela
let resp2 = self
.http
.post(&format!("{}/rest/v1/fechamentos_web", self.base_url))
.headers(self.headers(true))
.header("Prefer", "resolution=merge-duplicates")
.json(&payload)
.send()?;
if resp2.status().is_success() {
Ok(serde_json::json!({ "ok": true, "uuid": uuid }))
} else {
Err(SupabaseError::Api(format!(
"HTTP {}: {}",
resp2.status().as_u16(),
body
)))
}
}
}
/// Busca o rascunho mais recente para loja+data.
pub fn carregar_rascunho(&self, loja: &str, data: &str) -> Result<Option<Value>, SupabaseError> {
let url = format!(
"{}/rest/v1/fechamentos_web?loja=eq.{}&data=eq.{}&observacoes=eq.rascunho&order=atualizado_em.desc&limit=1&select=*",
self.base_url,
urlencoding::encode(loja),
urlencoding::encode(data),
);
let resp = self
.http
.get(&url)
.headers(self.headers(false))
.send()?;
if resp.status().is_success() {
let arr: Vec<Value> = resp.json()?;
Ok(arr.into_iter().next())
} else {
let status = resp.status();
let body = resp.text().unwrap_or_default();
Err(SupabaseError::Api(format!(
"HTTP {}: {}",
status.as_u16(),
body
)))
}
}
/// Lista fechamentos recentes de uma loja.
pub fn listar_recentes(&self, loja: &str, limite: i64) -> Result<Vec<Value>, SupabaseError> {
let url = format!(
"{}/rest/v1/fechamentos_web?loja=eq.{}&order=data.desc,atualizado_em.desc&limit={}&select=id,uuid,data,operador,turno,saldo_troco,saldo_esperado,fechamento,sync_status,observacoes,atualizado_em",
self.base_url,
urlencoding::encode(loja),
limite,
);
let resp = self
.http
.get(&url)
.headers(self.headers(false))
.send()?;
if resp.status().is_success() {
let arr: Vec<Value> = resp.json()?;
Ok(arr)
} else {
let status = resp.status();
let body = resp.text().unwrap_or_default();
Err(SupabaseError::Api(format!(
"HTTP {}: {}",
status.as_u16(),
body
)))
}
}
/// Busca um fechamento por ID.
pub fn buscar_por_id(&self, id: i64) -> Result<Option<Value>, SupabaseError> {
let url = format!(
"{}/rest/v1/fechamentos_web?id=eq.{}&select=*",
self.base_url, id
);
let resp = self
.http
.get(&url)
.headers(self.headers(false))
.send()?;
if resp.status().is_success() {
let arr: Vec<Value> = resp.json()?;
Ok(arr.into_iter().next())
} else {
let status = resp.status();
let body = resp.text().unwrap_or_default();
Err(SupabaseError::Api(format!(
"HTTP {}: {}",
status.as_u16(),
body
)))
}
}
/// Salva operadores/gerentes/despesas customizadas.
pub fn salvar_listas(&self, loja: &str, listas: &serde_json::Value) -> Result<Value, SupabaseError> {
let payload = serde_json::json!({
"loja": loja,
"operadores": listas.get("operadores"),
"gerentes": listas.get("gerentes"),
"despesas_custom": listas.get("despesas_custom"),
"clientes": listas.get("clientes"),
});
let _url = format!(
"{}/rest/v1/listas_personalizadas?loja=eq.{}&select=id",
self.base_url,
urlencoding::encode(loja),
);
let resp = self
.http
.post(&format!("{}/rest/v1/listas_personalizadas", self.base_url))
.headers(self.headers(true))
.header("Prefer", "resolution=merge-duplicates")
.json(&payload)
.send()?;
if resp.status().is_success() {
Ok(serde_json::json!({ "ok": true }))
} else {
let status = resp.status();
let body = resp.text().unwrap_or_default();
Err(SupabaseError::Api(format!(
"HTTP {}: {}",
status.as_u16(),
body
)))
}
}
/// Carrega listas personalizadas do servidor.
pub fn carregar_listas(&self, loja: &str) -> Result<Option<Value>, SupabaseError> {
let url = format!(
"{}/rest/v1/listas_personalizadas?loja=eq.{}&limit=1&select=*",
self.base_url,
urlencoding::encode(loja),
);
let resp = self
.http
.get(&url)
.headers(self.headers(false))
.send()?;
if resp.status().is_success() {
let arr: Vec<Value> = resp.json()?;
Ok(arr.into_iter().next())
} else {
let status = resp.status();
let body = resp.text().unwrap_or_default();
Err(SupabaseError::Api(format!(
"HTTP {}: {}",
status.as_u16(),
body
)))
}
}
/// Verifica se está online.
pub fn health_check(&self) -> bool {
let url = format!("{}/rest/v1/?limit=0", self.base_url);
self.http
.get(&url)
.headers(self.headers(false))
.send()
.map(|r| r.status().is_success())
.unwrap_or(false)
}
}
// ── Tauri Commands ─────────────────────────────────────────────────────────
#[tauri::command]
pub fn sb_salvar_fechamento(
sb: State<'_, SupabaseClient>,
estado: serde_json::Value,
) -> Result<serde_json::Value, SupabaseError> {
sb.salvar_fechamento(&estado)
}
#[tauri::command]
pub fn sb_carregar_rascunho(
sb: State<'_, SupabaseClient>,
loja: String,
data: String,
) -> Result<Option<serde_json::Value>, SupabaseError> {
sb.carregar_rascunho(&loja, &data)
}
#[tauri::command]
pub fn sb_listar_recentes(
sb: State<'_, SupabaseClient>,
loja: String,
limite: Option<i64>,
) -> Result<Vec<serde_json::Value>, SupabaseError> {
sb.listar_recentes(&loja, limite.unwrap_or(30))
}
#[tauri::command]
pub fn sb_buscar_por_id(
sb: State<'_, SupabaseClient>,
id: i64,
) -> Result<Option<serde_json::Value>, SupabaseError> {
sb.buscar_por_id(id)
}
#[tauri::command]
pub fn sb_salvar_listas(
sb: State<'_, SupabaseClient>,
loja: String,
listas: serde_json::Value,
) -> Result<serde_json::Value, SupabaseError> {
sb.salvar_listas(&loja, &listas)
}
#[tauri::command]
pub fn sb_carregar_listas(
sb: State<'_, SupabaseClient>,
loja: String,
) -> Result<Option<serde_json::Value>, SupabaseError> {
sb.carregar_listas(&loja)
}
#[tauri::command]
pub fn sb_online(sb: State<'_, SupabaseClient>) -> bool {
sb.health_check()
}
pub fn init(anon_key: String, service_role_key: String) -> SupabaseClient {
SupabaseClient::new(anon_key, service_role_key)
}
// urlencoding helper (we'll add it as a dep)
mod urlencoding {
pub fn encode(s: &str) -> String {
let mut out = String::new();
for b in s.bytes() {
match b {
b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'-' | b'_' | b'.' | b'~' => {
out.push(b as char);
}
_ => {
out.push_str(&format!("%{:02X}", b));
}
}
}
out
}
}
+43
View File
@@ -0,0 +1,43 @@
{
"$schema": "https://schema.tauri.app/config/2",
"productName": "Fechamento de Caixa",
"identifier": "com.ofrangao.fechamento-caixa",
"version": "1.0.0",
"build": {
"beforeBuildCommand": "",
"beforeDevCommand": "",
"frontendDist": "../src"
},
"app": {
"windows": [
{
"title": "Fechamento de Caixa — O Frangão",
"width": 1100,
"height": 780,
"minWidth": 900,
"minHeight": 650,
"resizable": true,
"fullscreen": false,
"center": true
}
],
"security": {
"csp": null
},
"withGlobalTauri": true
},
"bundle": {
"active": true,
"targets": "all",
"icon": [
"icons/32x32.png",
"icons/128x128.png",
"icons/128x128@2x.png",
"icons/icon.icns",
"icons/icon.ico"
],
"windows": {
"webviewInstallMode": { "type": "embedBootstrapper" }
}
}
}