Fechamento de Caixa - Tauri 2 initial commit
- 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:
@@ -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
|
||||
Generated
+232
@@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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": {}
|
||||
}
|
||||
Generated
+5505
File diff suppressed because it is too large
Load Diff
@@ -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"
|
||||
@@ -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 |
Binary file not shown.
|
After Width: | Height: | Size: 2.0 KiB |
@@ -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");
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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)]
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
pub mod app;
|
||||
pub mod escpos;
|
||||
pub mod printer;
|
||||
pub mod storage;
|
||||
pub mod supabase;
|
||||
@@ -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()
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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,'&').replace(/</g,'<').replace(/>/g,'>');
|
||||
|
||||
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>
|
||||
Reference in New Issue
Block a user