Compare commits
46 Commits
v1.4.11-fix
..
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 275d92abe6 | |||
| 237ceab042 | |||
| 73c20272e5 | |||
| 56a2ed5d31 | |||
| acb90f093d | |||
| 6eb08b6bd4 | |||
| 5a0126417c | |||
| 733519e1d3 | |||
| 6440b262cc | |||
| 420e104df7 | |||
| b04e5acfd6 | |||
| 65c444ad09 | |||
| 2bfbf29ccb | |||
| cbe2c22440 | |||
| 1dc278dbbd | |||
| c2c9a29247 | |||
| 659d9779b3 | |||
| 5c9cb1905b | |||
| a2d3dcc366 | |||
| 12e3d081a7 | |||
| ad967bf0a1 | |||
| d39cc7518b | |||
| 52bec03988 | |||
| 9639048bf7 | |||
| b1585af3f9 | |||
| 155782f50a | |||
| 16972f4734 | |||
| d3d5c4fbca | |||
| 5f1d9a4e2f | |||
| cc45ed9df7 | |||
| 378a589572 | |||
| 50b0ba4498 | |||
| 7154901ce3 | |||
| 8b24a06496 | |||
| 3058908b37 | |||
| 9b1d45ae50 | |||
| fbed0e57c2 | |||
| 362a76961b | |||
| 7a3a4b221b | |||
| 94bb8840ea | |||
| dba7eb3856 | |||
| 7b3b7a9f1d | |||
| 5641de21f8 | |||
| d77ad55fbd | |||
| 4a5cae8f2f | |||
| 313c740470 |
@@ -0,0 +1,139 @@
|
||||
# Integração Frontend ↔ Tauri (Rust) ↔ Supabase
|
||||
|
||||
## Visão geral da comunicação
|
||||
|
||||
```
|
||||
┌─────────────────┐ ┌─────────────────┐ ┌──────────────────┐
|
||||
│ JavaScript │ invoke │ Tauri/Rust │ HTTP │ Supabase │
|
||||
│ (index.html) │────────▶│ commands │────────▶│ (cloud) │
|
||||
│ │◀────────│ │◀────────│ │
|
||||
└─────────────────┘ response└─────────────────┘ response└──────────────────┘
|
||||
```
|
||||
|
||||
O frontend **nunca faz requisições HTTP direto** para o Supabase. Toda comunicação passa pelos comandos Tauri (Rust).
|
||||
|
||||
---
|
||||
|
||||
## Comandos Tauri disponíveis
|
||||
|
||||
### App
|
||||
| Comando | O que faz |
|
||||
|---------|-----------|
|
||||
| `get_loja` | Retorna a loja configurada |
|
||||
| `get_loja_fixa` | Retorna `"Uniao"`, `"Alianca"` ou `null` (se build fixa) |
|
||||
| `set_loja(loja)` | Define a loja (bloqueado se build fixa) |
|
||||
| `get_config` | Retorna config completo `{ loja, pdv_nome, operador_default, ... }` |
|
||||
| `set_config(cfg)` | Salva config |
|
||||
|
||||
### Storage (SQLite local)
|
||||
| Comando | O que faz |
|
||||
|---------|-----------|
|
||||
| `salvar_fechamento(estado)` | Salva estado completo no SQLite local |
|
||||
| `carregar_rascunho(loja, data)` | Retorna último rascunho para loja+data |
|
||||
| `listar_fechamentos(loja, limite)` | Lista fechamentos de uma loja |
|
||||
| `buscar_fechamento_por_id(id)` | Busca por ID local |
|
||||
| `pendentes_sync` | Retorna fechamentos que ainda não foram syncados |
|
||||
|
||||
### Supabase
|
||||
| Comando | O que faz |
|
||||
|---------|-----------|
|
||||
| `sb_salvar_fechamento(estado)` | Upsert no Supabase via service_role JWT |
|
||||
| `sb_carregar_rascunho(loja, data)` | Busca rascunho mais recente |
|
||||
| `sb_listar_recentes(loja, limite)` | Lista fechamentos recentes |
|
||||
| `sb_buscar_por_id(id)` | Busca por ID |
|
||||
| `sb_buscar_por_uuid(uuid)` | Busca por UUID |
|
||||
| `sb_buscar_por_id_fechamento(id_fechamento)` | Busca por chave natural |
|
||||
| `sb_atualizar_fechamento(id_fechamento, updates)` | PATCH campos específicos |
|
||||
| `sb_salvar_listas(loja, listas)` | Salva operadores/gerentes customizados |
|
||||
| `sb_carregar_listas(loja)` | Carrega listas salvas |
|
||||
| `sb_online` | Retorna `true`/`false` se Supabase está acessível |
|
||||
|
||||
### Printer
|
||||
| Comando | O que faz |
|
||||
|---------|-----------|
|
||||
| `detectar_impressora` | Detecta EPSON TM-T20 USB |
|
||||
| `configurar_impressora(caminho)` | Define caminho da impressora |
|
||||
| `caminho_impressora` | Retorna caminho configurado |
|
||||
| `imprimir_recibo(html)` | Envia HTML puro para impressora |
|
||||
| `teste_impressora` | Imprime página de teste |
|
||||
| `ativar_modo_teste_impressao` | Ativa modo teste (salva PDF em vez de imprimir) |
|
||||
|
||||
---
|
||||
|
||||
## Fluxo de salvar um fechamento
|
||||
|
||||
```javascript
|
||||
// No frontend (index.html), quando operador clica "Salvar":
|
||||
async function salvarFechamento() {
|
||||
const estado = buildEstado(); // coleta todos os campos do formulário
|
||||
|
||||
// 1. Salva localmente (sempre funciona offline)
|
||||
await invoke('salvar_fechamento', { estado });
|
||||
|
||||
// 2. Tenta sync com Supabase
|
||||
try {
|
||||
await invoke('sb_salvar_fechamento', { estado });
|
||||
toast('✅ Salvo e sincronizado');
|
||||
} catch(e) {
|
||||
// Supabase offline — fica pendente no SQLite
|
||||
toast('💾 Salvo localmente (sync pendente)');
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Como o Rust conversa com o Supabase
|
||||
|
||||
Em `supabase.rs`, o plugin usa `reqwest` (HTTP client) com:
|
||||
|
||||
```rust
|
||||
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
|
||||
}
|
||||
```
|
||||
|
||||
- **`use_service = true`:** para INSERT/UPDATE (salvar fechamento)
|
||||
- **`use_service = false`:** para GET (listar, buscar)
|
||||
|
||||
---
|
||||
|
||||
## Upsert — salvar sem duplicar
|
||||
|
||||
O Supabase não tem UPSERT nativo via REST. O plugin usa um truque:
|
||||
|
||||
```http
|
||||
POST /fechamentos_web HTTP/1.1
|
||||
Prefer: resolution=merge-duplicates
|
||||
```
|
||||
|
||||
Se o `id_fechamento` já existe, o PostgREST faz um UPDATE em vez de INSERT. Se não existe, faz INSERT.
|
||||
|
||||
---
|
||||
|
||||
## UUID vs id_fechamento
|
||||
|
||||
| Campo | O que é | Quem gera |
|
||||
|-------|---------|-----------|
|
||||
| `id` | ID sequencial do PostgreSQL | Supabase (auto) |
|
||||
| `uuid` | UUID único do registro local | Frontend JS (`crypto.randomUUID()`) |
|
||||
| `id_fechamento` | Chave natural: `{loja}_{data}_{operador}` | Frontend JS (construído na hora) |
|
||||
|
||||
O `id_fechamento` é a chave de negócio. O `uuid` é para rastrear registros entre local e cloud.
|
||||
|
||||
---
|
||||
|
||||
## Offline first — como funciona
|
||||
|
||||
1. **Operador/edita** → `salvar_fechamento` → SQLite (sempre funciona)
|
||||
2. **Operador/edita** → `sb_salvar_fechamento` → Supabase (só se online)
|
||||
3. **Se offline:** o fechamento fica no SQLite com `sync_status = "local"`
|
||||
4. **Quando conecta:** `pendentes_sync` retorna os pendentes → sync automático
|
||||
|
||||
O sync automático é controlado por `sync_automatico` na config da app.
|
||||
+209
@@ -0,0 +1,209 @@
|
||||
# Builds — Como gerar fc-uniao.exe, fc-alianca.exe, fc-ambos.exe
|
||||
|
||||
## Conceito
|
||||
|
||||
O mesmo código gera 3 binários diferentes. A diferença entre eles é apenas o valor da variável de ambiente `DOBRADO_LOJA` passado na hora da compilação Rust.
|
||||
|
||||
- **Build fixa (União ou Aliança):** o operador **não consegue trocar a loja** no formulário
|
||||
- **Build livre (Ambós):** o operador escolhe entre União e Aliança no dropdown
|
||||
|
||||
---
|
||||
|
||||
## Mecanismo: `option_env!` em Rust
|
||||
|
||||
Em `src-tauri/src/plugins/app.rs`:
|
||||
|
||||
```rust
|
||||
/// DOBRADO_LOJA é uma variável de ambiente lida em TEMPO DE COMPILAÇÃO.
|
||||
/// Se não definida, retorna None.
|
||||
pub const LOJA_FIXA: Option<&'static str> = option_env!("DOBRADO_LOJA");
|
||||
```
|
||||
|
||||
O `option_env!` é uma macro built-in do Rust que avalia expressões const em tempo de compilação. Se `DOBRADO_LOJA` estiver definida como env var, `LOJA_FIXA` contém o valor. Se não, `None`.
|
||||
|
||||
### Por que compile-time?
|
||||
|
||||
Se fosse runtime (`std::env::var`), o valor estaría hardcoded no binário da mesma forma, mas compile-time é mais limpo porque o Rust elimina código morto (dead code elimination). Se `LOJA_FIXA` é `None`, o Rust pode optimizar o código de "loja fixa" para fora do binário final.
|
||||
|
||||
---
|
||||
|
||||
## Comportamento por build
|
||||
|
||||
| Build | `DOBRADO_LOJA` | Seletor loja | `set_loja` | `get_loja_fixa` |
|
||||
|-------|----------------|-------------|-------------|-----------------|
|
||||
| União | `Uniao` | **Escondido** | Bloqueado com erro | `"Uniao"` |
|
||||
| Aliança | `Alianca` | **Escondido** | Bloqueado com erro | `"Alianca"` |
|
||||
| Ambos | _(não definido)_ | Visível + funcional | Permitido | `null` |
|
||||
|
||||
---
|
||||
|
||||
## Como buildar (Linux → Windows cross-compile)
|
||||
|
||||
### Pré-requisitos
|
||||
|
||||
```bash
|
||||
# Instalar target cross-compile
|
||||
rustup target add x86_64-pc-windows-gnu
|
||||
|
||||
# Ou via apt (Ubuntu/Debian)
|
||||
sudo apt install mingw-w64
|
||||
```
|
||||
|
||||
### Comandos
|
||||
|
||||
```bash
|
||||
cd src-tauri/
|
||||
|
||||
# ── UNIÃO ─────────────────────────────────────────────
|
||||
DOBRADO_LOJA=Uniao \
|
||||
cargo build --release --target x86_64-pc-windows-gnu
|
||||
|
||||
# Binário gerado:
|
||||
# src-tauri/target/x86_64-pc-windows-gnu/release/fechamento-caixa.exe
|
||||
|
||||
# ── ALIANÇA ──────────────────────────────────────────
|
||||
DOBRADO_LOJA=Alianca \
|
||||
cargo build --release --target x86_64-pc-windows-gnu
|
||||
|
||||
# ── AMBOS ────────────────────────────────────────────
|
||||
cargo build --release --target x86_64-pc-windows-gnu
|
||||
# (sem DOBRADO_LOJA → loja livre)
|
||||
```
|
||||
|
||||
### Local do binário
|
||||
|
||||
```
|
||||
src-tauri/target/x86_64-pc-windows-gnu/release/fechamento-caixa.exe
|
||||
```
|
||||
|
||||
Renomear para `fc-uniao.exe`, `fc-alianca.exe`, `fc-ambos.exe` antes de distribuir.
|
||||
|
||||
### ⚠️ Distribuição: WebView2Loader.dll
|
||||
|
||||
O binário **precisa da `WebView2Loader.dll` na mesma pasta** para funcionar. Esta DLL é o loader do WebView2 (o navegador embutido do Tauri).
|
||||
|
||||
```
|
||||
Pasta de distribuição (cada build):
|
||||
├── fc-uniao.exe
|
||||
├── WebView2Loader.dll ← copiar junto!
|
||||
```
|
||||
|
||||
A DLL está em:
|
||||
```
|
||||
src-tauri/target/x86_64-pc-windows-gnu/release/WebView2Loader.dll
|
||||
```
|
||||
|
||||
Para copiar junto ao buildar:
|
||||
```bash
|
||||
DLL="src-tauri/target/x86_64-pc-windows-gnu/release/WebView2Loader.dll"
|
||||
cp ${DLL} /tmp/fc-uniao/
|
||||
cp ${DLL} /tmp/fc-alianca/
|
||||
cp ${DLL} /tmp/fc-ambos/
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## O que acontece no frontend quando a loja é fixa
|
||||
|
||||
Em `src/index.html`, `DOMContentLoaded`:
|
||||
|
||||
```javascript
|
||||
document.addEventListener('DOMContentLoaded', async () => {
|
||||
try {
|
||||
const fixa = await window.__TAURI__.core.invoke('get_loja_fixa');
|
||||
if (fixa) {
|
||||
// Build fixa: esconde o <select id="f-loja">
|
||||
const sel = document.getElementById('f-loja');
|
||||
sel.style.display = 'none';
|
||||
sel.disabled = true;
|
||||
|
||||
// Esconde o label "Loja"
|
||||
const label = document.querySelector('.meta-label');
|
||||
if (label && label.textContent.trim() === 'Loja') {
|
||||
label.style.display = 'none';
|
||||
}
|
||||
|
||||
estado.loja = fixa; // define sem precisar do select
|
||||
}
|
||||
} catch(e) {
|
||||
console.warn('get_loja_fixa não disponível:', e);
|
||||
}
|
||||
// ... resto do init
|
||||
});
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## O que acontece no Rust quando a loja é fixa
|
||||
|
||||
```rust
|
||||
// set_loja retorna erro se a build tem loja fixa
|
||||
#[tauri::command]
|
||||
pub fn set_loja(state: State<'_, AppState>, loja: String) -> Result<(), String> {
|
||||
if LOJA_FIXA.is_some() {
|
||||
return Err("Loja fixa em tempo de build — não é possível trocar.".to_string());
|
||||
}
|
||||
state.config.lock().unwrap().loja = loja;
|
||||
state.save()
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Testar localmente (dev mode)
|
||||
|
||||
```bash
|
||||
cd src-tauri/
|
||||
|
||||
# Com loja fixa (dev)
|
||||
DOBRADO_LOJA=Uniao cargo tauri dev
|
||||
|
||||
# Com loja livre (dev)
|
||||
cargo tauri dev
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Criar release no Gitea
|
||||
|
||||
```bash
|
||||
# 1. Buildar as 3 versões e copiar para /tmp/
|
||||
cp src-tauri/target/x86_64-pc-windows-gnu/release/fechamento-caixa.exe /tmp/fc-uniao.exe
|
||||
cp src-tauri/target/x86_64-pc-windows-gnu/release/fechamento-caixa.exe /tmp/fc-alianca.exe
|
||||
cp src-tauri/target/x86_64-pc-windows-gnu/release/fechamento-caixa.exe /tmp/fc-ambos.exe
|
||||
|
||||
# 2. Criar releases via Gitea API
|
||||
GITEA_TOKEN="seu_token_aqui"
|
||||
REPO="filipe/fechamento-caixa"
|
||||
|
||||
for build in uniao alianca ambos; do
|
||||
TAG="v2.0.0-${build}"
|
||||
FILE="/tmp/fc-${build}.exe"
|
||||
|
||||
# Criar release
|
||||
RESP=$(curl -s -H "Authorization: token ${GITEA_TOKEN}" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d "{\"tag_name\":\"${TAG}\",\"name\":\"${TAG}\",\"body\":\"Build ${build}\"}" \
|
||||
"https://git.ofrangao.com.br/api/v1/repos/${REPO}/releases")
|
||||
ID=$(echo $RESP | python3 -c "import sys,json; print(json.load(sys.stdin)['id'])")
|
||||
|
||||
# Upload asset
|
||||
curl -s -H "Authorization: token ${GITEA_TOKEN}" \
|
||||
-H "Content-Type: application/octet-stream" \
|
||||
--data-binary @${FILE} \
|
||||
"https://git.ofrangao.com.br/api/v1/repos/${REPO}/releases/${ID}/assets?name=fc-${build}.exe"
|
||||
done
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Arquivos que mudam entre builds
|
||||
|
||||
**Nenhum arquivo fonte muda.** O binário é identico exceto pelo valor de `LOJA_FIXA` hardcoded.
|
||||
|
||||
Para verificar o valor hardcoded no binário:
|
||||
```bash
|
||||
strings fc-uniao.exe | grep -E "Uniao|Alianca"
|
||||
```
|
||||
|
||||
O binário `fc-uniao.exe` contém a string `Uniao` no lugar de `LOJA_FIXA`, enquanto `fc-ambos.exe` não contém nada (ou contém `None` ou não tem referência).
|
||||
@@ -0,0 +1,164 @@
|
||||
# Histórico do Projeto — Fechamento de Caixa O Frangão
|
||||
|
||||
## Contexto
|
||||
|
||||
**Dono:** Filipe.Tavares — O Frangão (lojas União e Aliança)
|
||||
**Problema original:** Planilhas manuais + webhooks n8n frágeis para registrar fechamento de caixa
|
||||
**Solução:** App desktop Tauri 2 + SQLite offline + Supabase cloud + impressão térmica 80mm
|
||||
|
||||
---
|
||||
|
||||
## Repositório
|
||||
|
||||
```
|
||||
https://git.ofrangao.com.br/filipe/fechamento-caixa
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Stack Técnica
|
||||
|
||||
| Camada | Tecnologia |
|
||||
|--------|-----------|
|
||||
| Frontend | HTML5 + CSS + JavaScript vanilla (single-file `src/index.html`) |
|
||||
| Runtime | Tauri 2 (Rust + WebView2) |
|
||||
| Dados local | SQLite via `rusqlite` |
|
||||
| Dados cloud | Supabase — PostgreSQL + REST API |
|
||||
| Impressão | HTML → `window.print()` com CSS @media print (80mm) |
|
||||
| Build | Cross-compile Linux → Windows (`x86_64-pc-windows-gnu`) |
|
||||
|
||||
---
|
||||
|
||||
## Releases
|
||||
|
||||
| Tag | Data | Descrição |
|
||||
|-----|------|-----------|
|
||||
| `v2.0.4-alianca` | 2026-07-05 | Fix preview modal re-open + sys_total recalc |
|
||||
| `v2.0.3-uniao` | 2026-07-05 | Fix diferença cálculo from array |
|
||||
| `v2.0.2-uniao` | 2026-07-05 | Fix buildPrintHTML sys_total |
|
||||
| `v2.0.1-uniao` | 2026-07-05 | Builds separadas + docs |
|
||||
| `v2.0.0-uniao/alianca/ambos` | 2026-07-04 | Lançamento — app completo com Tauri 2 |
|
||||
|
||||
---
|
||||
|
||||
## Bugs Corrigidos (desde v2.0.0)
|
||||
|
||||
### Preview + Modal (v2.0.4)
|
||||
- **237ceab** — `confirm-print` não fechava mais o modal antes de abrir o preview; `openPrintPreview` reabria o modal ao fechar se ele ainda estava aberto
|
||||
- **73c2027** — `closeConfirmModal()` era chamada antes de `openPrintPreview()` — preview cobria o modal, mas ao fechar voltava pro form
|
||||
- **56a2ed5** — `buildPrintHTML` usava `data.diferenca` (precomputado, stale) em vez de recalcular `sys_total - saldo_esperado - cancelamentos` inline
|
||||
|
||||
### Cálculo da Diferença (v2.0.3)
|
||||
- **acb90f0** — `diferenca` sempre recalculada da array de cancelamentos (não do escalar `total_cancelamentos`)
|
||||
- **6eb08b6** — mesmo fix em `buildReportHTML`, `buildPrintHTML`, `buildConfirmHTML`
|
||||
- **5a01264** — ignora `data.diferenca` pré-computado, recalcula sempre de `sys_total`
|
||||
|
||||
### Rust ESCPOS (v2.0.3)
|
||||
- **733519e** — Rust `to_escpos`: diferença = `sys_total - saldo_esperado - total_cancelamentos` (não mais `fechamento`)
|
||||
|
||||
### CSS / Layout (v2.0.2)
|
||||
- **6440b26** — `buildReportHTML`: diferença = `sysTotal - saldoEsp - cancelamentos`
|
||||
- **420e104** — `buildConfirmHTML` e `buildPrintHTML` usavam `sysTotal`/`total_cancelamentos` em vez de `sys_total`/`cancelamentos`
|
||||
- **b04e5ac** — `.main min-height:0` (footer invisível em resoluções pequenas)
|
||||
- **65c444a** — seletor loja escondia errado (caracteres asiáticos no DOM)
|
||||
- **2bfbf29** — values das options de loja sem acento (`Uniao`/`Alianca`) — alinhado com `LOJA_FIXA` do Rust
|
||||
|
||||
### Impressão (v2.0.1)
|
||||
- **b1585af / 155782f / 16972f4 / d3d5c4f** — fonte 18px→22px bold nos relatórios e preview
|
||||
- **5f1d9a4 / cc45ed9** — botão "Dia Anterior" agora exige senha antes de abrir
|
||||
|
||||
### Builds (v2.0.0)
|
||||
- **659d977** — `DOBRADO_LOJA` injetado via env no build — 3 binários: `fc-uniao.exe`, `fc-alianca.exe`, `fc-ambos.exe`
|
||||
|
||||
---
|
||||
|
||||
## Bugs Pendentes (conhecidos)
|
||||
|
||||
| # | Bug | Prioridade | Status |
|
||||
|---|-----|-----------|--------|
|
||||
| #6 | `salvarEdicaoModal` reconstrói `id_fechamento` dos inputs em vez de usar `full.id_fechamento` — PATCH pode errar se operador mudar loja/data/operador | Média | Pendente |
|
||||
|
||||
---
|
||||
|
||||
## Arquitetura de Dados
|
||||
|
||||
### SQLite local (offline-first)
|
||||
```
|
||||
%LOCALAPPDATA%/FechamentoCaixa/fechamento.db
|
||||
```
|
||||
|
||||
### Supabase cloud
|
||||
```
|
||||
https://supabase.ofrangao.com.br
|
||||
Tabela: fechamentos_web
|
||||
```
|
||||
|
||||
### Fluxo de sync
|
||||
```
|
||||
App abre → carrega rascunho do SQLite → operador trabalha offline
|
||||
↓
|
||||
operador clica "Salvar/Sincronizar"
|
||||
↓
|
||||
1. Salva em SQLite local
|
||||
2. Envia para Supabase (se online)
|
||||
↓
|
||||
operador clica "Imprimir"
|
||||
↓
|
||||
window.print() → receipt 80mm
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Comandos Tauri (frontend chama via `invoke`)
|
||||
|
||||
| Comando | Descrição |
|
||||
|---------|-----------|
|
||||
| `salvar_fechamento` | Salva estado no SQLite local |
|
||||
| `carregar_rascunho` | Carrega rascunho por loja+data |
|
||||
| `sb_salvar_fechamento` | POST para Supabase |
|
||||
| `sb_listar_recentes` | Lista fechamentos recentes |
|
||||
| `sb_buscar_por_id` | Busca por ID numérico |
|
||||
| `sb_buscar_por_uuid` | Busca por UUID |
|
||||
| `detectar_impressora` | Detecta EPSON TM-T20 |
|
||||
| `imprimir_recibo` | Imprime via ESCPOS ou fallback window.print() |
|
||||
|
||||
---
|
||||
|
||||
## Builds — 3 versões
|
||||
|
||||
| Binário | Loja | Seletor |
|
||||
|---------|------|---------|
|
||||
| `fc-uniao.exe` | Fixa: UNIÃO | Escondido |
|
||||
| `fc-alianca.exe` | Fixa: ALIANÇA | Escondido |
|
||||
| `fc-ambos.exe` | Livre | Dropdown visível |
|
||||
|
||||
### Como buildar
|
||||
|
||||
```bash
|
||||
cd src-tauri/
|
||||
|
||||
# UNIÃO
|
||||
DOBRADO_LOJA=Uniao cargo build --release --target x86_64-pc-windows-gnu
|
||||
|
||||
# ALIANÇA
|
||||
DOBRADO_LOJA=Alianca cargo build --release --target x86_64-pc-windows-gnu
|
||||
|
||||
# AMBOS
|
||||
cargo build --release --target x86_64-pc-windows-gnu
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Lições Aprendidas
|
||||
|
||||
1. **Cálculos sempre inline** — nunca usar valores pré-computados (`data.diferenca`, `data.total_cancelamentos`) que podem estar stale. Sempre recalcular da fonte.
|
||||
2. **Fechar modal ANTES de abrir overlay** — se um modal vai ser coberto por outro, fechar primeiro e reabrir depois (salvar estado).
|
||||
3. **Cross-compile Linux→Windows** — funciona perfeitamente com `x86_64-pc-windows-gnu` target.
|
||||
4. **Gitea API** — `Authorization: token {TOKEN}` (minúsculo `token`) funciona; `Token {TOKEN}` não. Upload de assets funciona; DELETE de assets retorna 404 (bug do Gitea?).
|
||||
|
||||
---
|
||||
|
||||
## Autores
|
||||
|
||||
- **Filipe.Tavares** — dono das lojas / demandante
|
||||
- **Hermes Agent** — desenvolvimento + debugging
|
||||
@@ -1,140 +1,192 @@
|
||||
# Fechamento de Caixa — O Frangão
|
||||
|
||||
App desktop nativo para fechamento de caixa com impressão em impressora térmica 80mm.
|
||||
## O que é
|
||||
|
||||
## Stack
|
||||
App desktop nativo para registrar o fechamento de caixa diário das lojas **União** e **Aliança** do O Frangão. Cada fechamento registra: saldo em dinheiro, cartões de crédito/débito/alimentação/PIX, vales, sangrias, despesas, PIX CNPJ, e itens a receber.
|
||||
|
||||
- **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)
|
||||
## Ideia central
|
||||
|
||||
## Estrutura do Projeto
|
||||
O app é um **formulário inteligente** que:
|
||||
|
||||
1. Captura todos os dados de fechamento de caixa (dinheiro, cartões, despesas, etc.)
|
||||
2. Salva localmente em **SQLite** (funciona offline)
|
||||
3. Envia automaticamente para o **Supabase** (cloud) quando há conexão
|
||||
4. Gera um **recibo térmico 80mm** para impressão
|
||||
|
||||
O objetivo é substituir planilhas manuais e webhooks frágeis, dando uma fonte única de verdade para os dados de caixa.
|
||||
|
||||
---
|
||||
|
||||
## Arquitetura geral
|
||||
|
||||
```
|
||||
┌─────────────────┐ ┌──────────────────┐ ┌────────────────────┐
|
||||
│ HTML/JS │────▶│ Tauri (Rust) │────▶│ SQLite (local) │
|
||||
│ (frontend) │◀────│ plugins: │◀────│ fechamentos.db │
|
||||
│ │ │ app, storage, │ │ - funciona offline │
|
||||
│ - formulário │ │ supabase, │ │ - sync automático │
|
||||
│ - impressão │ │ printer │ └────────────────────┘
|
||||
│ - estado JS │ └────────┬─────────┘ │
|
||||
└─────────────────┘ │ ▼
|
||||
│ ┌────────────────────┐
|
||||
│ │ Supabase Cloud │
|
||||
│ │ (supabase. │
|
||||
└─────────────▶│ ofrangao.com.br) │
|
||||
│ - PostgreSQL │
|
||||
│ - RLS + JWT auth │
|
||||
│ - API REST │
|
||||
└────────────────────┘
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Stack técnica
|
||||
|
||||
| Camada | Tecnologia |
|
||||
|--------|-----------|
|
||||
| Frontend | HTML5 + CSS + JavaScript vanilla (single-file `src/index.html`) |
|
||||
| Runtime | [Tauri 2](https://tauri.app/) (Rust + WebView2) |
|
||||
| Dados local | SQLite via `rusqlite` |
|
||||
| Dados cloud | [Supabase](https://supabase.com/) — PostgreSQL + REST API |
|
||||
| Impressão | HTML → `window.print()` com CSS @media print (80mm) |
|
||||
| Build | Cross-compile Linux → Windows (`x86_64-pc-windows-gnu`) |
|
||||
|
||||
---
|
||||
|
||||
## Repositório
|
||||
|
||||
```
|
||||
https://git.ofrangao.com.br/filipe/fechamento-caixa
|
||||
```
|
||||
|
||||
### Estrutura de arquivos
|
||||
|
||||
```
|
||||
fechamento-caixa/
|
||||
├── src/
|
||||
│ └── index.html # Frontend completo (HTML + CSS + JS inline)
|
||||
│ └── index.html ← Frontend completo (HTML + CSS + JS)
|
||||
├── 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
|
||||
│ ├── src/
|
||||
│ │ ├── main.rs ← Entry point + plugin initialization
|
||||
│ │ └── plugins/
|
||||
│ │ ├── app.rs ← Config da app (loja, PDV, sync)
|
||||
│ │ ├── storage.rs ← SQLite local
|
||||
│ │ ├── supabase.rs ← Sync com Supabase
|
||||
│ │ ├── printer.rs ← Impressão
|
||||
│ │ └── escpos.rs ← Helpers ESCPOS
|
||||
│ └── tauri.conf.json ← Config Tauri (janela, bundle, etc.)
|
||||
├── SPEC.md ← Este arquivo
|
||||
├── SUPABASE_SCHEMA.md ← Schema do banco de dados
|
||||
├── API_INTEGRATION.md ← Como o frontend se comunica com Supabase
|
||||
└── BUILS_FARM.md ← Como gerar fc-uniao.exe, fc-alianca.exe, fc-ambos.exe
|
||||
```
|
||||
|
||||
## 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)
|
||||
## Builds — 3 versões do app
|
||||
|
||||
## Setup
|
||||
O mesmo código gera 3 binários diferentes conforme a variável de ambiente `DOBRADO_LOJA`:
|
||||
|
||||
### 1. Clonar o repositório
|
||||
## Downloads
|
||||
|
||||
```bash
|
||||
git clone https://git.ofrangao.com.br/filipe/fechamento-caixa.git
|
||||
cd fechamento-caixa
|
||||
```
|
||||
| Binário | Loja | Download |
|
||||
|---------|------|----------|
|
||||
| `fc-uniao.exe` | **Travada: UNIÃO** | [Baixar](https://git.ofrangao.com.br/filipe/fechamento-caixa/releases/download/v2.0.0-uniao/fc-uniao.exe) |
|
||||
| `fc-alianca.exe` | **Travada: ALIANÇA** | [Baixar](https://git.ofrangao.com.br/filipe/fechamento-caixa/releases/download/v2.0.0-alianca/fc-alianca.exe) |
|
||||
| `fc-ambos.exe` | **Seletor livre** | [Baixar](https://git.ofrangao.com.br/filipe/fechamento-caixa/releases/download/v2.0.0-ambos/fc-ambos.exe) |
|
||||
|
||||
### 2. Configurar chaves do Supabase
|
||||
Quando a loja é fixa, o seletor de loja no formulário é **escondido via JavaScript** e tentativas de mudar via API retornam erro.
|
||||
|
||||
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
|
||||
## Fluxo de um fechamento
|
||||
|
||||
```
|
||||
[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()
|
||||
1. Operador abre o app
|
||||
↓
|
||||
2. Seleciona Data, Operador, Turno, Loja (se não for fixa)
|
||||
↓
|
||||
3. Preenche os campos:
|
||||
- Saldo de Troy
|
||||
- Cartões (crédito, débito, alimentação, PIX) — um valor por vez
|
||||
- Despesas (descrição + valor)
|
||||
- Sangrias (hora + gerente + valor)
|
||||
- PIX CNPJ (nome + valor)
|
||||
- Vales (nome + observação + valor)
|
||||
- A Receber (nome + valor)
|
||||
- Cancelamentos (número + motivo + valor)
|
||||
↓
|
||||
4. App calcula:
|
||||
TOTAL SISTEMA = soma de todos os campos acima
|
||||
DIFERENÇA = TOTAL SISTEMA − Saldo Esperado − Cancelamentos
|
||||
↓
|
||||
5. Operador clica "💾 Salvar / Sincronizar"
|
||||
→ Salva em SQLite local
|
||||
→ Envia para Supabase (se online)
|
||||
↓
|
||||
6. Operador clica "🖨️ Imprimir"
|
||||
→ Abre preview do recibo 80mm
|
||||
→ window.print() para impressora térmica
|
||||
```
|
||||
|
||||
## 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 |
|
||||
## Tauri — o runtime desktop
|
||||
|
||||
## Problemas conhecidos
|
||||
O app roda como **Tauri 2**, um runtime que embute um navegador WebView2 (Windows) dentro de um processo Rust.
|
||||
|
||||
- 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
|
||||
### O que o Tauri fornece
|
||||
|
||||
## Version History
|
||||
| Recurso | Como é usado |
|
||||
|---------|-------------|
|
||||
| **Janela nativa** | A janela do app com barra de título, controls de redimensionar, ícone |
|
||||
| **Sistema de arquivos** | Lê/escreve `config.json` e `fechamento.db` em `%LOCALAPPDATA%/FechamentoCaixa/` |
|
||||
| **Invocação de comandos Rust** | Frontend JS chama `invoke('nome_comando', args)` → Rust executa e retorna |
|
||||
| **HTTP client** | Plugin `supabase.rs` usa `reqwest` para falar com o Supabase |
|
||||
| **Shell** | Abre diálogo de impressoras, executa `cmd /C copy` para impressão |
|
||||
| **Diálogos nativos** | `tauri_plugin_dialog` para escolher arquivos, pastas, impressora |
|
||||
|
||||
- **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)
|
||||
### Comandos Tauri (Rust → JS)
|
||||
|
||||
O frontend **nunca acessa** Supabase, SQLite ou impressora diretamente. Tudo passa por `invoke`:
|
||||
|
||||
```javascript
|
||||
// Salvar fechamento (SQLite + Supabase)
|
||||
await invoke('salvar_fechamento', { estado });
|
||||
|
||||
// Sync com Supabase
|
||||
await invoke('sb_salvar_fechamento', { estado });
|
||||
|
||||
// Detectar impressora
|
||||
await invoke('detectar_impressora');
|
||||
|
||||
// Imprimir
|
||||
await invoke('imprimir_recibo', { html: reciboHTML });
|
||||
```
|
||||
|
||||
### Estrutura dos plugins Rust
|
||||
|
||||
```
|
||||
src-tauri/src/plugins/
|
||||
├── app.rs — Config da app (loja, operador default, sync)
|
||||
├── storage.rs — SQLite local (fonte primária offline)
|
||||
├── supabase.rs — Cliente HTTP para Supabase (service_role JWT)
|
||||
├── printer.rs — Detecção EPSON TM-T20, fallback para window.print()
|
||||
└── escpos.rs — Helpers ESCPOS (alinhamento, negrito, corte)
|
||||
```
|
||||
|
||||
### Onde os dados ficam
|
||||
|
||||
| Dado | Local |
|
||||
|------|-------|
|
||||
| Config (SUPABASE keys, loja default) | `%LOCALAPPDATA%/FechamentoCaixa/config.json` |
|
||||
| Banco SQLite (fechamentos) | `%LOCALAPPDATA%/FechamentoCaixa/fechamento.db` |
|
||||
| Rascunho auto-save | `%LOCALAPPDATA%/FechamentoCaixa/fechamento_rascunho.db` |
|
||||
|
||||
---
|
||||
|
||||
## Autores & Contexto
|
||||
|
||||
- **Desenvolvedor:** Filipe.Tavares
|
||||
- **Dono das lojas:** O Frangão — União e Aliança
|
||||
- **Objetivo:** Substituir planilhas manuais e webhooks n8n por um sistema de fechamento de caixa confiável e offline-first
|
||||
|
||||
@@ -0,0 +1,142 @@
|
||||
# Schema do Supabase — Fechamento de Caixa
|
||||
|
||||
## Servidor
|
||||
|
||||
- **URL:** `https://supabase.ofrangao.com.br`
|
||||
- **Instância:** O Frangão — PostgreSQL hospedado
|
||||
|
||||
---
|
||||
|
||||
## Tabela principal: `fechamentos_web`
|
||||
|
||||
```sql
|
||||
CREATE TABLE public.fechamentos_web (
|
||||
id SERIAL PRIMARY KEY,
|
||||
id_fechamento TEXT UNIQUE NOT NULL, -- chave natural: {loja}_{data}_{operador}
|
||||
loja TEXT NOT NULL, -- "Uniao" | "Alianca"
|
||||
data DATE NOT NULL, -- data do fechamento (YYYY-MM-DD)
|
||||
operador TEXT NOT NULL, -- nome do operador
|
||||
turno TEXT NOT NULL, -- "manha" | "tarde"
|
||||
observacoes TEXT DEFAULT 'rascunho', -- "rascunho" | "finalizado"
|
||||
|
||||
-- Totais de fechamento (campos escalares)
|
||||
fechamento_dinheiro REAL DEFAULT 0, -- dinheiro no caixa
|
||||
fechamento_cartoes REAL DEFAULT 0, -- total cartões
|
||||
fechamento_receber REAL DEFAULT 0, -- a receber
|
||||
fechamento REAL DEFAULT 0, -- TOTAL: dinheiro + cartões + receber
|
||||
|
||||
-- Totais do sistema (valores lidos do sistema PAF-ECF)
|
||||
sys_total REAL DEFAULT 0, -- soma: troco+crédito+débito+alimentação+vales+receber+pixCartao+pixCnpj
|
||||
saldo_esperado REAL DEFAULT 0, -- quanto deveria ter (informado pelo operador)
|
||||
|
||||
-- Controle
|
||||
criado_em TIMESTAMPTZ DEFAULT NOW(),
|
||||
atualizado_em TIMESTAMPTZ DEFAULT NOW(),
|
||||
enviado_por TEXT,
|
||||
ip_origem TEXT,
|
||||
user_agent TEXT,
|
||||
|
||||
-- Dados variáveis em formato livre (JSONB)
|
||||
dados JSONB DEFAULT '{}'::jsonb
|
||||
);
|
||||
```
|
||||
|
||||
### Comentários sobre colunas
|
||||
|
||||
| Coluna | O que guarda |
|
||||
|--------|-------------|
|
||||
| `id_fechamento` | Chave natural única. Ex: `uniao_2026-06-19_filipe`. Formato: `{loja}_{data}_{operador}` em minúsculas, espaços → `_` |
|
||||
| `sys_total` | Total calculado pelo sistema — é a soma de **todos** os campos de cartão + vales + despesas + sangrias + etc |
|
||||
| `saldo_esperado` | Valor informado pelo operador — o quanto ele espera ter em caixa |
|
||||
| `dados` | JSONB com todos os arrays: `cartoes_credito[]`, `cartoes_debito[]`, `cartoes_alimentacao[]`, `cartoes_pix[]`, `despesas[]`, `sangrias[]`, `pixcnpj[]`, `vales[]`, `receber[]`, `cancelamentos[]` |
|
||||
| `observacoes` | `rascunho` enquanto.edita, `finalizado` quando o operador confirma |
|
||||
|
||||
---
|
||||
|
||||
## Fórmula de diferença
|
||||
|
||||
```
|
||||
DIFERENÇA = sys_total − saldo_esperado − total_cancelamentos
|
||||
```
|
||||
|
||||
Se `DIFERENÇA = 0` → ✅ OK (caixa fecha perfeito)
|
||||
Se `DIFERENÇA > 0` → ⚠️ Sobrou dinheiro
|
||||
Se `DIFERENÇA < 0` → 🔴 Faltou dinheiro
|
||||
|
||||
---
|
||||
|
||||
## Colunas dentro do JSONB `dados`
|
||||
|
||||
```json
|
||||
{
|
||||
"cartoes_credito": [152.00, 123.50, ...],
|
||||
"cartoes_debito": [15.45, 20.00, ...],
|
||||
"cartoes_alimentacao": [12.55, ...],
|
||||
"cartoes_pix": [112.50, ...],
|
||||
"despesas": [{"desc": "luz", "valor": 150.00}, ...],
|
||||
"sangrias": [{"hora": "14:30", "gerente": "João", "retirada": 50.00, "valor": 50.00}, ...],
|
||||
"pixcnpj": [{"nome": "João Silva", "valor": 80.00}, ...],
|
||||
"vales": [{"nome": "Maria", "obs": "vale transporte", "valor": 30.00}, ...],
|
||||
"receber": [{"nome": "Pedro", "valor": 45.00}, ...],
|
||||
"cancelamentos": [{"numero": "001", "motivo": "erro digitação", "valor": 25.00}, ...]
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Row Level Security (RLS)
|
||||
|
||||
A tabela usa RLS com duas políticas:
|
||||
|
||||
1. **Anon (chave pública):** pode ler registros
|
||||
2. **Service role:** pode ler + escrever registros (usado pelo app com JWT service_role)
|
||||
|
||||
### Chave anon (pública)
|
||||
Usada para consultasreadonly pelo frontend.
|
||||
|
||||
### Chave service_role
|
||||
Usada pelo app Tauri para INSERT/UPDATE via plugin `supabase.rs`. Esta chave **nunca deve ser exposta publicamente** — está embedada no binário compilado.
|
||||
|
||||
---
|
||||
|
||||
## Endpoints da API REST
|
||||
|
||||
Base: `https://supabase.ofrangao.com.br/rest/v1/`
|
||||
|
||||
| Método | Endpoint | Uso |
|
||||
|--------|----------|-----|
|
||||
| `GET` | `/fechamentos_web?loja=eq.uniao&data=eq.2026-06-19&select=*` | Lista fechamentos |
|
||||
| `GET` | `/fechamentos_web?id_fechamento=eq.uniao_2026-06-19_filipe` | Busca por chave |
|
||||
| `POST` | `/fechamentos_web` | Cria/atualiza (upsert via `Prefer: resolution=merge-duplicates`) |
|
||||
| `PATCH` | `/fechamentos_web?id_fechamento=eq.uniao_2026-06-19_filipe` | Atualiza campos |
|
||||
|
||||
---
|
||||
|
||||
## Header de autenticação
|
||||
|
||||
```http
|
||||
Authorization: Bearer {SUPABASE_SERVICE_ROLE_JWT}
|
||||
apikey: {SUPABASE_ANON_KEY}
|
||||
Content-Type: application/json
|
||||
Prefer: resolution=merge-duplicates (para upsert no POST)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Supabase — Configuração do projeto
|
||||
|
||||
- **Projeto:** O Frangão
|
||||
- **Host:** `https://supabase.ofrangao.com.br`
|
||||
- **Banco:** PostgreSQL 15+
|
||||
- **API:** REST via PostgREST
|
||||
- **Auth:** JWT (anon key + service role key)
|
||||
|
||||
O app busca a service_role key via `config.json` local (em `%LOCALAPPDATA%/FechamentoCaixa/config.json`). O fallback é a chave embedada no binário.
|
||||
|
||||
```json
|
||||
// config.json (opcional — sobrepõe a embedada)
|
||||
{
|
||||
"SUPABASE_ANON_KEY": "sb_publishable_...",
|
||||
"SUPABASE_SERVICE_KEY": "sb_secret_..."
|
||||
}
|
||||
```
|
||||
@@ -111,6 +111,7 @@ fn main() {
|
||||
plugins::printer::ativar_modo_teste_impressao,
|
||||
// App
|
||||
plugins::app::get_loja,
|
||||
plugins::app::get_loja_fixa,
|
||||
plugins::app::set_loja,
|
||||
plugins::app::get_config,
|
||||
plugins::app::set_config,
|
||||
|
||||
@@ -1,5 +1,8 @@
|
||||
//! Plugin de configuração da aplicação (loja, preferências)
|
||||
//! Persistido em config.json ao lado do banco SQLite.
|
||||
//!
|
||||
//! LOJA_FIXA: definido em tempo de build via env DOBRADO_LOJA (Uniao | Alianca).
|
||||
//! Quando definido, a loja é travada e o seletor do formulário fica desabilitado.
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::fs;
|
||||
@@ -7,6 +10,9 @@ use std::path::PathBuf;
|
||||
use std::sync::Mutex;
|
||||
use tauri::State;
|
||||
|
||||
/// Indica se a loja foi fixada em tempo de build.
|
||||
pub const LOJA_FIXA: Option<&'static str> = option_env!("DOBRADO_LOJA");
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize, Clone)]
|
||||
pub struct AppConfig {
|
||||
pub loja: String,
|
||||
@@ -15,17 +21,28 @@ pub struct AppConfig {
|
||||
pub turno_default: String,
|
||||
pub sync_automatico: bool,
|
||||
pub auto_open_printer: bool,
|
||||
/// Verdadeiro quando a build tem loja fixa (não permite trocar no formulário)
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub loja_fixa: Option<bool>,
|
||||
}
|
||||
|
||||
impl AppConfig {
|
||||
/// Loja padrão: o env DOBRADO_LOJA se existir, senão "Uniao"
|
||||
pub fn default_loja() -> String {
|
||||
LOJA_FIXA.unwrap_or("Uniao").to_string()
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for AppConfig {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
loja: "Uniao".to_string(),
|
||||
loja: Self::default_loja(),
|
||||
pdv_nome: "PDV-01".to_string(),
|
||||
operador_default: String::new(),
|
||||
turno_default: String::new(),
|
||||
sync_automatico: true,
|
||||
auto_open_printer: false,
|
||||
loja_fixa: LOJA_FIXA.map(|_| true),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -65,8 +82,17 @@ pub fn get_loja(state: State<'_, AppState>) -> String {
|
||||
state.config.lock().unwrap().loja.clone()
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn get_loja_fixa() -> Option<String> {
|
||||
LOJA_FIXA.map(|s| s.to_string())
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn set_loja(state: State<'_, AppState>, loja: String) -> Result<(), String> {
|
||||
// Se loja_fixa está ativa, não permite alterar
|
||||
if LOJA_FIXA.is_some() {
|
||||
return Err("Loja fixa em tempo de build — não é possível trocar.".to_string());
|
||||
}
|
||||
state.config.lock().unwrap().loja = loja;
|
||||
state.save()
|
||||
}
|
||||
|
||||
@@ -322,13 +322,12 @@ impl ReciboData {
|
||||
out.extend(escpos::divider());
|
||||
|
||||
// ── DIFERENÇA ──
|
||||
// Diferença = total_fechamento - saldo_esperado - cancelamentos
|
||||
// Usa o valor calculado pelo frontend (já correto)
|
||||
out.extend(escpos::line_bold("DIFERENCA"));
|
||||
let diff = self.fechamento - self.saldo_esperado - self.cancelamentos;
|
||||
let diff_str = if diff >= 0.0 {
|
||||
format!("+{}", self.fmt_money(diff))
|
||||
let diff_str = if self.diferenca >= 0.0 {
|
||||
format!("+{}", self.fmt_money(self.diferenca))
|
||||
} else {
|
||||
self.fmt_money(diff)
|
||||
self.fmt_money(self.diferenca)
|
||||
};
|
||||
out.extend(escpos::title(&diff_str));
|
||||
|
||||
@@ -528,8 +527,10 @@ pub fn caminho_impressora() -> Option<String> {
|
||||
|
||||
#[tauri::command]
|
||||
pub fn imprimir_recibo(data: String) -> Result<usize, String> {
|
||||
eprintln!("[DEBUG RECIBO JSON] {}", &data[..data.len().min(500)]);
|
||||
let r: ReciboData =
|
||||
serde_json::from_str(&data).map_err(|e| format!("Erro ao analisar dados: {}", e))?;
|
||||
eprintln!("[DEBUG DIFERENCA] self.diferenca={}", r.diferenca);
|
||||
let bytes = r.to_escpos();
|
||||
log::info!("Imprimindo recibo: {} bytes ESC/POS", bytes.len());
|
||||
PRINTER.imprimir_bytes(&bytes)
|
||||
|
||||
@@ -312,7 +312,7 @@ impl SupabaseClient {
|
||||
pub fn listar_recentes(&self, loja: &str, limite: i64) -> Result<Vec<Value>, SupabaseError> {
|
||||
let loja_normalized = sem_acento(&loja.to_lowercase());
|
||||
let url = format!(
|
||||
"{}/rest/v1/fechamentos_web?loja=eq.{}&order=data.desc,atualizado_em.desc&limit={}&select=id,id_fechamento,data,operador,turno,saldo_troco,saldo_esperado,fechamento_dinheiro,fechamento_cartoes,fechamento_areceber,diferenca,status_diferenca,observacoes,atualizado_em,dados",
|
||||
"{}/rest/v1/fechamentos_web?loja=eq.{}&order=data.desc,atualizado_em.desc&limit={}&select=id,id_fechamento,data,operador,turno,saldo_troco,saldo_credito,saldo_debito,saldo_alimentacao,saldo_vales,saldo_areceber,saldo_esperado,total_pixcnpj,total_cartao_credito,total_cartao_debito,total_cartao_alimentacao,total_cartao_pix,total_cancelamentos,fechamento_dinheiro,fechamento_cartoes,fechamento_areceber,diferenca,status_diferenca,observacoes,atualizado_em,dados",
|
||||
self.base_url,
|
||||
urlencoding::encode(&loja_normalized),
|
||||
limite,
|
||||
|
||||
+401
-212
@@ -160,7 +160,7 @@
|
||||
padding: 10px 16px; display: flex; justify-content: space-between; align-items: center;
|
||||
}
|
||||
.dif-label { font-size: 12px; font-weight: 700; color: var(--text); text-transform: uppercase; letter-spacing: 0.5px; }
|
||||
.dif-value { font-size: 22px; font-weight: 700; font-variant-numeric: tabular-nums; }
|
||||
.dif-value { font-size: 18px; font-weight: 700; font-variant-numeric: tabular-nums; }
|
||||
.dif-value.pos, .dif-value.zero { color: var(--green); }
|
||||
.dif-value.neg { color: var(--red); }
|
||||
|
||||
@@ -543,7 +543,8 @@
|
||||
<div class="cfg-section">
|
||||
<div class="cfg-section-title">🔐 Senha Admin (editar dias anteriores)</div>
|
||||
<div style="display:flex;gap:6px;margin-top:4px">
|
||||
<input type="password" id="cfg-admin-password" style="flex:1;padding:6px;border:1px solid #ddd;border-radius:4px;font-size:13px" placeholder="Deixe em branco se não quiser senha">
|
||||
<input type="password" id="cfg-admin-password" style="flex:1;padding:6px;border:1px solid #ddd;border-radius:4px;font-size:13px" placeholder="Senha fixa (ou deixe em branco)">
|
||||
<input type="password" id="cfg-dynamic-password" style="flex:1;padding:6px;border:1px solid #ddd;border-radius:4px;font-size:13px" placeholder="Dinâmica: 10+dia+mes (ex: 1217)">
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -564,7 +565,7 @@
|
||||
<p style="font-size:13px;color:#444;margin-bottom:14px">
|
||||
Tem certeza que deseja <strong>enviar este fechamento e encerrar o caixa?</strong>
|
||||
</p>
|
||||
<div id="confirm-summary" style="border:1px solid #b3d9f7;border-radius:8px;padding:14px;background:#f0f7ff;font-size:14px;font-family:Arial,sans-serif;line-height:1.6">
|
||||
<div id="confirm-summary" style="border:1px solid #b3d9f7;border-radius:8px;padding:18px;background:#f0f7ff;font-size:16px;font-family:Arial,sans-serif;line-height:1.8">
|
||||
</div>
|
||||
<p style="font-size:11px;color:#888;margin-top:10px">
|
||||
Após enviar, <strong>não dá pra editar este fechamento</strong> sem a senha do Filipe.
|
||||
@@ -1233,6 +1234,7 @@ function loadConfig() {
|
||||
}
|
||||
function saveConfig() {
|
||||
config.admin_password = document.getElementById('cfg-admin-password').value.trim();
|
||||
config.dynamic_password = document.getElementById('cfg-dynamic-password').value.trim();
|
||||
try { localStorage.setItem(CFG_KEY, JSON.stringify(config)); } catch(e) {}
|
||||
buildDatalists();
|
||||
}
|
||||
@@ -1246,6 +1248,7 @@ function openConfig() {
|
||||
renderCfgList('clientes', 'cfg-clientes-list');
|
||||
renderCfgList('vales_nomes', 'cfg-vales-list');
|
||||
document.getElementById('cfg-admin-password').value = config.admin_password || '';
|
||||
document.getElementById('cfg-dynamic-password').value = config.dynamic_password || '';
|
||||
}
|
||||
function closeConfig() { document.getElementById('modal-config').classList.remove('open'); }
|
||||
function renderCfgList(key, containerId) {
|
||||
@@ -1320,9 +1323,12 @@ function buildConfirmHTML(data) {
|
||||
// Relatório completo — mesmo layout do PDF
|
||||
const fmt = v => 'R$ ' + (v || 0).toFixed(2).replace('.', ',');
|
||||
const fmtNeg = v => (v < 0 ? '-' : '') + 'R$ ' + Math.abs(v || 0).toFixed(2).replace('.', ',');
|
||||
// Diferença = fechamento - saldo_esperado - cancelamentos
|
||||
const diferenca = data.diferenca !== undefined ? data.diferenca
|
||||
: ((data.fechamento || 0) - (data.saldo_esperado || 0) - (data.cancelamentos || 0));
|
||||
// Cancelamentos: SEMPRE recalcular da array (o escalar pode estar desatualizado)
|
||||
const cancelamentos_arr = Array.isArray(data.cancelamentos_arr) ? data.cancelamentos_arr : [];
|
||||
const totalCancelCalc = cancelamentos_arr.reduce((s,r) => s+(r.valor||0), 0);
|
||||
const sysTotalCalc = (data.sys_total || data.fechamento || 0);
|
||||
const saldoEsperadoCalc = (data.saldo_esperado || 0);
|
||||
const diferenca = sysTotalCalc - saldoEsperadoCalc - totalCancelCalc;
|
||||
const diffStr = diferenca >= 0 ? `+${fmt(diferenca)}` : fmtNeg(diferenca);
|
||||
const diffOk = diferenca === 0;
|
||||
|
||||
@@ -1331,7 +1337,6 @@ function buildConfirmHTML(data) {
|
||||
const pixcnpj_arr = Array.isArray(data.pixcnpj_arr) ? data.pixcnpj_arr : [];
|
||||
const vales_arr = Array.isArray(data.vales_arr) ? data.vales_arr : [];
|
||||
const receber_arr = Array.isArray(data.receber_arr) ? data.receber_arr : [];
|
||||
const cancelamentos_arr = Array.isArray(data.cancelamentos_arr) ? data.cancelamentos_arr : [];
|
||||
|
||||
const cartCred_arr = Array.isArray(data.cartoes_credito) ? data.cartoes_credito : [];
|
||||
const cartDeb_arr = Array.isArray(data.cartoes_debito) ? data.cartoes_debito : [];
|
||||
@@ -1367,17 +1372,17 @@ function buildConfirmHTML(data) {
|
||||
const al = cartAli_arr[i] || 0;
|
||||
const px = cartPix_arr[i] || 0;
|
||||
cartTableRows += `<tr>
|
||||
<td>${cr > 0 ? fmt(cr) : '-'}</td>
|
||||
<td>${db > 0 ? fmt(db) : '-'}</td>
|
||||
<td>${al > 0 ? fmt(al) : '-'}</td>
|
||||
<td>${px > 0 ? fmt(px) : '-'}</td>
|
||||
<td>${cr > 0 ? fmt(cr).replace('R$ ','') : '-'}</td>
|
||||
<td>${db > 0 ? fmt(db).replace('R$ ','') : '-'}</td>
|
||||
<td>${al > 0 ? fmt(al).replace('R$ ','') : '-'}</td>
|
||||
<td>${px > 0 ? fmt(px).replace('R$ ','') : '-'}</td>
|
||||
</tr>`;
|
||||
}
|
||||
cartTableRows += `<tr class="total-row">
|
||||
<td>${cartCred > 0 ? fmt(cartCred) : '-'}</td>
|
||||
<td>${cartDeb > 0 ? fmt(cartDeb) : '-'}</td>
|
||||
<td>${cartAli > 0 ? fmt(cartAli) : '-'}</td>
|
||||
<td>${cartPix > 0 ? fmt(cartPix) : '-'}</td>
|
||||
<td>${cartCred > 0 ? fmt(cartCred).replace('R$ ','') : '-'}</td>
|
||||
<td>${cartDeb > 0 ? fmt(cartDeb).replace('R$ ','') : '-'}</td>
|
||||
<td>${cartAli > 0 ? fmt(cartAli).replace('R$ ','') : '-'}</td>
|
||||
<td>${cartPix > 0 ? fmt(cartPix).replace('R$ ','') : '-'}</td>
|
||||
</tr>`;
|
||||
|
||||
let html = `<!DOCTYPE html>
|
||||
@@ -1386,28 +1391,28 @@ function buildConfirmHTML(data) {
|
||||
<title>Fechamento de Caixa</title>
|
||||
<style>
|
||||
* { box-sizing: border-box; margin: 0; padding: 0; }
|
||||
body { font-family: 'Courier New', monospace; font-size: 15px; width: 80mm; margin: 0 auto; padding: 8px; font-weight: bold !important; print-color-adjust: exact; -webkit-print-color-adjust: exact; }
|
||||
body { font-family: 'Courier New', monospace; font-size: 18px; font-weight: bold !important; width: 80mm; margin: 0 auto; padding: 8px; font-weight: bold !important; print-color-adjust: exact; -webkit-print-color-adjust: exact; }
|
||||
.header { text-align: center; margin-bottom: 8px; }
|
||||
.header h1 { font-size: 20px; font-weight: bold !important; }
|
||||
.header h2 { font-size: 16px; font-weight: bold !important; }
|
||||
.header h1 { font-size: 24px; font-weight: bold !important; }
|
||||
.header h2 { font-size: 18px; font-weight: bold !important; }
|
||||
hr { border: none; border-top: 2px solid #000; margin: 6px 0; }
|
||||
.section-title { font-weight: bold !important; font-size: 14px; border-top: 2px solid #000; padding-top: 4px; margin-top: 8px; }
|
||||
.row { display: flex; justify-content: space-between; padding: 2px 0; font-size: 14px; font-weight: bold !important; }
|
||||
.section-title { font-weight: bold !important; font-size: 18px; border-top: 2px solid #000; padding-top: 4px; margin-top: 8px; }
|
||||
.row { display: flex; justify-content: space-between; padding: 2px 0; font-size: 18px; font-weight: bold !important; }
|
||||
.row.total-row { font-weight: bold !important; }
|
||||
.cart-table { table-layout: fixed; width: 100%; border-collapse: collapse; font-size: 13px; margin-top: 4px; font-weight: bold !important; }
|
||||
.cart-table th { font-weight: bold !important; border-bottom: 2px solid #000; padding: 3px; font-size: 13px; width: 25%; }
|
||||
.cart-table td { padding: 3px 5px; text-align: right; font-weight: bold !important; font-size: 13px; }
|
||||
.cart-table { table-layout: fixed; width: 100%; border-collapse: collapse; font-size: 12px; margin-top: 4px; font-weight: bold !important; }
|
||||
.cart-table th { font-weight: bold !important; border-bottom: 2px solid #000; padding: 2px 3px; font-size: 12px; width: 25%; }
|
||||
.cart-table td { padding: 2px 3px; text-align: right; font-weight: bold !important; font-size: 12px; }
|
||||
.cart-table td:first-child { text-align: left; }
|
||||
.cart-table tr.total-row { font-weight: bold !important; border-top: 2px solid #000; }
|
||||
.subtotal { font-size: 13px; margin-top: 4px; }
|
||||
.subtotal { font-size: 12px; margin-top: 4px; }
|
||||
.subtotal .row { padding: 1px 0; font-weight: bold !important; }
|
||||
.diff-ok { color: #2e7d32; font-weight: bold !important; }
|
||||
.diff-bad { color: #c0272d; font-weight: bold !important; }
|
||||
.obs { font-size: 12px; color: #555; margin-top: 6px; white-space: pre-wrap; font-weight: bold !important; }
|
||||
.footer { text-align: center; font-size: 12px; color: #555; margin-top: 8px; font-weight: bold !important; }
|
||||
@media print { body { background: #fff; font-weight: bold !important; font-size: 15px !important; } }
|
||||
</head><body>
|
||||
<div class="header">
|
||||
.obs { font-size: 16px; color: #555; margin-top: 6px; white-space: pre-wrap; font-weight: bold !important; }
|
||||
.footer { text-align: center; font-size: 14px; color: #555; margin-top: 8px; font-weight: bold !important; }
|
||||
@media print { body { background: #fff; font-weight: bold !important; font-size: 18px !important; } }
|
||||
</head><body>
|
||||
<div class="header">
|
||||
<h1>${data.loja}</h1>
|
||||
<h2>Fechamento de Caixa</h2>
|
||||
</div>
|
||||
@@ -1444,7 +1449,7 @@ ${cancelamentos_arr.map(c => `<div class="row"><span>${c.numero ? `Nº ${c.numer
|
||||
<table class="cart-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>CRÉDITO</th><th>DÉBITO</th><th>ALIMENT.</th><th>PIX</th>
|
||||
<th>CRÉD</th><th>DÉBIT</th><th>ALIM</th><th>PIX</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@@ -1487,10 +1492,12 @@ function buildPrintHTML(data) {
|
||||
const fmt = v => 'R$ ' + (v || 0).toFixed(2).replace('.', ',');
|
||||
const fmtNeg = v => (v < 0 ? '-' : '') + 'R$ ' + Math.abs(v || 0).toFixed(2).replace('.', ',');
|
||||
// Diferença = Total Sistema - Saldo Esperado - Cancelamentos
|
||||
const sysTotalCalc = (data.sysTotal || 0);
|
||||
const sysTotalCalc = (data.sys_total || 0);
|
||||
const saldoEsperadoCalc = (data.saldo_esperado || 0);
|
||||
const cancelamentosCalc = (data.cancelamentos || 0);
|
||||
const diferenca = sysTotalCalc - saldoEsperadoCalc - cancelamentosCalc;
|
||||
// Cancelamentos: SEMPRE recalcular da array (o scalar pode estar desatualizado)
|
||||
const cancelamentos_arr = Array.isArray(data.cancelamentos_arr) ? data.cancelamentos_arr : [];
|
||||
const totalCancelCalc = cancelamentos_arr.reduce((s,r) => s+(r.valor||0), 0);
|
||||
const diferenca = sysTotalCalc - saldoEsperadoCalc - totalCancelCalc;
|
||||
const diffStr = diferenca >= 0 ? `+${fmt(diferenca)}` : fmtNeg(diferenca);
|
||||
const diffColor = diferenca === 0 ? '#2e7d32' : (diferenca > 0 ? '#e65100' : '#c0272d');
|
||||
|
||||
@@ -1500,7 +1507,6 @@ function buildPrintHTML(data) {
|
||||
const pixcnpj_arr = Array.isArray(data.pixcnpj_arr) ? data.pixcnpj_arr : [];
|
||||
const vales_arr = Array.isArray(data.vales_arr) ? data.vales_arr : [];
|
||||
const receber_arr = Array.isArray(data.receber_arr) ? data.receber_arr : [];
|
||||
const cancelamentos_arr = Array.isArray(data.cancelamentos_arr) ? data.cancelamentos_arr : [];
|
||||
|
||||
// Cartoes arrays from estado
|
||||
const cartCred_arr = Array.isArray(data.cartoes_credito) ? data.cartoes_credito : [];
|
||||
@@ -1582,18 +1588,18 @@ function buildPrintHTML(data) {
|
||||
const al = cartAli_arr[i] || 0;
|
||||
const px = cartPix_arr[i] || 0;
|
||||
cartTableRows += `<tr>
|
||||
<td>${cr > 0 ? fmt(cr) : '-'}</td>
|
||||
<td>${db > 0 ? fmt(db) : '-'}</td>
|
||||
<td>${al > 0 ? fmt(al) : '-'}</td>
|
||||
<td>${px > 0 ? fmt(px) : '-'}</td>
|
||||
<td>${cr > 0 ? fmt(cr).replace('R$ ','') : '-'}</td>
|
||||
<td>${db > 0 ? fmt(db).replace('R$ ','') : '-'}</td>
|
||||
<td>${al > 0 ? fmt(al).replace('R$ ','') : '-'}</td>
|
||||
<td>${px > 0 ? fmt(px).replace('R$ ','') : '-'}</td>
|
||||
</tr>`;
|
||||
}
|
||||
// Totals row
|
||||
cartTableRows += `<tr class="total-row">
|
||||
<td>${cartCred > 0 ? fmt(cartCred) : '-'}</td>
|
||||
<td>${cartDeb > 0 ? fmt(cartDeb) : '-'}</td>
|
||||
<td>${cartAli > 0 ? fmt(cartAli) : '-'}</td>
|
||||
<td>${cartPix > 0 ? fmt(cartPix) : '-'}</td>
|
||||
<td>${cartCred > 0 ? fmt(cartCred).replace('R$ ','') : '-'}</td>
|
||||
<td>${cartDeb > 0 ? fmt(cartDeb).replace('R$ ','') : '-'}</td>
|
||||
<td>${cartAli > 0 ? fmt(cartAli).replace('R$ ','') : '-'}</td>
|
||||
<td>${cartPix > 0 ? fmt(cartPix).replace('R$ ','') : '-'}</td>
|
||||
</tr>`;
|
||||
|
||||
const sysTotal = data.sys_total || 0;
|
||||
@@ -1606,26 +1612,26 @@ function buildPrintHTML(data) {
|
||||
<title>Fechamento de Caixa</title>
|
||||
<style>
|
||||
* { box-sizing: border-box; margin: 0; padding: 0; }
|
||||
body { font-family: 'Courier New', monospace; font-size: 15px; width: 80mm; margin: 0 auto; padding: 8px; font-weight: bold !important; print-color-adjust: exact; -webkit-print-color-adjust: exact; }
|
||||
body { font-family: 'Courier New', monospace; font-size: 18px; font-weight: bold !important; width: 80mm; margin: 0 auto; padding: 8px; print-color-adjust: exact; -webkit-print-color-adjust: exact; }
|
||||
.header { text-align: center; margin-bottom: 8px; }
|
||||
.header h1 { font-size: 20px; font-weight: bold !important; }
|
||||
.header h2 { font-size: 16px; font-weight: bold !important; }
|
||||
.header h1 { font-size: 24px; font-weight: bold !important; }
|
||||
.header h2 { font-size: 18px; font-weight: bold !important; }
|
||||
hr { border: none; border-top: 2px solid #000; margin: 6px 0; }
|
||||
.section-title { font-weight: bold !important; font-size: 14px; border-top: 2px solid #000; padding-top: 4px; margin-top: 8px; }
|
||||
.row { display: flex; justify-content: space-between; padding: 2px 0; font-size: 14px; font-weight: bold !important; }
|
||||
.section-title { font-weight: bold !important; font-size: 18px; border-top: 2px solid #000; padding-top: 4px; margin-top: 8px; }
|
||||
.row { display: flex; justify-content: space-between; padding: 2px 0; font-size: 18px; font-weight: bold !important; }
|
||||
.row.total-row { font-weight: bold !important; }
|
||||
.cart-table { table-layout: fixed; width: 100%; border-collapse: collapse; font-size: 13px; margin-top: 4px; font-weight: bold !important; }
|
||||
.cart-table th { font-weight: bold !important; border-bottom: 2px solid #000; padding: 3px; font-size: 13px; width: 25%; }
|
||||
.cart-table td { padding: 3px 5px; text-align: right; font-weight: bold !important; font-size: 13px; }
|
||||
.cart-table { table-layout: fixed; width: 100%; border-collapse: collapse; font-size: 12px; margin-top: 4px; font-weight: bold !important; }
|
||||
.cart-table th { font-weight: bold !important; border-bottom: 2px solid #000; padding: 2px 3px; font-size: 12px; width: 25%; }
|
||||
.cart-table td { padding: 2px 3px; text-align: right; font-weight: bold !important; font-size: 12px; }
|
||||
.cart-table td:first-child { text-align: left; }
|
||||
.cart-table tr.total-row { font-weight: bold !important; border-top: 2px solid #000; }
|
||||
.subtotal { font-size: 13px; margin-top: 4px; }
|
||||
.subtotal { font-size: 12px; margin-top: 4px; }
|
||||
.subtotal .row { padding: 1px 0; font-weight: bold !important; }
|
||||
.diff-ok { color: #2e7d32; font-weight: bold !important; }
|
||||
.diff-bad { color: #c0272d; font-weight: bold !important; }
|
||||
.obs { font-size: 12px; color: #555; margin-top: 6px; white-space: pre-wrap; font-weight: bold !important; }
|
||||
.footer { text-align: center; font-size: 12px; color: #555; margin-top: 8px; font-weight: bold !important; }
|
||||
@media print { body { background: #fff; font-weight: bold !important; font-size: 15px !important; } }
|
||||
.obs { font-size: 16px; color: #555; margin-top: 6px; white-space: pre-wrap; font-weight: bold !important; }
|
||||
.footer { text-align: center; font-size: 14px; color: #555; margin-top: 8px; font-weight: bold !important; }
|
||||
@media print { body { background: #fff; font-weight: bold !important; font-size: 18px !important; } }
|
||||
</style>
|
||||
</head><body>
|
||||
<div class="header">
|
||||
@@ -1665,7 +1671,7 @@ ${cancLines.map(l => `<div class="row"><span>${l.label}</span><span>${fmt(l.valo
|
||||
<table class="cart-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>CRÉDITO</th><th>DÉBITO</th><th>ALIMENT.</th><th>PIX</th>
|
||||
<th>CRÉD</th><th>DÉBIT</th><th>ALIM</th><th>PIX</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@@ -1720,12 +1726,13 @@ function buildReportHTML(data) {
|
||||
const vales = data.vales || 0;
|
||||
const receber = data.areceber || 0;
|
||||
const pixcnpj = data.pixcnpj || 0;
|
||||
const cancelamentos = data.cancelamentos || 0;
|
||||
// Cancelamentos: SEMPRE recalcular da array (o escalar pode estar desatualizado)
|
||||
const cancelamentos_arr = Array.isArray(data.cancelamentos_arr) ? data.cancelamentos_arr : [];
|
||||
const totalCancelCalc = cancelamentos_arr.reduce((s,r) => s+(r.valor||0), 0);
|
||||
const fechamento = data.fechamento || 0;
|
||||
const sysTotal = data.sys_total || 0;
|
||||
const saldoEsp = data.saldo_esperado || 0;
|
||||
const diferenca = data.diferenca !== undefined ? data.diferenca
|
||||
: (fechamento - saldoEsp - cancelamentos);
|
||||
const diferenca = sysTotal - saldoEsp - totalCancelCalc;
|
||||
const diffStr = diferenca >= 0 ? `+${fmt(diferenca)}` : fmtNeg(diferenca);
|
||||
const diffColor = diferenca === 0 ? '#2e7d32' : '#c0272d';
|
||||
|
||||
@@ -1735,17 +1742,20 @@ function buildReportHTML(data) {
|
||||
<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; }
|
||||
body { font-family: 'Courier New', monospace; font-size: 18px; font-weight: bold; width: 80mm; margin: 0 auto; padding: 8px; }
|
||||
.header { text-align: center; margin-bottom: 10px; }
|
||||
hr { border: none; border-top: 1px dashed #000; margin: 6px 0; }
|
||||
.row { display: flex; justify-content: space-between; padding: 2px 0; font-size: 12px; }
|
||||
.row { display: flex; justify-content: space-between; padding: 3px 0; font-size: 18px; font-weight: bold; }
|
||||
.row .val { font-weight: bold; }
|
||||
.footer { text-align: center; font-size: 11px; color: #555; margin-top: 8px; }
|
||||
@media print { body { margin: 0; } }
|
||||
.footer { text-align: center; font-size: 13px; color: #555; margin-top: 8px; }
|
||||
@media print { body { margin: 0; font-size: 18px !important; font-weight: bold !important; } }
|
||||
</style>
|
||||
</head><body>
|
||||
<div class="header">
|
||||
<div>📅 Data: ${data.data} 👤 Operador: ${data.operador} ⏰ Turno: ${data.turno} 🏪 Loja: ${data.loja}</div>
|
||||
<div>📅 Data: ${data.data}</div>
|
||||
<div>👤 Operador: ${data.operador}</div>
|
||||
<div>⏰ Turno: ${data.turno}</div>
|
||||
<div>🏪 Loja: ${data.loja}</div>
|
||||
</div>
|
||||
<hr>
|
||||
<div class="row"><span>💸 Sangrias:</span><span class="val">${fmt(sangrias)}</span></div>
|
||||
@@ -1756,7 +1766,7 @@ function buildReportHTML(data) {
|
||||
<div class="row"><span>💳 Crédito:</span><span class="val">${fmt(cartCred)}</span></div>
|
||||
<div class="row"><span>💳 Débito:</span><span class="val">${fmt(cartDeb)}</span></div>
|
||||
<div class="row"><span>💳 Alimentação:</span><span class="val">${fmt(cartAli)}</span></div>
|
||||
<div class="row"><span>❌ Cancelamentos:</span><span class="val">${fmt(cancelamentos)}</span></div>
|
||||
<div class="row"><span>❌ Cancelamentos:</span><span class="val">${fmt(totalCancelCalc)}</span></div>
|
||||
<hr>
|
||||
<div class="row"><span>📋 Fechamento (contado):</span><span class="val">${fmt(fechamento)}</span></div>
|
||||
<div class="row"><span>🖥️ Total Sistema:</span><span class="val">${fmt(sysTotal)}</span></div>
|
||||
@@ -1768,7 +1778,7 @@ function buildReportHTML(data) {
|
||||
<div class="row"><span>🍗 Frango Assado:</span><span class="val">${data.frango || 0}</span></div>
|
||||
<div class="row"><span>💰 Vendas:</span><span class="val">${fmt(data.vendas || 0)}</span></div>
|
||||
<hr>
|
||||
<div style="text-align:center;font-size:10px;color:#888">ID: ${data.loja}_${data.data}_${data.operador}</div>
|
||||
<div style="text-align:center;font-size:12px;color:#888">ID: ${data.loja}_${data.data}_${data.operador}</div>
|
||||
<div class="footer">Gerado em ${new Date().toLocaleString('pt-BR')}</div>
|
||||
</body></html>`;
|
||||
}
|
||||
@@ -1792,21 +1802,21 @@ function openPrintPreview(data, mode) {
|
||||
|
||||
const previewStyle = `
|
||||
<style>
|
||||
body { width: 80mm; margin: 0 auto; font-family: 'Courier New', monospace; font-size: 13px; background: #fff; padding: 10px; }
|
||||
body { width: 80mm; margin: 0 auto; font-family: 'Courier New', monospace; font-size: 6px; background: #fff; padding: 10px; font-weight: bold; }
|
||||
.header { text-align: center; margin-bottom: 8px; }
|
||||
.header h1 { font-size: 18px; }
|
||||
.header h2 { font-size: 14px; font-weight: normal; }
|
||||
.header h1 { font-size: 18px; font-weight: bold; }
|
||||
.header h2 { font-size: 6px; font-weight: bold; }
|
||||
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; }
|
||||
.section-title { font-weight: bold; font-size: 6px; border-top: 1px dashed #000; padding-top: 4px; margin-top: 6px; }
|
||||
.row { display: flex; justify-content: space-between; padding: 2px 0; font-size: 6px; font-weight: bold; }
|
||||
table { width: 100%; border-collapse: collapse; font-size: 6px; font-weight: bold; }
|
||||
td { padding: 2px 4px; vertical-align: top; font-weight: bold; }
|
||||
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; } }
|
||||
td:nth-child(3) { text-align: right; color: #555; font-size: 5px; }
|
||||
.grand-total { font-size: 18px; font-weight: bold; text-align: center; padding: 6px; border: 2px solid #000; margin: 8px 0; }
|
||||
.obs { font-size: 5px; color: #555; margin-top: 6px; white-space: pre-wrap; font-weight: normal; }
|
||||
.footer { text-align: center; font-size: 5px; color: #555; margin-top: 8px; font-weight: normal; }
|
||||
@media print { body { background: #fff; font-size: 6px !important; font-weight: bold !important; } }
|
||||
</style>
|
||||
`;
|
||||
|
||||
@@ -1825,6 +1835,11 @@ function openPrintPreview(data, mode) {
|
||||
`;
|
||||
btnFechar.onclick = () => {
|
||||
document.body.removeChild(overlay);
|
||||
// Garante que o modal de confirmação volte a aparecer (caso ainda tenha a classe 'open')
|
||||
const modalConfirm = document.getElementById('modal-confirm');
|
||||
if (modalConfirm && !modalConfirm.classList.contains('open')) {
|
||||
modalConfirm.classList.add('open');
|
||||
}
|
||||
};
|
||||
|
||||
const btnImprimir = document.createElement('button');
|
||||
@@ -1871,12 +1886,28 @@ function buildReciboData() {
|
||||
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'));
|
||||
|
||||
// Lê do DOM — mas se estiver vazio usa estado (registro anterior)
|
||||
const trocoDom = parseNum(val('sys-troco'));
|
||||
const saldoTroco = trocoDom || estado.saldo_troco || 0;
|
||||
const saldoCred = parseNum(val('sys-credito')) || cartCred;
|
||||
const saldoDeb = parseNum(val('sys-debito')) || cartDeb;
|
||||
const saldoAli = parseNum(val('sys-alimentacao')) || cartAli;
|
||||
const saldoPixCartao = parseNum(val('sys-pix')) || cartPix;
|
||||
const saldoPixCnpj = parseNum(val('sys-pixcnpj')) || pixCnpjTotal;
|
||||
const saldoVales = parseNum(val('sys-vales')) || valesTotal;
|
||||
const saldoReceber = parseNum(val('sys-receber')) || receberTotal;
|
||||
|
||||
const fechamentoDinheiro = parseNum(val('f-fech-dinheiro')) || estado.fechamento_contado?.dinheiro || 0;
|
||||
const fechamentoCartoes = parseNum(val('f-fech-cartoes')) || estado.fechamento_contado?.cartoes || 0;
|
||||
const fechamentoReceber = parseNum(val('f-fech-receber')) || estado.fechamento_contado?.receber || 0;
|
||||
const fechamentoCounted = fechamentoDinheiro + fechamentoCartoes + fechamentoReceber;
|
||||
const saldoEsperado = parseNum(val('f-saldo-esperado')) || estado.saldo_esperado || 0;
|
||||
|
||||
// Saldo de Caixa (Sistema) = troco + crédito + débito + alimentação + voucher pix + pixcnpj + vales + receber
|
||||
const sysTotal = parseNum(val('sys-troco')) + cartCred + cartDeb + cartAli + cartPix + pixCnpjTotal + valesTotal + receberTotal;
|
||||
const sysTotal = saldoTroco + cartCred + cartDeb + cartAli + cartPix + pixCnpjTotal + valesTotal + receberTotal;
|
||||
// Diferença = saldo de caixa (sistema) - saldo_esperado - cancelamentos
|
||||
const diferenca = sysTotal - saldoEsp - cancelTotal;
|
||||
const diferenca = sysTotal - saldoEsperado - cancelTotal;
|
||||
return {
|
||||
uuid: estado.uuid,
|
||||
id_local: estado.id_local,
|
||||
@@ -1885,14 +1916,14 @@ function buildReciboData() {
|
||||
data: estado.data,
|
||||
operador: estado.operador,
|
||||
turno: estado.turno,
|
||||
saldo_troco: parseNum(val('sys-troco')),
|
||||
saldo_esperado: saldoEsp,
|
||||
saldo_troco: saldoTroco,
|
||||
saldo_esperado: saldoEsperado,
|
||||
fechamento: fechamentoCounted,
|
||||
diferenca: diferenca,
|
||||
sys_total: sysTotal,
|
||||
fechamento_dinheiro: parseNum(val('f-fech-dinheiro')),
|
||||
fechamento_cartoes: parseNum(val('f-fech-cartoes')),
|
||||
fechamento_receber: parseNum(val('f-fech-receber')),
|
||||
fechamento_dinheiro: fechamentoDinheiro,
|
||||
fechamento_cartoes: fechamentoCartoes,
|
||||
fechamento_receber: fechamentoReceber,
|
||||
// Flat totals for ReciboData struct (Rust) — must be numbers
|
||||
credito: cartCred,
|
||||
debito: cartDeb,
|
||||
@@ -1986,26 +2017,26 @@ function openConfirmModal() {
|
||||
const diferenca = sysTotal - saldoEsp - cancelamentos;
|
||||
const diffClass = diferenca === 0 ? '#2e7d32' : (diferenca > 0 ? '#e65100' : '#c0272d');
|
||||
document.getElementById('confirm-summary').innerHTML =
|
||||
`<div style="margin-bottom:6px"><strong>📅 Data:</strong> ${estado.data} <strong>👤 Operador:</strong> ${operador} <strong>⏰ Turno:</strong> ${turno} <strong>🏪 Loja:</strong> ${loja}</div>
|
||||
`<div style="margin-bottom:6px;font-size:17px"><strong>📅 Data:</strong> ${estado.data} <strong>👤 Operador:</strong> ${operador} <strong>⏰ Turno:</strong> ${turno} <strong>🏪 Loja:</strong> ${loja}</div>
|
||||
<hr style="border:none;border-top:1px dashed #b3d9f7;margin:6px 0">
|
||||
<div>💸 Sangrias: <strong>${fmt(sangrias)}</strong></div>
|
||||
<div>📝 Despesas: <strong>${fmt(despesas)}</strong></div>
|
||||
<div>💰 Vales: <strong>${fmt(vales)}</strong></div>
|
||||
<div>👥 À Receber: <strong>${fmt(receber)}</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>👥 Clientes: <strong>${estado.clientes || 0}</strong></div>
|
||||
<div>🍗 Frango Assado: <strong>${estado.frango || 0}</strong></div>
|
||||
<div>💰 Vendas: <strong>${fmt(estado.vendas || 0)}</strong></div>
|
||||
<div>📋 Fechamento (contado): <strong>${fmt(fechamento)}</strong></div>
|
||||
<div>🖥️ Total Sistema: <strong>${fmt(sysTotal)}</strong></div>
|
||||
<div style="font-size:16px">💸 Sangrias: <strong>${fmt(sangrias)}</strong></div>
|
||||
<div style="font-size:16px">📝 Despesas: <strong>${fmt(despesas)}</strong></div>
|
||||
<div style="font-size:16px">💰 Vales: <strong>${fmt(vales)}</strong></div>
|
||||
<div style="font-size:16px">👥 À Receber: <strong>${fmt(receber)}</strong></div>
|
||||
<div style="font-size:16px">🔵 PIX CNPJ: <strong>${fmt(pixcnpj)}</strong></div>
|
||||
<div style="font-size:16px">💳 Crédito: <strong>${fmt(cartCred)}</strong></div>
|
||||
<div style="font-size:16px">💳 Débito: <strong>${fmt(cartDeb)}</strong></div>
|
||||
<div style="font-size:16px">💳 Alimentação: <strong>${fmt(cartAli)}</strong></div>
|
||||
<div style="font-size:16px">❌ Cancelamentos: <strong>${fmt(cancelamentos)}</strong></div>
|
||||
<div style="font-size:16px">👥 Clientes: <strong>${estado.clientes || 0}</strong></div>
|
||||
<div style="font-size:16px">🍗 Frango Assado: <strong>${estado.frango || 0}</strong></div>
|
||||
<div style="font-size:16px">💰 Vendas: <strong>${fmt(estado.vendas || 0)}</strong></div>
|
||||
<div style="font-size:16px">📋 Fechamento (contado): <strong>${fmt(fechamento)}</strong></div>
|
||||
<div style="font-size:16px">🖥️ Total Sistema: <strong>${fmt(sysTotal)}</strong></div>
|
||||
<hr style="border:none;border-top:1px dashed #b3d9f7;margin:6px 0">
|
||||
<div>🎯 Saldo Esperado (contado): <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>`;
|
||||
<div style="font-size:16px">🎯 Saldo Esperado (contado): <strong>${fmt(saldoEsp)}</strong></div>
|
||||
<div style="margin-top:4px;font-size:16px">📐 Diferença: <strong style="color:${diffClass}">${fmt(diferenca)}</strong></div>
|
||||
<div style="margin-top:8px;font-size:12px;color:#888">ID: ${estado.loja}_${estado.data}_${estado.operador}</div>`;
|
||||
console.log('[DEBUG] openConfirmModal: tentando abrir modal');
|
||||
document.getElementById('modal-confirm').classList.add('open');
|
||||
}
|
||||
@@ -2019,6 +2050,8 @@ document.getElementById('confirm-cancel').addEventListener('click', closeConfirm
|
||||
document.getElementById('modal-confirm').addEventListener('click', e => { if (e.target.id === 'modal-confirm') closeConfirmModal(); });
|
||||
|
||||
// Print from confirm modal — abre preview idêntico ao modal de confirmação
|
||||
// Não fecha o modal aqui — o preview é um overlay por cima; quando fechar o preview,
|
||||
// o modal já estará visível por baixo (continua no DOM, só que coberto)
|
||||
document.getElementById('confirm-print').addEventListener('click', async () => {
|
||||
// Usa buildReciboData para não duplicar lógica e ter todos os campos certos
|
||||
let data;
|
||||
@@ -2029,8 +2062,7 @@ document.getElementById('confirm-print').addEventListener('click', async () => {
|
||||
alert('Erro ao montar dados do recibo.');
|
||||
return;
|
||||
}
|
||||
// Fecha modal APÓS gerar preview (não antes)
|
||||
closeConfirmModal();
|
||||
// NÃO fecha o modal aqui — só abre o preview por cima
|
||||
openPrintPreview(data, 'report');
|
||||
});
|
||||
|
||||
@@ -2086,25 +2118,48 @@ 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);
|
||||
estado = {
|
||||
uuid: crypto.randomUUID ? crypto.randomUUID() : Date.now().toString(),
|
||||
loja: estado.loja, data: estado.data, operador: '', turno: '',
|
||||
saldo_troco: 0, fechamento: 0, saldo_esperado: 0, diferenca: 0,
|
||||
despesas: [], sangrias: [], pixcnpj: [], vales: [], receber: [], cancelamentos: [],
|
||||
cartoes: { credito: [], debito: [], alimentacao: [], pix: [] },
|
||||
fechamento_contado: { dinheiro: 0, cartoes: 0, receber: 0 },
|
||||
observacoes: '', id_local: null, sync_status: 'local'
|
||||
};
|
||||
setVal('f-operador', ''); setVal('f-turno', '');
|
||||
setVal('f-fech-dinheiro', ''); setVal('f-fech-cartoes', ''); setVal('f-fech-receber', '');
|
||||
setVal('f-saldo-esperado', ''); setVal('f-obs', '');
|
||||
setVal('sys-troco', '');
|
||||
updateTotals();
|
||||
renderAllTables();
|
||||
syncUI();
|
||||
showToast('Rascunho limpo.', 'info');
|
||||
const overlay = document.createElement('div');
|
||||
overlay.style.cssText = 'position:fixed;top:0;left:0;right:0;bottom:0;background:rgba(0,0,0,0.5);z-index:999999;display:flex;align-items:center;justify-content:center;';
|
||||
overlay.id = 'clear-modal';
|
||||
overlay.innerHTML = `
|
||||
<div style="background:#fff;border-radius:10px;padding:28px;width:340px;text-align:center">
|
||||
<div style="font-size:20px;margin-bottom:12px">⚠️ Limpar Rascunho</div>
|
||||
<div style="font-size:13px;color:#555;margin-bottom:20px;line-height:1.5">Tem certeza?<br>Irá <strong>apagar todas as informações</strong> lançadas para iniciar uma nova.</div>
|
||||
<div style="display:flex;gap:10px;justify-content:center">
|
||||
<button id="clear-cancel-btn" style="padding:9px 20px;background:#888;color:#fff;border:none;border-radius:6px;cursor:pointer;font-size:13px">Cancelar</button>
|
||||
<button id="clear-ok-btn" style="padding:9px 20px;background:#c0272d;color:#fff;border:none;border-radius:6px;cursor:pointer;font-size:13px">Apagar tudo</button>
|
||||
</div>
|
||||
</div>`;
|
||||
document.body.appendChild(overlay);
|
||||
document.getElementById('clear-cancel-btn').addEventListener('click', () => overlay.remove());
|
||||
document.getElementById('clear-ok-btn').addEventListener('click', () => {
|
||||
overlay.remove();
|
||||
localStorage.removeItem(LS_KEY);
|
||||
estado = {
|
||||
uuid: crypto.randomUUID ? crypto.randomUUID() : Date.now().toString(),
|
||||
loja: estado.loja, data: estado.data, operador: '', turno: '',
|
||||
saldo_troco: 0, fechamento: 0, saldo_esperado: 0, diferenca: 0,
|
||||
despesas: [], sangrias: [], pixcnpj: [], vales: [], receber: [], cancelamentos: [],
|
||||
cartoes: { credito: [], debito: [], alimentacao: [], pix: [] },
|
||||
fechamento_contado: { dinheiro: 0, cartoes: 0, receber: 0 },
|
||||
observacoes: '', id_local: null, sync_status: 'local'
|
||||
};
|
||||
estado.sangrias = []; estado.despesas = []; estado.pixcnpj = [];
|
||||
estado.vales = []; estado.receber = []; estado.cancelamentos = [];
|
||||
estado.cartoes = { credito: [], debito: [], alimentacao: [], pix: [] };
|
||||
estado.fechamento_contado = { dinheiro: 0, cartoes: 0, receber: 0 };
|
||||
setVal('f-operador', ''); setVal('f-turno', '');
|
||||
setVal('f-fech-dinheiro', ''); setVal('f-fech-cartoes', ''); setVal('f-fech-receber', '');
|
||||
setVal('f-saldo-esperado', ''); setVal('f-obs', '');
|
||||
setVal('f-vendas', ''); setVal('f-clientes', ''); setVal('f-frango', '');
|
||||
setVal('sys-troco', ''); setVal('sys-total', '');
|
||||
setVal('sys-credito', ''); setVal('sys-debito', '');
|
||||
setVal('sys-alimentacao', ''); setVal('sys-pixcnpj', '');
|
||||
Object.keys(TABLES).forEach(renderTable);
|
||||
updateTotals();
|
||||
syncUI();
|
||||
showToast('Rascunho limpo.', 'info');
|
||||
});
|
||||
});
|
||||
|
||||
// ─── Dia Anterior — seleção + consulta + edição de registros do Supabase ───
|
||||
@@ -2151,11 +2206,6 @@ function buildConsultaModal(recentList) {
|
||||
return `${day}/${m}/${y}`;
|
||||
};
|
||||
const fmtMoney = v => v != null ? `R$ ${parseFloat(v).toFixed(2).replace('.', ',')}` : 'R$ 0,00';
|
||||
const syncBadge = s => {
|
||||
const colors = { synced: '#2e7d32', local: '#e65100', syncing: '#f57f17', error: '#c0272d' };
|
||||
const labels = { synced: '✓ Sync', local: '⚠ Local', syncing: '⏳ Sync', error: '✗ Erro' };
|
||||
return `<span style="background:${colors[s]||'#888'};color:#fff;padding:1px 5px;border-radius:3px;font-size:10px">${labels[s]||s||'?'}</span>`;
|
||||
};
|
||||
const statusBadge = s => {
|
||||
if (s === 'ok') return '<span style="color:#2e7d32;font-weight:bold">✓ OK</span>';
|
||||
if (s === 'sobrando') return '<span style="color:#e65100;font-weight:bold">⚠ SOBRANDO</span>';
|
||||
@@ -2163,56 +2213,136 @@ function buildConsultaModal(recentList) {
|
||||
return s || '?';
|
||||
};
|
||||
|
||||
const rows = recentList.map((item, i) => {
|
||||
const sysTotal = (parseFloat(item.saldo_troco)||0)
|
||||
+ (parseFloat(item.saldo_credito)||0)
|
||||
+ (parseFloat(item.saldo_debito)||0)
|
||||
+ (parseFloat(item.saldo_alimentacao)||0)
|
||||
+ (parseFloat(item.saldo_vales)||0)
|
||||
+ (parseFloat(item.saldo_areceber)||0)
|
||||
+ (parseFloat(item.total_pixcnpj)||0)
|
||||
+ (parseFloat(item.total_cartao_pix)||0);
|
||||
return `<tr>
|
||||
<td style="padding:6px 8px;border-bottom:1px solid #eee;font-size:12px;cursor:pointer" onclick="openConsultaRegistro(${i})">${fmtDate(item.data)}</td>
|
||||
<td style="padding:6px 8px;border-bottom:1px solid #eee;font-size:12px;cursor:pointer" onclick="openConsultaRegistro(${i})">${item.operador||'?'}</td>
|
||||
<td style="padding:6px 8px;border-bottom:1px solid #eee;font-size:12px;cursor:pointer" onclick="openConsultaRegistro(${i})">${item.turno||'?'}</td>
|
||||
<td style="padding:6px 8px;border-bottom:1px solid #eee;font-size:12px;text-align:right;cursor:pointer" onclick="openConsultaRegistro(${i})">${fmtMoney(sysTotal)}</td>
|
||||
<td style="padding:6px 8px;border-bottom:1px solid #eee;font-size:12px;text-align:right;cursor:pointer" onclick="openConsultaRegistro(${i})">${fmtMoney(item.saldo_esperado)}</td>
|
||||
<td style="padding:6px 8px;border-bottom:1px solid #eee;font-size:12px;cursor:pointer" onclick="openConsultaRegistro(${i})">${statusBadge(item.status_diferenca)}</td>
|
||||
<td style="padding:6px 8px;border-bottom:1px solid #eee;font-size:12px;cursor:pointer" onclick="openConsultaRegistro(${i})">${syncBadge(item.sync_status)}</td>
|
||||
<td style="padding:4px 6px;border-bottom:1px solid #eee;text-align:center">
|
||||
<button onclick="event.stopPropagation();window.abrirEditaFechamentoIndex(${i})" style="background:#e65100;color:#fff;border:none;border-radius:4px;padding:4px 10px;cursor:pointer;font-size:11px;white-space:nowrap">✏️ Editar</button>
|
||||
</td>
|
||||
</tr>`;
|
||||
}).join('');
|
||||
// Calcula saldo de caixa real (mesma fórmula do relatório)
|
||||
// SALDO CAIXA = soma dos campos individuais (total do sistema no relatório)
|
||||
// Lê do top-level E do JSON dados (compatibilidade com registros antigos)
|
||||
// SALDO CAIXA = soma dos campos individuais (total do sistema no relatório)
|
||||
// Prioriza top-level, só usa dados.dados se top-level for 0/missing (compat old records)
|
||||
const f = (item, dados, field) => parseFloat(item[field]||dados[field]||0);
|
||||
const calcSaldoCaixa = item => {
|
||||
const dados = item.dados || {};
|
||||
return f(item,dados,'saldo_troco')
|
||||
+ f(item,dados,'saldo_credito')
|
||||
+ f(item,dados,'saldo_debito')
|
||||
+ f(item,dados,'saldo_alimentacao')
|
||||
+ f(item,dados,'saldo_vales')
|
||||
+ f(item,dados,'saldo_areceber')
|
||||
+ f(item,dados,'total_pixcnpj')
|
||||
+ f(item,dados,'total_cartao_pix');
|
||||
};
|
||||
const calcCancelamentos = item => {
|
||||
const dados = item.dados || {};
|
||||
return (parseFloat(item.total_cancelamentos)||0)
|
||||
+ (parseFloat(dados.total_cancelamentos)||0)
|
||||
+ (parseFloat(dados.cancelamentos)||0); // old format: array of cancelamentos
|
||||
};
|
||||
|
||||
// Ordena por data desc e pega os últimos 5 dias distintos
|
||||
const sortedDesc = [...recentList].sort((a,b) => b.data.localeCompare(a.data));
|
||||
const cincoDias = sortedDesc.slice(0, 5);
|
||||
|
||||
// Último dia distinto (para placeholder do input)
|
||||
const ultimoDia = sortedDesc[0]?.data || '';
|
||||
|
||||
function renderRows(list) {
|
||||
if (!list || list.length === 0) {
|
||||
return `<tr><td colspan="8" style="padding:20px;text-align:center;color:#888;font-size:13px">Nenhum registro encontrado.</td></tr>`;
|
||||
}
|
||||
return list.map((item, i) => {
|
||||
const saldoCaixa = calcSaldoCaixa(item);
|
||||
const canc = calcCancelamentos(item);
|
||||
const diff = saldoCaixa - (parseFloat(item.saldo_esperado)||0) - canc;
|
||||
const diffClass = Math.abs(diff) <= 4.50 ? '#2e7d32' : diff > 0 ? '#e65100' : '#c0272d';
|
||||
const diffLabel = Math.abs(diff) <= 4.50 ? `✓ OK (${diff===0?'0':(diff>0?'+':'-')+Math.abs(diff).toFixed(2).replace('.',',')})` : diff > 0 ? `+R$ ${diff.toFixed(2).replace('.',',')} SOBRANDO` : `-R$ ${Math.abs(diff).toFixed(2).replace('.',',')} FALTANDO`;
|
||||
return `<tr data-idx="${recentList.indexOf(item)}" style="background:${i%2===0?'#fff':'#fafafa'}" onmouseover="this.style.background='#fff3e0'" onmouseout="this.style.background='${i%2===0?'#fff':'#fafafa'}'">
|
||||
<td style="padding:6px 8px;border-bottom:1px solid #eee;font-size:12px;cursor:pointer" onclick="openConsultaRegistro(${recentList.indexOf(item)})">${fmtDate(item.data)}</td>
|
||||
<td style="padding:6px 8px;border-bottom:1px solid #eee;font-size:12px;cursor:pointer" onclick="openConsultaRegistro(${recentList.indexOf(item)})">${item.operador||'?'}</td>
|
||||
<td style="padding:6px 8px;border-bottom:1px solid #eee;font-size:12px;cursor:pointer" onclick="openConsultaRegistro(${recentList.indexOf(item)})">${item.turno||'?'}</td>
|
||||
<td style="padding:6px 8px;border-bottom:1px solid #eee;font-size:12px;text-align:right;cursor:pointer" onclick="openConsultaRegistro(${recentList.indexOf(item)})">${fmtMoney(saldoCaixa)}</td>
|
||||
<td style="padding:6px 8px;border-bottom:1px solid #eee;font-size:12px;text-align:right;cursor:pointer" onclick="openConsultaRegistro(${recentList.indexOf(item)})">${fmtMoney(item.saldo_esperado)}</td>
|
||||
<td style="padding:6px 8px;border-bottom:1px solid #eee;font-size:12px;text-align:right;cursor:pointer" onclick="openConsultaRegistro(${recentList.indexOf(item)})">${fmtMoney(canc)}</td>
|
||||
<td style="padding:6px 8px;border-bottom:1px solid #eee;font-size:12px;text-align:center;cursor:pointer;color:${diffClass};font-weight:bold" onclick="openConsultaRegistro(${recentList.indexOf(item)})">${diffLabel}</td>
|
||||
<td style="padding:4px 6px;border-bottom:1px solid #eee;text-align:center">
|
||||
<button onclick="event.stopPropagation();window.abrirEditaFechamentoIndex(${recentList.indexOf(item)})" style="background:#e65100;color:#fff;border:none;border-radius:4px;padding:4px 10px;cursor:pointer;font-size:11px;white-space:nowrap">✏️ Editar</button>
|
||||
</td>
|
||||
</tr>`;
|
||||
}).join('');
|
||||
}
|
||||
|
||||
overlay.innerHTML = `
|
||||
<div style="background:#fff;border-radius:10px;padding:20px;max-width:780px;width:95%;max-height:85vh;overflow-y:auto">
|
||||
<div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:12px">
|
||||
<h2 style="margin:0;font-size:16px">📅 Consultar Fechamentos Anteriores</h2>
|
||||
<button onclick="closeConsultaModal()" style="background:none;border:none;font-size:18px;cursor:pointer">✕</button>
|
||||
<div style="background:#fff;border-radius:10px;padding:20px;max-width:900px;width:95%;max-height:85vh;overflow-y:auto">
|
||||
<div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:12px;flex-wrap:wrap;gap:8px">
|
||||
<h2 style="margin:0;font-size:16px">📅 Consultar Fechamentos</h2>
|
||||
<div style="display:flex;gap:6px;align-items:center;flex-wrap:wrap">
|
||||
<span style="font-size:12px;color:#555">De:</span>
|
||||
<input type="date" id="consulta-busca-de" value="" max="${ultimoDia}" style="padding:6px 10px;border:1px solid #ccc;border-radius:6px;font-size:13px" />
|
||||
<span style="font-size:12px;color:#555">Até:</span>
|
||||
<input type="date" id="consulta-busca-ate" value="" max="${ultimoDia}" style="padding:6px 10px;border:1px solid #ccc;border-radius:6px;font-size:13px" />
|
||||
<select id="consulta-busca-turno" style="padding:6px 10px;border:1px solid #ccc;border-radius:6px;font-size:13px;background:#fff">
|
||||
<option value="">Todos turnos</option>
|
||||
<option value="manha">Manhã</option>
|
||||
<option value="tarde">Tarde</option>
|
||||
<option value="integral">Integral</option>
|
||||
</select>
|
||||
<button onclick="window._filtrarConsulta()" style="background:#1565c0;color:#fff;border:none;border-radius:6px;padding:6px 14px;cursor:pointer;font-size:13px">🔍 Buscar</button>
|
||||
<button onclick="closeConsultaModal()" style="background:none;border:none;font-size:18px;cursor:pointer;padding:4px 8px">✕</button>
|
||||
</div>
|
||||
</div>
|
||||
<div id="consulta-info" style="font-size:11px;color:#888;margin-bottom:8px">Mostrando últimos 5 dias</div>
|
||||
<div style="max-height:55vh;overflow-y:auto;border:1px solid #eee;border-radius:6px">
|
||||
<table style="width:100%;border-collapse:collapse">
|
||||
<thead style="position:sticky;top:0;background:#f5f5f5;z-index:1">
|
||||
<tr>
|
||||
<th style="padding:6px 8px;text-align:left;font-size:11px;color:#888">DATA</th>
|
||||
<th style="padding:6px 8px;text-align:left;font-size:11px;color:#888">OPERADOR</th>
|
||||
<th style="padding:6px 8px;text-align:left;font-size:11px;color:#888">TURNO</th>
|
||||
<th style="padding:6px 8px;text-align:right;font-size:11px;color:#888">SALDO CAIXA</th>
|
||||
<th style="padding:6px 8px;text-align:right;font-size:11px;color:#888">ESPERADO</th>
|
||||
<th style="padding:6px 8px;text-align:right;font-size:11px;color:#888">CANCEL.</th>
|
||||
<th style="padding:6px 8px;text-align:center;font-size:11px;color:#888">DIFERENÇA</th>
|
||||
<th style="padding:4px 6px;text-align:center;font-size:11px;color:#888">AÇÃO</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="consulta-tbody">${renderRows(cincoDias)}</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<div style="margin-top:8px;font-size:11px;color:#888;text-align:center">
|
||||
Mostrando últimos 5 dias. Use a <strong>busca por data</strong> para encontrar registros mais antigos.
|
||||
</div>
|
||||
<p style="font-size:12px;color:#666;margin:0 0 10px">Clique em um registro para ver o relatório ou clique em <strong>Editar</strong> para abrir a janela de edição.</p>
|
||||
<table style="width:100%;border-collapse:collapse">
|
||||
<thead>
|
||||
<tr style="background:#f5f5f5">
|
||||
<th style="padding:6px 8px;text-align:left;font-size:11px;color:#888">DATA</th>
|
||||
<th style="padding:6px 8px;text-align:left;font-size:11px;color:#888">OPERADOR</th>
|
||||
<th style="padding:6px 8px;text-align:left;font-size:11px;color:#888">TURNO</th>
|
||||
<th style="padding:6px 8px;text-align:right;font-size:11px;color:#888">SALDO CAIXA</th>
|
||||
<th style="padding:6px 8px;text-align:right;font-size:11px;color:#888">ESPERADO</th>
|
||||
<th style="padding:6px 8px;text-align:center;font-size:11px;color:#888">STATUS</th>
|
||||
<th style="padding:6px 8px;text-align:center;font-size:11px;color:#888">SYNC</th>
|
||||
<th style="padding:6px 8px;text-align:center;font-size:11px;color:#888">AÇÃO</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>${rows}</tbody>
|
||||
</table>
|
||||
</div>
|
||||
`;
|
||||
|
||||
document.body.appendChild(overlay);
|
||||
|
||||
// Função de busca por período
|
||||
window._filtrarConsulta = () => {
|
||||
const de = document.getElementById('consulta-busca-de')?.value;
|
||||
const ate = document.getElementById('consulta-busca-ate')?.value;
|
||||
const turno = document.getElementById('consulta-busca-turno')?.value || '';
|
||||
const tbody = document.getElementById('consulta-tbody');
|
||||
const info = document.getElementById('consulta-info');
|
||||
if (!tbody) return;
|
||||
let filtrados = [...recentList];
|
||||
if (de) filtrados = filtrados.filter(item => item.data >= de);
|
||||
if (ate) filtrados = filtrados.filter(item => item.data <= ate);
|
||||
if (turno) filtrados = filtrados.filter(item => (item.turno||'').toLowerCase() === turno);
|
||||
if (de || ate || turno) {
|
||||
tbody.innerHTML = renderRows(filtrados);
|
||||
const label = `${filtrados.length} registro${filtrados.length!==1?'s':''}${de&&ate?` de ${fmtDate(de)} a ${fmtDate(ate)}`:de?` a partir de ${fmtDate(de)}`:ate?` até ${fmtDate(ate)}`:''}`;
|
||||
if (info) info.textContent = label;
|
||||
} else {
|
||||
tbody.innerHTML = renderRows(cincoDias);
|
||||
if (info) info.textContent = 'Mostrando últimos 5 dias';
|
||||
}
|
||||
};
|
||||
|
||||
// Enter na busca
|
||||
['consulta-busca-de','consulta-busca-ate'].forEach(id => {
|
||||
document.getElementById(id)?.addEventListener('keydown', e => { if (e.key === 'Enter') window._filtrarConsulta(); });
|
||||
});
|
||||
|
||||
window._recentList = recentList;
|
||||
window._consultaAll = recentList;
|
||||
}
|
||||
|
||||
// Abre a janela de edição completa de um registro pelo índice na lista
|
||||
@@ -2246,6 +2376,7 @@ window.abrirEditaFechamentoIndex = async function(idx) {
|
||||
}
|
||||
|
||||
closeConsultaModal();
|
||||
if (!await askPassword()) return;
|
||||
window.abrirEditaFechamento(full);
|
||||
};
|
||||
|
||||
@@ -2530,25 +2661,26 @@ window.toggleEditConsulta = async function() {
|
||||
// Se já está editando, apenas fecha
|
||||
if (window._editModalOpen) { closeEditModal(); return; }
|
||||
|
||||
// Pede senha admin primeiro — sempre, para proteger edições
|
||||
const pw = config.admin_password;
|
||||
if (pw) {
|
||||
const ok = await askPassword();
|
||||
if (!ok) return;
|
||||
} else {
|
||||
// Sem senha configurada — avisa e abre mesmo assim (primeiro uso)
|
||||
const ok = await askPassword(); // mostra diálogo vazio para o admin configurar mentalmente
|
||||
if (!ok) return;
|
||||
}
|
||||
// Pede senha — sempre, uma vez (aceita fixa ou dinâmica)
|
||||
if (!await askPassword()) return;
|
||||
|
||||
openEditModal(full);
|
||||
closeRelatorioModal();
|
||||
};
|
||||
|
||||
// Pede senha admin — retorna Promise<boolean>
|
||||
// Calcula senha dinâmica do dia: dia e mês como 2 dígitos cada, concatenados
|
||||
// ex: 17/12 → "1712"
|
||||
function getDynamicPw() {
|
||||
const d = new Date();
|
||||
const day = String(d.getDate()).padStart(2, '0');
|
||||
const month = String(d.getMonth() + 1).padStart(2, '0');
|
||||
return day + month;
|
||||
}
|
||||
|
||||
function askPassword() {
|
||||
return new Promise(resolve => {
|
||||
const pw = config.admin_password;
|
||||
const fixedPw = config.admin_password;
|
||||
const dynPw = getDynamicPw();
|
||||
const overlay = document.createElement('div');
|
||||
overlay.style.cssText = 'position:fixed;top:0;left:0;right:0;bottom:0;background:rgba(0,0,0,0.5);z-index:999999;display:flex;align-items:center;justify-content:center;';
|
||||
overlay.id = 'pw-modal';
|
||||
@@ -2573,7 +2705,7 @@ function askPassword() {
|
||||
inp.addEventListener('keydown', e => { if (e.key === 'Enter') document.getElementById('pw-ok-btn').click(); });
|
||||
document.getElementById('pw-ok-btn').addEventListener('click', () => {
|
||||
const val = document.getElementById('pw-input').value;
|
||||
if (val === pw) {
|
||||
if (val === fixedPw || val === dynPw || val === config.dynamic_password) {
|
||||
document.getElementById('pw-modal').remove();
|
||||
resolve(true);
|
||||
} else {
|
||||
@@ -2700,7 +2832,35 @@ window._recalcEditTotals = function() {
|
||||
if (el('tot-areceber')) el('tot-areceber').textContent = fmtTot(recv_tot);
|
||||
if (el('tot-sangrias')) el('tot-sangrias').textContent = fmtTot(sang_tot);
|
||||
if (el('tot-despesas')) el('tot-despesas').textContent = fmtTot(desp_tot);
|
||||
if (el('tot-cancel')) el('tot-cancel').textContent = fmtTot(canc_tot);
|
||||
if (el('tot-cancel')) el('tot-cancel').textContent = fmtTot(canc_tot);
|
||||
|
||||
// Atualiza campos readonly do saldo de caixa com totais calculados
|
||||
const setReadonly = (id, v) => { if (el(id)) el(id).value = fmtTot(v); };
|
||||
setReadonly('edit-sld-credito', cartCred_tot);
|
||||
setReadonly('edit-sld-debito', cartDeb_tot);
|
||||
setReadonly('edit-sld-alimentacao', cartAli_tot);
|
||||
setReadonly('edit-sld-vales', vales_tot);
|
||||
setReadonly('edit-sld-pixcnpj', pixcnpj_tot);
|
||||
setReadonly('edit-sld-areceber', recv_tot);
|
||||
setReadonly('edit-sld-pixcartao', cartPix_tot);
|
||||
|
||||
// Calcula Saldo Caixa e Diferença em tempo real
|
||||
const trocoVal = parseNum(el('edit-troco')?.value || '0');
|
||||
const esperadoVal = parseNum(el('edit-esperado')?.value || '0');
|
||||
const saldoCaixa = trocoVal + cartCred_tot + cartDeb_tot + cartAli_tot + cartPix_tot + pixcnpj_tot + vales_tot + recv_tot;
|
||||
const diferenca = saldoCaixa - esperadoVal - canc_tot;
|
||||
|
||||
if (el('edit-saldo-caixa')) {
|
||||
el('edit-saldo-caixa').textContent = saldoCaixa > 0 ? fmtTot(saldoCaixa) : 'R$ 0,00';
|
||||
}
|
||||
if (el('edit-diferenca')) {
|
||||
const fmtDif = v => v >= 0 ? `R$ ${v.toLocaleString('pt-BR', { minimumFractionDigits: 2 })}` : `-R$ ${Math.abs(v).toLocaleString('pt-BR', { minimumFractionDigits: 2 })}`;
|
||||
el('edit-diferenca').textContent = fmtDif(diferenca);
|
||||
// Alerta visual se diferença != 0
|
||||
if (el('edit-diferenca-box')) {
|
||||
el('edit-diferenca-box').style.background = diferenca === 0 ? '#e8f5e9' : diferenca > 0 ? '#fff3e0' : '#ffebee';
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
// Modal de edição completo com todas as grades
|
||||
@@ -2762,20 +2922,32 @@ function openEditModal(full) {
|
||||
const receber_rows = (dados.receber || []).map(r => [r.nome||'', r.valor||0]);
|
||||
const receberHTML = editTableHTML('receber', ['Nome', 'Valor (R$)'], receber_rows.length ? receber_rows : [['','']], [1]);
|
||||
|
||||
// --- Totais (só leitura formatado) ---
|
||||
const totaisInfo = `
|
||||
// --- Totais (só leitura formatado) — calcula dos dados reais ---
|
||||
const totaisInfo = (() => {
|
||||
const calcCred = (dados.cartoes?.credito || full.cartoes_credito || []).reduce((s,r) => s+(parseFloat(typeof r==='object'?r.valor:r)||0), 0);
|
||||
const calcDeb = (dados.cartoes?.debito || full.cartoes_debito || []).reduce((s,r) => s+(parseFloat(typeof r==='object'?r.valor:r)||0), 0);
|
||||
const calcAli = (dados.cartoes?.alimentacao || full.cartoes_alimentacao || []).reduce((s,r) => s+(parseFloat(typeof r==='object'?r.valor:r)||0), 0);
|
||||
const calcPix = (dados.cartoes?.pix || full.cartoes_pix || []).reduce((s,r) => s+(parseFloat(typeof r==='object'?r.valor:r)||0), 0);
|
||||
const calcSang = (dados.sangrias || []).reduce((s,r) => s+(parseFloat(r.retirada||r.valor||0)||0), 0);
|
||||
const calcDesp = (dados.despesas || []).reduce((s,r) => s+(parseFloat(r.valor||0)||0), 0);
|
||||
const calcVales = (dados.vales || []).reduce((s,r) => s+(parseFloat(r.valor||0)||0), 0);
|
||||
const calcPixCnpj = (dados.pixcnpj || []).reduce((s,r) => s+(parseFloat(r.valor||0)||0), 0);
|
||||
const calcRecv = (dados.receber || []).reduce((s,r) => s+(parseFloat(r.valor||0)||0), 0);
|
||||
const calcCanc = (dados.cancelamentos || []).reduce((s,r) => s+(parseFloat(r.valor||0)||0), 0);
|
||||
return `
|
||||
<div style="display:grid;grid-template-columns:1fr 1fr 1fr 1fr;gap:4px;background:#f5f5f5;border-radius:6px;padding:8px;margin-bottom:8px;font-size:11px">
|
||||
<div><strong>Total Crédito:</strong> <span id="edit-total-credito">${fmtMR(full.total_cartao_credito||0)}</span></div>
|
||||
<div><strong>Total Débito:</strong> <span id="edit-total-debito">${fmtMR(full.total_cartao_debito||0)}</span></div>
|
||||
<div><strong>Total Alimentação:</strong> <span id="edit-total-alimentacao">${fmtMR(full.total_cartao_alimentacao||0)}</span></div>
|
||||
<div><strong>Total PIX:</strong> <span id="edit-total-pix">${fmtMR(full.total_cartao_pix||0)}</span></div>
|
||||
<div><strong>Total Sangrias:</strong> <span id="edit-total-sangrias">${fmtMR(full.total_sangrias||0)}</span></div>
|
||||
<div><strong>Total Despesas:</strong> <span id="edit-total-despesas">${fmtMR(full.total_despesas||0)}</span></div>
|
||||
<div><strong>Total Vales:</strong> <span id="edit-total-vales">${fmtMR(full.total_vales||0)}</span></div>
|
||||
<div><strong>Total PIX CNPJ:</strong> <span id="edit-total-pixcnpj">${fmtMR(full.total_pixcnpj||0)}</span></div>
|
||||
<div><strong>Total Receber:</strong> <span id="edit-total-receber">${fmtMR(full.total_areceber||0)}</span></div>
|
||||
<div><strong>Total Cancel:</strong> <span id="edit-total-cancel">${fmtMR(full.total_cancelamentos||0)}</span></div>
|
||||
<div><strong>Total Crédito:</strong> <span id="edit-total-credito">${fmtMR(calcCred)}</span></div>
|
||||
<div><strong>Total Débito:</strong> <span id="edit-total-debito">${fmtMR(calcDeb)}</span></div>
|
||||
<div><strong>Total Alimentação:</strong> <span id="edit-total-alimentacao">${fmtMR(calcAli)}</span></div>
|
||||
<div><strong>Total PIX:</strong> <span id="edit-total-pix">${fmtMR(calcPix)}</span></div>
|
||||
<div><strong>Total Sangrias:</strong> <span id="edit-total-sangrias">${fmtMR(calcSang)}</span></div>
|
||||
<div><strong>Total Despesas:</strong> <span id="edit-total-despesas">${fmtMR(calcDesp)}</span></div>
|
||||
<div><strong>Total Vales:</strong> <span id="edit-total-vales">${fmtMR(calcVales)}</span></div>
|
||||
<div><strong>Total PIX CNPJ:</strong> <span id="edit-total-pixcnpj">${fmtMR(calcPixCnpj)}</span></div>
|
||||
<div><strong>Total Receber:</strong> <span id="edit-total-receber">${fmtMR(calcRecv)}</span></div>
|
||||
<div><strong>Total Cancel:</strong> <span id="edit-total-cancel">${fmtMR(calcCanc)}</span></div>
|
||||
</div>`;
|
||||
})();
|
||||
|
||||
const overlay = document.createElement('div');
|
||||
overlay.id = 'modal-edit';
|
||||
@@ -2907,7 +3079,8 @@ function openEditModal(full) {
|
||||
<div style="margin-bottom:12px;background:#f5f5f5;border-radius:6px;padding:8px">
|
||||
<strong style="font-size:12px;color:#333;margin-bottom:4px;display:block">💰 Saldo de Caixa <span style="font-size:10px;color:#888">(apenas Troco é editável)</span></strong>
|
||||
<div style="display:grid;grid-template-columns:repeat(4,1fr);gap:6px">
|
||||
<div><label style="font-size:10px;color:#555">Troco:</label><input type="text" id="edit-troco" value="${fmtMR(full.saldo_troco)}" style="width:100%;padding:5px;border:1px solid #2e7d32;border-radius:4px;text-align:right" onblur="this.value=fmtBR(this)" /></div>
|
||||
<div><label style="font-size:10px;color:#555">Troco:</label><input type="text" id="edit-troco" value="${fmtMR(full.saldo_troco)}" style="width:100%;padding:5px;border:1px solid #2e7d32;border-radius:4px;text-align:right" oninput="window._recalcEditTotals()" onblur="this.value=fmtBR(this)" /></div>
|
||||
<div><label style="font-size:10px;color:#555">Saldo Esperado:</label><input type="text" id="edit-esperado" value="${fmtMR(full.saldo_esperado)}" style="width:100%;padding:5px;border:1px solid #e65100;border-radius:4px;text-align:right;font-weight:bold" oninput="window._recalcEditTotals()" onblur="this.value=fmtBR(this)" /></div>
|
||||
<div><label style="font-size:10px;color:#888">Crédito:</label><input type="text" id="edit-sld-credito" value="${fmtMR(full.saldo_credito)}" readonly tabindex="-1" style="width:100%;padding:5px;border:1px solid #ddd;border-radius:4px;text-align:right;background:#eee;color:#666" /></div>
|
||||
<div><label style="font-size:10px;color:#888">Débito:</label><input type="text" id="edit-sld-debito" value="${fmtMR(full.saldo_debito)}" readonly tabindex="-1" style="width:100%;padding:5px;border:1px solid #ddd;border-radius:4px;text-align:right;background:#eee;color:#666" /></div>
|
||||
<div><label style="font-size:10px;color:#888">Alimentação:</label><input type="text" id="edit-sld-alimentacao" value="${fmtMR(full.saldo_alimentacao)}" readonly tabindex="-1" style="width:100%;padding:5px;border:1px solid #ddd;border-radius:4px;text-align:right;background:#eee;color:#666" /></div>
|
||||
@@ -2916,14 +3089,23 @@ function openEditModal(full) {
|
||||
<div><label style="font-size:10px;color:#888">À Receber:</label><input type="text" id="edit-sld-areceber" value="${fmtMR(full.saldo_areceber)}" readonly tabindex="-1" style="width:100%;padding:5px;border:1px solid #ddd;border-radius:4px;text-align:right;background:#eee;color:#666" /></div>
|
||||
<div><label style="font-size:10px;color:#888">PIX Cartão:</label><input type="text" id="edit-sld-pixcartao" value="${fmtMR(full.total_cartao_pix)}" readonly tabindex="-1" style="width:100%;padding:5px;border:1px solid #ddd;border-radius:4px;text-align:right;background:#eee;color:#666" /></div>
|
||||
</div>
|
||||
<div style="display:grid;grid-template-columns:1fr 1fr;gap:6px;margin-top:6px">
|
||||
<div style="background:#e3f2fd;border-radius:4px;padding:6px 8px;text-align:right">
|
||||
<span style="font-size:11px;color:#1565c0">SALDO CAIXA:</span>
|
||||
<span id="edit-saldo-caixa" style="font-size:16px;font-weight:bold;color:#1565c0;margin-left:8px">R$ 0,00</span>
|
||||
</div>
|
||||
<div id="edit-diferenca-box" style="background:#fff3e0;border-radius:4px;padding:6px 8px;text-align:right">
|
||||
<span style="font-size:11px;color:#e65100">DIFERENÇA:</span>
|
||||
<span id="edit-diferenca" style="font-size:16px;font-weight:bold;color:#e65100;margin-left:8px">R$ 0,00</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Contadores -->
|
||||
<div style="display:grid;grid-template-columns:1fr 1fr 1fr 1fr;gap:6px;margin-bottom:12px">
|
||||
<div style="display:grid;grid-template-columns:1fr 1fr 1fr;gap:6px;margin-bottom:12px">
|
||||
<div><label style="font-size:10px;color:#555">Clientes:</label><input type="number" id="edit-clientes" value="${dados.clientes||full.clientes||0}" min="0" style="width:100%;padding:5px;border:1px solid #ccc;border-radius:4px" /></div>
|
||||
<div><label style="font-size:10px;color:#555">Frango Assado:</label><input type="number" id="edit-frango" value="${dados.frango||full.frango||0}" min="0" style="width:100%;padding:5px;border:1px solid #ccc;border-radius:4px" /></div>
|
||||
<div><label style="font-size:10px;color:#555">Vendas (R$):</label><input type="text" id="edit-vendas" value="${fmtMR(dados.vendas||full.vendas||0)}" style="width:100%;padding:5px;border:1px solid #e65100;border-radius:4px;text-align:right;font-weight:bold" onblur="this.value=fmtBR(this)" /></div>
|
||||
<div><label style="font-size:10px;color:#555">Saldo Esperado:</label><input type="text" id="edit-esperado" value="${fmtMR(full.saldo_esperado)}" style="width:100%;padding:5px;border:1px solid #e65100;border-radius:4px;text-align:right;font-weight:bold" onblur="this.value=fmtBR(this)" /></div>
|
||||
</div>
|
||||
|
||||
<!-- Observações -->
|
||||
@@ -2939,6 +3121,8 @@ function openEditModal(full) {
|
||||
</div>`;
|
||||
|
||||
document.body.appendChild(overlay);
|
||||
// Calcula totais iniciais após renderizar o DOM
|
||||
setTimeout(() => window._recalcEditTotals(), 50);
|
||||
}
|
||||
|
||||
// Formata campo de dinheiro brasileiro
|
||||
@@ -3125,6 +3309,11 @@ window.salvarEdicaoModal = async function() {
|
||||
total_pixcnpj: pixcnpj_tot,
|
||||
total_areceber: recv_tot,
|
||||
total_cancelamentos: canc_tot,
|
||||
total_cartao_pix: cartPix_tot,
|
||||
|
||||
// Diferença calculada
|
||||
diferenca: p(inp('edit-troco')) + cartCred_tot + cartDeb_tot + cartAli_tot + cartPix_tot + pixcnpj_tot + vales_tot + recv_tot
|
||||
- p(inp('edit-esperado')) - canc_tot,
|
||||
|
||||
// Fechamento (campos calculados que o formato antigo usava no dados)
|
||||
fechamento_dinheiro: p(inp('edit-troco')) + vales_tot + sang_tot - desp_tot - canc_tot,
|
||||
@@ -3374,26 +3563,26 @@ window.printRelatorio = function() {
|
||||
<title>Fechamento de Caixa</title>
|
||||
<style>
|
||||
* { box-sizing: border-box; margin: 0; padding: 0; }
|
||||
body { font-family: 'Courier New', monospace; font-size: 14px; width: 80mm; margin: 0 auto; padding: 8px; font-weight: bold; print-color-adjust: exact; -webkit-print-color-adjust: exact; }
|
||||
body { font-family: 'Courier New', monospace; font-size: 18px; width: 80mm; margin: 0 auto; padding: 8px; font-weight: bold; print-color-adjust: exact; -webkit-print-color-adjust: exact; }
|
||||
.header { text-align: center; margin-bottom: 8px; }
|
||||
.header h1 { font-size: 20px; font-weight: bold; }
|
||||
.header h2 { font-size: 15px; font-weight: normal; }
|
||||
.header h1 { font-size: 24px; font-weight: bold; }
|
||||
.header h2 { font-size: 18px; font-weight: normal; }
|
||||
hr { border: none; border-top: 2px solid #000; margin: 6px 0; }
|
||||
.section-title { font-weight: bold; font-size: 14px; border-top: 2px solid #000; padding-top: 4px; margin-top: 8px; }
|
||||
.row { display: flex; justify-content: space-between; padding: 2px 0; font-size: 13px; font-weight: bold; }
|
||||
.section-title { font-weight: bold; font-size: 17px; border-top: 2px solid #000; padding-top: 4px; margin-top: 8px; }
|
||||
.row { display: flex; justify-content: space-between; padding: 2px 0; font-size: 16px; font-weight: bold; }
|
||||
.row.total-row { font-weight: bold; }
|
||||
.cart-table { table-layout: fixed; width: 100%; border-collapse: collapse; font-size: 13px; margin-top: 4px; font-weight: bold; }
|
||||
.cart-table { table-layout: fixed; width: 100%; border-collapse: collapse; font-size: 16px; margin-top: 4px; font-weight: bold; }
|
||||
.cart-table th { font-weight: bold; border-bottom: 2px solid #000; padding: 2px; width: 25%; }
|
||||
.cart-table td { padding: 2px 4px; text-align: right; font-weight: bold; }
|
||||
.cart-table td:first-child { text-align: left; }
|
||||
.cart-table tr.total-row { font-weight: bold; border-top: 2px solid #000; }
|
||||
.subtotal { font-size: 13px; margin-top: 4px; }
|
||||
.subtotal { font-size: 16px; margin-top: 4px; }
|
||||
.subtotal .row { padding: 2px 0; }
|
||||
.diff-ok { color: #2e7d32; font-weight: bold; }
|
||||
.diff-bad { color: #c0272d; font-weight: bold; }
|
||||
.obs { font-size: 11px; color: #555; margin-top: 6px; white-space: pre-wrap; font-weight: normal; }
|
||||
.footer { text-align: center; font-size: 11px; color: #555; margin-top: 8px; }
|
||||
@media print { body { background: #fff; font-weight: bold !important; } }
|
||||
.obs { font-size: 13px; color: #555; margin-top: 6px; white-space: pre-wrap; font-weight: normal; }
|
||||
.footer { text-align: center; font-size: 13px; color: #555; margin-top: 8px; }
|
||||
@media print { body { background: #fff; font-weight: bold !important; font-size: 18px !important; } }
|
||||
</style>
|
||||
</head><body>
|
||||
${buildPrintHTML(data)}
|
||||
|
||||
Reference in New Issue
Block a user