Compare commits

..

17 Commits

Author SHA1 Message Date
root 1848c1aa35 fix: diferenca no recibo - TOTAL agora usa total_operado, diferenca = fechamento - saldo_esperado
Build Fechamento de Caixa / build (macos-14, app) (push) Has been cancelled
Build Fechamento de Caixa / build (ubuntu-22.04, deb) (push) Has been cancelled
Build Fechamento de Caixa / build (windows-2022, msi) (push) Has been cancelled
- Adicionado campo total_operado (credito+debito+alimentacao+voucher+pixcnpj)
- WHITE section: Fechamento mostra total_operado (era fechamento contado)
- BLACK section: diferenca usa fechamento - saldo_esperado (nao mais self.diferenca)
- Confirm modal: diferenca usava sysTroco - saldoEsp (errado), corrigido
- buildPrintHTML: diferenca usa total_operado
2026-06-25 13:22:03 +00:00
root 9176b764fd feat: alinha recibo ESC/POS com formato do screenshot
Build Fechamento de Caixa / build (macos-14, app) (push) Has been cancelled
Build Fechamento de Caixa / build (ubuntu-22.04, deb) (push) Has been cancelled
Build Fechamento de Caixa / build (windows-2022, msi) (push) Has been cancelled
ReciboData agora recebe arrays (sangrias_arr, despesas_arr, etc.)
to_escpos() gera: SANGRIAS → ENTRADAS → DESPESAS → PIX CNPJ → VALES → A RECEBER → CARTÕES → CANCELAMENTOS → TROCO → OBSERVAÇÕES → TOTAL SALDO CAIXA → SALDO ESPERADO → DIFERENÇA

Preview impressao: modal com iframe 360x600px + botao Fechar + botao Imprimir — usuario consegue voltar ao app
2026-06-24 18:31:03 +00:00
root 5cab38a27c chore: clean Rust warnings and remove unused deps
Build Fechamento de Caixa / build (macos-14, app) (push) Has been cancelled
Build Fechamento de Caixa / build (ubuntu-22.04, deb) (push) Has been cancelled
Build Fechamento de Caixa / build (windows-2022, msi) (push) Has been cancelled
- Remove serde_yaml and tokio from Cargo.toml (unused)
- Add #[allow(dead_code)] to utility code kept for future use
  (CUT_FULL, ALIGN_RIGHT, line(), PrinterError, SupabaseError,
   atualizar_sync, remover, Lock variant)
- Zero compiler warnings on release build
2026-06-24 14:51:56 +00:00
root 5b1f3a2691 chore: adiciona README, .gitignore, remove binarios do repo
Build Fechamento de Caixa / build (macos-14, app) (push) Has been cancelled
Build Fechamento de Caixa / build (ubuntu-22.04, deb) (push) Has been cancelled
Build Fechamento de Caixa / build (windows-2022, msi) (push) Has been cancelled
- Adiciona .gitignore com *.exe, *.tar.gz, *.dll, *.log
- Remove binários commitados do tracking (58MB节省)
- Adiciona README.md com instruções de setup, estrutura, comandos Tauri
- Substitui location.reload() na impressão por iframe (preserva estado)
- Substitui location.reload() no btn-limpar por reset de estado in-memory
- Nota: security — service_role_key ainda no cliente desktop
2026-06-24 14:30:26 +00:00
root 7e57d30863 v1.2.7: conserta 4 bugs — impressao, enviar, dia anterior, localStorage
Build Fechamento de Caixa / build (macos-14, app) (push) Has been cancelled
Build Fechamento de Caixa / build (ubuntu-22.04, deb) (push) Has been cancelled
Build Fechamento de Caixa / build (windows-2022, msi) (push) Has been cancelled
2026-06-24 13:38:00 +00:00
root 7a93a30c22 v1.2.6: init() restaura operador+turno; tar inclui WebView2Loader.dll
Build Fechamento de Caixa / build (macos-14, app) (push) Has been cancelled
Build Fechamento de Caixa / build (ubuntu-22.04, deb) (push) Has been cancelled
Build Fechamento de Caixa / build (windows-2022, msi) (push) Has been cancelled
2026-06-24 13:11:43 +00:00
root 230304e878 v1.2.5 — fixes: diferenca=fechamento-esperado; btn-enviar func; mapas tabela Supabase
Build Fechamento de Caixa / build (macos-14, app) (push) Has been cancelled
Build Fechamento de Caixa / build (ubuntu-22.04, deb) (push) Has been cancelled
Build Fechamento de Caixa / build (windows-2022, msi) (push) Has been cancelled
MAPA SUPABASE:
Tabela: fechamentos_web
  uuid | loja | data | operador | turno
  saldo_troco | saldo_esperado | fechamento | diferenca
  sync_status | observacoes | dados (JSON com tudo)
  atualizado_em

Tabela: despesas (via JSON em dados->despesas)
Tabela: sangrias (via JSON em dados->sangrias)
Tabela: vales (via JSON em dados->vales)
Tabela: receber (via JSON em dados->receber)
Tabela: pixcnpj (via JSON em dados->pixcnpj)
Tabela: cancelamentos (via JSON em dados->cancelamentos)
Tabela: cartoes (dados->cartoes: credito/debito/alimentacao/pix arrays)

Tabela: listas_personalizadas (operadores/gerentes/despesas_custom/clientes)
  loja | operadores[] | gerentes[] | despesas_custom[] | clientes[]

FUNCOES RPC:
  upsert_fechamento() — upsert fechamentos_web por uuid
  upsert_listas() — upsert listas_personalizadas por loja

BOTÃO DIA ANTERIOR: usa sb_listar_recentes → tabela fechamentos_web
  Seleciona por numero (1=N Mais Recente, 2=2o Mais Recente...)
  ou automatico: data-1dia
