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
+56
View File
@@ -0,0 +1,56 @@
name: Build Fechamento de Caixa
on:
push:
branches: [main]
workflow_dispatch:
jobs:
build:
strategy:
fail-fast: false
matrix:
include:
- platform: ubuntu-22.04
target: deb
- platform: windows-2022
target: msi
- platform: macos-14
target: app
runs-on: ${{ matrix.platform }}
steps:
- uses: actions/checkout@v4
- name: Install dependencies (Ubuntu)
if: matrix.platform == 'ubuntu-22.04'
run: |
sudo apt-get update
sudo apt-get install -y libwebkit2gtk-4.1-dev libappindicator3-dev librsvg2-dev patchelf
- name: Setup Node
uses: actions/setup-node@v4
with:
node-version: 20
- name: Setup Rust
uses: dtolnay/rust-toolchain@stable
- name: Add targets
run: rustup target add ${{ matrix.platform == 'windows-2022' && 'x86_64-pc-windows-gnu' || 'aarch64-apple-darwin' }}
- name: Install frontend deps
run: npm ci
- name: Build
run: npm run build
- name: Upload artifacts
uses: actions/upload-artifact@v4
with:
name: fechamento-caixa-${{ matrix.platform }}
path: |
src-tauri/target/release/bundle/**/*
src-tauri/target/release/fechamento-caixa*
if-no-files-found: ignore
+232
View File
@@ -0,0 +1,232 @@
{
"name": "fechamento-caixa",
"version": "1.0.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "fechamento-caixa",
"version": "1.0.0",
"devDependencies": {
"@tauri-apps/cli": "^2"
}
},
"node_modules/@tauri-apps/cli": {
"version": "2.11.3",
"resolved": "https://registry.npmjs.org/@tauri-apps/cli/-/cli-2.11.3.tgz",
"integrity": "sha512-EElQe8z8uD7Pi5++tJ/UfEwWuK08rd3oCDYdeIbJAb6pZRrxlqmoF5gh5H5YvzmUPhS4IRCaLSsQhvWkrfK+GQ==",
"dev": true,
"license": "Apache-2.0 OR MIT",
"bin": {
"tauri": "tauri.js"
},
"engines": {
"node": ">= 10"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/tauri"
},
"optionalDependencies": {
"@tauri-apps/cli-darwin-arm64": "2.11.3",
"@tauri-apps/cli-darwin-x64": "2.11.3",
"@tauri-apps/cli-linux-arm-gnueabihf": "2.11.3",
"@tauri-apps/cli-linux-arm64-gnu": "2.11.3",
"@tauri-apps/cli-linux-arm64-musl": "2.11.3",
"@tauri-apps/cli-linux-riscv64-gnu": "2.11.3",
"@tauri-apps/cli-linux-x64-gnu": "2.11.3",
"@tauri-apps/cli-linux-x64-musl": "2.11.3",
"@tauri-apps/cli-win32-arm64-msvc": "2.11.3",
"@tauri-apps/cli-win32-ia32-msvc": "2.11.3",
"@tauri-apps/cli-win32-x64-msvc": "2.11.3"
}
},
"node_modules/@tauri-apps/cli-darwin-arm64": {
"version": "2.11.3",
"resolved": "https://registry.npmjs.org/@tauri-apps/cli-darwin-arm64/-/cli-darwin-arm64-2.11.3.tgz",
"integrity": "sha512-BxpaM8bsCoXs3wd4WKYhas/G1gs7+r7B+e4WnyRk2GEoVOouJB1hoL6E6YLXZDXbYci6VFdrNnobQwd2uVL4ew==",
"cpu": [
"arm64"
],
"dev": true,
"license": "Apache-2.0 OR MIT",
"optional": true,
"os": [
"darwin"
],
"engines": {
"node": ">= 10"
}
},
"node_modules/@tauri-apps/cli-darwin-x64": {
"version": "2.11.3",
"resolved": "https://registry.npmjs.org/@tauri-apps/cli-darwin-x64/-/cli-darwin-x64-2.11.3.tgz",
"integrity": "sha512-DbZYuPB1ZEzcAHYeyCvo3ltzM27+aXwPloCrtexPnmgPgulYJm3TOq6aC4S+wPhSXteddg8zImtNkvx/gQzmwg==",
"cpu": [
"x64"
],
"dev": true,
"license": "Apache-2.0 OR MIT",
"optional": true,
"os": [
"darwin"
],
"engines": {
"node": ">= 10"
}
},
"node_modules/@tauri-apps/cli-linux-arm-gnueabihf": {
"version": "2.11.3",
"resolved": "https://registry.npmjs.org/@tauri-apps/cli-linux-arm-gnueabihf/-/cli-linux-arm-gnueabihf-2.11.3.tgz",
"integrity": "sha512-741NduqBmz1XkdU8yz3OI/kBZtqHbvxo9F9ytIeWYU69/Ba9dcZEbqOU++Dp0G/XU8vAI0TfTywEl+p+BbLvaA==",
"cpu": [
"arm"
],
"dev": true,
"license": "Apache-2.0 OR MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">= 10"
}
},
"node_modules/@tauri-apps/cli-linux-arm64-gnu": {
"version": "2.11.3",
"resolved": "https://registry.npmjs.org/@tauri-apps/cli-linux-arm64-gnu/-/cli-linux-arm64-gnu-2.11.3.tgz",
"integrity": "sha512-RWAXT8pTqIczXcoic+LXlo6uEbAXGB0cgh6Pg7Y9xVnEbzryQ1JHtRGj9SxzrKSemBIDBH6Qc24kK2G69i8ofA==",
"cpu": [
"arm64"
],
"dev": true,
"license": "Apache-2.0 OR MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">= 10"
}
},
"node_modules/@tauri-apps/cli-linux-arm64-musl": {
"version": "2.11.3",
"resolved": "https://registry.npmjs.org/@tauri-apps/cli-linux-arm64-musl/-/cli-linux-arm64-musl-2.11.3.tgz",
"integrity": "sha512-qomqYS+yAkd0gXMRmhguWXc7RfVN+XKKXaEwbf5QmKURwydLFOTldd6F8/WoZDSsBMrV8dpNxz0YneGLmobiSA==",
"cpu": [
"arm64"
],
"dev": true,
"license": "Apache-2.0 OR MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">= 10"
}
},
"node_modules/@tauri-apps/cli-linux-riscv64-gnu": {
"version": "2.11.3",
"resolved": "https://registry.npmjs.org/@tauri-apps/cli-linux-riscv64-gnu/-/cli-linux-riscv64-gnu-2.11.3.tgz",
"integrity": "sha512-jOCXbDqeDj5XcclsOBAaXjtTgwZCVg8zEZ+dbPUCoADOgljFgL0rOkYTc96vUYgOrYEfuHYihWMxIDGaD6GwJw==",
"cpu": [
"riscv64"
],
"dev": true,
"license": "Apache-2.0 OR MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">= 10"
}
},
"node_modules/@tauri-apps/cli-linux-x64-gnu": {
"version": "2.11.3",
"resolved": "https://registry.npmjs.org/@tauri-apps/cli-linux-x64-gnu/-/cli-linux-x64-gnu-2.11.3.tgz",
"integrity": "sha512-+u3HO/F3gHwL48t9gWN/urqZvpaEJzBFmTaq5eSIhvy8TOvnhb+LgJr3Q3BG+5JxuBrCUjqtOEz6gMttdJFSBA==",
"cpu": [
"x64"
],
"dev": true,
"license": "Apache-2.0 OR MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">= 10"
}
},
"node_modules/@tauri-apps/cli-linux-x64-musl": {
"version": "2.11.3",
"resolved": "https://registry.npmjs.org/@tauri-apps/cli-linux-x64-musl/-/cli-linux-x64-musl-2.11.3.tgz",
"integrity": "sha512-spr5Jpr6KF/vehkLwJ0YmdGv8QwpWU+uw7J8bgijO0sox6ZCYsSNMbcsQjTqPi4xl+p0woIYpWXgChgHYpAc8g==",
"cpu": [
"x64"
],
"dev": true,
"license": "Apache-2.0 OR MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">= 10"
}
},
"node_modules/@tauri-apps/cli-win32-arm64-msvc": {
"version": "2.11.3",
"resolved": "https://registry.npmjs.org/@tauri-apps/cli-win32-arm64-msvc/-/cli-win32-arm64-msvc-2.11.3.tgz",
"integrity": "sha512-abkoRQih5xBa3vz2spWaex0kP/MzVzVPQHom2f8jnCq46R/luOD6Uy85EMU9/bfzf6ZzdorWJsgO+OMX90Fx2w==",
"cpu": [
"arm64"
],
"dev": true,
"license": "Apache-2.0 OR MIT",
"optional": true,
"os": [
"win32"
],
"engines": {
"node": ">= 10"
}
},
"node_modules/@tauri-apps/cli-win32-ia32-msvc": {
"version": "2.11.3",
"resolved": "https://registry.npmjs.org/@tauri-apps/cli-win32-ia32-msvc/-/cli-win32-ia32-msvc-2.11.3.tgz",
"integrity": "sha512-Vy6AvzFm1G40hg3r+OYDB3jkuu7R4wnMzbQBKuun9v6Cgg8IierpLL7toMzrZKs/8NlG8Sg4x1iLFR52oknyHg==",
"cpu": [
"ia32"
],
"dev": true,
"license": "Apache-2.0 OR MIT",
"optional": true,
"os": [
"win32"
],
"engines": {
"node": ">= 10"
}
},
"node_modules/@tauri-apps/cli-win32-x64-msvc": {
"version": "2.11.3",
"resolved": "https://registry.npmjs.org/@tauri-apps/cli-win32-x64-msvc/-/cli-win32-x64-msvc-2.11.3.tgz",
"integrity": "sha512-GlciF75GdbseajOyib2aCHwE3BXIqZ1liGKWLFRvCdN5wm8h8hFssEVKQ/6E+2jsMLg9v7LCTb983YFnn0QSww==",
"cpu": [
"x64"
],
"dev": true,
"license": "Apache-2.0 OR MIT",
"optional": true,
"os": [
"win32"
],
"engines": {
"node": ">= 10"
}
}
}
}
+14
View File
@@ -0,0 +1,14 @@
{
"name": "fechamento-caixa",
"version": "1.0.0",
"description": "Fechamento de Caixa — O Frangão",
"scripts": {
"dev": "tauri dev",
"build": "tauri build",
"tauri": "tauri"
},
"devDependencies": {
"@tauri-apps/cli": "^2"
},
"dependencies": {}
}
+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" }
}
}
}
+958
View File
@@ -0,0 +1,958 @@
<!DOCTYPE html>
<html lang="pt-BR">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Fechamento de Caixa — O Frangão</title>
<style>
/* ─── Reset & Base ─────────────────────────────────────────────────── */
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
:root {
--bg: #0d0d0d;
--surface: #181818;
--surface2: #242424;
--border: #333;
--text: #e0e0e0;
--muted: #888;
--accent: #e63946;
--accent2: #f4a261;
--success: #2a9d8f;
--warning: #e9c46a;
--danger: #e63946;
--radius: 8px;
--font: 'Segoe UI', system-ui, sans-serif;
}
html, body { height: 100%; }
body {
font-family: var(--font);
background: var(--bg);
color: var(--text);
display: flex;
flex-direction: column;
overflow: hidden;
}
/* ─── Header ──────────────────────────────────────────────────────── */
header {
display: flex;
align-items: center;
gap: 12px;
padding: 10px 16px;
background: var(--surface);
border-bottom: 1px solid var(--border);
flex-shrink: 0;
}
header .logo {
font-size: 1.1rem;
font-weight: 700;
color: var(--accent);
}
header .subtitle { font-size: 0.8rem; color: var(--muted); }
.header-spacer { flex: 1; }
.sync-badge {
font-size: 0.7rem;
padding: 3px 8px;
border-radius: 12px;
background: var(--surface2);
border: 1px solid var(--border);
color: var(--muted);
}
.sync-badge.synced { border-color: var(--success); color: var(--success); }
.sync-badge.error { border-color: var(--danger); color: var(--danger); }
/* ─── Toolbar ──────────────────────────────────────────────────────── */
.toolbar {
display: flex;
gap: 8px;
padding: 8px 16px;
background: var(--surface);
border-bottom: 1px solid var(--border);
flex-shrink: 0;
flex-wrap: wrap;
}
.toolbar select, .toolbar input {
background: var(--surface2);
border: 1px solid var(--border);
color: var(--text);
border-radius: var(--radius);
padding: 5px 10px;
font-size: 0.85rem;
}
.toolbar button {
background: var(--surface2);
border: 1px solid var(--border);
color: var(--text);
border-radius: var(--radius);
padding: 5px 14px;
font-size: 0.85rem;
cursor: pointer;
transition: all 0.15s;
}
.toolbar button:hover { border-color: var(--accent); color: var(--accent); }
.toolbar button.primary { background: var(--accent); border-color: var(--accent); color: #fff; }
.toolbar button.primary:hover { background: #c1121f; }
/* ─── Main Form Area ───────────────────────────────────────────────── */
.main {
flex: 1;
overflow-y: auto;
padding: 12px 16px;
}
/* ─── Form Sections ────────────────────────────────────────────────── */
.section {
background: var(--surface);
border: 1px solid var(--border);
border-radius: var(--radius);
margin-bottom: 12px;
overflow: hidden;
}
.section-header {
display: flex;
align-items: center;
padding: 8px 12px;
background: var(--surface2);
border-bottom: 1px solid var(--border);
cursor: pointer;
user-select: none;
gap: 8px;
}
.section-header:hover { background: #2a2a2a; }
.section-title {
font-size: 0.85rem;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.05em;
color: var(--accent2);
}
.section-badge {
font-size: 0.7rem;
padding: 2px 7px;
border-radius: 10px;
background: var(--accent);
color: #fff;
display: none;
}
.section-badge.visible { display: inline; }
.section-toggle { margin-left: auto; color: var(--muted); font-size: 0.8rem; }
.section-body { padding: 12px; }
/* ─── Form Grid ────────────────────────────────────────────────────── */
.grid { display: grid; gap: 8px; }
.grid-2 { grid-template-columns: 1fr 1fr; }
.grid-3 { grid-template-columns: 1fr 1fr 1fr; }
.grid-4 { grid-template-columns: 1fr 1fr 1fr 1fr; }
label {
display: flex;
flex-direction: column;
gap: 3px;
font-size: 0.75rem;
color: var(--muted);
}
input, select, textarea {
background: var(--surface2);
border: 1px solid var(--border);
color: var(--text);
border-radius: var(--radius);
padding: 7px 10px;
font-size: 0.9rem;
font-family: var(--font);
width: 100%;
transition: border-color 0.15s;
}
input:focus, select:focus, textarea:focus {
outline: none;
border-color: var(--accent);
}
input[type="number"] { font-variant-numeric: tabular-nums; }
input:read-only { opacity: 0.6; cursor: not-allowed; }
.input-group { display: flex; gap: 6px; }
.input-group input { flex: 1; }
/* ─── Table Editor ─────────────────────────────────────────────────── */
.table-wrap { overflow-x: auto; }
table { width: 100%; border-collapse: collapse; font-size: 0.85rem; }
th {
background: var(--surface2);
color: var(--muted);
font-size: 0.7rem;
text-transform: uppercase;
letter-spacing: 0.04em;
padding: 5px 8px;
text-align: left;
border-bottom: 1px solid var(--border);
}
td { padding: 3px 8px; border-bottom: 1px solid #222; vertical-align: middle; }
tr:hover td { background: #1e1e1e; }
td input, td select {
padding: 4px 6px;
font-size: 0.82rem;
border-radius: 4px;
}
td input[type="number"] { text-align: right; width: 110px; }
td input[placeholder] { width: 140px; }
.btn-remove {
width: 26px; height: 26px;
background: transparent;
border: 1px solid transparent;
border-radius: 4px;
color: var(--muted);
cursor: pointer;
font-size: 1rem;
display: flex; align-items: center; justify-content: center;
}
.btn-remove:hover { border-color: var(--danger); color: var(--danger); background: rgba(230,57,70,0.1); }
.btn-add-row {
width: 100%;
padding: 6px;
margin-top: 6px;
background: transparent;
border: 1px dashed var(--border);
color: var(--muted);
border-radius: var(--radius);
cursor: pointer;
font-size: 0.8rem;
}
.btn-add-row:hover { border-color: var(--accent2); color: var(--accent2); }
/* ─── Diferença Display ────────────────────────────────────────────── */
.resultado {
display: flex;
align-items: center;
justify-content: space-between;
padding: 10px 12px;
border-radius: var(--radius);
margin-top: 8px;
font-size: 1rem;
}
.resultado.ok { background: rgba(42,157,143,0.15); border: 1px solid var(--success); }
.resultado.diff { background: rgba(230,57,70,0.15); border: 1px solid var(--danger); }
.resultado .label { font-size: 0.8rem; color: var(--muted); text-transform: uppercase; }
.resultado .value { font-size: 1.4rem; font-weight: 700; font-variant-numeric: tabular-nums; }
.resultado.ok .value { color: var(--success); }
.resultado.diff .value { color: var(--danger); }
/* ─── Action Bar ───────────────────────────────────────────────────── */
.actions {
display: flex;
gap: 8px;
padding: 12px 16px;
background: var(--surface);
border-top: 1px solid var(--border);
flex-shrink: 0;
flex-wrap: wrap;
}
.actions button {
flex: 1;
min-width: 120px;
padding: 10px 16px;
border-radius: var(--radius);
font-size: 0.9rem;
font-weight: 600;
cursor: pointer;
border: 1px solid var(--border);
background: var(--surface2);
color: var(--text);
transition: all 0.15s;
}
.actions button:hover { border-color: var(--accent); }
.actions button.btn-enviar {
background: var(--accent);
border-color: var(--accent);
color: #fff;
}
.actions button.btn-enviar:hover { background: #c1121f; }
.actions button:disabled { opacity: 0.4; cursor: not-allowed; }
/* ─── Toast ────────────────────────────────────────────────────────── */
#toast {
position: fixed;
bottom: 80px;
left: 50%;
transform: translateX(-50%) translateY(20px);
background: var(--surface2);
border: 1px solid var(--border);
color: var(--text);
padding: 10px 20px;
border-radius: var(--radius);
font-size: 0.85rem;
opacity: 0;
transition: all 0.3s;
z-index: 1000;
pointer-events: none;
max-width: 90vw;
}
#toast.show { opacity: 1; transform: translateX(-50%) translateY(0); }
#toast.success { border-color: var(--success); color: var(--success); }
#toast.error { border-color: var(--danger); color: var(--danger); }
/* ─── Loading Overlay ──────────────────────────────────────────────── */
#loading {
display: none;
position: fixed;
inset: 0;
background: rgba(0,0,0,0.6);
z-index: 999;
align-items: center;
justify-content: center;
flex-direction: column;
gap: 12px;
}
#loading.show { display: flex; }
.spinner {
width: 40px; height: 40px;
border: 3px solid var(--border);
border-top-color: var(--accent);
border-radius: 50%;
animation: spin 0.8s linear infinite;
}
@keyframes spin { to { transform: rotate(360deg); } }
/* ─── Responsivo ───────────────────────────────────────────────────── */
@media (max-width: 700px) {
.grid-2, .grid-3, .grid-4 { grid-template-columns: 1fr 1fr; }
.toolbar { gap: 6px; }
.actions button { min-width: 100px; font-size: 0.8rem; }
}
/* ─── Print Preview Modal ─────────────────────────────────────────── */
#modal-print {
display: none;
position: fixed;
inset: 0;
background: rgba(0,0,0,0.8);
z-index: 500;
align-items: center;
justify-content: center;
}
#modal-print.show { display: flex; }
#modal-print .paper {
background: #fff;
color: #000;
width: 280px;
padding: 16px;
border-radius: 4px;
font-family: 'Courier New', monospace;
font-size: 11px;
line-height: 1.4;
max-height: 80vh;
overflow-y: auto;
}
#modal-print .paper .p-title { text-align: center; font-weight: bold; font-size: 14px; }
#modal-print .paper .p-section { margin-top: 8px; }
#modal-print .paper .p-row { display: flex; justify-content: space-between; }
#modal-print .paper .p-divider { border-top: 1px dashed #000; margin: 4px 0; }
#modal-print .actions { background: transparent; border: none; justify-content: center; gap: 12px; }
</style>
</head>
<body>
<!-- Header -->
<header>
<span class="logo">🍗 Fechamento de Caixa</span>
<span class="subtitle" id="loja-label"></span>
<div class="header-spacer"></div>
<span class="sync-badge" id="sync-badge">● Local</span>
</header>
<!-- Toolbar -->
<div class="toolbar">
<select id="sel-loja">
<option value="">Loja…</option>
<option value="Uniao">União</option>
<option value="Alianca">Aliança</option>
</select>
<input type="date" id="inp-data">
<select id="sel-turno">
<option value="">Turno…</option>
<option value="1">1º Turno</option>
<option value="2">2º Turno</option>
</select>
<select id="sel-operador">
<option value="">Operador…</option>
</select>
<div class="header-spacer"></div>
<button onclick="carregarRascunho()">↺ Carregar</button>
<button onclick="listarRecentes()">📋 Recentes</button>
<button onclick="abrirConfig()"></button>
</div>
<!-- Main Form -->
<div class="main" id="main-form">
<!-- Entradas -->
<div class="section" id="sec-entradas">
<div class="section-header" onclick="toggleSec('entradas')">
<span class="section-title">Entradas</span>
<span class="section-badge" id="badge-entradas">0</span>
<span class="section-toggle" id="toggle-entradas"></span>
</div>
<div class="section-body" id="body-entradas">
<div class="grid grid-3">
<label>Saldo Troco<input type="number" id="f-saldo-troco" step="0.01" placeholder="0,00"></label>
<label>Fechamento (Dinheiro)<input type="number" id="f-fechamento" step="0.01" placeholder="0,00"></label>
<label>Saldo Esperado<input type="number" id="f-saldo-esperado" step="0.01" placeholder="0,00" readonly></label>
</div>
<div class="resultado" id="res-diferenca">
<span class="label">Diferença</span>
<span class="value" id="val-diferenca">R$ 0,00</span>
</div>
</div>
</div>
<!-- Sangrias / Despesas -->
<div class="section" id="sec-despesas">
<div class="section-header" onclick="toggleSec('despesas')">
<span class="section-title">Sangrias / Despesas</span>
<span class="section-badge" id="badge-despesas">0</span>
<span class="section-toggle" id="toggle-despesas"></span>
</div>
<div class="section-body" id="body-despesas">
<div class="table-wrap">
<table id="tbl-despesas">
<thead><tr>
<th>Descrição</th><th>Valor</th><th></th>
</tr></thead>
<tbody></tbody>
</table>
<button class="btn-add-row" onclick="addDespesa()">+ Adicionar despesa</button>
</div>
<div class="grid grid-3" style="margin-top:10px">
<label>Total Sangrias<input type="number" id="f-total-sangrias" step="0.01" readonly></label>
<label>Total Despesas<input type="number" id="f-total-despesas" step="0.01" readonly></label>
<label>Vales<input type="number" id="f-vales" step="0.01"></label>
</div>
</div>
</div>
<!-- Cartões -->
<div class="section" id="sec-cartoes">
<div class="section-header" onclick="toggleSec('cartoes')">
<span class="section-title">Cartões / Voucher</span>
<span class="section-badge" id="badge-cartoes">0</span>
<span class="section-toggle" id="toggle-cartoes"></span>
</div>
<div class="section-body" id="body-cartoes">
<div class="grid grid-4">
<label>Crédito<input type="number" id="f-credito" step="0.01" placeholder="0,00"></label>
<label>Débito<input type="number" id="f-debito" step="0.01" placeholder="0,00"></label>
<label>Alimentação<input type="number" id="f-alimentacao" step="0.01" placeholder="0,00"></label>
<label>Voucher<input type="number" id="f-voucher" step="0.01" placeholder="0,00"></label>
</div>
<div style="margin-top:8px">
<label>Cancelamentos<input type="number" id="f-cancelamentos" step="0.01" placeholder="0,00"></label>
</div>
</div>
</div>
<!-- A Receber -->
<div class="section" id="sec-receber">
<div class="section-header" onclick="toggleSec('receber')">
<span class="section-title">A Receber</span>
<span class="section-badge" id="badge-receber">0</span>
<span class="section-toggle" id="toggle-receber"></span>
</div>
<div class="section-body" id="body-receber">
<div class="table-wrap">
<table id="tbl-receber">
<thead><tr><th>Descrição</th><th>Valor</th><th></th></tr></thead>
<tbody></tbody>
</table>
<button class="btn-add-row" onclick="addReceber()">+ Adicionar</button>
</div>
</div>
</div>
<!-- Observaes -->
<div class="section" id="sec-obs">
<div class="section-header" onclick="toggleSec('obs')">
<span class="section-title">Observaes / Info</span>
<span class="section-toggle" id="toggle-obs"></span>
</div>
<div class="section-body">
<textarea id="f-observacoes" rows="3" placeholder="Observaes do fechamento…"></textarea>
</div>
</div>
</div><!-- /.main -->
<!-- Actions -->
<div class="actions">
<button onclick="salvarRascunho()" id="btn-salvar">💾 Salvar</button>
<button onclick="limparForm()" id="btn-limpar">🗑 Limpar</button>
<button onclick="abrirImpressao()" id="btn-imprimir">🖨 Imprimir</button>
<button onclick="enviarFechamento()" class="btn-enviar" id="btn-enviar">📤 Enviar</button>
</div>
<!-- Toast -->
<div id="toast"></div>
<!-- Loading -->
<div id="loading"><div class="spinner"></div><span id="loading-msg">Aguarde…</span></div>
<!-- Config Modal -->
<div id="modal-config" style="display:none;position:fixed;inset:0;background:rgba(0,0,0,0.8);z-index:600;align-items:center;justify-content:center;">
<div style="background:var(--surface);border:1px solid var(--border);border-radius:12px;padding:24px;width:360px;max-width:90vw;">
<h3 style="margin-bottom:16px;font-size:1rem;">⚙ Configuraes</h3>
<label style="margin-bottom:12px">Impressora USB
<select id="cfg-impressora" style="margin-top:4px">
<option value="">— Detectar automaticamente —</option>
</select>
</label>
<div style="display:flex;gap:8px;margin-top:16px">
<button onclick="detectarImpressora()" style="flex:1;padding:8px;border-radius:var(--radius);background:var(--surface2);border:1px solid var(--border);color:var(--text);cursor:pointer;">Detectar</button>
<button onclick="fecharConfig()" style="flex:1;padding:8px;border-radius:var(--radius);background:var(--accent);border:1px solid var(--accent);color:#fff;cursor:pointer;">Fechar</button>
</div>
<div id="cfg-status" style="margin-top:10px;font-size:0.8rem;color:var(--muted);"></div>
</div>
</div>
<!-- Print Preview Modal -->
<div id="modal-print">
<div class="paper" id="paper-content"></div>
<div class="actions" style="position:absolute;bottom:20px;left:0;right:0;">
<button onclick="imprimirAgora()">🖨 Imprimir</button>
<button onclick="fecharPrintPreview()">Cancelar</button>
</div>
</div>
<script>
const { invoke } = window.__TAURI__.core;
let estado = {
uuid: null,
id_local: null,
loja: '',
data: '',
operador: '',
turno: '',
saldo_troco: 0,
fechamento: 0,
saldo_esperado: 0,
diferenca: 0,
despesas: [], // [{desc, valor}]
total_sangrias: 0,
total_despesas: 0,
vales: 0,
cartoes: {credito:0, debito:0, alimentacao:0, voucher:0, cancelamentos:0},
receber: [], // [{desc, valor}]
observacoes: 'rascunho'
};
let autoSaveTimer = null;
// ── Init ──────────────────────────────────────────────────────────────────
async function init() {
// Defaults: loja do config, data hoje, turno 1
const cfg = await invoke('get_config').catch(() => ({}));
if (cfg.loja) {
document.getElementById('sel-loja').value = cfg.loja;
estado.loja = cfg.loja;
}
document.getElementById('inp-data').value = new Date().toISOString().split('T')[0];
estado.data = document.getElementById('inp-data').value;
// Load operadores from server
await carregarOperadores();
// Add Enter key handler for all value inputs to add new row
document.querySelectorAll('input').forEach(inp => {
inp.addEventListener('keydown', e => {
if (e.key === 'Enter' && inp.type === 'number') {
e.preventDefault();
inp.blur();
}
});
});
// Auto-calculate on input change
['f-saldo-troco','f-fechamento','f-credito','f-debito','f-alimentacao','f-voucher','f-cancelamentos','f-vales'].forEach(id => {
document.getElementById(id)?.addEventListener('input', calcular);
});
calcular();
document.getElementById('loja-label').textContent = estado.loja || '—';
}
// ── Store ─────────────────────────────────────────────────────────────────
async function invokeSafe(cmd, args = {}) {
try {
return await invoke(cmd, args);
} catch(e) {
console.error(`[${cmd}]`, e);
toast(`Erro: ${e}`, 'error');
throw e;
}
}
// ── Auto-save (local SQLite) ─────────────────────────────────────────────
function scheduleAutoSave() {
clearTimeout(autoSaveTimer);
autoSaveTimer = setTimeout(() => salvarRascunho(true), 1500);
}
function calcular() {
const sto = parseFloat(val('f-saldo-troco')) || 0;
const fec = parseFloat(val('f-fechamento')) || 0;
const esp = sto + fec;
document.getElementById('f-saldo-esperado').value = fmt(esp);
estado.saldo_troco = sto;
estado.fechamento = fec;
estado.saldo_esperado = esp;
const dif = fec - esp;
estado.diferenca = dif;
const el = document.getElementById('res-diferenca');
const vl = document.getElementById('val-diferenca');
el.className = 'resultado ' + (Math.abs(dif) < 0.01 ? 'ok' : 'diff');
vl.textContent = (dif >= 0 ? '+' : '') + fmt(dif);
// Totals
estado.despesas = getTableRows('tbl-despesas').map(r => ({desc: r[0], valor: parseFloat(r[1])||0}));
estado.total_sangrias = estado.despesas.filter(d => d.desc.includes('Sangria')).reduce((s,d) => s+d.valor, 0);
estado.total_despesas = estado.despesas.filter(d => !d.desc.includes('Sangria')).reduce((s,d) => s+d.valor, 0);
estado.vales = parseFloat(val('f-vales')) || 0;
estado.cartoes = {
credo: parseFloat(val('f-credito')) || 0,
debito: parseFloat(val('f-debito')) || 0,
alimentacao: parseFloat(val('f-alimentacao')) || 0,
voucher: parseFloat(val('f-voucher')) || 0,
cancelamentos: parseFloat(val('f-cancelamentos')) || 0
};
estado.receber = getTableRows('tbl-receber').map(r => ({desc: r[0], valor: parseFloat(r[1])||0}));
estado.observacoes = val('f-observacoes') || 'rascunho';
estado.loja = val('sel-loja');
estado.data = val('inp-data');
estado.operador = val('sel-operador');
estado.turno = val('sel-turno');
updateBadges();
}
function updateBadges() {
const d = estado.despesas.length;
const r = estado.receber.length;
const bD = document.getElementById('badge-despesas');
const bR = document.getElementById('badge-receber');
bD.textContent = d; bD.className = 'section-badge' + (d ? ' visible' : '');
bR.textContent = r; bR.className = 'section-badge' + (r ? ' visible' : '');
}
// ── Salvar Rascunho ──────────────────────────────────────────────────────
async function salvarRascunho(silent = false) {
calcular();
try {
const result = await invokeSafe('salvar_local', { estado: JSON.stringify(estado) });
estado.id_local = result.id;
estado.uuid = result.uuid;
if (!silent) toast('Rascunho salvo (local)', 'success');
atualizarSyncBadge('local');
} catch(e) {
if (!silent) toast('Erro ao salvar rascunho', 'error');
}
}
// ── Carregar Rascunho ───────────────────────────────────────────────────
async function carregarRascunho() {
const loja = val('sel-loja');
const data = val('inp-data');
if (!loja || !data) { toast('Selecione loja e data primeiro', 'error'); return; }
showLoad('Carregando rascunho…');
try {
const r = await invokeSafe('carregar_rascunho', { loja, data });
if (!r) { toast('Nenhum rascunho encontrado', ''); hideLoad(); return; }
Object.assign(estado, r);
// Restore form fields
document.getElementById('f-saldo-troco').value = r.saldo_troco || '';
document.getElementById('f-fechamento').value = r.fechamento || '';
document.getElementById('f-vales').value = r.vales || '';
document.getElementById('f-credito').value = r.cartoes?.credito || '';
document.getElementById('f-debito').value = r.cartoes?.debito || '';
document.getElementById('f-alimentacao').value = r.cartoes?.alimentacao || '';
document.getElementById('f-voucher').value = r.cartoes?.voucher || '';
document.getElementById('f-cancelamentos').value = r.cartoes?.cancelamentos || '';
document.getElementById('f-observacoes').value = r.observacoes || '';
document.getElementById('sel-operador').value = r.operador || '';
document.getElementById('sel-turno').value = r.turno || '';
// Restore tables
restoreTable('tbl-despesas', (r.despesas||[]).map(d => [d.desc, d.valor]));
restoreTable('tbl-receber', (r.receber||[]).map(d => [d.desc, d.valor]));
calcular();
toast('Rascunho carregado', 'success');
} catch(e) { /* toast already shown */ }
hideLoad();
}
// ── Enviar (sync to Supabase) ─────────────────────────────────────────────
async function enviarFechamento() {
calcular();
if (!estado.loja || !estado.data) { toast('Loja e data so obrigatorios', 'error'); return; }
showLoad('Enviando ao servidor…');
try {
await invokeSafe('enviar_fechamento', { estado: JSON.stringify(estado) });
toast('Enviado com sucesso!', 'success');
atualizarSyncBadge('synced');
} catch(e) { toast(`Erro ao enviar: ${e}`, 'error'); }
hideLoad();
}
// ── Impresso ─────────────────────────────────────────────────────────────
function abrirImpressao() {
calcular();
const p = document.getElementById('paper-content');
const dif = estado.diferenca;
p.innerHTML = `
<div class="p-title">FECHAMENTO DE CAIXA</div>
<div style="text-align:center">${estado.loja}${estado.data} — Turno ${estado.turno}</div>
<div style="text-align:center">Operador: ${estado.operador}</div>
<div class="p-divider"></div>
<div class="p-section">
<div class="p-row"><span>Saldo Troco:</span><span>${fmt(estado.saldo_troco)}</span></div>
<div class="p-row"><span>Fechamento:</span><span>${fmt(estado.fechamento)}</span></div>
<div class="p-row"><span>Saldo Esperado:</span><span>${fmt(estado.saldo_esperado)}</span></div>
<div class="p-row" style="font-weight:bold;color:${Math.abs(dif)<0.01?'green':'red'}">
<span>Diferenca:</span><span>${dif>=0?'+':''}${fmt(dif)}</span>
</div>
</div>
<div class="p-divider"></div>
<div class="p-section">
<div class="p-row"><span>Credito:</span><span>${fmt(estado.cartoes.credito)}</span></div>
<div class="p-row"><span>Debito:</span><span>${fmt(estado.cartoes.debito)}</span></div>
<div class="p-row"><span>Alimentacao:</span><span>${fmt(estado.cartoes.alimentacao)}</span></div>
<div class="p-row"><span>Voucher:</span><span>${fmt(estado.cartoes.voucher)}</span></div>
<div class="p-row"><span>Cancelamentos:</span><span>${fmt(estado.cartoes.cancelamentos)}</span></div>
</div>
<div class="p-divider"></div>
<div class="p-section">
<div class="p-row"><span>Sangrias:</span><span>${fmt(estado.total_sangrias)}</span></div>
<div class="p-row"><span>Despesas:</span><span>${fmt(estado.total_despesas)}</span></div>
<div class="p-row"><span>Vales:</span><span>${fmt(estado.vales)}</span></div>
</div>
${estado.observacoes && estado.observacoes!='rascunho' ? `<div class="p-divider"></div><div>Obs: ${estado.observacoes}</div>` : ''}
<div class="p-divider"></div>
<div style="text-align:center;font-size:10px">${new Date().toLocaleString('pt-BR')}</div>
`;
document.getElementById('modal-print').classList.add('show');
}
async function imprimirAgora() {
document.getElementById('modal-print').classList.remove('show');
showLoad('Imprimindo…');
try {
await invokeSafe('imprimir_fechamento', { estado: JSON.stringify(estado) });
toast('Impresso com sucesso!', 'success');
} catch(e) { toast(`Erro na impressao: ${e}`, 'error'); }
hideLoad();
}
function fecharPrintPreview() {
document.getElementById('modal-print').classList.remove('show');
}
// ── Listar Recentes ──────────────────────────────────────────────────────
async function listarRecentes() {
const loja = val('sel-loja');
if (!loja) { toast('Selecione a loja primeiro', 'error'); return; }
showLoad('Carregando recentes…');
try {
const lista = await invokeSafe('listar_recentes', { loja, limite: 20 });
const html = lista.map(r => `
<tr onclick="selecionarRecente(${r.id})" style="cursor:pointer">
<td>${r.data}</td><td>${r.operador||'—'}</td><td>${r.turno}º</td>
<td style="text-align:right">${fmt(r.saldo_esperado||0)}</td>
<td><span style="font-size:0.7rem;padding:2px 6px;border-radius:8px;background:${r.sync_status==='synced'?'var(--success)':'var(--muted)'};color:#fff">${r.sync_status||'local'}</span></td>
</tr>`).join('');
const m = document.getElementById('modal-recentes') || createRecentesModal();
m.querySelector('tbody').innerHTML = html || '<tr><td colspan=5 style="text-align:center;color:var(--muted)">Nenhum registro</td></tr>';
m.classList.add('show');
} catch(e) { /* toast */ }
hideLoad();
}
function createRecentesModal() {
const div = document.createElement('div');
div.id = 'modal-recentes';
div.style.cssText = 'display:none;position:fixed;inset:0;background:rgba(0,0,0,0.8);z-index:600;align-items:center;justify-content:center;';
div.innerHTML = `<div style="background:var(--surface);border:1px solid var(--border);border-radius:12px;width:500px;max-width:90vw;max-height:80vh;overflow:hidden;display:flex;flex-direction:column;">
<div style="padding:14px 16px;border-bottom:1px solid var(--border);font-weight:600">📋 Fechamentos Recentes <span style="margin-left:auto;cursor:pointer" onclick="this.closest('#modal-recentes').classList.remove('show')">✕</span></div>
<div style="overflow-y:auto;flex:1"><table style="width:100%"><thead><tr><th>Data</th><th>Operador</th><th>Turno</th><th style="text-align:right">Valor</th><th>Status</th></tr></thead><tbody></tbody></table></div>
</div>`;
document.body.appendChild(div);
return div;
}
async function selecionarRecente(id) {
document.getElementById('modal-recentes')?.classList.remove('show');
showLoad('Carregando…');
try {
const r = await invokeSafe('buscar_fechamento', { id });
Object.assign(estado, r);
document.getElementById('f-saldo-troco').value = r.saldo_troco || '';
document.getElementById('f-fechamento').value = r.fechamento || '';
document.getElementById('f-vales').value = r.vales || '';
document.getElementById('f-credito').value = r.cartoes?.credito || '';
document.getElementById('f-debito').value = r.cartoes?.debito || '';
document.getElementById('f-alimentacao').value = r.cartoes?.alimentacao || '';
document.getElementById('f-voucher').value = r.cartoes?.voucher || '';
document.getElementById('f-cancelamentos').value = r.cartoes?.cancelamentos || '';
document.getElementById('f-observacoes').value = r.observacoes || '';
document.getElementById('sel-operador').value = r.operador || '';
document.getElementById('sel-turno').value = r.turno || '';
restoreTable('tbl-despesas', (r.despesas||[]).map(d => [d.desc, d.valor]));
restoreTable('tbl-receber', (r.receber||[]).map(d => [d.desc, d.valor]));
calcular();
toast('Fechamento carregado', 'success');
} catch(e) {}
hideLoad();
}
// ── Operadores ───────────────────────────────────────────────────────────
async function carregarOperadores() {
const loja = val('sel-loja');
if (!loja) return;
try {
const listas = await invoke('carregar_listas', { loja }).catch(() => null);
const ops = (listas?.operadores || []).map(o => `<option value="${o}">${o}</option>`).join('');
document.getElementById('sel-operador').innerHTML = '<option value="">Operador…</option>' + ops;
} catch(e) {}
}
// ── Helpers ───────────────────────────────────────────────────────────────
const val = id => document.getElementById(id)?.value || '';
const fmt = v => {
v = parseFloat(v) || 0;
return 'R$ ' + v.toLocaleString('pt-BR', {minimumFractionDigits: 2, maximumFractionDigits: 2});
};
function toast(msg, cls = '') {
const t = document.getElementById('toast');
t.textContent = msg; t.className = 'show ' + cls;
clearTimeout(t._timer);
t._timer = setTimeout(() => t.className = '', 3000);
}
function showLoad(msg = 'Aguarde…') {
document.getElementById('loading-msg').textContent = msg;
document.getElementById('loading').classList.add('show');
}
const hideLoad = () => document.getElementById('loading').classList.remove('show');
function toggleSec(name) {
const body = document.getElementById('body-' + name);
const toggle = document.getElementById('toggle-' + name);
const hidden = body.style.display === 'none';
body.style.display = hidden ? '' : 'none';
toggle.textContent = hidden ? '▼' : '▲';
}
function getTableRows(tblId) {
const rows = [];
document.querySelectorAll(`#${tblId} tbody tr`).forEach(tr => {
const inputs = tr.querySelectorAll('input');
if (inputs[0]?.value) rows.push([inputs[0].value, inputs[1]?.value || '0']);
});
return rows;
}
function restoreTable(tblId, data) {
const tbody = document.querySelector(`#${tblId} tbody`);
tbody.innerHTML = '';
(data || []).forEach(([desc, valor]) => addTableRow(tblId, desc, valor));
const fn = tblId === 'tbl-despesas' ? addDespesa : addReceber;
if (!data?.length) fn();
}
function addDespesa() { addTableRow('tbl-despesas'); }
function addReceber() { addTableRow('tbl-receber'); }
function addTableRow(tblId, desc = '', valor = '') {
const tbody = document.querySelector(`#${tblId} tbody`);
const tr = document.createElement('tr');
tr.innerHTML = `
<td><input value="${escHtml(desc)}" placeholder="Descricao" onchange="calcular()"></td>
<td><input type="number" value="${valor}" step="0.01" placeholder="0,00" oninput="calcular()"></td>
<td><button class="btn-remove" onclick="this.closest('tr').remove();calcular()">✕</button></td>`;
tbody.appendChild(tr);
}
const escHtml = s => (s||'').replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;');
function limparForm() {
if (!confirm('Limpar formulario? Rascunho local sera mantido.')) return;
document.getElementById('f-saldo-troco').value = '';
document.getElementById('f-fechamento').value = '';
document.getElementById('f-vales').value = '';
document.getElementById('f-credito').value = '';
document.getElementById('f-debito').value = '';
document.getElementById('f-alimentacao').value = '';
document.getElementById('f-voucher').value = '';
document.getElementById('f-cancelamentos').value = '';
document.getElementById('f-observacoes').value = '';
document.querySelectorAll('#tbl-despesas tbody tr, #tbl-receber tbody tr').forEach(r => r.remove());
estado.uuid = null;
estado.id_local = null;
estado.observacoes = 'rascunho';
calcular();
toast('Formulario limpo');
}
function atualizarSyncBadge(status) {
const b = document.getElementById('sync-badge');
if (status === 'synced') {
b.textContent = '● Sincronizado'; b.className = 'sync-badge synced';
} else if (status === 'error') {
b.textContent = '● Erro'; b.className = 'sync-badge error';
} else {
b.textContent = '● Local'; b.className = 'sync-badge';
}
}
// ── Config Modal ─────────────────────────────────────────────────────────
function abrirConfig() {
document.getElementById('modal-config').style.display = 'flex';
}
function fecharConfig() {
document.getElementById('modal-config').style.display = 'none';
}
async function detectarImpressora() {
const status = document.getElementById('cfg-status');
status.textContent = 'Detectando…';
try {
const path = await invoke('detectar_impressora');
if (path) {
status.textContent = 'Encontrada: ' + path;
toast('Impressora detectada!', 'success');
} else {
status.textContent = 'Nenhuma impressora USB encontrada.';
}
} catch(e) {
status.textContent = 'Erro: ' + e;
}
}
// ── Tauri event listeners ────────────────────────────────────────────────
window.__TAURI__.core.listen('tauri://error', e => {
console.error('Tauri error:', e);
toast('Erro interno: ' + e, 'error');
});
// ── Wire events ──────────────────────────────────────────────────────────
document.getElementById('sel-loja').addEventListener('change', async e => {
estado.loja = e.target.value;
document.getElementById('loja-label').textContent = estado.loja;
await invoke('set_loja', { loja: estado.loja }).catch(() => {});
await carregarOperadores();
scheduleAutoSave();
});
['inp-data','sel-turno','sel-operador'].forEach(id => {
document.getElementById(id)?.addEventListener('change', scheduleAutoSave);
});
document.querySelectorAll('input, textarea, select').forEach(el => {
el.addEventListener('input', scheduleAutoSave);
});
// Start
init();
</script>
</body>
</html>