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