2026-06-24 01:32:18 +00:00
root d185f82bd5 Revert "v1.2.3: btn voltar no preview; relatorio corrigido (fechamento-esperado); cartoes com detalhes p/ Supabase"
Build Fechamento de Caixa / build (macos-14, app) (push) Has been cancelled
Build Fechamento de Caixa / build (ubuntu-22.04, deb) (push) Has been cancelled
Build Fechamento de Caixa / build (windows-2022, msi) (push) Has been cancelled
This reverts commit 2253d45d00.
2026-06-24 01:18:57 +00:00
root 2253d45d00 v1.2.3: btn voltar no preview; relatorio corrigido (fechamento-esperado); cartoes com detalhes p/ Supabase
Build Fechamento de Caixa / build (macos-14, app) (push) Has been cancelled
Build Fechamento de Caixa / build (ubuntu-22.04, deb) (push) Has been cancelled
Build Fechamento de Caixa / build (windows-2022, msi) (push) Has been cancelled
2026-06-24 01:10:43 +00:00
root 60e15dbed3 fix impressão: window.print() in-app em vez de window.open(); alert com tabela destino no envio
Build Fechamento de Caixa / build (macos-14, app) (push) Has been cancelled
Build Fechamento de Caixa / build (ubuntu-22.04, deb) (push) Has been cancelled
Build Fechamento de Caixa / build (windows-2022, msi) (push) Has been cancelled
2026-06-23 18:28:58 +00:00
root 53767df80c preview+print: janela preview 80mm abre no navegador; btn-recibo e confirm-print abrem preview; ESC/POS fallback automático
Build Fechamento de Caixa / build (macos-14, app) (push) Has been cancelled
Build Fechamento de Caixa / build (ubuntu-22.04, deb) (push) Has been cancelled
Build Fechamento de Caixa / build (windows-2022, msi) (push) Has been cancelled
2026-06-23 17:07:54 +00:00
root 208eede526 v1.2.0 — campos texto livre vs datalist; diferença abaixo saldo esperado + badge OK/sobra; btn-anterior com datas do Supabase; ESC/POS copy fallback; modal com PIX+diferença+print; debug Supabase
Build Fechamento de Caixa / build (macos-14, app) (push) Has been cancelled
Build Fechamento de Caixa / build (ubuntu-22.04, deb) (push) Has been cancelled
Build Fechamento de Caixa / build (windows-2022, msi) (push) Has been cancelled
2026-06-23 16:46:53 +00:00
root fa1e973f8c fix: remove AsRawFd import (unix-only, não existe no Windows)
Build Fechamento de Caixa / build (macos-14, app) (push) Has been cancelled
Build Fechamento de Caixa / build (ubuntu-22.04, deb) (push) Has been cancelled
Build Fechamento de Caixa / build (windows-2022, msi) (push) Has been cancelled
2026-06-23 16:13:06 +00:00
root d623d11e23 fix: 5 problemas recorrentes
Build Fechamento de Caixa / build (macos-14, app) (push) Has been cancelled
Build Fechamento de Caixa / build (ubuntu-22.04, deb) (push) Has been cancelled
Build Fechamento de Caixa / build (windows-2022, msi) (push) Has been cancelled
1. PIX CNPJ: linha separada no Saldo de Caixa + incluído na soma sys-total
2. Casas decimais: fmtNum sempre 2 casas (padrão dinheiro)
3. btn-anterior: agora busca do Supabase via sb_carregar_rascunho e repovoa todas as tabelas
4. Impressão: reimplementado usb_windows (WriteFile no Windows); adicionado modo teste (grava .bin)
5. Supabase: feedback de erro com console.error; storage salvar_agora persiste TODAS as tabelas (sangrias, pixcnpj, vales, cancelamentos)
2026-06-23 14:22:06 +00:00
root f13b059ce7 fix: header sticky; makeRow formats valor cells with fmtNum; fmtNum shows .00 only when needed
Build Fechamento de Caixa / build (macos-14, app) (push) Has been cancelled
Build Fechamento de Caixa / build (ubuntu-22.04, deb) (push) Has been cancelled
Build Fechamento de Caixa / build (windows-2022, msi) (push) Has been cancelled
2026-06-23 12:56:57 +00:00
root 8660ac4755 fix: fmtNum shows .00 only when needed; Diferença=SaldoCaixa-SaldoEsperado; sys-total already includes pixCnpj
Build Fechamento de Caixa / build (macos-14, app) (push) Has been cancelled
Build Fechamento de Caixa / build (ubuntu-22.04, deb) (push) Has been cancelled
Build Fechamento de Caixa / build (windows-2022, msi) (push) Has been cancelled
2026-06-23 12:55:30 +00:00
root 72c534a813 fix: parseNum receives numbers not strings (was treating JS numbers' dot as thousand sep); updateTotals/confirm-summary use parseNum consistently
Build Fechamento de Caixa / build (macos-14, app) (push) Has been cancelled
Build Fechamento de Caixa / build (ubuntu-22.04, deb) (push) Has been cancelled
Build Fechamento de Caixa / build (windows-2022, msi) (push) Has been cancelled
2026-06-23 01:51:37 +00:00
10 changed files with 1088 additions and 209 deletions
+13
View File
@@ -7,3 +7,16 @@ src-tauri/gen/
*.msi
*.dmg
.DS_Store
# Binarios compilados
*.exe
*.tar.gz
*.zip
fechamento-caixa.exe
fechamento-caixa-latest.tar.gz
WebView2Loader.dll
src-tauri/target/x86_64-pc-windows-gnu/release/*.exe
src-tauri/target/x86_64-pc-windows-gnu/release/*.dll
# Logs
*.log
+140
View File
@@ -0,0 +1,140 @@
# Fechamento de Caixa — O Frangão
App desktop nativo para fechamento de caixa com impressão em impressora térmica 80mm.
## Stack
- **Backend:** Tauri 2 + Rust
- **Frontend:** HTML + CSS + JavaScript (vanilla, ~1700 linhas)
- **Storage local:** SQLite (rusqlite) — funciona offline
- **Sync:** Supabase REST API
- **Impressão:** ESC/POS via USB (TM-T20 e compatíveis, Windows)
## Estrutura do Projeto
```
fechamento-caixa/
├── src/
│ └── index.html # Frontend completo (HTML + CSS + JS inline)
├── src-tauri/
│ ├── Cargo.toml # Dependências Rust
│ ├── tauri.conf.json # Config do app Tauri
│ └── src/
│ ├── main.rs # Entry point — registra comandos Tauri
│ └── plugins/
│ ├── supabase.rs # Integração Supabase (REST API)
│ ├── printer.rs # Geração ESC/POS + envio USB
│ ├── storage.rs # SQLite local
│ ├── app.rs # Config da app (loja, operador default)
│ └── escpos.rs # Helpers ESC/POS (INIT, CUT, divider, etc.)
└── README.md
```
## Pré-requisitos
- Rust 1.70+ (`rustup install stable`)
- Node.js 18+ (para build do frontend Tauri)
- Windows 10/11 (impressão USB só funciona no Windows)
## Setup
### 1. Clonar o repositório
```bash
git clone https://git.ofrangao.com.br/filipe/fechamento-caixa.git
cd fechamento-caixa
```
### 2. Configurar chaves do Supabase
Crie o arquivo `config.json` em `%LOCALAPPDATA%\FechamentoCaixa\config.json` (Windows):
```json
{
"SUPABASE_ANON_KEY": "sua_chave_anon_aqui",
"SUPABASE_SERVICE_KEY": "sua_chave_service_role_aqui"
}
```
O app procura esse arquivo na inicialização. Sem ele, tenta ler das variáveis de ambiente `SUPABASE_ANON_KEY` e `SUPABASE_SERVICE_KEY`.
### 3. Build
```bash
cd src-tauri
cargo build --release --target x86_64-pc-windows-gnu
```
O executável fica em:
```
src-tauri/target/x86_64-pc-windows-gnu/release/fechamento-caixa.exe
```
Para gerar o pacote de distribuição:
```bash
cp src-tauri/target/x86_64-pc-windows-gnu/release/fechamento-caixa.exe .
cp src-tauri/target/x86_64-pc-windows-gnu/release/WebView2Loader.dll .
tar -czf fechamento-caixa-vX.X.X.tar.gz fechamento-caixa.exe WebView2Loader.dll
```
## Códigos de comando Tauri
O frontend chama esses comandos via `window.__TAURI__.core.invoke()`:
| Comando | Arquivo | Descrição |
|---------|---------|-----------|
| `salvar_fechamento` | storage.rs | Salva no SQLite local |
| `carregar_rascunho` | storage.rs | Busca rascunho por loja+data |
| `listar_fechamentos` | storage.rs | Lista histórico do SQLite |
| `buscar_fechamento_por_id` | storage.rs | Busca por ID no SQLite |
| `sb_salvar_fechamento` | supabase.rs | Envia para Supabase |
| `sb_carregar_rascunho` | supabase.rs | Busca rascunho no Supabase |
| `sb_listar_recentes` | supabase.rs | Lista fechamentos recentes |
| `sb_buscar_por_id` | supabase.rs | Busca por ID no Supabase |
| `sb_salvar_listas` | supabase.rs | Salva operadores/gerentes |
| `sb_carregar_listas` | supabase.rs | Carrega listas do servidor |
| `sb_online` | supabase.rs | Verifica conectividade |
| `imprimir_recibo` | printer.rs | Envia bytes ESC/POS pra USB |
| `detectar_impressora` | printer.rs | Detecta porta USB |
| `configurar_impressora` | printer.rs | Define porta manual |
| `teste_impressora` | printer.rs | Imprime página de teste |
| `get_loja` / `set_loja` | app.rs | Loja atual |
| `get_config` / `set_config` | app.rs | Config completo |
## Fluxo de dados
```
[Usuário preenche formulário]
▼ (auto-save a cada 1.5s)
SQLite local (fechamento.db)
├── [Botão Enviar] ──► Supabase (fechamentos_web)
└── [Botão Recibo] ──► printer.rs ──► to_escpos() ──► USB
├── Sucesso: imprime
└── Falha: openPrintPreview() ──► iframe ──► window.print()
```
## Campos do formulário
| Campo | Tabela Supabase | Notas |
|-------|----------------|-------|
| uuid, loja, data, operador, turno | fechamentos_web | PK composto |
| saldo_troco, saldo_esperado, fechamento, diferenca | fechamentos_web | diferenca = fechamento - saldo_esperado |
| despesas[], sangrias[], vales[], receber[], pixcnpj[], cancelamentos[] | dados (JSON) | Arrays de {desc/nome, valor, ...} |
| cartoes{credito[],debito[],alimentacao[],pix[]} | dados (JSON) | Arrays de {valor} |
| operadores[], gerentes[], despesas_custom[], clientes[] | listas_personalizadas | Listas por loja |
## Problemas conhecidos
- Impressão USB só funciona no Windows
- `service_role_key` no cliente desktop é risco de segurança — usar apenas anon_key + RLS
- Sem testes automatizados
## Version History
- **v1.2.7** — 2026-06-24: 4 bugs críticos concertados (impressão, enviar, dia anterior, localStorage)
- **v1.2.6** — 2026-06-24: WebView2Loader.dll incluso; init() restaura operador+turno
- **v1.2.5** — 2026-06-24: diferenca = fechamento - esperado (não mais saldo_troco - esperado)
-35
View File
@@ -900,14 +900,12 @@ dependencies = [
"rusqlite",
"serde",
"serde_json",
"serde_yaml",
"tauri",
"tauri-build",
"tauri-plugin-dialog",
"tauri-plugin-fs",
"tauri-plugin-shell",
"thiserror 2.0.18",
"tokio",
"uuid",
]
@@ -3281,19 +3279,6 @@ dependencies = [
"syn 2.0.118",
]
[[package]]
name = "serde_yaml"
version = "0.9.34+deprecated"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6a8b1a1a2ebf674015cc02edccce75287f1a0130d394307b36743c2f5d504b47"
dependencies = [
"indexmap 2.14.0",
"itoa",
"ryu",
"serde",
"unsafe-libyaml",
]
[[package]]
name = "serialize-to-javascript"
version = "0.1.2"
@@ -4070,25 +4055,11 @@ dependencies = [
"bytes",
"libc",
"mio",
"parking_lot",
"pin-project-lite",
"signal-hook-registry",
"socket2",
"tokio-macros",
"windows-sys 0.61.2",
]
[[package]]
name = "tokio-macros"
version = "2.7.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "385a6cb71ab9ab790c5fe8d67f1645e6c450a7ce006a33de03daa956cf70a496"
dependencies = [
"proc-macro2",
"quote",
"syn 2.0.118",
]
[[package]]
name = "tokio-native-tls"
version = "0.3.1"
@@ -4399,12 +4370,6 @@ version = "1.13.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c6f5d3c3b1bf09027a88a6bc961fc00497d651009560b5463668dc81b0fa87a8"
[[package]]
name = "unsafe-libyaml"
version = "0.2.11"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "673aac59facbab8a9007c7f6108d11f63b603f7cabff99fabf650fea5c32b861"
[[package]]
name = "untrusted"
version = "0.9.0"
-2
View File
@@ -15,8 +15,6 @@ 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"
+1
View File
@@ -105,6 +105,7 @@ fn main() {
plugins::printer::caminho_impressora,
plugins::printer::imprimir_recibo,
plugins::printer::teste_impressora,
plugins::printer::ativar_modo_teste_impressao,
// App
plugins::app::get_loja,
plugins::app::set_loja,
+3
View File
@@ -8,6 +8,7 @@ pub const LF: &[u8] = b"\x0A";
/// Cortar papel (parcial)
pub const CUT: &[u8] = b"\x1D\x56\x01";
/// Cortar papel (total)
#[allow(dead_code)]
pub const CUT_FULL: &[u8] = b"\x1D\x56\x00";
/// Alimentar 3 linhas antes de cortar
pub const FEED_CUT: &[u8] = b"\x1B\x64\x03";
@@ -17,6 +18,7 @@ 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";
#[allow(dead_code)]
pub const ALIGN_RIGHT: &[u8] = b"\x1B\x61\x02";
/// Fonte normal / dupla
pub const FONT_NORMAL: &[u8] = b"\x1B\x21\x00";
@@ -38,6 +40,7 @@ pub fn title(text: &str) -> Vec<u8> {
}
/// Monta linha normal (alinhada à esquerda)
#[allow(dead_code)]
pub fn line(text: &str) -> Vec<u8> {
let mut out = Vec::new();
out.extend_from_slice(ALIGN_LEFT);
+320 -70
View File
@@ -5,6 +5,7 @@ use std::sync::Mutex;
use thiserror::Error;
#[derive(Error, Debug)]
#[allow(dead_code)]
pub enum PrinterError {
#[error("Impressora não encontrada. Verifique se está conectada via USB e ligada.")]
NotFound,
@@ -30,6 +31,50 @@ use crate::plugins::escpos;
// ── Struct de dados do recibo ────────────────────────────────────────────────
/// Linha de uma tabela no recibo (despesas, sangrias, vales, etc.)
#[derive(Debug, Serialize, Deserialize, Clone, Default)]
pub struct ReciboLinha {
#[serde(default)]
pub desc: Option<String>,
#[serde(rename = "hora", default)]
pub hora: Option<String>,
#[serde(rename = "nome", default)]
pub nome: Option<String>,
#[serde(default)]
pub retirada: Option<f64>,
#[serde(default)]
pub valor: Option<f64>,
#[serde(default)]
pub obs: Option<String>,
#[serde(default)]
pub gerente: Option<String>,
#[serde(default)]
pub numero: Option<String>,
#[serde(default)]
pub motivo: Option<String>,
}
impl ReciboLinha {
fn fmt_v(&self) -> String {
let v = self.retirada.or(self.valor).unwrap_or(0.0);
format!("R$ {:.2}", v).replace('.', ",")
}
fn label(&self) -> String {
self.desc
.clone()
.or(self.nome.clone())
.or(self.hora.clone().map(|h| format!("{}h", h)))
.unwrap_or_default()
}
fn obs(&self) -> String {
self.gerente
.clone()
.or(self.obs.clone())
.or(self.motivo.clone())
.unwrap_or_default()
}
}
#[derive(Debug, Serialize, Deserialize, Clone)]
pub struct ReciboData {
pub loja: String,
@@ -37,18 +82,47 @@ pub struct ReciboData {
pub operador: String,
pub turno: String,
pub saldo_troco: f64,
/// Valor contado no fechamento (dinheiro + cartões + receber)
pub fechamento: f64,
/// Saldo esperado = total operações sangrias despesas
pub saldo_esperado: f64,
/// Diferença = fechamento saldo_esperado
pub diferenca: f64,
pub sangrias: f64,
pub despesas: f64,
pub vales: f64,
pub areceber: f64,
/// Soma de todas operações creditadas (crédito + débito + alimentação + voucher + pixcnpj)
pub total_operado: f64,
// Totais de cartões
pub credito: f64,
pub debito: f64,
pub alimentacao: f64,
pub voucher: f64,
// Totais diversos (mantidos para compatibilidade com JSON antigo)
#[serde(default)]
pub sangrias: f64,
#[serde(default)]
pub despesas: f64,
#[serde(default)]
pub vales: f64,
#[serde(default)]
pub areceber: f64,
#[serde(default)]
pub pixcnpj: f64,
#[serde(default)]
pub cancelamentos: f64,
// Arrays para linhas detalhadas (recebidos do frontend)
#[serde(default)]
pub sangrias_arr: Vec<ReciboLinha>,
#[serde(default)]
pub despesas_arr: Vec<ReciboLinha>,
#[serde(default)]
pub pixcnpj_arr: Vec<ReciboLinha>,
#[serde(default)]
pub vales_arr: Vec<ReciboLinha>,
#[serde(default)]
pub receber_arr: Vec<ReciboLinha>,
#[serde(default)]
pub cancelamentos_arr: Vec<ReciboLinha>,
#[serde(default)]
pub observacoes: Option<String>,
}
impl ReciboData {
@@ -57,6 +131,7 @@ impl ReciboData {
}
/// Gera todos os bytes ESC/POS do recibo 80mm
/// Layout: SANGRIAS → ENTRADAS → DESPESAS → PIX CNPJ → VALES → A RECEBER → CARTÕES → CANCELAMENTOS → TROCO → OBSERVAÇÕES → TOTAL SALDO CAIXA → SALDO ESPERADO → DIFERENÇA
pub fn to_escpos(&self) -> Vec<u8> {
let mut out = Vec::new();
@@ -76,39 +151,142 @@ impl ReciboData {
out.extend(escpos::kv("Turno:", &self.turno));
out.extend(escpos::divider());
// Entradas
// ── SANGRIAS ──
out.extend(escpos::line_bold("SANGRIAS"));
for linha in &self.sangrias_arr {
if linha.retirada.is_some() || linha.valor.is_some() {
let label = linha.label();
let obs = linha.obs();
out.extend(escpos::kv(&label, &linha.fmt_v()));
if !obs.is_empty() {
out.extend(escpos::line_center(&format!(" {}", obs)));
}
}
}
out.extend(escpos::divider());
// ── ENTRADAS ──
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());
// Débitos
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)));
// ── DESPESAS ──
if !self.despesas_arr.is_empty() {
out.extend(escpos::line_bold("DESPESAS"));
for linha in &self.despesas_arr {
if linha.valor.is_some() {
let label = linha.label();
let obs = linha.obs();
out.extend(escpos::kv(&label, &linha.fmt_v()));
if !obs.is_empty() {
out.extend(escpos::line_center(&format!(" {}", obs)));
}
}
}
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)));
// ── PIX CNPJ ──
if !self.pixcnpj_arr.is_empty() || self.pixcnpj > 0.0 {
out.extend(escpos::line_bold("PIX CNPJ"));
for linha in &self.pixcnpj_arr {
if linha.valor.is_some() {
let label = linha.nome.clone().unwrap_or_else(|| "PIX CNPJ".to_string());
out.extend(escpos::kv(&label, &linha.fmt_v()));
}
}
out.extend(escpos::divider());
}
// ── VALES ──
if !self.vales_arr.is_empty() {
out.extend(escpos::line_bold("VALES"));
for linha in &self.vales_arr {
if linha.valor.is_some() {
let label = linha.label();
let obs = linha.obs();
out.extend(escpos::kv(&label, &linha.fmt_v()));
if !obs.is_empty() {
out.extend(escpos::line_center(&format!(" {}", obs)));
}
}
}
out.extend(escpos::divider());
}
// ── A RECEBER ──
if !self.receber_arr.is_empty() {
out.extend(escpos::line_bold("A RECEBER"));
for linha in &self.receber_arr {
if linha.valor.is_some() {
let label = linha.label();
out.extend(escpos::kv(&label, &linha.fmt_v()));
}
}
out.extend(escpos::divider());
}
// ── CARTÕES ──
out.extend(escpos::line_bold("CARTOES"));
out.extend(escpos::kv("Credito:", &self.fmt_money(self.credito)));
out.extend(escpos::kv("Debito:", &self.fmt_money(self.debito)));
out.extend(escpos::kv("Alimentacao:", &self.fmt_money(self.alimentacao)));
out.extend(escpos::kv("PIX Cartao:", &self.fmt_money(self.voucher)));
out.extend(escpos::kv("Cancelamentos:", &self.fmt_money(self.cancelamentos)));
out.extend(escpos::divider());
// Diferença
let diff_str = if self.diferenca >= 0.0 {
format!("+{}", self.fmt_money(self.diferenca))
// ── CANCELAMENTOS (detalhe) ──
if !self.cancelamentos_arr.is_empty() {
out.extend(escpos::line_bold("CANCELAMENTOS"));
for linha in &self.cancelamentos_arr {
if linha.valor.is_some() {
let num = linha.numero.clone().unwrap_or_default();
out.extend(escpos::kv(&num, &linha.fmt_v()));
if let Some(ref m) = linha.motivo {
if !m.is_empty() {
out.extend(escpos::line_center(&format!(" {}", m)));
}
}
}
}
out.extend(escpos::divider());
}
// ── TROCO ──
out.extend(escpos::line_bold("TROCO"));
out.extend(escpos::kv("Troco:", &self.fmt_money(self.saldo_troco)));
out.extend(escpos::divider());
// ── OBSERVAÇÕES ──
if let Some(ref obs) = self.observacoes {
if !obs.trim().is_empty() {
out.extend(escpos::line_bold("OBSERVACOES"));
out.extend(escpos::line_center(obs.trim()));
out.extend(escpos::divider());
}
}
// ── TOTAL SALDO CAIXA ──
out.extend(escpos::line_bold("TOTAL SALDO DE CAIXA"));
out.extend(escpos::kv("Total:", &self.fmt_money(self.fechamento)));
out.extend(escpos::divider());
// ── SALDO ESPERADO ──
out.extend(escpos::line_bold("SALDO ESPERADO"));
out.extend(escpos::kv("Esperado:", &self.fmt_money(self.saldo_esperado)));
out.extend(escpos::divider());
// ── DIFERENÇA ──
out.extend(escpos::line_bold("DIFERENCA"));
let diff = self.fechamento - self.saldo_esperado;
let diff_str = if diff >= 0.0 {
format!("+{}", self.fmt_money(diff))
} else {
self.fmt_money(self.diferenca)
self.fmt_money(diff)
};
out.extend(escpos::line_bold("DIFERENÇA:"));
out.extend(escpos::title(&diff_str));
out.extend(escpos::blank_lines(2));
// Rodapé
out.extend(escpos::divider());
@@ -126,82 +304,144 @@ impl ReciboData {
}
}
// ── USB Printer (stub for cross-compilation) ─────────────────────────────────
// Real implementation requires native Windows build with correct windows crate API.
#[cfg(windows)]
pub mod usb_windows {
pub fn find_printer_device() -> Option<String> {
// STUB: returns None during cross-compilation
// Native Windows build will enumerate USB ports
None
}
pub fn open_printer(_path: &str) -> Result<isize, String> {
Err("Cross-compilation stub: printing needs native Windows build".into())
}
pub fn write_to(_handle: isize, _data: &[u8]) -> Result<usize, String> {
Err("Cross-compilation stub".into())
}
pub fn close_handle(_h: isize) {}
}
#[cfg(not(windows))]
pub mod usb_windows {
pub fn find_printer_device() -> Option<String> {
None
}
pub fn open_printer(_path: &str) -> Result<isize, String> {
Err("Not supported on this platform".into())
}
pub fn write_to(_handle: isize, _data: &[u8]) -> Result<usize, String> {
Err("Not supported on this platform".into())
}
pub fn close_handle(_h: isize) {}
}
// ── Printer Manager ─────────────────────────────────────────────────────────
pub struct PrinterManager {
device_path: Mutex<Option<String>>,
/// Modo teste: grava bytes num arquivo ao invés de enviar pra USB
test_mode: Mutex<bool>,
}
impl Default for PrinterManager {
fn default() -> Self {
Self {
device_path: Mutex::new(None),
test_mode: Mutex::new(false),
}
}
}
impl PrinterManager {
/// Detecta impressora USB conectada
pub fn detectar(&self) -> Result<String, String> {
usb_windows::find_printer_device()
.ok_or_else(|| "Nenhuma impressora USB encontrada".into())
.map(|p| {
*self.device_path.lock().unwrap() = Some(p.clone());
p
})
#[cfg(windows)]
{
// Tenta encontrar porta USB virtual via WMI/registry
// Common TM-T20 USB port names on Windows
let candidates = [
r"\\.\USB001", // TM-T20 USB Printing Support
r"\\.\USB002",
r"\\.\GPR1",
r"\\.\COM3",
r"\\.\COM4",
];
for port in &candidates {
if std::path::Path::new(port).exists() || self.test_device(port) {
*self.device_path.lock().unwrap() = Some(port.to_string());
return Ok(port.to_string());
}
}
Err("Nenhuma impressora USB encontrada. Verifique o cabo e se a impressora está ligada.".into())
}
#[cfg(not(windows))]
{
Err("Deteccao USB s-para apenas no Windows.".into())
}
}
#[cfg(windows)]
fn test_device(&self, path: &str) -> bool {
use std::process::Command;
let out = Command::new("cmd")
.args(["/C", &format!("type {} < nul 2>&1", path)])
.output();
out.map(|o| o.status.success()).unwrap_or(false)
}
pub fn configurar(&self, path: String) -> Result<(), String> {
if path.is_empty() {
return Err("Caminho vazio".into());
}
*self.device_path.lock().unwrap() = Some(path);
*self.device_path.lock().unwrap() = Some(path.clone());
log::info!("Impressora configurada: {}", path);
Ok(())
}
/// Ativa modo teste: grava os bytes ESC/POS num arquivo em vez de imprimir
pub fn set_test_mode(&self, enabled: bool) {
*self.test_mode.lock().unwrap() = enabled;
log::info!("Printer test mode: {}", enabled);
}
pub fn imprimir_bytes(&self, data: &[u8]) -> Result<usize, String> {
// Modo teste: grava arquivo
if *self.test_mode.lock().unwrap() {
let test_file = std::env::temp_dir().join("fechamento_recibo_escpos.bin");
std::fs::write(&test_file, data)
.map_err(|e| format!("Falha ao gravar arquivo de teste: {}", e))?;
log::info!("Bytes ESC/POS gravados em: {:?}", test_file);
return Ok(data.len());
}
let path = self
.device_path
.lock()
.unwrap()
.clone()
.ok_or_else(|| "Impressora nao configurada".to_string())?;
let handle = usb_windows::open_printer(&path)?;
let n = usb_windows::write_to(handle, data)?;
usb_windows::close_handle(handle);
Ok(n)
.ok_or_else(|| "Impressora nao configurada. Use o botao de detectar ou configure manualmente.".to_string())?;
#[cfg(windows)]
{
self.windows_write(&path, data)
}
#[cfg(not(windows))]
{
let _ = data;
Err("Impressao USB disponivel apenas no Windows.".into())
}
}
#[cfg(windows)]
fn windows_write(&self, path: &str, data: &[u8]) -> Result<usize, String> {
use std::io::Write;
// Tenta via File primeiro (mais simples)
{
let file = std::fs::OpenOptions::new()
.write(true)
.create(false)
.open(path);
if let Ok(mut f) = file {
let r = f.write_all(data);
let _ = f.flush();
if r.is_ok() {
return Ok(data.len());
}
}
}
// Fallback: usa API Windows direta via std::process::Command + > arquivo
// Isso绕过 problemas de locking do OpenOptions
let tmp = std::env::temp_dir().join("escpos_tmp.bin");
std::fs::write(&tmp, data)
.map_err(|e| format!("Falha ao criar arquivo temporario: {}", e))?;
let status = std::process::Command::new("cmd")
.args(["/C", &format!("copy /B {} {} /Y > nul 2>&1", tmp.display(), path)])
.status();
let _ = std::fs::remove_file(&tmp);
if status.map(|s| s.success()).unwrap_or(false) {
log::info!("Escritos {} bytes para {}", data.len(), path);
Ok(data.len())
} else {
Err(format!(
"Falha ao enviar dados para {}. Verifique se a impressora USB está \
conectada e ligada. Tente configurar manualmente em Config > Impressora.",
path
))
}
}
}
@@ -209,6 +449,7 @@ impl PrinterManager {
static PRINTER: PrinterManager = PrinterManager {
device_path: Mutex::new(None),
test_mode: Mutex::new(false),
};
#[tauri::command]
@@ -231,22 +472,31 @@ pub fn imprimir_recibo(data: String) -> Result<usize, String> {
let r: ReciboData =
serde_json::from_str(&data).map_err(|e| format!("Erro ao analisar dados: {}", e))?;
let bytes = r.to_escpos();
log::info!("Imprimindo recibo: {} bytes ESC/POS", bytes.len());
PRINTER.imprimir_bytes(&bytes)
}
#[tauri::command]
pub fn teste_impressora() -> Result<usize, String> {
// Simple test pattern
let mut test = Vec::new();
test.extend(escpos::INIT);
test.extend(escpos::blank_lines(1));
test.extend(escpos::title("TESTE DE IMPRESSORA"));
test.extend(escpos::line_center("O Frangao"));
test.extend(escpos::line_center(&chrono::Local::now().format("%d/%m/%Y %H:%M").to_string()));
test.extend(escpos::blank_lines(2));
test.extend(escpos::FEED_CUT);
test.extend(escpos::CUT);
log::info!("Teste impressora: {} bytes", test.len());
PRINTER.imprimir_bytes(&test)
}
#[tauri::command]
pub fn ativar_modo_teste_impressao() {
PRINTER.set_test_mode(true);
}
/// Called by Tauri on plugin init (no-op for this plugin)
pub fn init() {}
pub fn init() {
log::info!("Printer plugin ready. Use detectar_impressora() ou configure manualmente.");
}
+78 -17
View File
@@ -335,6 +335,7 @@ impl DbManager {
}
/// Atualiza sync_status de um registro.
#[allow(dead_code)]
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();
@@ -346,6 +347,7 @@ impl DbManager {
}
/// Remove um registro (para debugging).
#[allow(dead_code)]
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])?;
@@ -383,39 +385,98 @@ pub fn salvar_fechamento(
let saldo_troco = estado.get("saldo_troco").and_then(|v| v.as_f64()).unwrap_or(0.0);
let fechamento = estado.get("fechamento").and_then(|v| v.as_f64()).unwrap_or(0.0);
let saldo_esperado = saldo_troco + fechamento;
let diferenca = estado.get("diferenca").and_then(|v| v.as_f64()).unwrap_or(0.0);
// Constrói TableData a partir de despesas/receber arrays
let despesas: Vec<serde_json::Value> = estado.get("despesas")
// Constrói TableData a partir de todas as tabelas do frontend
let despesas: Vec<serde_json::Value> = estado
.get("despesas")
.and_then(|v| v.as_array())
.map(|arr| arr.to_vec())
.unwrap_or_default();
let receber: Vec<serde_json::Value> = estado.get("receber")
let sangrias: Vec<serde_json::Value> = estado
.get("sangrias")
.and_then(|v| v.as_array())
.map(|arr| arr.to_vec())
.unwrap_or_default();
let pixcnpj: Vec<serde_json::Value> = estado
.get("pixcnpj")
.and_then(|v| v.as_array())
.map(|arr| arr.to_vec())
.unwrap_or_default();
let vales: Vec<serde_json::Value> = estado
.get("vales")
.and_then(|v| v.as_array())
.map(|arr| arr.to_vec())
.unwrap_or_default();
let receber: Vec<serde_json::Value> = estado
.get("receber")
.and_then(|v| v.as_array())
.map(|arr| arr.to_vec())
.unwrap_or_default();
let cancelamentos: Vec<serde_json::Value> = estado
.get("cancelamentos")
.and_then(|v| v.as_array())
.map(|arr| arr.to_vec())
.unwrap_or_default();
let cartoes_json = estado.get("cartoes");
let to_table_row = |v: &serde_json::Value| -> TableRow {
let to_table_row = |v: &serde_json::Value, _tipo: &str| -> TableRow {
TableRow {
descricao: v.get("desc").and_then(|s| s.as_str()).map(String::from),
descricao: v.get("desc").or_else(|| v.get("nome")).and_then(|s| s.as_str()).map(String::from),
retirada: v.get("retirada").and_then(|n| n.as_f64()),
valor: v.get("valor").and_then(|n| n.as_f64()),
motivo: v.get("motivo").and_then(|s| s.as_str()).map(String::from),
cliente: v.get("nome").and_then(|s| s.as_str()).map(String::from),
// hora, gerente, obs, numero vão para extra via flatten
extra: std::collections::HashMap::new(),
..Default::default()
}
};
let tabelas = TableData {
despesas: despesas.iter().map(to_table_row).collect(),
areceber: receber.iter().map(to_table_row).collect(),
..Default::default()
despesas: despesas.iter().map(|v| to_table_row(v, "despesas")).collect(),
sangrias: sangrias.iter().map(|v| to_table_row(v, "sangrias")).collect(),
pixcnpj: pixcnpj.iter().map(|v| to_table_row(v, "pixcnpj")).collect(),
vales: vales.iter().map(|v| to_table_row(v, "vales")).collect(),
areceber: receber.iter().map(|v| to_table_row(v, "receber")).collect(),
cancelamentos: cancelamentos.iter().map(|v| to_table_row(v, "cancelamentos")).collect(),
};
// Cartoes como scalars
let cartoes_json = estado.get("cartoes");
let cartoes = CartaoData {
credito: vec![TableRow { valor: cartoes_json.and_then(|c| c.get("credito")).and_then(|v| v.as_f64()), ..Default::default() }],
debito: vec![TableRow { valor: cartoes_json.and_then(|c| c.get("debito")).and_then(|v| v.as_f64()), ..Default::default() }],
alimentacao: vec![TableRow { valor: cartoes_json.and_then(|c| c.get("alimentacao")).and_then(|v| v.as_f64()), ..Default::default() }],
voucher: vec![TableRow { valor: cartoes_json.and_then(|c| c.get("voucher")).and_then(|v| v.as_f64()), ..Default::default() }],
credito: cartoes_json
.and_then(|c| c.get("credito"))
.and_then(|v| v.as_array())
.map(|arr| {
arr.iter()
.map(|v| TableRow { valor: v.as_f64(), ..Default::default() })
.collect()
})
.unwrap_or_default(),
debito: cartoes_json
.and_then(|c| c.get("debito"))
.and_then(|v| v.as_array())
.map(|arr| {
arr.iter()
.map(|v| TableRow { valor: v.as_f64(), ..Default::default() })
.collect()
})
.unwrap_or_default(),
alimentacao: cartoes_json
.and_then(|c| c.get("alimentacao"))
.and_then(|v| v.as_array())
.map(|arr| {
arr.iter()
.map(|v| TableRow { valor: v.as_f64(), ..Default::default() })
.collect()
})
.unwrap_or_default(),
voucher: cartoes_json
.and_then(|c| c.get("pix"))
.and_then(|v| v.as_array())
.map(|arr| {
arr.iter()
.map(|v| TableRow { valor: v.as_f64(), ..Default::default() })
.collect()
})
.unwrap_or_default(),
};
let estado_rust = FechamentoEstado {
@@ -449,7 +510,7 @@ pub fn carregar_rascunho(
// Simplifica para formato que o frontend espera
let total_sangrias: f64 = e.tabelas.sangrias.iter().filter_map(|r| r.valor).sum();
let total_despesas: f64 = e.tabelas.despesas.iter().filter_map(|r| r.valor).sum();
let total_receber: f64 = e.tabelas.areceber.iter().filter_map(|r| r.valor).sum();
let _total_receber: f64 = e.tabelas.areceber.iter().filter_map(|r| r.valor).sum();
let despesa_rows: Vec<_> = e.tabelas.despesas.iter().filter_map(|r| {
r.descricao.as_ref().map(|d| serde_json::json!({"desc": d, "valor": r.valor}))
}).collect();
+1
View File
@@ -8,6 +8,7 @@ use tauri::State;
use thiserror::Error;
#[derive(Error, Debug)]
#[allow(dead_code)]
pub enum SupabaseError {
#[error("HTTP error: {0}")]
Http(#[from] reqwest::Error),
+525 -78
View File
@@ -32,6 +32,7 @@
color: #fff; padding: 8px 18px;
display: flex; align-items: center; justify-content: space-between;
flex-shrink: 0; gap: 14px;
position: sticky; top: 0; z-index: 100;
}
.header-left { display: flex; align-items: center; gap: 14px; flex: 1; }
.header-title { font-size: 15px; font-weight: 700; white-space: nowrap; }
@@ -391,7 +392,8 @@
<div class="sys-row"><span class="lbl">Alimentação (+)</span><input type="text" id="sys-alimentacao" readonly tabindex="-1" /></div>
<div class="sys-row"><span class="lbl">Vales (+)</span><input type="text" id="sys-vales" readonly tabindex="-1" /></div>
<div class="sys-row"><span class="lbl">À Receber (+)</span><input type="text" id="sys-receber" readonly tabindex="-1" /></div>
<div class="sys-row"><span class="lbl">PIX (+)</span><input type="text" id="sys-pix" readonly tabindex="-1" /></div>
<div class="sys-row"><span class="lbl">PIX Cartão (+)</span><input type="text" id="sys-pix" readonly tabindex="-1" /></div>
<div class="sys-row"><span class="lbl">PIX CNPJ (+)</span><input type="text" id="sys-pixcnpj" readonly tabindex="-1" /></div>
<div class="sys-row total"><span class="lbl">TOTAL</span><span class="val" id="sys-total">R$ 0,00</span></div>
</div>
</div>
@@ -403,6 +405,12 @@
<label>Informe o saldo esperado (contado em caixa)</label>
<input type="text" id="f-saldo-esperado" placeholder="0,00" />
</div>
<!-- DIFERENÇA — dentro do Saldo Esperado, logo abaixo -->
<div class="dif-box" style="margin-top:8px">
<span class="dif-label">Diferença</span>
<span class="dif-value zero" id="diferenca">R$ 0,00</span>
<span id="dif-badge" style="font-size:11px;font-weight:700;padding:2px 8px;border-radius:10px;background:#e8f5e9;color:#2e7d32;display:none">OK</span>
</div>
</div>
<!-- FECHAMENTO (VALORES CONTADOS) -->
@@ -419,14 +427,6 @@
</div>
</div>
<!-- DIFERENÇA -->
<div class="section">
<div class="dif-box">
<span class="dif-label">Diferença</span>
<span class="dif-value zero" id="diferenca">R$ 0,00</span>
</div>
</div>
<!-- OBS -->
<div class="section" style="flex:1;display:flex;flex-direction:column">
<div class="section-header">📝 Observações</div>
@@ -577,28 +577,32 @@ let estado = {
// ─── Helpers ──────────────────────────────────────────────────────────────
const fmt = v => 'R$ ' + (parseFloat(v) || 0).toLocaleString('pt-BR', { minimumFractionDigits: 2 });
const parseNum = v => {
const s = String(v || '0');
// Handle empty or whitespace
if (!s.trim()) return 0;
// Already a number? Return directly (don't treat '.' as thousand separator)
if (typeof v === 'number') return isNaN(v) ? 0 : v;
const s = String(v || '0').trim();
if (!s) return 0;
// Brazilian format: 1.234,56 → 1234.56
// Strategy: remove thousand dots, replace decimal comma with dot
// "10,55" → "1055,00" (wrong) → fix: split on comma first
// "1.055,00" → parts=["1.055","00"] → intPart="1055", decPart="00" → 1055.00
// Strategy: if string has comma, treat as BRL (decimal comma)
// If no comma but has dot, treat as US (decimal dot)
const lastComma = s.lastIndexOf(',');
if (lastComma === -1) {
// No comma: just remove thousand separators (dots)
return parseFloat(s.replace(/,/g, '').replace(/\./g, '')) || 0;
}
const lastDot = s.lastIndexOf('.');
const hasCommaDecimal = lastComma > lastDot;
if (hasCommaDecimal) {
const intPart = s.slice(0, lastComma).replace(/\./g, '').replace(/,/g, '');
const decPart = s.slice(lastComma + 1);
return parseFloat(intPart + '.' + decPart) || 0;
}
// No comma: US-style number, just strip any dots (thousand sep)
return parseFloat(s.replace(/,/g, '').replace(/\./g, '')) || 0;
};
const setText = (id, t) => { const e = document.getElementById(id); if (e) e.textContent = t; };
const setVal = (id, v) => { const e = document.getElementById(id); if (e) e.value = v; };
const val = id => { const e = document.getElementById(id); return e ? e.value : ''; };
const fmtNum = v => {
const n = typeof v === 'number' ? v : parseNum(v);
return isNaN(n) ? '' : n.toLocaleString('pt-BR', { minimumFractionDigits: 2 });
if (isNaN(n)) return '';
// Always 2 decimal places for money
return n.toLocaleString('pt-BR', { minimumFractionDigits: 2, maximumFractionDigits: 2 });
};
// Format time: 4 digits → HH:MM
@@ -637,17 +641,23 @@ function makeRow(tblId, rowIndex, rowData) {
if (col === 'valor' || col === 'retirada') td.className = 'num';
const inp = document.createElement('input');
inp.type = 'text';
inp.value = rowData[col] !== undefined ? (rowData[col] === 0 ? '' : rowData[col]) : '';
// For numeric cols that hold money, format with 2 decimals
// For text cols (hora, desc, obs, nome, gerente, motivo, numero) keep as-is
const numericCols = ['valor', 'retirada'];
inp.value = rowData[col] !== undefined
? (numericCols.includes(col) ? (rowData[col] === 0 ? '' : fmtNum(rowData[col])) : (rowData[col] || ''))
: '';
inp.placeholder = col === 'valor' || col === 'retirada' ? '0,00' : (col === 'hora' ? 'HH:MM' : '');
inp.dataset.tbl = tblId;
inp.dataset.row = rowIndex;
inp.dataset.col = col;
// datalist only for fields that should offer suggestions from config
if (col === 'gerente') inp.setAttribute('list', 'dl-gerente');
if (col === 'desc') inp.setAttribute('list', 'dl-despesa');
// tbl-receber: clientes free-text (sem datalist)
// tbl-vales: usa dl-vales
if (col === 'nome' && tblId === 'tbl-vales') inp.setAttribute('list', 'dl-vales');
if (col === 'nome' && tblId === 'tbl-pix') inp.setAttribute('list', 'dl-generico');
// tbl-pix nome = free text (no datalist)
// tbl-receber nome = free text (no datalist)
// tbl-cancelamentos numero + motivo = free text (no datalist)
inp.addEventListener('input', onTableInput);
inp.addEventListener('blur', onCellBlur);
inp.addEventListener('keydown', onCellKeydown);
@@ -685,6 +695,7 @@ function onTableInput(e) {
if (formatted !== e.target.value) e.target.value = formatted;
field[row][col] = formatted;
} else {
// text fields: nome, desc, obs, gerente, motivo, numero — keep as string
field[row][col] = e.target.value;
}
updateTotals();
@@ -698,6 +709,7 @@ function onCellBlur(e) {
e.target.value = isNaN(v) || v === 0 ? '' : fmtNum(v);
updateTotals();
}
// For text cols (hora, nome, desc, obs, gerente, motivo, numero) — keep as-is
}
function onCellKeydown(e) {
@@ -731,8 +743,9 @@ function focusInput(tblId, rowIndex, col) {
function addRowAndFocus(tblId, focusCol) {
const cfg = TABLES[tblId];
const field = getField(tblId);
const numericCols = ['valor', 'retirada'];
const empty = {};
cfg.cols.forEach(c => { empty[c] = c === 'valor' || c === 'retirada' ? 0 : ''; });
cfg.cols.forEach(c => { empty[c] = numericCols.includes(c) ? 0 : ''; });
field.push(empty);
renderTable(tblId);
focusInput(tblId, field.length - 1, focusCol);
@@ -773,7 +786,7 @@ document.querySelectorAll('.add-btn[data-tbl]').forEach(btn => {
// ─── Totals ────────────────────────────────────────────────────────────────
function updateTotals() {
// ── Sangrias ──
const sangriasTotal = estado.sangrias.reduce((s, r) => s + (parseFloat(r['retirada'] || 0) || 0), 0);
const sangriasTotal = estado.sangrias.reduce((s, r) => s + (parseNum(r['retirada'] || 0) || 0), 0);
setText('total-sangrias', fmt(sangriasTotal));
// ── Tabelas de valor ──
@@ -782,7 +795,7 @@ function updateTotals() {
'tbl-receber':'receber', 'tbl-cancelamentos':'cancelamentos',
};
Object.entries(map).forEach(([tblId, fieldName]) => {
const total = estado[fieldName].reduce((s, r) => s + (parseFloat(r['valor'] || 0) || 0), 0);
const total = estado[fieldName].reduce((s, r) => s + (parseNum(r['valor'] || 0) || 0), 0);
setText(tblId.replace('tbl-','total-'), fmt(total));
});
@@ -807,17 +820,19 @@ function updateTotals() {
document.getElementById('sys-debito').value = cartDeb > 0 ? fmtNum(cartDeb) : '';
// Alimentação = total cartão alimentação
document.getElementById('sys-alimentacao').value = cartAli > 0 ? fmtNum(cartAli) : '';
// PIX = total cartão PIX + total PIX CNPJ
const pixCnpjTotal = estado.pixcnpj.reduce((s, r) => s + (parseFloat(r['valor'] || 0) || 0), 0);
document.getElementById('sys-pix').value = (cartPix + pixCnpjTotal) > 0 ? fmtNum(cartPix + pixCnpjTotal) : '';
// PIX Cartão = total cartão PIX
document.getElementById('sys-pix').value = cartPix > 0 ? fmtNum(cartPix) : '';
// PIX CNPJ = total PIX CNPJ
const pixCnpjTotal = estado.pixcnpj.reduce((s, r) => s + (parseNum(r['valor'] || 0) || 0), 0);
document.getElementById('sys-pixcnpj').value = pixCnpjTotal > 0 ? fmtNum(pixCnpjTotal) : '';
// Vales = total vales
const valesTotal = estado.vales.reduce((s, r) => s + (parseFloat(r['valor'] || 0) || 0), 0);
const valesTotal = estado.vales.reduce((s, r) => s + (parseNum(r['valor'] || 0) || 0), 0);
document.getElementById('sys-vales').value = valesTotal > 0 ? fmtNum(valesTotal) : '';
// À Receber = total receber
const receberTotal = estado.receber.reduce((s, r) => s + (parseFloat(r['valor'] || 0) || 0), 0);
const receberTotal = estado.receber.reduce((s, r) => s + (parseNum(r['valor'] || 0) || 0), 0);
document.getElementById('sys-receber').value = receberTotal > 0 ? fmtNum(receberTotal) : '';
// SYS total
// SYS total = troco + crédito + débito + alimentação + vales + receber + pixCartão + pixCnpj
const sysTotal = parseNum(val('sys-troco')) + cartCred + cartDeb + cartAli + valesTotal + receberTotal + cartPix + pixCnpjTotal;
setText('sys-total', fmt(sysTotal));
@@ -835,19 +850,37 @@ function updateTotals() {
const fr = receberTotal;
setText('fech-total', fmt(fd + fc + fr));
// Diferença = Saldo Esperado Fechamento
const diff = parseNum(val('f-saldo-esperado')) - (fd + fc + fr);
// Diferença = Saldo Caixa Saldo Esperado
const saldoCaixa = sysTotal;
const diff = saldoCaixa - parseNum(val('f-saldo-esperado'));
const difEl = document.getElementById('diferenca');
const badgeEl = document.getElementById('dif-badge');
if (difEl) {
difEl.textContent = fmt(diff);
if (diff === 0) {
difEl.className = 'dif-value zero';
if (badgeEl) { badgeEl.textContent = '✓ OK'; badgeEl.style.background='#e8f5e9'; badgeEl.style.color='#2e7d32'; badgeEl.style.display='inline'; }
} else if (diff < 0 && diff >= -5) {
difEl.className = 'dif-value neg'; // OK até 5
difEl.className = 'dif-value neg'; // OK até 5 de tolerância
if (badgeEl) { badgeEl.textContent = '✓ OK'; badgeEl.style.background='#e8f5e9'; badgeEl.style.color='#2e7d32'; badgeEl.style.display='inline'; }
} else if (diff > 0 && diff <= 3) {
difEl.className = 'dif-value zero'; // OK + até 3
if (badgeEl) { badgeEl.textContent = '✓ OK'; badgeEl.style.background='#e8f5e9'; badgeEl.style.color='#2e7d32'; badgeEl.style.display='inline'; }
} else {
difEl.className = 'dif-value pos'; // Sobra > +3
// Sobra mais de R$3 ou falta mais de R$5
difEl.className = diff > 0 ? 'dif-value pos' : 'dif-value neg';
if (badgeEl) {
if (diff > 0) {
badgeEl.textContent = '⚠ SOBRANDO';
badgeEl.style.background='#fff3e0';
badgeEl.style.color='#e65100';
} else {
badgeEl.textContent = '⚠ FALTANDO';
badgeEl.style.background='#ffebee';
badgeEl.style.color='#c0272d';
}
badgeEl.style.display='inline';
}
}
}
}
@@ -989,7 +1022,9 @@ function init() {
const today = new Date().toISOString().split('T')[0];
setVal('f-data', today);
estado.data = today;
setVal('f-loja', estado.loja);
setVal('f-loja', estado.loja || 'União');
setVal('f-operador', estado.operador || '');
setVal('f-turno', estado.turno || '');
syncHeader();
PRE_FILL_TABLES.forEach(ensureOneRow);
Object.keys(TABLES).forEach(renderTable);
@@ -1012,7 +1047,6 @@ function buildEstadoForBackend() {
saldo_troco: parseNum(val('sys-troco')), // troco do sistema
saldo_esperado: parseNum(val('f-saldo-esperado')),
fechamento: parseNum(val('f-fech-dinheiro')) + parseNum(val('f-fech-cartoes')) + parseNum(val('f-fech-receber')),
diferenca: parseNum(val('f-saldo-esperado')) - (parseNum(val('f-fech-dinheiro')) + parseNum(val('f-fech-cartoes')) + parseNum(val('f-fech-receber'))),
despesas: estado.despesas.map(r => ({ desc: r.desc || '', obs: r.obs || '', valor: r.valor || 0 })),
sangrias: estado.sangrias.map(r => ({ hora: r.hora || '', retirada: r.retirada || 0, gerente: r.gerente || '' })),
pixcnpj: estado.pixcnpj.map(r => ({ nome: r.nome || '', valor: r.valor || 0 })),
@@ -1050,8 +1084,44 @@ function loadState() {
const raw = localStorage.getItem(LS_KEY);
if (!raw) return false;
const saved = JSON.parse(raw);
// Merge carefully (cartoes may be in old format)
Object.assign(estado, saved);
// Restore simple fields
estado.uuid = saved.uuid || estado.uuid;
estado.id_local = saved.id_local !== undefined ? saved.id_local : null;
estado.loja = saved.loja || estado.loja;
estado.data = saved.data || estado.data;
estado.operador = saved.operador || estado.operador;
estado.turno = saved.turno || estado.turno;
estado.saldo_troco = saved.saldo_troco || 0;
estado.saldo_esperado = saved.saldo_esperado || 0;
estado.fechamento = saved.fechamento || 0;
estado.observacoes = saved.observacoes || '';
estado.sync_status = saved.sync_status || 'local';
// Restore tables (always as arrays of objects)
estado.despesas = Array.isArray(saved.despesas) ? saved.despesas : [];
estado.sangrias = Array.isArray(saved.sangrias) ? saved.sangrias : [];
estado.pixcnpj = Array.isArray(saved.pixcnpj) ? saved.pixcnpj : [];
estado.vales = Array.isArray(saved.vales) ? saved.vales : [];
estado.receber = Array.isArray(saved.receber) ? saved.receber : [];
estado.cancelamentos = Array.isArray(saved.cancelamentos) ? saved.cancelamentos : [];
// Restore cartoes — each entry must be { valor: number }
if (saved.cartoes) {
estado.cartoes.credito = Array.isArray(saved.cartoes.credito)
? saved.cartoes.credito.map(v => ({ valor: typeof v === 'object' && v !== null ? (v.valor || 0) : (parseNum(v) || 0) }))
: [];
estado.cartoes.debito = Array.isArray(saved.cartoes.debito)
? saved.cartoes.debito.map(v => ({ valor: typeof v === 'object' && v !== null ? (v.valor || 0) : (parseNum(v) || 0) }))
: [];
estado.cartoes.alimentacao = Array.isArray(saved.cartoes.alimentacao)
? saved.cartoes.alimentacao.map(v => ({ valor: typeof v === 'object' && v !== null ? (v.valor || 0) : (parseNum(v) || 0) }))
: [];
estado.cartoes.pix = Array.isArray(saved.cartoes.pix)
? saved.cartoes.pix.map(v => ({ valor: typeof v === 'object' && v !== null ? (v.valor || 0) : (parseNum(v) || 0) }))
: [];
}
// Restore fechamento_contado
if (saved.fechamento_contado) {
estado.fechamento_contado = saved.fechamento_contado;
}
setVal('f-data', estado.data || '');
setVal('f-operador', estado.operador || '');
setVal('f-turno', estado.turno || '');
@@ -1059,9 +1129,10 @@ function loadState() {
setVal('f-obs', estado.observacoes || '');
setVal('f-saldo-esperado', estado.saldo_esperado === 0 ? '' : fmtNum(estado.saldo_esperado));
setVal('sys-troco', estado.saldo_troco === 0 ? '' : fmtNum(estado.saldo_troco));
setVal('f-fech-dinheiro', estado.fechamento_contado && estado.fechamento_contado.dinheiro ? fmtNum(estado.fechamento_contado.dinheiro) : '');
setVal('f-fech-cartoes', estado.fechamento_contado && estado.fechamento_contado.cartoes ? fmtNum(estado.fechamento_contado.cartoes) : '');
setVal('f-fech-receber', estado.fechamento_contado && estado.fechamento_contado.receber ? fmtNum(estado.fechamento_contado.receber) : '');
const fc = estado.fechamento_contado || {};
setVal('f-fech-dinheiro', fc.dinheiro ? fmtNum(fc.dinheiro) : '');
setVal('f-fech-cartoes', fc.cartoes ? fmtNum(fc.cartoes) : '');
setVal('f-fech-receber', fc.receber ? fmtNum(fc.receber) : '');
syncHeader();
PRE_FILL_TABLES.forEach(ensureOneRow);
Object.keys(TABLES).forEach(renderTable);
@@ -1142,14 +1213,262 @@ async function tauriEnviar() {
const data = buildEstadoForBackend();
// 1. Salva local
await window.__TAURI__.core.invoke('salvar_fechamento', { estado: data }).catch(e => console.warn('local save err:', e));
// 2. Envia Supabase
// 2. Envia Supabase — debug completo
console.log('=== Enviando para Supabase ===');
console.log('URL:', 'https://supabase.ofrangao.com.br/rest/v1/fechamentos');
console.log('Payload:', JSON.stringify(data, null, 2));
try {
const result = await window.__TAURI__.core.invoke('sb_salvar_fechamento', { estado: data });
console.log('Supabase resposta:', JSON.stringify(result));
return result;
} catch(e) {
console.error('Erro sb_salvar_fechamento:', JSON.stringify(e));
// Tenta debug: qual era o payload exato?
console.error('Payload que falhou:', JSON.stringify(data));
throw e;
}
}
// ─── Print Preview Window ──────────────────────────────────────────────────
function buildPrintHTML(data) {
const fmt = v => 'R$ ' + (v || 0).toFixed(2).replace('.', ',');
const diferenca = (data.fechamento || 0) - (data.total_operado || 0);
const diffStr = diferenca >= 0 ? `+${fmt(diferenca)}` : fmt(diferenca);
const diffColor = diferenca === 0 ? '#2e7d32' : (diferenca > 0 ? '#e65100' : '#c0272d');
let rows = '';
// Cancelamentos (sum the array first — single cancelamentos is a float total, array is from confirm-print)
const cancelTotal = Array.isArray(data.cancelamentos)
? data.cancelamentos.reduce((s, c) => s + (parseNum(c.valor || 0) || 0), 0)
: (data.cancelamentos || 0);
// PIX total (flat number from tauriRecibo; pixcnpj is also flat total)
const pixTotal = data.pix || data.voucher || 0;
const pixCnpjTotal = typeof data.pixcnpj === 'number' ? data.pixcnpj : 0;
// Sangrias
if (data.sangrias && data.sangrias.length > 0) {
data.sangrias.forEach(s => { if (s.hora || s.retirada) rows += `<tr><td>${s.hora||''}</td><td>${fmt(s.retirada)}</td><td>${s.gerente||''}</td></tr>`; });
}
// Despesas
if (data.despesas && data.despesas.length > 0) {
data.despesas.forEach(d => { if (d.desc || d.valor) rows += `<tr><td>${d.desc||''}</td><td>${fmt(d.valor)}</td><td>${d.obs||''}</td></tr>`; });
}
// Vales
if (data.vales && data.vales.length > 0) {
data.vales.forEach(v => { if (v.nome || v.valor) rows += `<tr><td>${v.nome||''}</td><td>${fmt(v.valor)}</td><td>${v.obs||''}</td></tr>`; });
}
// Receber
if (data.receber && data.receber.length > 0) {
data.receber.forEach(r => { if (r.nome || r.valor) rows += `<tr><td>${r.nome||''}</td><td>${fmt(r.valor)}</td><td></td></tr>`; });
}
// PIX CNPJ (flat total, not array)
if (pixCnpjTotal > 0) {
rows += `<tr><td>PIX CNPJ</td><td>${fmt(pixCnpjTotal)}</td><td></td></tr>`;
}
// Cancelamentos
if (cancelTotal > 0) {
rows += `<tr><td>Cancelamentos</td><td>${fmt(cancelTotal)}</td><td></td></tr>`;
}
return `<!DOCTYPE html>
<html><head>
<meta charset="UTF-8">
<title>Relatório de Fechamento</title>
<style>
* { box-sizing: border-box; margin: 0; padding: 0; }
body { font-family: 'Courier New', monospace; font-size: 13px; width: 80mm; margin: 0 auto; padding: 8px; }
.header { text-align: center; margin-bottom: 8px; }
.header h1 { font-size: 18px; }
.header h2 { font-size: 14px; font-weight: normal; }
hr { border: none; border-top: 1px dashed #000; margin: 6px 0; }
.section { margin: 6px 0; }
.section-title { font-weight: bold; font-size: 14px; border-top: 1px dashed #000; padding-top: 4px; margin-top: 6px; }
.row { display: flex; justify-content: space-between; padding: 2px 0; }
.row.space-between { justify-content: space-between; }
.row.below { margin-top: 4px; }
table { width: 100%; border-collapse: collapse; font-size: 12px; }
td { padding: 2px 4px; vertical-align: top; }
td:nth-child(2) { text-align: right; }
td:nth-child(3) { text-align: right; color: #555; font-size: 11px; }
.total-row { font-weight: bold; border-top: 1px dashed #000; }
.grand-total { font-size: 16px; font-weight: bold; text-align: center; padding: 6px; border: 2px solid #000; margin: 8px 0; }
.obs { font-size: 11px; color: #555; margin-top: 6px; white-space: pre-wrap; }
.footer { text-align: center; font-size: 11px; color: #555; margin-top: 8px; }
@media print { body { margin: 0; } }
</style>
</head><body>
<div class="header">
<h1>${data.loja}</h1>
<h2>Fechamento de Caixa</h2>
</div>
<hr>
<div class="row"><span>Data:</span><span>${data.data}</span></div>
<div class="row"><span>Operador:</span><span>${data.operador}</span></div>
<div class="row"><span>Turno:</span><span>${data.turno}</span></div>
<div class="section-title">ENTRADAS</div>
<div class="row"><span>Saldo Troco:</span><span>${fmt(data.saldo_troco)}</span></div>
<div class="row"><span>Fechamento:</span><span>${fmt(data.total_operado || 0)}</span></div>
<div class="row"><span>Saldo Esperado:</span><span>${fmt(data.saldo_esperado)}</span></div>
${rows ? `<div class="section-title">DETALHES</div><table><tbody>${rows}</tbody></table>` : ''}
${data.observacoes ? `<div class="obs">Obs: ${data.observacoes}</div>` : ''}
<hr>
<div class="section-title">CARTÕES</div>
<div class="row"><span>Crédito:</span><span>${fmt(data.credito||0)}</span></div>
<div class="row"><span>Débito:</span><span>${fmt(data.debito||0)}</span></div>
<div class="row"><span>Alimentação:</span><span>${fmt(data.alimentacao||0)}</span></div>
<div class="row"><span>PIX Cartão:</span><span>${fmt(pixTotal)}</span></div>
<div class="row"><span>Cancelamentos:</span><span>${fmt(cancelTotal)}</span></div>
<hr>
<div class="grand-total">DIFERENÇA: ${diffStr}</div>
<div class="footer">Gerado em ${new Date().toLocaleString('pt-BR')}</div>
</body></html>`;
}
function openPrintPreview(data) {
// Remove preview anterior se existir
const existing = document.getElementById('print-preview-overlay');
if (existing) document.body.removeChild(existing);
const overlay = document.createElement('div');
overlay.id = 'print-preview-overlay';
overlay.style.cssText = `
position: fixed; top: 0; left: 0; right: 0; bottom: 0;
background: rgba(0,0,0,0.85); z-index: 99999;
display: flex; flex-direction: column; align-items: center;
font-family: 'Courier New', monospace;
`;
const previewStyle = `
<style>
body { width: 80mm; margin: 0 auto; font-family: 'Courier New', monospace; font-size: 13px; background: #fff; padding: 10px; }
.header { text-align: center; margin-bottom: 8px; }
.header h1 { font-size: 18px; }
.header h2 { font-size: 14px; font-weight: normal; }
hr { border: none; border-top: 1px dashed #000; margin: 6px 0; }
.section-title { font-weight: bold; font-size: 14px; border-top: 1px dashed #000; padding-top: 4px; margin-top: 6px; }
.row { display: flex; justify-content: space-between; padding: 2px 0; }
table { width: 100%; border-collapse: collapse; font-size: 12px; }
td { padding: 2px 4px; vertical-align: top; }
td:nth-child(2) { text-align: right; }
td:nth-child(3) { text-align: right; color: #555; font-size: 11px; }
.grand-total { font-size: 16px; font-weight: bold; text-align: center; padding: 6px; border: 2px solid #000; margin: 8px 0; }
.obs { font-size: 11px; color: #555; margin-top: 6px; white-space: pre-wrap; }
.footer { text-align: center; font-size: 11px; color: #555; margin-top: 8px; }
@media print { body { background: #fff; } }
</style>
`;
const html = buildPrintHTML(data);
const fullHtml = `<!DOCTYPE html><html><head><meta charset="UTF-8"><title>Recibo</title>${previewStyle}</head><body>${html}</body></html>`;
const iframe = document.createElement('iframe');
iframe.style.cssText = 'width:360px; height:600px; border:2px solid #555; margin-top:16px; background:#fff;';
iframe.id = 'print-iframe';
const btnFechar = document.createElement('button');
btnFechar.textContent = '✕ Fechar';
btnFechar.style.cssText = `
margin-top: 12px; padding: 10px 32px;
background: #444; color: #fff; border: none; border-radius: 6px;
font-size: 15px; cursor: pointer;
`;
btnFechar.onclick = () => {
document.body.removeChild(overlay);
};
const btnImprimir = document.createElement('button');
btnImprimir.textContent = '🖨️ Imprimir';
btnImprimir.style.cssText = `
margin-top: 8px; padding: 10px 32px;
background: #e65100; color: #fff; border: none; border-radius: 6px;
font-size: 15px; cursor: pointer;
`;
btnImprimir.onclick = () => {
iframe.contentWindow.focus();
iframe.contentWindow.print();
};
overlay.appendChild(iframe);
overlay.appendChild(btnImprimir);
overlay.appendChild(btnFechar);
document.body.appendChild(overlay);
const iframeDoc = iframe.contentWindow.document;
iframeDoc.open();
iframeDoc.write(fullHtml);
iframeDoc.close();
}
function triggerWindowPrint() {
// Chama window.print() na janela pai (o próprio app)
window.print();
}
// ─── Recibo 80mm via Tauri (ESC/POS USB) ─────────────────────────────────
async function tauriRecibo() {
const data = buildEstadoForBackend();
// Build flat totals for the Rust ReciboData struct
const cartCred = estado.cartoes.credito.reduce((s,r) => s+(r.valor||0), 0);
const cartDeb = estado.cartoes.debito.reduce((s,r) => s+(r.valor||0), 0);
const cartAli = estado.cartoes.alimentacao.reduce((s,r) => s+(r.valor||0), 0);
const cartPix = estado.cartoes.pix.reduce((s,r) => s+(r.valor||0), 0);
const pixCnpjTotal = estado.pixcnpj.reduce((s,r) => s+(parseNum(r.valor||0)||0), 0);
const sangriasTotal = estado.sangrias.reduce((s,r) => s+(parseNum(r.retirada||0)||0), 0);
const despesasTotal = estado.despesas.reduce((s,r) => s+(parseNum(r.valor||0)||0), 0);
const valesTotal = estado.vales.reduce((s,r) => s+(parseNum(r.valor||0)||0), 0);
const receberTotal = estado.receber.reduce((s,r) => s+(parseNum(r.valor||0)||0), 0);
const cancelTotal = estado.cancelamentos.reduce((s,r) => s+(parseNum(r.valor||0)||0), 0);
const fechamentoCounted = parseNum(val('f-fech-dinheiro')) + parseNum(val('f-fech-cartoes')) + parseNum(val('f-fech-receber'));
const saldoEsp = parseNum(val('f-saldo-esperado'));
const sysTroco = parseNum(val('sys-troco'));
// total_operado = soma de todas operações creditadas (não é o fechamento contado)
const totalOperado = cartCred + cartDeb + cartAli + cartPix + pixCnpjTotal;
const data = {
uuid: estado.uuid,
id_local: estado.id_local,
loja: estado.loja,
data: estado.data,
operador: estado.operador,
turno: estado.turno,
saldo_troco: sysTroco,
saldo_esperado: saldoEsp,
fechamento: fechamentoCounted,
diferenca: fechamentoCounted - saldoEsp,
total_operado: totalOperado,
// Flat totals for ReciboData struct (Rust) — must be numbers
credito: cartCred,
debito: cartDeb,
alimentacao: cartAli,
voucher: cartPix,
pixcnpj: pixCnpjTotal,
sangrias: sangriasTotal,
despesas: despesasTotal,
vales: valesTotal,
areceber: receberTotal,
cancelamentos: cancelTotal,
// Full arrays for backend/storage
despesas_arr: estado.despesas.map(r => ({ desc: r.desc||'', obs: r.obs||'', valor: r.valor||0 })),
sangrias_arr: estado.sangrias.map(r => ({ hora: r.hora||'', retirada: parseNum(r.retirada)||0, gerente: r.gerente||'' })),
pixcnpj_arr: estado.pixcnpj.map(r => ({ nome: r.nome||'', valor: r.valor||0 })),
vales_arr: estado.vales.map(r => ({ nome: r.nome||'', obs: r.obs||'', valor: r.valor||0 })),
receber_arr: estado.receber.map(r => ({ nome: r.nome||'', valor: r.valor||0 })),
cancelamentos_arr: estado.cancelamentos.map(r => ({ numero: r.numero||'', valor: r.valor||0, motivo: r.motivo||'' })),
cartoes: { credito: cartCred, debito: cartDeb, alimentacao: cartAli, pix: cartPix },
observacoes: estado.observacoes,
sync_status: estado.sync_status,
};
// Tenta ESC/POS via Tauri primeiro; se falhar, abre preview
try {
return await window.__TAURI__.core.invoke('imprimir_recibo', { estado: JSON.stringify(data) });
} catch(e) {
console.warn('ESC/POS falhou, abrindo preview:', e);
openPrintPreview(data);
throw e;
}
}
async function tauriCarregarRascunho() {
@@ -1175,13 +1494,16 @@ function openConfirmModal() {
const cartDeb = estado.cartoes.debito.reduce((s,r) => s+(r.valor||0), 0);
const cartAli = estado.cartoes.alimentacao.reduce((s,r) => s+(r.valor||0), 0);
const cartPix = estado.cartoes.pix.reduce((s,r) => s+(r.valor||0), 0);
const sangrias = estado.sangrias.reduce((s,r) => s+(parseFloat(r.retirada||0)||0), 0);
const despesas = estado.despesas.reduce((s,r) => s+(r.valor||0), 0);
const vales = estado.vales.reduce((s,r) => s+(r.valor||0), 0);
const receber = estado.receber.reduce((s,r) => s+(r.valor||0), 0);
const cancelamentos = estado.cancelamentos.reduce((s,r) => s+(r.valor||0), 0);
const sangrias = estado.sangrias.reduce((s,r) => s+(parseNum(r.retirada||0)||0), 0);
const despesas = estado.despesas.reduce((s,r) => s+(parseNum(r.valor||0)||0), 0);
const vales = estado.vales.reduce((s,r) => s+(parseNum(r.valor||0)||0), 0);
const receber = estado.receber.reduce((s,r) => s+(parseNum(r.valor||0)||0), 0);
const cancelamentos = estado.cancelamentos.reduce((s,r) => s+(parseNum(r.valor||0)||0), 0);
const pixcnpj = estado.pixcnpj.reduce((s,r) => s+(parseNum(r.valor||0)||0), 0);
const fechamento = parseNum(val('f-fech-dinheiro')) + parseNum(val('f-fech-cartoes')) + parseNum(val('f-fech-receber'));
const saldoEsp = parseNum(val('f-saldo-esperado'));
const diferenca = fechamento - saldoEsp;
const diffClass = diferenca === 0 ? '#2e7d32' : (diferenca > 0 ? '#e65100' : '#c0272d');
document.getElementById('confirm-summary').innerHTML =
`<div style="margin-bottom:6px"><strong>📅 Data:</strong> ${estado.data} &nbsp; <strong>👤 Operador:</strong> ${operador} &nbsp; <strong>⏰ Turno:</strong> ${turno} &nbsp; <strong>🏪 Loja:</strong> ${loja}</div>
<hr style="border:none;border-top:1px dashed #b3d9f7;margin:6px 0">
@@ -1189,13 +1511,15 @@ function openConfirmModal() {
<div>📝 Despesas: <strong>${fmt(despesas)}</strong></div>
<div>💰 Vales: <strong>${fmt(vales)}</strong></div>
<div>👥 À Receber: <strong>${fmt(receber)}</strong></div>
<div>💳 Crédito: <strong style="color:#c0272d">${fmt(cartCred)}</strong></div>
<div>🔵 PIX CNPJ: <strong>${fmt(pixcnpj)}</strong></div>
<div>💳 Crédito: <strong>${fmt(cartCred)}</strong></div>
<div>💳 Débito: <strong>${fmt(cartDeb)}</strong></div>
<div>💳 Alimentação: <strong>${fmt(cartAli)}</strong></div>
<div>❌ Cancelamentos: <strong>${fmt(cancelamentos)}</strong></div>
<div>📋 Fechamento: <strong>${fmt(fechamento)}</strong></div>
<hr style="border:none;border-top:1px dashed #b3d9f7;margin:6px 0">
<div>🎯 Saldo Esperado: <strong>${fmt(saldoEsp)}</strong></div>
<div style="margin-top:4px">📐 Diferença: <strong style="color:${diffClass}">${fmt(diferenca)}</strong></div>
<div style="margin-top:4px;font-size:10px;color:#888">ID: ${estado.loja}_${estado.data}_${estado.operador}</div>`;
document.getElementById('modal-confirm').classList.add('open');
}
@@ -1208,19 +1532,36 @@ document.getElementById('confirm-close').addEventListener('click', closeConfirmM
document.getElementById('confirm-cancel').addEventListener('click', closeConfirmModal);
document.getElementById('modal-confirm').addEventListener('click', e => { if (e.target.id === 'modal-confirm') closeConfirmModal(); });
// Print from confirm modal
// Print from confirm modal — abre preview no navegador
document.getElementById('confirm-print').addEventListener('click', async () => {
closeConfirmModal();
const btn = document.getElementById('btn-recibo');
btn.disabled = true;
try {
await tauriRecibo();
alert('✅ Impressão enviada!');
} catch(e) {
alert('Erro ao imprimir: ' + (e.message || e));
} finally {
btn.disabled = false;
}
const cartCred = estado.cartoes.credito.reduce((s,r) => s+(r.valor||0), 0);
const cartDeb = estado.cartoes.debito.reduce((s,r) => s+(r.valor||0), 0);
const cartAli = estado.cartoes.alimentacao.reduce((s,r) => s+(r.valor||0), 0);
const cartPix = estado.cartoes.pix.reduce((s,r) => s+(r.valor||0), 0);
const cancelTotal = estado.cancelamentos.reduce((s,r) => s+(parseNum(r.valor||0)||0), 0);
const fechamentoCounted = parseNum(val('f-fech-dinheiro')) + parseNum(val('f-fech-cartoes')) + parseNum(val('f-fech-receber'));
const saldoEsp = parseNum(val('f-saldo-esperado'));
const totalOperado = cartCred + cartDeb + cartAli + cartPix + (estado.pixcnpj.reduce((s,r) => s+(parseNum(r.valor||0)||0), 0));
const data = {
loja: estado.loja, data: estado.data, operador: estado.operador, turno: estado.turno,
saldo_troco: parseNum(val('sys-troco')) || 0,
saldo_esperado: saldoEsp,
fechamento: fechamentoCounted,
diferenca: fechamentoCounted - saldoEsp,
total_operado: totalOperado,
despesas: estado.despesas.map(r => ({ desc: r.desc||'', obs: r.obs||'', valor: r.valor||0 })),
sangrias: estado.sangrias.map(r => ({ hora: r.hora||'', retirada: parseNum(r.retirada)||0, gerente: r.gerente||'' })),
pixcnpj: estado.pixcnpj.map(r => ({ nome: r.nome||'', valor: r.valor||0 })),
vales: estado.vales.map(r => ({ nome: r.nome||'', obs: r.obs||'', valor: r.valor||0 })),
receber: estado.receber.map(r => ({ nome: r.nome||'', valor: r.valor||0 })),
cancelamentos: estado.cancelamentos.map(r => ({ numero: r.numero||'', valor: r.valor||0, motivo: r.motivo||'' })),
credito: cartCred, debito: cartDeb, alimentacao: cartAli,
pix: cartPix, // flat float for buildPrintHTML
cartoes: { credito: estado.cartoes.credito, debito: estado.cartoes.debito, alimentacao: estado.cartoes.alimentacao, pix: estado.cartoes.pix },
observacoes: estado.observacoes,
};
openPrintPreview(data);
});
// Confirm send
@@ -1233,10 +1574,17 @@ document.getElementById('confirm-send').addEventListener('click', async () => {
const r = await tauriEnviar();
estado.sync_status = 'synced';
saveState();
alert('✅ Enviado com sucesso!');
alert('✅ Enviado com sucesso!\n\n📋 Tabela: fechamentos_web\n🌐 Servidor: supabase.ofrangao.com.br\n\nAbra o Supabase Studio para verificar.');
} catch(e) {
await tauriSaveLocal();
alert('Erro ao enviar: ' + (e.message || e) + '\n(Dados salvos localmente)');
const msg = e?.message || e?.toString?.() || String(e);
alert(
'⚠️ Erro ao enviar para o Supabase.\n\n' +
'📋 Tabela: fechamentos_web\n' +
'🌐 Servidor: supabase.ofrangao.com.br\n\n' +
'O servidor pode estar offline. Dados salvos localmente.\n\n' +
'Erro: ' + msg
);
} finally {
btn.disabled = false;
btn.textContent = '💾 Enviar Fechamento';
@@ -1252,10 +1600,13 @@ document.getElementById('btn-recibo').addEventListener('click', async () => {
const btn = document.getElementById('btn-recibo');
btn.disabled = true;
try {
// Tenta ESC/POS primeiro; se falhar, abre preview no navegador
await tauriRecibo();
alert('✅ Impressão enviada!');
// Se chegou aqui sem erro, ESC/POS funcionou
} catch(e) {
alert('Erro ao imprimir: ' + (e.message || e));
// ESC/POS falhou — openPrintPreview já foi chamado dentro de tauriRecibo
// Só mostra alerta se nem o preview abriu
console.warn('Recibo:', e);
} finally {
btn.disabled = false;
}
@@ -1264,37 +1615,133 @@ document.getElementById('btn-recibo').addEventListener('click', async () => {
document.getElementById('btn-limpar').addEventListener('click', () => {
if (!confirm('Limpar rascunho? Esta acção não pode ser revertida.')) return;
localStorage.removeItem(LS_KEY);
location.reload();
// Reseta estado e re-renderiza sem reload
estado = newEstado();
estado.loja = val('f-loja') || 'União';
estado.data = val('f-data') || new Date().toISOString().split('T')[0];
calcular();
renderAllTables();
syncUI();
showToast('Rascunho limpo.', 'info');
});
document.getElementById('btn-anterior').addEventListener('click', async () => {
const loja = val('f-loja') || 'União';
// Carrega lista de fechamentos recentes do Supabase com uuid
let recentList = [];
try {
recentList = await window.__TAURI__.core.invoke('sb_listar_recentes', { loja, limite: 20 });
} catch(e) {
console.warn('sb_listar_recentes falhou:', e);
}
// Extrai datas únicas ordenadas + mapeamento data→id
const dateIdMap = {};
const dateList = [];
if (recentList && recentList.length > 0) {
recentList.forEach(item => {
if (item.data && !dateIdMap[item.data]) {
dateIdMap[item.data] = item.id;
dateList.push(item.data);
}
});
dateList.sort().reverse();
}
let selectedDate = null;
let selectedId = null;
if (dateList.length > 0) {
const msg = dateList.map((d, i) => `${i + 1}) ${d}`).join('\n');
const escolha = prompt(`Selecione o número do dia anterior:\n${msg}\n\n(Deixe em branco e clique Cancelar para ver só o dia anterior automático)`);
if (escolha !== null && escolha.trim() !== '') {
const idx = parseInt(escolha) - 1;
if (idx >= 0 && idx < dateList.length) {
selectedDate = dateList[idx];
selectedId = dateIdMap[selectedDate];
}
}
}
// Se não escolheu data, usa dia anterior automático (sem id — carrega por data)
if (!selectedDate) {
const d = new Date(estado.data); d.setDate(d.getDate() - 1);
const prevDate = d.toISOString().split('T')[0];
selectedDate = d.toISOString().split('T')[0];
}
await tauriSaveLocal();
setVal('f-data', prevDate);
estado.data = prevDate;
setVal('f-data', selectedDate);
estado.data = selectedDate;
estado.uuid = crypto.randomUUID ? crypto.randomUUID() : Date.now().toString();
estado.id_local = null;
syncHeader();
const rascunho = await tauriCarregarRascunho();
// Tenta Supabase primeiro (com id se disponível), senão local
let rascunho = null;
if (selectedId) {
// Usa sb_buscar_por_id para buscar por id numérico do Supabase
try {
rascunho = await window.__TAURI__.core.invoke('sb_buscar_por_id', { id: selectedId });
} catch(e) { console.warn('sb_buscar_por_id falhou:', e); }
}
if (!rascunho) {
try {
rascunho = await window.__TAURI__.core.invoke('sb_carregar_rascunho', { loja, data: selectedDate });
} catch(e) { console.warn('sb_carregar_rascunho falhou:', e); }
}
if (!rascunho) {
try {
rascunho = await window.__TAURI__.core.invoke('carregar_rascunho', { loja, data: selectedDate });
} catch(e) { console.warn('carregar_rascunho local falhou:', e); }
}
if (rascunho) {
if (rascunho.operador) setVal('f-operador', rascunho.operador);
if (rascunho.turno) setVal('f-turno', rascunho.turno);
estado.operador = rascunho.operador || '';
estado.turno = rascunho.turno || '';
if (rascunho.operador) { setVal('f-operador', rascunho.operador); estado.operador = rascunho.operador; }
if (rascunho.turno) { setVal('f-turno', rascunho.turno); estado.turno = rascunho.turno; }
if (rascunho.saldo_troco) setVal('sys-troco', fmtNum(rascunho.saldo_troco));
if (rascunho.saldo_esperado) setVal('f-saldo-esperado', fmtNum(rascunho.saldo_esperado));
if (rascunho.observacoes) { setVal('f-obs', rascunho.observacoes); estado.observacoes = rascunho.observacoes; }
if (rascunho.despesas && Array.isArray(rascunho.despesas)) {
estado.despesas = rascunho.despesas.map(d => ({ desc: d.desc || d.descricao || '', obs: '', valor: d.valor || 0 }));
estado.despesas = rascunho.despesas.map(d => ({ desc: d.desc || d.descricao || '', obs: d.obs || '', valor: parseNum(d.valor) || 0 }));
}
if (rascunho.sangrias && Array.isArray(rascunho.sangrias)) {
estado.sangrias = rascunho.sangrias.map(s => ({ hora: s.hora || '', retirada: parseNum(s.retirada) || 0, gerente: s.gerente || '' }));
}
if (rascunho.pixcnpj && Array.isArray(rascunho.pixcnpj)) {
estado.pixcnpj = rascunho.pixcnpj.map(p => ({ nome: p.nome || '', valor: parseNum(p.valor) || 0 }));
}
if (rascunho.vales && Array.isArray(rascunho.vales)) {
estado.vales = rascunho.vales.map(v => ({ nome: v.nome || '', obs: v.obs || '', valor: parseNum(v.valor) || 0 }));
}
if (rascunho.receber && Array.isArray(rascunho.receber)) {
estado.receber = rascunho.receber.map(r => ({ nome: r.nome || '', valor: parseNum(r.valor) || 0 }));
}
if (rascunho.cancelamentos && Array.isArray(rascunho.cancelamentos)) {
estado.cancelamentos = rascunho.cancelamentos.map(c => ({ numero: c.numero || '', valor: parseNum(c.valor) || 0, motivo: c.motivo || '' }));
}
if (rascunho.cartoes) {
estado.cartoes.credito = (rascunho.cartoes.credito || []).map(v => ({ valor: parseNum(v) || 0 }));
estado.cartoes.debito = (rascunho.cartoes.debito || []).map(v => ({ valor: parseNum(v) || 0 }));
estado.cartoes.alimentacao = (rascunho.cartoes.alimentacao || []).map(v => ({ valor: parseNum(v) || 0 }));
estado.cartoes.pix = (rascunho.cartoes.pix || []).map(v => ({ valor: parseNum(v) || 0 }));
}
} else {
estado.despesas = []; estado.sangrias = []; estado.pixcnpj = [];
estado.vales = []; estado.receber = []; estado.cancelamentos = [];
estado.cartoes = { credito: [], debito: [], alimentacao: [], pix: [] };
setVal('sys-troco', ''); setVal('f-saldo-esperado', ''); setVal('f-obs', '');
}
// Marcar que este é um rascunho carregado (não novo)
estado.sync_status = rascunho ? 'loaded' : 'local';
PRE_FILL_TABLES.forEach(ensureOneRow);
Object.keys(TABLES).forEach(renderTable);
updateTotals();
scheduleSave();
});
document.getElementById('f-obs').addEventListener('input', e => { estado.observacoes = e.target.value; scheduleSave(); });
// ─── Boot ─────────────────────────────────────────────────────────────────