feat: Add payment partial support, comanda printing, and direct print configuration

- Implement partial payment flow with 'Pagamento Parcial' button
- Calculate remaining value considering existing payments
- Add payment tracking in comanda list and PDV modal
- Add 'Imprimir' button for comanda receipt printing
- Add print silent mode configuration
- Fix product name display in print and list views
- Add fallback when no printer is configured
This commit is contained in:
Welton Silva
2026-04-04 17:35:21 -03:00
parent 82d25eb68f
commit 9ef245b9fe
11 changed files with 1238 additions and 402 deletions

86
ELECTRON_INTEGRATION.md Normal file
View File

@@ -0,0 +1,86 @@
# RRBEC Middleware - Guia de Integração (Electron/Desktop)
Este guia documenta como a aplicação Electron deve se comunicar com o servidor middleware local em Go.
## 1. Configurações Base
- **URL Base**: `http://localhost:8080/api/v1`
- **Porta Default**: `8080` (configurável no arquivo `.env` do servidor).
- **Formato**: Todas as requisições e respostas utilizam `application/json`.
## 2. Autenticação (SimpleAuth)
O servidor não utiliza tokens JWT complexos localmente. A autenticação funciona assim:
1. Faça login em `/login` enviando `username` e `password`.
2. O servidor retornará um objeto de usuário. Capture o valor do campo `id` (inteiro).
3. Envie esse valor no cabeçalho HTTP `X-User-ID` em todas as rotas marcadas como **[PROTEGIDO]**.
---
## 3. Endpoints da API
### [PÚBLICO] Login
**POST** `/login`
- **Body**: `{ "username": "seu_usuario", "password": "sua_senha" }`
- **Retorno**: Objeto User completo.
### [PÚBLICO] Listar Mesas
**GET** `/mesas`
- **Retorno**: Array de objetos mesas com `id`, `uuid`, `name`, `active`.
### [PÚBLICO] Listar Produtos/Estoque
**GET** `/products`
- **Retorno**: Array de produtos com preços e quantidade em estoque.
### [PÚBLICO] Listar Categorias
**GET** `/categories`
### [PÚBLICO] Listar Clientes
**GET** `/clients`
### [PÚBLICO] Listar Pedidos (Cozinha/Orders)
**GET** `/orders`
### [PÚBLICO] Listar Tipos de Pagamento
**GET** `/payment-types`
### [PÚBLICO] Listar Pagamentos Realizados
**GET** `/payments`
### [PÚBLICO] Ver Comanda por ID
**GET** `/comandas/:id` (Ex: `/api/v1/comandas/9`)
- **Retorno**: Detalhes da comanda.
---
## 4. Comandas e Itens (Ações)
### [PROTEGIDO] Abrir Nova Comanda
**POST** `/comandas`
- **Headers**: `X-User-ID: <id_do_usuario>`
- **Body**: `{ "mesa_id": 1, "client_id": null }`
### [PROTEGIDO] Lançar Pedido (Adicionar Item)
**POST** `/items-comanda`
- **Headers**: `X-User-ID: <id_do_usuario>`
- **Body**: `{ "comanda_id": 9, "product_id": 50, "applicant": "Nome do Garçom" }`
### [PROTEGIDO] Deletar Item Individual
**DELETE** `/items-comanda/:id`
- **Headers**: `X-User-ID: <id_do_usuario>`
### [PROTEGIDO] Limpar e Fechar Comanda (Apagar Inteira)
**POST** `/comandas/:id/apagar`
- **Headers**: `X-User-ID: <id_do_usuario>`
- **Efeito**: Remove todos os itens da comanda e muda o status para `CLOSED`.
### [PROTEGIDO] Pagar e Fechar Comanda
**POST** `/comandas/:id/pagar`
- **Headers**: `X-User-ID: <id_do_usuario>`
- **Body**:
```json
{
"value": 50.00,
"type_pay_id": 1,
"client_id": null
}
```
- **Efeito**: Registra o pagamento localmente e fecha a comanda.

View File

@@ -6,7 +6,14 @@ const axios = require('axios');
const store = new Store(); const store = new Store();
function getBaseUrl() { function getBaseUrl() {
return store.get('api_url', 'http://localhost:8000/api/v1'); const currentUrl = store.get('api_url');
// Migração automática da URL antiga para o novo middleware local
if (!currentUrl || currentUrl.includes('squareweb.app')) {
const newUrl = 'http://localhost:8080/api/v1';
store.set('api_url', newUrl);
return newUrl;
}
return currentUrl;
} }
// ─── Janela Principal ──────────────────────────────────────────────────────── // ─── Janela Principal ────────────────────────────────────────────────────────
@@ -31,89 +38,45 @@ function createWindow() {
}); });
mainWindow.loadFile(path.join(__dirname, '../renderer/index.html')); mainWindow.loadFile(path.join(__dirname, '../renderer/index.html'));
mainWindow.webContents.openDevTools(); // mainWindow.webContents.openDevTools();
} }
// ─── Helpers de Token ──────────────────────────────────────────────────────── // ─── Helpers de Autenticação (Middleware Go) ──────────────────────────────────
let isRefreshing = false;
let refreshPromise = null;
function getHeaders() { function getHeaders() {
const token = store.get('access_token'); const user = store.get('user');
// console.log('[MAIN] getHeaders - Token exists:', !!token); return user && user.id ? { 'X-User-ID': String(user.id) } : {};
return token ? { Authorization: `Bearer ${token}` } : {};
}
async function refreshAccessToken() {
if (isRefreshing) {
console.log('[JWT] Refresh already in progress, waiting...');
return refreshPromise;
}
const refresh = store.get('refresh_token');
if (!refresh) {
console.error('[JWT] No refresh token available.');
throw new Error('No refresh token');
}
isRefreshing = true;
console.log('[JWT] Starting token refresh flow...');
refreshPromise = axios.post(`${getBaseUrl()}/token/refresh/`, { refresh })
.then(res => {
const { access, refresh: newRefresh } = res.data;
store.set('access_token', access);
if (newRefresh) {
store.set('refresh_token', newRefresh);
console.log('[JWT] Refresh token rotated and updated.');
}
console.log('[JWT] Access token updated successfully.');
return access;
})
.catch(err => {
console.error('[JWT] Refresh Failed:', err.response?.data || err.message);
// Limpa tudo se o refresh falhar (refresh_token expirou definitivamente)
store.delete('access_token');
store.delete('refresh_token');
store.delete('user');
throw err;
})
.finally(() => {
isRefreshing = false;
refreshPromise = null;
});
return refreshPromise;
} }
async function requestWithRetry(method, endpoint, data) { async function requestWithRetry(method, endpoint, data) {
const url = `${getBaseUrl()}${endpoint}`; const cleanEndpoint = endpoint.endsWith('/') ? endpoint.slice(0, -1) : endpoint;
console.log(`[API] ${method.toUpperCase()} ${url}`); const url = `${getBaseUrl()}${cleanEndpoint}`;
const headers = {
...getHeaders(),
'Content-Type': 'application/json'
};
console.log(`[DEBUG_API] >>> ${method.toUpperCase()} ${url}`);
console.log(`[DEBUG_API] >>> Headers:`, JSON.stringify(headers));
if (data) console.log(`[DEBUG_API] >>> Body:`, JSON.stringify(data));
try { try {
const res = await axios({ method, url, data, headers: getHeaders() }); const res = await axios({
method,
url,
data,
headers
});
return { ok: true, data: res.data }; return { ok: true, data: res.data };
} catch (err) { } catch (err) {
const status = err.response?.status; const status = err.response?.status;
// Se for 401 ou 403, tentamos o refresh uma única vez
if ((status === 401 || status === 403) && store.get('refresh_token')) {
console.warn(`[API] ${status} Unauthorized/Forbidden on ${endpoint}. Attempting refresh...`);
try {
await refreshAccessToken();
// Tenta a requisição original novamente com o novo header
const retryRes = await axios({ method, url, data, headers: getHeaders() });
console.log(`[API] Retry successful for ${endpoint}`);
return { ok: true, data: retryRes.data };
} catch (refreshErr) {
console.error(`[API] Retry failed after refresh for ${endpoint}`);
if (mainWindow) mainWindow.webContents.send('auth:expired');
return { ok: false, error: 'Sessão expirada. Faça login novamente.', expired: true };
}
}
const msg = err.response?.data || err.message; const msg = err.response?.data || err.message;
console.error(`[API ERROR] ${status || 'NET'} ${endpoint}:`, msg); console.error(`[DEBUG_API] >>> ERROR ${status || 'NET'} on ${endpoint}:`, msg);
if (status === 401 || status === 403) {
if (mainWindow) mainWindow.webContents.send('auth:expired');
return { ok: false, error: 'Sessão expirada ou não autorizado.', expired: true };
}
return { ok: false, error: typeof msg === 'object' ? JSON.stringify(msg) : msg }; return { ok: false, error: typeof msg === 'object' ? JSON.stringify(msg) : msg };
} }
} }
@@ -121,29 +84,55 @@ async function requestWithRetry(method, endpoint, data) {
// ─── IPC Handlers (Registrar IMEDIATAMENTE) ────────────────────────────────── // ─── IPC Handlers (Registrar IMEDIATAMENTE) ──────────────────────────────────
ipcMain.handle('auth:login', async (_, { username, password }) => { ipcMain.handle('auth:login', async (_, { username, password }) => {
try { try {
const res = await axios.post(`${getBaseUrl()}/token/`, { username, password }); const url = `${getBaseUrl()}/login`;
console.log('[MAIN] Login Successful. User:', res.data.user?.username); console.log(`[DEBUG_LOGIN] >>> Tentando login em: ${url}`);
store.set('access_token', res.data.access);
store.set('refresh_token', res.data.refresh); const res = await axios.post(url, { username, password }, {
store.set('user', res.data.user); headers: { 'Content-Type': 'application/json' }
});
const userData = res.data;
console.log('[DEBUG_LOGIN] >>> Resposta Completa do Servidor:', JSON.stringify(userData));
// Busca o ID em qualquer lugar possível (id, user.id, user_id, UID, pk)
const userId = userData.id ||
(userData.user && userData.user.id) ||
userData.user_id ||
userData.pk;
console.log('[DEBUG_LOGIN] >>> ID Capturado:', userId);
if (!userId) {
console.warn('[DEBUG_LOGIN] >>> AVISO: Não encontramos um ID numérico. Verifique a Resposta Completa acima.');
}
// Normaliza para garantir que user.id exista para o getHeaders()
if (!userData.id) userData.id = userId;
store.set('user', userData);
store.delete('access_token');
store.delete('refresh_token');
return { ok: true }; return { ok: true };
} catch (err) { } catch (err) {
console.error('[MAIN] Login Failed:', err.response?.data || err.message); console.error('[DEBUG_LOGIN] >>> Falha no Login:', err.response?.data || err.message);
return { ok: false, error: 'Credenciais inválidas.' }; return { ok: false, error: 'Erro de autenticação no servidor local.' };
} }
}); });
ipcMain.handle('auth:logout', () => { ipcMain.handle('auth:logout', () => {
store.delete('user');
store.delete('access_token'); store.delete('access_token');
store.delete('refresh_token'); store.delete('refresh_token');
store.delete('user');
return { ok: true }; return { ok: true };
}); });
ipcMain.handle('auth:check', () => ({ authenticated: !!store.get('access_token') })); ipcMain.handle('auth:check', () => {
const user = store.get('user');
return { authenticated: !!(user && user.id) };
});
ipcMain.handle('auth:user', () => { ipcMain.handle('auth:user', () => {
console.log('[MAIN] IPC auth:user requested.');
return store.get('user'); return store.get('user');
}); });
@@ -160,6 +149,38 @@ ipcMain.handle('config:set-url', (_, url) => {
return { ok: true }; return { ok: true };
}); });
ipcMain.handle('config:get-print-silent', () => store.get('print_silent', false));
ipcMain.handle('config:set-print-silent', (_, value) => {
store.set('print_silent', value);
console.log('[MAIN] Print silent mode:', value);
return { ok: true };
});
ipcMain.handle('print:direct', async (_, html) => {
const printSilent = store.get('print_silent', false);
try {
const win = new BrowserWindow({ show: false });
win.loadURL(`data:text/html;charset=utf-8,${encodeURIComponent(html)}`);
await new Promise(r => win.webContents.once('did-finish-load', r));
return new Promise((resolve) => {
win.webContents.print({
silent: printSilent,
printBackground: true,
deviceName: ''
}, (success, errorType) => {
win.close();
if (success) {
resolve({ ok: true });
} else {
resolve({ ok: false, error: 'Nenhuma impressora configurada ou disponível.' });
}
});
});
} catch (err) {
return { ok: false, error: err.message };
}
});
// ─── Lifecycle ────────────────────────────────────────────────────────────── // ─── Lifecycle ──────────────────────────────────────────────────────────────
app.whenReady().then(() => { app.whenReady().then(() => {
createWindow(); createWindow();

View File

@@ -10,6 +10,11 @@ contextBridge.exposeInMainWorld('electronAPI', {
// Config // Config
getConfigUrl: () => ipcRenderer.invoke('config:get-url'), getConfigUrl: () => ipcRenderer.invoke('config:get-url'),
setConfigUrl: (url) => ipcRenderer.invoke('config:set-url', url), setConfigUrl: (url) => ipcRenderer.invoke('config:set-url', url),
getPrintSilent: () => ipcRenderer.invoke('config:get-print-silent'),
setPrintSilent: (value) => ipcRenderer.invoke('config:set-print-silent', value),
// Print
printDirect: (html) => ipcRenderer.invoke('print:direct', html),
// API CRUD // API CRUD
get: (endpoint) => ipcRenderer.invoke('api:get', endpoint), get: (endpoint) => ipcRenderer.invoke('api:get', endpoint),

View File

@@ -31,28 +31,63 @@ export async function renderClientes(container) {
} }
let _clientesData = []; let _clientesData = [];
let _comandasData = [];
let _productsMap = {}; let _productsMap = {};
let _paymentTypes = [];
async function loadClientes() { async function loadClientes() {
const wrap = document.getElementById('clientes-table'); const wrap = document.getElementById('clientes-table');
if (!wrap) return; if (!wrap) return;
wrap.innerHTML = `<div class="loading-screen"><div class="spinner"></div></div>`; wrap.innerHTML = `<div class="loading-screen"><div class="spinner"></div></div>`;
// Carrega clientes e produtos em paralelo para ter os preços // Carrega clientes, produtos, comandas e tipos de pagamento em paralelo
const [res, pRes] = await Promise.all([ const [res, pRes, cRes, ptRes] = await Promise.all([
window.electronAPI.get('/clients/'), window.electronAPI.get('/clients'),
window.electronAPI.get('/products/') window.electronAPI.get('/products'),
window.electronAPI.get('/comandas'),
window.electronAPI.get('/payment-types')
]); ]);
if (ptRes.ok) _paymentTypes = ptRes.data;
if (pRes.ok) { if (pRes.ok) {
_productsMap = pRes.data.reduce((acc, p) => { _productsMap = pRes.data.reduce((acc, p) => {
acc[p.id] = parseFloat(p.price || 0); acc[String(p.id)] = {
name: p.name,
price: parseFloat(p.price || 0)
};
return acc; return acc;
}, {}); }, {});
} }
if (cRes.ok) _comandasData = cRes.data;
if (!res.ok) { wrap.innerHTML = `<div class="table-empty">Erro ao carregar clientes.</div>`; return; } if (!res.ok) { wrap.innerHTML = `<div class="table-empty">Erro ao carregar clientes.</div>`; return; }
_clientesData = res.data;
_clientesData = res.data || [];
_comandasData = cRes.ok ? (cRes.data || []) : [];
// Calcula o débito real de cada cliente somando suas comandas FIADO
_clientesData.forEach(c => {
const fiados = _comandasData.filter(com => {
// Baseado no model Go: json:"client"
const cid = com.client;
return String(cid) === String(c.id) && String(com.status).toUpperCase() === 'FIADO';
});
c.real_debt = fiados.reduce((acc, com) => {
const totalComanda = (com.items || []).reduce((sum, item) => {
const pInfo = _productsMap[String(item.product)];
const preco = pInfo ? pInfo.price : parseFloat(item.product_price || 0);
return sum + preco;
}, 0);
return acc + totalComanda;
}, 0);
});
const comDebito = _clientesData.filter(c => c.real_debt > 0);
console.log(`[DEBUG_CLIENTS] Cálculo dinâmico finalizado. Sucesso p/ ${comDebito.length} clientes.`);
renderClientesTable(_clientesData); renderClientesTable(_clientesData);
} }
@@ -61,8 +96,8 @@ function renderClientesTable(data) {
if (!wrap) return; if (!wrap) return;
if (!data.length) { wrap.innerHTML = `<div class="table-empty">Nenhum cliente encontrado.</div>`; return; } if (!data.length) { wrap.innerHTML = `<div class="table-empty">Nenhum cliente encontrado.</div>`; return; }
// Ordena por maior débito por padrão // Ordena por maior débito por padrão usando o cálculo dinâmico
const sorted = [...data].sort((a, b) => parseFloat(b.debt || 0) - parseFloat(a.debt || 0)); const sorted = [...data].sort((a, b) => (b.real_debt || 0) - (a.real_debt || 0));
wrap.innerHTML = ` wrap.innerHTML = `
<table> <table>
@@ -70,20 +105,20 @@ function renderClientesTable(data) {
<th>#</th> <th>#</th>
<th>Nome</th> <th>Nome</th>
<th>Contato</th> <th>Contato</th>
<th>Débito</th> <th style="color:var(--primary)">Débito Dinâmico</th>
<th>Status</th> <th>Status</th>
<th>Cadastrado em</th> <th>Cadastrado em</th>
<th>Ações</th> <th>Ações</th>
</tr></thead> </tr></thead>
<tbody> <tbody>
${sorted.map(c => { ${sorted.map(c => {
const debt = parseFloat(c.debt || 0); const debt = c.real_debt || 0;
return `<tr> return `<tr>
<td style="color:var(--text-muted)">#${c.id}</td> <td style="color:var(--text-muted)">#${c.id}</td>
<td><strong>${c.name}</strong></td> <td><strong>${c.name}</strong></td>
<td>${c.contact || ''}</td> <td>${c.contact || ''}</td>
<td> <td>
<span style="font-weight:600; color: ${debt > 0 ? 'var(--danger)' : 'var(--text-secondary)'}"> <span style="font-weight:700; font-size:1.05rem; color: ${debt > 0 ? 'var(--danger)' : 'var(--text-secondary)'}">
R$ ${debt.toFixed(2)} R$ ${debt.toFixed(2)}
</span> </span>
</td> </td>
@@ -95,7 +130,7 @@ function renderClientesTable(data) {
<td style="font-size:0.82rem;color:var(--text-muted)">${formatDate(c.created_at)}</td> <td style="font-size:0.82rem;color:var(--text-muted)">${formatDate(c.created_at)}</td>
<td> <td>
<div style="display:flex;gap:6px"> <div style="display:flex;gap:6px">
<button class="btn btn-info btn-sm btn-hist-cli" data-id="${c.id}" title="Ver Fiados">📜</button> <button class="btn btn-info btn-sm btn-hist-cli" data-id="${c.id}">📜 Ver Fiados</button>
<button class="btn btn-secondary btn-sm btn-edit-cli" data-id="${c.id}">Editar</button> <button class="btn btn-secondary btn-sm btn-edit-cli" data-id="${c.id}">Editar</button>
</div> </div>
</td> </td>
@@ -131,7 +166,7 @@ function filtrarClientes() {
(c.contact || '').toLowerCase().includes(q) || (c.contact || '').toLowerCase().includes(q) ||
String(c.id).includes(q); String(c.id).includes(q);
const debtValue = parseFloat(c.debt || 0); const debtValue = c.real_debt || 0;
const matchDebt = !debtFltr || const matchDebt = !debtFltr ||
(debtFltr === 'has-debt' ? debtValue > 0 : debtValue === 0); (debtFltr === 'has-debt' ? debtValue > 0 : debtValue === 0);
@@ -187,8 +222,8 @@ function abrirModalCliente(cliente = null) {
if (!data.name) return showToast('Informe o nome do cliente.', 'error'); if (!data.name) return showToast('Informe o nome do cliente.', 'error');
const r = isEdit const r = isEdit
? await window.electronAPI.put(`/clients/${cliente.id}/`, data) ? await window.electronAPI.put(`/clients/${cliente.id}`, data)
: await window.electronAPI.post('/clients/', data); : await window.electronAPI.post('/clients', data);
if (r.ok) { showToast(isEdit ? 'Cliente atualizado!' : 'Cliente criado!', 'success'); closeModal(); loadClientes(); } if (r.ok) { showToast(isEdit ? 'Cliente atualizado!' : 'Cliente criado!', 'success'); closeModal(); loadClientes(); }
else showToast(r.error, 'error'); else showToast(r.error, 'error');
@@ -200,42 +235,64 @@ async function abrirHistoricoFiados(cliente) {
title: `📜 Fiados: ${cliente.name}`, title: `📜 Fiados: ${cliente.name}`,
body: ` body: `
<div id="fiados-modal-content"> <div id="fiados-modal-content">
<div id="fiados-list" class="loading-screen"><div class="spinner"></div></div> <div id="fiados-list"></div>
<div id="fiados-summary" class="hidden" style="margin-top:20px; padding-top:15px; border-top:2px solid var(--border); display:flex; justify-content:space-between; align-items:center;"> <div id="fiados-summary" class="hidden" style="margin-top:20px; padding-top:15px; border-top:2px solid var(--border); display:flex; flex-direction:column; gap:12px;">
<div> <div style="display:flex; justify-content:space-between; align-items:center">
<div style="font-size:0.85rem; color:var(--text-secondary)">Selecionados: <span id="selected-count">0</span></div> <div>
<div style="font-size:1.2rem; font-weight:700; color:var(--success)">Total: R$ <span id="selected-total">0.00</span></div> <div style="font-size:0.85rem; color:var(--text-secondary)">Selecionados: <span id="selected-count">0</span></div>
<div style="font-size:1.2rem; font-weight:700; color:var(--success)">Total: R$ <span id="selected-total">0.00</span></div>
</div>
<div style="width:200px">
<label style="font-size:0.75rem; color:var(--text-muted); text-transform:uppercase; font-weight:700">Forma de Pagamento</label>
<select id="pay-select-type" class="form-control" style="margin-top:4px">
${_paymentTypes.map(pt => `<option value="${pt.id}">${pt.nome || pt.name}</option>`).join('')}
</select>
</div>
</div> </div>
<button class="btn btn-primary btn-md" id="btn-pagar-selecionados" disabled>💳 Pagar Selecionados</button>
<button class="btn btn-primary btn-md" id="btn-pagar-selecionados" disabled style="width:100%">💳 Pagar Selecionados</button>
</div> </div>
</div>`, </div>`,
footer: `<button class="btn btn-secondary btn-md" onclick="closeModal()">Sair</button>` footer: `<button class="btn btn-secondary btn-md" onclick="closeModal()">Sair</button>`
}); });
const res = await window.electronAPI.get(`/clients/${cliente.id}/fiados/`);
const listContainer = document.getElementById('fiados-list'); const listContainer = document.getElementById('fiados-list');
const summary = document.getElementById('fiados-summary'); const summary = document.getElementById('fiados-summary');
if (!listContainer) return; if (!listContainer) return;
if (!res.ok) { console.log(`[DEBUG_FIADOS] Procurando fiados para cliente ${cliente.id} (${cliente.name})...`);
listContainer.innerHTML = `<div class="table-empty">Erro ao carregar fiados: ${res.error}</div>`; console.log(`[DEBUG_FIADOS] Total de comandas na memória: ${_comandasData.length}`);
return;
} // Filtra as comandas FIADO do cliente de forma robusta
const fiados = _comandasData.filter(com => {
const isFiado = String(com.status).toUpperCase() === 'FIADO';
const isMeuClient = String(com.client) === String(cliente.id); // Usando json:"client"
return isFiado && isMeuClient;
});
console.log(`[DEBUG_FIADOS] Encontradas:`, fiados);
const fiados = res.data;
if (!fiados.length) { if (!fiados.length) {
listContainer.innerHTML = `<div class="table-empty">Nenhuma comanda pendente para este cliente.</div>`; listContainer.innerHTML = `
<div class="table-empty">
Nenhuma comanda pendente para este cliente.<br/>
<small style="color:var(--text-muted)">Debug: ${_comandasData.length} comandas totais na memória</small>
</div>`;
return; return;
} }
listContainer.classList.remove('loading-screen');
listContainer.style.maxHeight = '400px'; listContainer.style.maxHeight = '400px';
listContainer.style.overflowY = 'auto'; listContainer.style.overflowY = 'auto';
summary.classList.remove('hidden'); summary.classList.remove('hidden');
listContainer.innerHTML = fiados.map(f => { listContainer.innerHTML = fiados.map(f => {
const totalComanda = (f.items || []).reduce((acc, it) => acc + (_productsMap[it.product] || 0), 0); const totalComanda = (f.items || []).reduce((acc, it) => {
const pInfo = _productsMap[String(it.product)];
const preco = pInfo ? pInfo.price : parseFloat(it.product_price || 0);
return acc + preco;
}, 0);
return ` return `
<div class="card card-fiado" style="margin-bottom:15px; border-left: 4px solid var(--warning); position:relative; padding-left:50px"> <div class="card card-fiado" style="margin-bottom:15px; border-left: 4px solid var(--warning); position:relative; padding-left:50px">
@@ -245,7 +302,7 @@ async function abrirHistoricoFiados(cliente) {
<div style="display:flex; justify-content:space-between; align-items:flex-start; margin-bottom:10px;"> <div style="display:flex; justify-content:space-between; align-items:flex-start; margin-bottom:10px;">
<div> <div>
<div style="font-weight:600; font-size:1.1rem;">Comanda #${f.id} - ${f.name || 'Sem nome'}</div> <div style="font-weight:600; font-size:1.1rem;">Comanda #${f.id} ${f.name || 'Sem nome'}</div>
<div style="font-size:0.8rem; color:var(--text-muted)">Abertura: ${formatDate(f.dt_open)}</div> <div style="font-size:0.8rem; color:var(--text-muted)">Abertura: ${formatDate(f.dt_open)}</div>
</div> </div>
<div style="text-align:right"> <div style="text-align:right">
@@ -254,25 +311,26 @@ async function abrirHistoricoFiados(cliente) {
</div> </div>
</div> </div>
<div style="font-size:0.85rem;">
<div style="margin-bottom:5px; color:var(--text-secondary)">Mesa: ${f.mesa_name || ''} | Lançado por: ${f.user_name || ''}</div>
</div>
<div style="margin-top:10px; border-top:1px solid var(--border); padding-top:10px;"> <div style="margin-top:10px; border-top:1px solid var(--border); padding-top:10px;">
<details> <details>
<summary style="font-weight:600; font-size:0.8rem; text-transform:uppercase; color:var(--text-muted); cursor:pointer; outline:none"> <summary style="font-weight:600; font-size:0.8rem; text-transform:uppercase; color:var(--text-muted); cursor:pointer; outline:none">
Ver Itens (${(f.items || []).length}) Ver Itens (${(f.items || []).length})
</summary> </summary>
<ul style="list-style:none; padding:10px 0 0 0; margin:0; font-size:0.85rem;"> <ul style="list-style:none; padding:10px 0 0 0; margin:0; font-size:0.85rem;">
${(f.items || []).map(it => ` ${(f.items || []).map(it => {
const pInfo = _productsMap[String(it.product)];
const prodName = pInfo ? pInfo.name : (it.product_name || `Produto #${it.product}`);
const prodPreco = pInfo ? pInfo.price : parseFloat(it.product_price || 0);
return `
<li style="display:flex; justify-content:space-between; padding:4px 0; border-bottom:1px dashed var(--border)"> <li style="display:flex; justify-content:space-between; padding:4px 0; border-bottom:1px dashed var(--border)">
<span>• ${it.product_name}</span> <span>• ${prodName}</span>
<div style="text-align:right"> <div style="text-align:right">
<span>R$ ${(_productsMap[it.product] || 0).toFixed(2)}</span> <span>R$ ${prodPreco.toFixed(2)}</span>
<div style="font-size:0.7rem; color:var(--text-muted)">${formatDateShort(it.data_time)}</div> <div style="font-size:0.7rem; color:var(--text-muted)">${formatDateShort(it.data_time)}</div>
</div> </div>
</li> </li>
`).join('')} `}).join('')}
</ul> </ul>
</details> </details>
</div> </div>
@@ -298,24 +356,45 @@ async function abrirHistoricoFiados(cliente) {
}); });
document.getElementById('btn-pagar-selecionados').addEventListener('click', async () => { document.getElementById('btn-pagar-selecionados').addEventListener('click', async () => {
const selecionados = Array.from(listContainer.querySelectorAll('.fiado-check:checked')).map(c => parseInt(c.dataset.id)); const checks = Array.from(listContainer.querySelectorAll('.fiado-check:checked'));
const selecionados = checks.map(c => ({
id: parseInt(c.dataset.id),
total: parseFloat(c.dataset.total)
}));
const payTypeId = parseInt(document.getElementById('pay-select-type').value);
const totalPrompt = document.getElementById('selected-total').textContent; const totalPrompt = document.getElementById('selected-total').textContent;
if (confirm(`Deseja confirmar o pagamento de R$ ${totalPrompt} referente a ${selecionados.length} comanda(s)?`)) { if (confirm(`Confirmar o recebimento de R$ ${totalPrompt} referente a ${selecionados.length} comanda(s)?`)) {
const btn = document.getElementById('btn-pagar-selecionados'); const btn = document.getElementById('btn-pagar-selecionados');
btn.disabled = true; btn.disabled = true;
btn.textContent = 'Processando...'; btn.textContent = 'Processando...';
const r = await window.electronAPI.post('/clients/pagar_fiados/', { ids: selecionados }); let erros = 0;
for (const item of selecionados) {
// Encontra os detalhes da comanda para a descrição
const comanda = _comandasData.find(c => c.id === item.id);
const desc = `RECEBIMENTO FIADO — Comanda #${item.id} (${comanda?.name || ''})`.trim();
if (r.ok) { const payload = {
showToast('Pagamento realizado com sucesso!', 'success'); value: item.total,
type_pay: payTypeId,
client: parseInt(cliente.id),
description: desc,
status: 'CLOSED'
};
const r = await window.electronAPI.post(`/comandas/${item.id}/pagar`, payload);
if (!r.ok) erros++;
}
if (erros === 0) {
showToast('Todos os pagamentos foram processados!', 'success');
closeModal(); closeModal();
loadClientes(); // Recarrega a lista para atualizar os débitos loadClientes();
} else { } else {
showToast(r.error || 'Erro ao processar pagamento.', 'error'); showToast(`Concluído com ${erros} erro(s). Verifique os recibos.`, 'warning');
btn.disabled = false; loadClientes();
btn.textContent = '💳 Pagar Selecionados';
} }
} }
}); });

View File

@@ -1,3 +1,123 @@
function imprimirComanda(comanda, pagamentosComanda, totalComanda) {
const itens = comanda.items || [];
const totalPago = (pagamentosComanda || []).reduce((acc, p) => acc + parseFloat(p.value || 0), 0);
const valorRestante = totalComanda - totalPago;
const dataAtual = new Date().toLocaleString('pt-BR');
const nomeEstabelecimento = 'RRBEC - Bar & Restaurante';
const htmlImpressao = `
<div class="print-comanda">
<div class="print-header">
<div class="print-title">${nomeEstabelecimento}</div>
<div class="print-info">COMANDA #${comanda.id}</div>
<div class="print-info">Data: ${dataAtual}</div>
${comanda.name ? `<div class="print-info">Cliente: ${comanda.name}</div>` : ''}
${comanda.mesa_name ? `<div class="print-info">Mesa: ${comanda.mesa_name}</div>` : ''}
</div>
<div class="print-items">
<div style="font-weight:bold;border-bottom:1px solid #000;padding-bottom:4px;margin-bottom:4px">
<span style="float:left">PRODUTO</span>
<span style="float:right">VALOR</span>
</div>
<div style="clear:both"></div>
${itens.map(it => {
const price = _productsMap[String(it.product)] || 0;
const nome = _productsNames[String(it.product)] || it.product_name || `Produto #${it.product}`;
return `
<div class="print-item">
<span>${nome}</span>
<span>R$ ${price.toFixed(2)}</span>
</div>
`;
}).join('')}
</div>
<div class="print-totals">
<div style="display:flex;justify-content:space-between;padding:4px 0">
<span>TOTAL:</span>
<span>R$ ${totalComanda.toFixed(2)}</span>
</div>
${totalPago > 0 ? `
<div style="display:flex;justify-content:space-between;padding:4px 0">
<span>PAGO:</span>
<span>R$ ${totalPago.toFixed(2)}</span>
</div>
<div style="display:flex;justify-content:space-between;padding:4px 0;font-weight:bold">
<span>RESTANTE:</span>
<span>R$ ${valorRestante.toFixed(2)}</span>
</div>
` : ''}
</div>
${(pagamentosComanda || []).length > 0 ? `
<div style="margin-top:10px;border-top:1px dashed #000;padding-top:8px">
<div style="font-weight:bold;margin-bottom:4px">PAGAMENTOS:</div>
${pagamentosComanda.map(p => {
const tipoNome = _paymentTypes.find(t => String(t.id) === String(p.type_pay))?.name ||
_paymentTypes.find(t => String(t.id) === String(p.type_pay))?.nome || '';
return `
<div style="display:flex;justify-content:space-between;padding:2px 0">
<span>${tipoNome}</span>
<span>R$ ${parseFloat(p.value || 0).toFixed(2)}</span>
</div>
`;
}).join('')}
</div>
` : ''}
<div class="print-footer">
<div>------------------------</div>
<div>Obrigado pela preferência!</div>
<div>Volte sempre!</div>
</div>
</div>
`;
const htmlCompleto = `
<!DOCTYPE html>
<html>
<head>
<meta charset="UTF-8">
<title>Comanda #${comanda.id}</title>
<style>
* { box-sizing: border-box; margin: 0; padding: 0; }
body { font-family: 'Courier New', monospace; font-size: 12px; padding: 10px; width: 80mm; }
.print-comanda { display: block; }
.print-comanda * { color: black !important; background: transparent !important; }
.print-header { text-align: center; border-bottom: 1px dashed #000; padding-bottom: 8px; margin-bottom: 10px; }
.print-title { font-size: 16px; font-weight: bold; margin-bottom: 4px; }
.print-info { font-size: 11px; margin: 2px 0; }
.print-items { padding-bottom: 8px; margin-bottom: 8px; }
.print-item { display: flex; justify-content: space-between; padding: 3px 0; }
.print-totals { font-weight: bold; }
.print-footer { text-align: center; margin-top: 15px; font-size: 10px; }
@media print {
@page { size: 80mm auto; margin: 0; }
body { width: 80mm; }
}
</style>
</head>
<body>
${htmlImpressao}
</body>
</html>
`;
window.electronAPI.printDirect(htmlCompleto).then(r => {
if (r.ok) {
showToast('Impressão enviada!', 'success');
} else {
showToast('Nenhuma impressora configurada. Abrindo diálogo...', 'warning');
const printWindow = window.open('', '', 'width=300,height=600');
printWindow.document.write(htmlCompleto);
printWindow.document.close();
printWindow.onload = () => printWindow.print();
}
});
}
export async function renderComandas(container) { export async function renderComandas(container) {
container.innerHTML = ` container.innerHTML = `
<div class="page-header"> <div class="page-header">
@@ -24,7 +144,7 @@ export async function renderComandas(container) {
</div>`; </div>`;
let mesas = []; let mesas = [];
const mesasRes = await window.electronAPI.get('/mesas/'); const mesasRes = await window.electronAPI.get('/mesas');
if (mesasRes.ok) mesas = mesasRes.data; if (mesasRes.ok) mesas = mesasRes.data;
await loadComandas(mesas); await loadComandas(mesas);
@@ -37,8 +157,10 @@ export async function renderComandas(container) {
let _comandasData = []; let _comandasData = [];
let _mesasRef = []; let _mesasRef = [];
let _productsMap = {}; // Cache de preços {id: price} let _productsMap = {}; // Cache de preços {id: price}
let _productsNames = {}; // Cache de nomes {id: name}
let _paymentTypes = []; let _paymentTypes = [];
let _clients = []; let _clients = [];
let _paymentsMap = {}; // Cache de pagamentos por comanda {comandaId: [pagamentos]}
async function loadComandas(mesas) { async function loadComandas(mesas) {
_mesasRef = mesas; _mesasRef = mesas;
@@ -46,12 +168,12 @@ async function loadComandas(mesas) {
if (!wrap) return; if (!wrap) return;
wrap.innerHTML = `<div class="loading-screen"><div class="spinner"></div></div>`; wrap.innerHTML = `<div class="loading-screen"><div class="spinner"></div></div>`;
// Carrega dados necessários em paralelo const [res, pRes, ptRes, cRes, pagsRes] = await Promise.all([
const [res, pRes, ptRes, cRes] = await Promise.all([ window.electronAPI.get('/comandas'),
window.electronAPI.get('/comandas/'), window.electronAPI.get('/products'),
window.electronAPI.get('/products/'), window.electronAPI.get('/payment-types'),
window.electronAPI.get('/payment-types/'), window.electronAPI.get('/clients'),
window.electronAPI.get('/clients/') window.electronAPI.get('/payments')
]); ]);
if (ptRes.ok) _paymentTypes = ptRes.data; if (ptRes.ok) _paymentTypes = ptRes.data;
@@ -59,15 +181,30 @@ async function loadComandas(mesas) {
if (pRes.ok) { if (pRes.ok) {
_productsMap = pRes.data.reduce((acc, p) => { _productsMap = pRes.data.reduce((acc, p) => {
acc[p.id] = parseFloat(p.price || 0); acc[String(p.id)] = parseFloat(p.price || 0);
return acc;
}, {});
_productsNames = pRes.data.reduce((acc, p) => {
acc[String(p.id)] = p.name || `Produto #${p.id}`;
return acc; return acc;
}, {}); }, {});
} }
if (!res.ok) { wrap.innerHTML = `<div class="table-empty">Erro ao carregar comandas.</div>`; return; } _paymentsMap = {};
_comandasData = res.data; if (pagsRes.ok) {
(pagsRes.data || []).forEach(p => {
if (p.comanda) {
if (!_paymentsMap[p.comanda]) _paymentsMap[p.comanda] = [];
_paymentsMap[p.comanda].push(p);
}
});
}
if (!res.ok) { wrap.innerHTML = `<div class="table-empty">Erro ao carregar comandas.</div>`; return; }
_comandasData = res.ok ? res.data : [];
_comandasData.reverse();
console.log('[PDV] Comandas carregadas do servidor:', _comandasData);
// Aplica o filtro padrão (Ativas) logo no carregamento
filtrarComandas(); filtrarComandas();
} }
@@ -88,7 +225,6 @@ function renderComandasTable(data) {
<th>Status</th> <th>Status</th>
<th>Total</th> <th>Total</th>
<th>Aberta em</th> <th>Aberta em</th>
<th>Itens</th>
<th>Ações</th> <th>Ações</th>
</tr></thead> </tr></thead>
<tbody> <tbody>
@@ -100,29 +236,33 @@ function renderComandasTable(data) {
}; };
const cfg = statusCfg[c.status] || { label: c.status, badge: 'badge-muted' }; const cfg = statusCfg[c.status] || { label: c.status, badge: 'badge-muted' };
const ativa = c.status === 'OPEN' || c.status === 'PAYING'; const ativa = c.status === 'OPEN' || c.status === 'PAYING';
const totalComanda = (c.items || []).reduce((acc, item) => acc + (_productsMap[item.product] || 0), 0); const totalComanda = (c.items || []).reduce((acc, item) => acc + (_productsMap[String(item.product)] || 0), 0);
return `<tr> const pagamentos = _paymentsMap[c.id] || [];
const totalPago = pagamentos.reduce((acc, p) => acc + parseFloat(p.value || 0), 0);
const valorRestante = totalComanda - totalPago;
const temPagamentos = pagamentos.length > 0;
return `<tr class="comanda-row" data-id="${c.id}" style="cursor:pointer">
<td><strong>#${c.id}</strong></td> <td><strong>#${c.id}</strong></td>
<td>${c.name || ''}</td> <td>${c.name || ''}</td>
<td>${c.mesa_name || `Mesa ${c.mesa}` || ''}</td> <td>${c.mesa_name || `Mesa ${c.mesa}` || ''}</td>
<td><span class="badge ${cfg.badge}">${cfg.label}</span></td> <td><span class="badge ${cfg.badge}">${cfg.label}</span></td>
<td><strong style="color:var(--success)">R$ ${totalComanda.toFixed(2)}</strong></td> <td>
${temPagamentos ? `<div style="display:flex;flex-direction:column;gap:2px">
<span style="text-decoration:line-through;color:var(--text-muted);font-size:0.85rem">R$ ${totalComanda.toFixed(2)}</span>
<strong style="color:${valorRestante <= 0 ? 'var(--success)' : 'var(--warning)'}">R$ ${Math.max(0, valorRestante).toFixed(2)}</strong>
${temPagamentos ? `<span style="font-size:0.7rem;color:var(--text-muted)">Pago: R$ ${totalPago.toFixed(2)}</span>` : ''}
</div>` : `<strong style="color:var(--success)">R$ ${totalComanda.toFixed(2)}</strong>`}
</td>
<td>${formatDate(c.dt_open)}</td> <td>${formatDate(c.dt_open)}</td>
<td>
<span class="badge badge-info" style="cursor:pointer" data-id="${c.id}" title="Ver itens">
${(c.items || []).length} ${(c.items || []).length === 1 ? 'item' : 'itens'}
</span>
</td>
<td> <td>
<div style="display:flex;gap:6px"> <div style="display:flex;gap:6px">
<button class="btn btn-secondary btn-sm btn-itens" data-id="${c.id}" title="Itens">🛒</button> <button class="btn btn-secondary btn-sm btn-editar" data-id="${c.id}" title="Editar"></button>
${ativa ? `<button class="btn btn-success btn-sm btn-receber" data-id="${c.id}" title="Receber">💰</button>` : ''} ${ativa ? `<button class="btn btn-success btn-sm btn-receber" data-id="${c.id}" title="Receber">💰</button>` : ''}
${ativa && c.status === 'OPEN' ? `<button class="btn btn-warning btn-sm btn-pagar" data-id="${c.id}" title="Avisar Pagamento">⏳</button>` : ''} ${ativa && c.status === 'OPEN' ? `<button class="btn btn-warning btn-sm btn-pagar" data-id="${c.id}" title="Avisar Pagamento">⏳</button>` : ''}
${ativa && c.status === 'PAYING' ? `<button class="btn btn-warning btn-sm btn-reopen" data-id="${c.id}" title="Reabrir Comanda">Reabrir</button>` : ''} ${ativa && c.status === 'PAYING' ? `<button class="btn btn-warning btn-sm btn-reopen" data-id="${c.id}" title="Reabrir Comanda">Reabrir</button>` : ''}
</div>
</div>
</td> </td>
</tr>`; </tr>`;
}).join('')} }).join('')}
@@ -131,119 +271,177 @@ function renderComandasTable(data) {
${data.length > 100 ? `<div style="padding:10px; text-align:center; color:var(--text-muted); font-size:0.8rem;">Exibindo apenas as últimas 100 de ${data.length} comandas.</div>` : ''} ${data.length > 100 ? `<div style="padding:10px; text-align:center; color:var(--text-muted); font-size:0.8rem;">Exibindo apenas as últimas 100 de ${data.length} comandas.</div>` : ''}
`; `;
// Listener para linha toda
wrap.querySelectorAll('.comanda-row').forEach(row => {
row.addEventListener('click', () => {
const comanda = _comandasData.find(c => c.id === parseInt(row.dataset.id));
if (comanda) abrirItensComanda(comanda);
});
});
// Listener para Receber // Listener para Receber
wrap.querySelectorAll('.btn-receber').forEach(btn => { wrap.querySelectorAll('.btn-receber').forEach(btn => {
btn.addEventListener('click', () => { btn.addEventListener('click', (e) => {
e.stopPropagation();
const comanda = _comandasData.find(c => c.id === parseInt(btn.dataset.id)); const comanda = _comandasData.find(c => c.id === parseInt(btn.dataset.id));
if (comanda) abrirModalReceber(comanda); if (comanda) abrirModalPagamento(comanda);
});
});
// Listener para Editar
wrap.querySelectorAll('.btn-editar').forEach(btn => {
btn.addEventListener('click', (e) => {
e.stopPropagation();
const comanda = _comandasData.find(c => c.id === parseInt(btn.dataset.id));
if (comanda) abrirModalNovaComanda(_mesasRef, comanda);
}); });
}); });
// Listener para botão "Pagar" (muda p/ PAYING) // Listener para botão "Pagar" (muda p/ PAYING)
wrap.querySelectorAll('.btn-pagar').forEach(btn => { wrap.querySelectorAll('.btn-pagar').forEach(btn => {
btn.addEventListener('click', async () => { btn.addEventListener('click', async (e) => {
const r = await window.electronAPI.patch(`/comandas/${btn.dataset.id}/`, { status: 'PAYING' }); e.stopPropagation();
const r = await window.electronAPI.patch(`/comandas/${btn.dataset.id}`, { status: 'PAYING' });
if (r.ok) { showToast('Comanda em fase de pagamento!', 'info'); loadComandas(_mesasRef); } if (r.ok) { showToast('Comanda em fase de pagamento!', 'info'); loadComandas(_mesasRef); }
else showToast(r.error, 'error'); else showToast(r.error, 'error');
}); });
}); });
// Listener para botão "Reabrir" (muda p/ OPEN) // Listener para botão "Reabrir" (muda p/ OPEN)
wrap.querySelectorAll('.btn-reopen').forEach(btn => { wrap.querySelectorAll('.btn-reopen').forEach(btn => {
btn.addEventListener('click', async () => { btn.addEventListener('click', async (e) => {
const r = await window.electronAPI.patch(`/comandas/${btn.dataset.id}/`, { status: 'OPEN' }); e.stopPropagation();
const r = await window.electronAPI.patch(`/comandas/${btn.dataset.id}`, { status: 'OPEN' });
if (r.ok) { showToast('Comanda reaberta!', 'info'); loadComandas(_mesasRef); } if (r.ok) { showToast('Comanda reaberta!', 'info'); loadComandas(_mesasRef); }
else showToast(r.error, 'error'); else showToast(r.error, 'error');
}); });
}); });
}
async function abrirModalPagamento(comanda, onPaymentComplete) {
try {
if (!comanda.items) comanda.items = [];
const totalBruto = comanda.items.reduce((acc, it) => acc + (_productsMap[String(it.product)] || 0), 0);
// Listener para ver itens da comanda const pagamentosAtuais = _paymentsMap[comanda.id] || [];
wrap.querySelectorAll('.btn-itens').forEach(btn => { const totalPago = pagamentosAtuais.reduce((acc, p) => acc + parseFloat(p.value || 0), 0);
btn.addEventListener('click', () => { const valorRestante = Math.max(0, totalBruto - totalPago);
const comanda = _comandasData.find(c => c.id === parseInt(btn.dataset.id));
if (comanda) abrirItensComanda(comanda); openModal({
title: `💰 Receber Pagamento - Comanda #${comanda.id}`,
body: `
<div class="form-grid">
<div class="form-group">
<label>Valor Total (R$)</label>
<input type="number" id="pay-value" class="form-control" value="${valorRestante.toFixed(2)}" step="0.01" />
${totalPago > 0 ? `<small style="color:var(--text-muted)">Valor restante (já pagos: R$ ${totalPago.toFixed(2)})</small>` : ''}
</div>
<div class="form-group">
<label>Forma de Pagamento</label>
<select id="pay-type" class="form-control">
${_paymentTypes.map(pt => `<option value="${pt.id}">${pt.name || pt.nome}</option>`).join('')}
</select>
</div>
<div class="form-group">
<label>Cliente (Opcional)</label>
<select id="pay-client" class="form-control">
<option value="">Consumidor Final</option>
${_clients.filter(c => c.active !== false).map(cl => `
<option value="${cl.id}" ${String(comanda.client) === String(cl.id) ? 'selected' : ''}>
${cl.name}
</option>
`).join('')}
</select>
</div>
<div class="form-group" style="grid-column: span 2">
<label>Descrição / Observações</label>
<input type="text" id="pay-desc" class="form-control" placeholder="Ex: Pagamento total..." value="Pagamento total" />
</div>
</div>`,
footer: `
<button class="btn btn-secondary btn-md" onclick="closeModal()">Cancelar</button>
<button class="btn btn-success btn-md" id="btn-confirmar-pagamento">${valorRestante <= 0 ? 'Quitar Dívida' : 'Confirmar Recebimento'}</button>`
}); });
});
// Badge de itens também abre o modal const btnConfirmar = document.getElementById('btn-confirmar-pagamento');
wrap.querySelectorAll('.badge[data-id]').forEach(badge => {
badge.addEventListener('click', () => { document.getElementById('pay-value').addEventListener('input', () => {
const comanda = _comandasData.find(c => c.id === parseInt(badge.dataset.id)); const valorInformado = parseFloat(document.getElementById('pay-value').value) || 0;
if (comanda) abrirItensComanda(comanda); if (valorInformado < valorRestante) {
btnConfirmar.textContent = 'Pagamento Parcial';
btnConfirmar.classList.remove('btn-success');
btnConfirmar.classList.add('btn-warning');
} else {
btnConfirmar.textContent = valorRestante <= 0 ? 'Quitar Dívida' : 'Confirmar Recebimento';
btnConfirmar.classList.remove('btn-warning');
btnConfirmar.classList.add('btn-success');
}
}); });
});
// Excluir comanda (Antigo Fechar) btnConfirmar.addEventListener('click', async () => {
wrap.querySelectorAll('.btn-excluir').forEach(btn => { const payTypeId = parseInt(document.getElementById('pay-type').value);
btn.addEventListener('click', async () => { const clientId = document.getElementById('pay-client').value || null;
if (confirm('Deseja realmente EXCLUIR/APAGAR esta comanda?')) { const totalOriginal = parseFloat(document.getElementById('pay-value').value);
const r = await window.electronAPI.post(`/comandas/${btn.dataset.id}/apagar/`, {});
if (r.ok) { const tipoPgto = document.getElementById('pay-type').options[document.getElementById('pay-type').selectedIndex].text;
showToast('Comanda excluída!', 'success'); const isVale = tipoPgto.toLowerCase().includes('vale');
loadComandas(_mesasRef); const isPagamentoParcial = totalOriginal < valorRestante;
if (isVale && !clientId) {
return showToast('Para pagamentos em Vale, selecione um cliente.', 'warning');
}
const numericClientId = clientId ? parseInt(clientId) : null;
const payload = {
value: isVale ? 0 : totalOriginal,
type_pay: payTypeId,
client: numericClientId,
description: isVale ? `Vale - R$ ${totalOriginal.toFixed(2)}` : (document.getElementById('pay-desc').value || ''),
status: isVale ? 'FIADO' : (isPagamentoParcial ? 'OPEN' : 'CLOSED')
};
if (!isVale && (isNaN(payload.value) || payload.value <= 0)) {
return showToast('Informe um valor válido.', 'error');
}
try {
btnConfirmar.disabled = true;
btnConfirmar.textContent = 'Processando...';
const rPay = await window.electronAPI.post(`/comandas/${comanda.id}/pagar`, payload);
if (!rPay.ok) throw new Error(rPay.error);
if (isVale) {
await new Promise(r => setTimeout(r, 300));
const rPatch = await window.electronAPI.patch(`/comandas/${comanda.id}`, {
status: 'FIADO',
client: numericClientId
});
if (!rPatch.ok) console.error('[PDV] Erro no patch FIADO:', rPatch.error);
}
if (isPagamentoParcial) {
showToast('Pagamento parcial registrado!', 'success');
} else if (valorRestante <= 0) {
showToast('Dívida quitada!', 'success');
} else { } else {
showToast(r.error, 'error'); showToast(isVale ? 'Venda registrada como FIADO!' : 'Pagamento realizado!', 'success');
}
closeModal();
loadComandas(_mesasRef);
if (onPaymentComplete) onPaymentComplete();
} catch (err) {
showToast(err.message, 'error');
if (btnConfirmar) {
btnConfirmar.disabled = false;
btnConfirmar.textContent = totalOriginal < valorRestante ? 'Pagamento Parcial' : 'Confirmar Recebimento';
} }
} }
}); });
}); } catch (err) {
} console.error('[PDV] Erro ao abrir modal de pagamento:', err);
showToast('Erro ao abrir tela de pagamento.', 'error');
function abrirModalReceber(comanda) { }
const total = (comanda.items || []).reduce((acc, it) => acc + (_productsMap[it.product] || 0), 0);
openModal({
title: `💰 Receber Pago - Comanda #${comanda.id}`,
body: `
<div class="form-grid">
<div class="form-group">
<label>Valor Total (R$)</label>
<input type="number" id="pay-value" class="form-control" value="${total.toFixed(2)}" step="0.01" />
</div>
<div class="form-group">
<label>Forma de Pagamento</label>
<select id="pay-type" class="form-control">
${_paymentTypes.map(pt => `<option value="${pt.id}">${pt.name || pt.nome}</option>`).join('')}
</select>
</div>
<div class="form-group">
<label>Cliente (Opcional)</label>
<select id="pay-client" class="form-control">
<option value="">Cliente não identificado</option>
${_clients.map(cl => `<option value="${cl.id}" ${comanda.client === cl.id ? 'selected' : ''}>${cl.name || cl.nome}</option>`).join('')}
</select>
</div>
<div class="form-group" style="grid-column: span 2">
<label>Descrição / Observações</label>
<input type="text" id="pay-desc" class="form-control" placeholder="Ex: Pagamento total..." value="Pagamento total" />
</div>
</div>`,
footer: `
<button class="btn btn-secondary btn-md" onclick="closeModal()">Cancelar</button>
<button class="btn btn-success btn-md" id="btn-confirmar-pagamento">Confirmar Recebimento</button>`
});
document.getElementById('btn-confirmar-pagamento').addEventListener('click', async () => {
const payload = {
value: parseFloat(document.getElementById('pay-value').value),
type_pay: parseInt(document.getElementById('pay-type').value),
client: document.getElementById('pay-client').value || null,
description: document.getElementById('pay-desc').value.trim()
};
if (isNaN(payload.value) || payload.value <= 0) {
return showToast('Informe um valor válido.', 'error');
}
const r = await window.electronAPI.post(`/comandas/${comanda.id}/pagar/`, payload);
if (r.ok) {
showToast('Pagamento processado e comanda encerrada!', 'success');
closeModal();
loadComandas(_mesasRef);
} else {
showToast(r.error, 'error');
}
});
} }
function filtrarComandas() { function filtrarComandas() {
@@ -263,13 +461,19 @@ function filtrarComandas() {
// ─── Modal de Itens (Novo Layout PDV Split) ─────────────────────────────────── // ─── Modal de Itens (Novo Layout PDV Split) ───────────────────────────────────
async function abrirItensComanda(comandaIdOrObj) { async function abrirItensComanda(comandaIdOrObj) {
let comanda = typeof comandaIdOrObj === 'object' ? comandaIdOrObj : _comandasData.find(c => c.id === comandaIdOrObj); let comanda = typeof comandaIdOrObj === 'object' ? comandaIdOrObj : _comandasData.find(c => String(c.id) === String(comandaIdOrObj));
if (!comanda) return showToast('Comanda não encontrada.', 'error');
console.log(`[PDV] Abrindo comanda #${comanda.id}:`, comanda);
console.log(`[PDV] Itens da comanda #${comanda.id}:`, comanda.items || []);
const ativa = comanda.status === 'OPEN' || comanda.status === 'PAYING'; const ativa = comanda.status === 'OPEN' || comanda.status === 'PAYING';
const podeAdd = comanda.status === 'OPEN'; // Só permite add se ainda não estiver pagando? const podeAdd = comanda.status === 'OPEN';
// Carrega produtos (ativos) // Carrega produtos (ativos)
const pRes = await window.electronAPI.get('/products/'); const pRes = await window.electronAPI.get('/products');
let todosProdutos = pRes.ok ? pRes.data.filter(p => p.active) : []; let todosProdutos = pRes.ok ? pRes.data.filter(p => p.active) : [];
openModal({ openModal({
@@ -292,16 +496,32 @@ async function abrirItensComanda(comandaIdOrObj) {
</div> </div>
</div> </div>
</div>`, </div>`,
footer: `<button class="btn btn-secondary btn-md" onclick="closeModal()">Sair do PDV</button>`, footer: `
<button class="btn btn-secondary btn-md" id="btn-pdv-imprimir">🖨️ Imprimir</button>
<button class="btn btn-secondary btn-md" onclick="closeModal()">Sair do PDV</button>
`,
}); });
// Funções internas de renderização // Carrega pagamentos específicos desta comanda
let pagamentosComanda = _paymentsMap[comanda.id] || [];
if (!pagamentosComanda.length) {
const pagsRes = await window.electronAPI.get('/payments');
if (pagsRes.ok) {
pagamentosComanda = (pagsRes.data || []).filter(p => String(p.comanda) === String(comanda.id));
}
}
// Calcula totais fora do renderLeft para uso na impressão
const totalComanda = (comanda.items || []).reduce((acc, it) => acc + (_productsMap[String(it.product)] || 0), 0);
const renderLeft = () => { const renderLeft = () => {
const container = document.getElementById('pdv-items-list'); const container = document.getElementById('pdv-items-list');
if (!container) return; if (!container) return;
const itens = comanda.items || []; const itens = comanda.items || [];
const totalComanda = itens.reduce((acc, it) => acc + (_productsMap[it.product] || 0), 0); const totalPago = pagamentosComanda.reduce((acc, p) => acc + parseFloat(p.value || 0), 0);
const valorRestante = totalComanda - totalPago;
const temPagamentos = pagamentosComanda.length > 0;
container.innerHTML = ` container.innerHTML = `
<div style="flex:1; overflow-y: auto;"> <div style="flex:1; overflow-y: auto;">
@@ -315,17 +535,17 @@ async function abrirItensComanda(comandaIdOrObj) {
</tr></thead> </tr></thead>
<tbody> <tbody>
${itens.map(it => { ${itens.map(it => {
const prod = todosProdutos.find(p => p.id === it.product); const prod = todosProdutos.find(p => String(p.id) === String(it.product));
const isCuisine = prod?.cuisine || false; const isCuisine = prod?.cuisine || false;
const tooltip = it.obs ? `title="${it.obs}"` : ''; const tooltip = it.obs ? `title="${it.obs}"` : '';
return ` return `
<tr data-item-id="${it.id}"> <tr data-item-id="${it.id}">
<td style="padding:10px 0;border-bottom:1px solid var(--border)" ${tooltip}> <td style="padding:10px 0;border-bottom:1px solid var(--border)" ${tooltip}>
${it.product_name} ${prod?.name || it.product_name || `Produto #${it.product}`}
</td> </td>
<td style="padding:10px 0;text-align:right;border-bottom:1px solid var(--border)"> <td style="padding:10px 0;text-align:right;border-bottom:1px solid var(--border)">
R$ ${(_productsMap[it.product] || 0).toFixed(2)} R$ ${(_productsMap[String(it.product)] || 0).toFixed(2)}
</td> </td>
${podeAdd ? ` ${podeAdd ? `
<td style="padding:10px 0;text-align:center;border-bottom:1px solid var(--border)"> <td style="padding:10px 0;text-align:center;border-bottom:1px solid var(--border)">
@@ -344,16 +564,50 @@ async function abrirItensComanda(comandaIdOrObj) {
}).join('')} }).join('')}
</tbody> </tbody>
</table> </table>
` : `<p style="padding:40px 0;text-align:center;color:var(--text-muted)">Nenhum item adicionado.</p>`} ` : `<p style="padding:20px 0;text-align:center;color:var(--text-muted)">Nenhum item adicionado.</p>`}
${temPagamentos ? `
<h4 style="margin:20px 0 12px 0;color:var(--text-secondary);font-size:0.8rem;text-transform:uppercase;border-top:1px solid var(--border);padding-top:16px">Pagamentos Recebidos</h4>
<table style="width:100%;font-size:0.85rem">
<thead><tr style="color:var(--text-muted);border-bottom:1px solid var(--border)">
<th style="text-align:left;padding:6px 0">Forma</th>
<th style="text-align:right;padding:6px 0">Valor</th>
</tr></thead>
<tbody>
${pagamentosComanda.map(p => {
const tipoNome = _paymentTypes.find(t => String(t.id) === String(p.type_pay))?.name || _paymentTypes.find(t => String(t.id) === String(p.type_pay))?.nome || '';
return `
<tr>
<td style="padding:8px 0;border-bottom:1px solid var(--border);color:var(--success)">
💰 ${tipoNome}
</td>
<td style="padding:8px 0;text-align:right;border-bottom:1px solid var(--border);color:var(--success)">
R$ ${parseFloat(p.value || 0).toFixed(2)}
</td>
</tr>`;
}).join('')}
</tbody>
</table>
` : ''}
</div> </div>
<div style="padding-top:20px;margin-top:auto;border-top:2px solid var(--border)"> <div style="padding-top:20px;margin-top:auto;border-top:2px solid var(--border)">
<div style="display:flex;justify-content:space-between;margin-bottom:6px"> <div style="display:flex;justify-content:space-between;margin-bottom:6px">
<span style="color:var(--text-secondary)">Total de Itens:</span> <span style="color:var(--text-secondary)">Total de Itens:</span>
<strong>${itens.length}</strong> <strong>${itens.length}</strong>
</div> </div>
<div style="display:flex;justify-content:space-between;margin-bottom:4px">
<span style="color:var(--text-secondary)">Total da Conta:</span>
<strong style="color:${temPagamentos ? 'var(--text-muted);text-decoration:line-through' : 'var(--success)'}">R$ ${totalComanda.toFixed(2)}</strong>
</div>
${temPagamentos ? `
<div style="display:flex;justify-content:space-between;margin-bottom:6px">
<span style="color:var(--text-secondary)">Valor Pago:</span>
<strong style="color:var(--success)">R$ ${totalPago.toFixed(2)}</strong>
</div>
` : ''}
<div style="display:flex;justify-content:space-between;margin-bottom:10px"> <div style="display:flex;justify-content:space-between;margin-bottom:10px">
<span style="color:var(--text-secondary);font-size:1.1rem">Total da Conta:</span> <span style="color:var(--text-secondary);font-size:1.1rem">${temPagamentos ? 'Restante:' : 'Total:'}</span>
<strong style="color:var(--success);font-size:1.3rem">R$ ${totalComanda.toFixed(2)}</strong> <strong style="color:${valorRestante <= 0 ? 'var(--success)' : 'var(--warning)'};font-size:1.3rem">R$ ${Math.max(0, valorRestante).toFixed(2)}</strong>
</div> </div>
${ativa ? ` ${ativa ? `
<div style="display:grid; grid-template-columns: 1fr 1fr; gap: 10px; margin-top: 10px;"> <div style="display:grid; grid-template-columns: 1fr 1fr; gap: 10px; margin-top: 10px;">
@@ -368,7 +622,7 @@ async function abrirItensComanda(comandaIdOrObj) {
btn.addEventListener('click', async () => { btn.addEventListener('click', async () => {
const itemId = btn.dataset.id; const itemId = btn.dataset.id;
if (confirm('Deseja realmente excluir este item da comanda?')) { if (confirm('Deseja realmente excluir este item da comanda?')) {
const r = await window.electronAPI.delete(`/items-comanda/${itemId}/`); const r = await window.electronAPI.delete(`/items-comanda/${itemId}`);
if (r.ok) { if (r.ok) {
showToast('Item excluído!', 'success'); showToast('Item excluído!', 'success');
comanda.items = comanda.items.filter(it => it.id !== parseInt(itemId)); comanda.items = comanda.items.filter(it => it.id !== parseInt(itemId));
@@ -384,34 +638,104 @@ async function abrirItensComanda(comandaIdOrObj) {
// Listeners de edição de observação // Listeners de edição de observação
container.querySelectorAll('.btn-edit-obs').forEach(btn => { container.querySelectorAll('.btn-edit-obs').forEach(btn => {
btn.addEventListener('click', async () => { btn.addEventListener('click', async () => {
const itemId = parseInt(btn.dataset.id); const itemId = String(btn.dataset.id);
const item = comanda.items.find(it => it.id === itemId); const item = comanda.items.find(it => String(it.id) === itemId);
const prod = todosProdutos.find(p => p.id === item.product); const prod = todosProdutos.find(p => String(p.id) === String(item?.product));
if (item && prod) { if (item && prod) {
window.abrirModalObsCozinhaGlobal(prod.name, item.obs, async (novaObs) => { window.abrirModalObsCozinhaGlobal(prod.name, item.obs || '', async (novaObs) => {
if (novaObs === null) return; if (novaObs === null || novaObs === item.obs) return;
const r = await window.electronAPI.patch(`/items-comanda/${itemId}/`, { obs: novaObs });
if (r.ok) { // Busca os pedidos para encontrar o ID da order vinculada
showToast('Observação atualizada!', 'success'); const ordersRes = await window.electronAPI.get('/orders');
item.obs = novaObs; if (ordersRes.ok) {
renderLeft(); const linkedOrder = ordersRes.data.find(o => String(o.productComanda) === itemId);
loadComandas(_mesasRef);
} else { if (linkedOrder) {
showToast(r.error, 'error'); // PATCH na Order (Cozinha)
const r = await window.electronAPI.patch(`/orders/${linkedOrder.id}`, { obs: novaObs });
if (r.ok) {
item.obs = novaObs; // Atualiza local
showToast('Observação enviada para a cozinha!', 'success');
renderLeft();
} else {
showToast('Erro ao atualizar na cozinha.', 'error');
}
} else {
// Se não achou a order, tenta atualizar só o item principal como fallback
await window.electronAPI.patch(`/items-comanda/${itemId}`, { obs: novaObs });
item.obs = novaObs;
renderLeft();
showToast('Observação salva (item sem vínculo cozinha).', 'info');
}
} }
}); });
} }
}); });
}); });
// Listeners do rodapé do PDV (Receber, Excluir e Imprimir)
const bRec = document.getElementById('btn-pdv-receber');
if (bRec) {
bRec.onclick = () => {
closeModal();
setTimeout(() => abrirModalPagamento(comanda, () => {
setTimeout(() => abrirItensComanda(comanda), 300);
}), 300);
};
}
const bImp = document.getElementById('btn-pdv-imprimir');
if (bImp) {
bImp.onclick = () => imprimirComanda(comanda, pagamentosComanda, totalComanda);
}
const bExc = document.getElementById('btn-pdv-excluir');
if (bExc) {
bExc.onclick = async () => {
if (confirm('Deseja realmente EXCLUIR/APAGAR esta comanda?')) {
const r = await window.electronAPI.post(`/comandas/${comanda.id}/apagar`, {});
if (r.ok) {
showToast('Comanda excluída!', 'success');
closeModal();
loadComandas(_mesasRef);
} else {
showToast(r.error, 'error');
}
}
};
}
}; };
const processarResultadoAdd = (r) => { const processarResultadoAdd = async (r, prod, obs = '') => {
if (r.ok) { if (r.ok) {
if (!comanda.items) comanda.items = []; if (!comanda.items) comanda.items = [];
comanda.items.push(r.data);
const novoItem = r.data;
// Normaliza o campo product caso o servidor retorne product_id
if (!novoItem.product && novoItem.product_id) novoItem.product = novoItem.product_id;
comanda.items.push(novoItem);
renderLeft(); renderLeft();
loadComandas(_mesasRef); loadComandas(_mesasRef);
// Se o produto for de cozinha, cria a order na nova rota
if (prod && prod.cuisine) {
const orderPayload = {
productComanda: novoItem.id, // ID do item vinculado
id_product: prod.id,
id_comanda: comanda.id,
obs: obs || novoItem.obs || ''
};
console.log('[PDV] Criando pedido na cozinha:', orderPayload);
const orderRes = await window.electronAPI.post('/orders', orderPayload);
if (orderRes.ok) {
showToast('Pedido enviado para a cozinha!', 'success');
} else {
showToast('Item adicionado, mas falhou ao enviar para cozinha.', 'warning');
}
}
} else { } else {
showToast(r.error, 'error'); showToast(r.error, 'error');
} }
@@ -421,25 +745,34 @@ async function abrirItensComanda(comandaIdOrObj) {
container.querySelectorAll('.pdv-product-card').forEach(card => { container.querySelectorAll('.pdv-product-card').forEach(card => {
card.addEventListener('click', async () => { card.addEventListener('click', async () => {
if (!podeAdd) return showToast('Comanda em fechamento ou fechada.', 'warning'); if (!podeAdd) return showToast('Comanda em fechamento ou fechada.', 'warning');
const pId = parseInt(card.dataset.id); const pId = String(card.dataset.id);
const prod = todosProdutos.find(x => x.id === pId); const prod = todosProdutos.find(x => String(x.id) === pId);
card.style.transform = 'scale(0.95)'; card.style.transform = 'scale(0.95)';
setTimeout(() => card.style.transform = '', 100); setTimeout(() => card.style.transform = '', 100);
if (!prod) return showToast('Erro: Produto não encontrado.', 'error');
if (prod.cuisine) { if (prod.cuisine) {
window.abrirModalObsCozinhaGlobal(prod.name, '', async (obs) => { window.abrirModalObsCozinhaGlobal(prod.name, '', async (obs) => {
if (obs === null) return; if (obs === null) return;
const r = await window.electronAPI.post('/items-comanda/', { const loggedUser = await window.electronAPI.getUser();
const r = await window.electronAPI.post('/items-comanda', {
comanda: comanda.id, comanda: comanda.id,
product: pId, product: prod.id,
obs: obs obs: obs,
applicant: loggedUser?.username || 'Sistema'
}); });
processarResultadoAdd(r); processarResultadoAdd(r, prod, obs);
}); });
} else { } else {
const r = await window.electronAPI.post('/items-comanda/', { comanda: comanda.id, product: pId }); const loggedUser = await window.electronAPI.getUser();
processarResultadoAdd(r); const r = await window.electronAPI.post('/items-comanda', {
comanda: comanda.id,
product: prod.id,
applicant: loggedUser?.username || 'Sistema'
});
processarResultadoAdd(r, prod);
} }
}); });
}); });
@@ -451,7 +784,7 @@ async function abrirItensComanda(comandaIdOrObj) {
const filtrados = todosProdutos.filter(p => !filtro || p.name.toLowerCase().includes(filtro.toLowerCase())).slice(0, 20); const filtrados = todosProdutos.filter(p => !filtro || p.name.toLowerCase().includes(filtro.toLowerCase())).slice(0, 20);
// console.log('Produtos carregados no PDV:', todosProdutos); // console.log('Produtos carregados no PDV:', todosProdutos);
container.innerHTML = filtrados.map(p => { container.innerHTML = filtrados.map(p => {
const imgTarget = p.image ? `url('${p.image}')` : `url('https://wallpapers.com/images/featured/fundo-abstrato-escuro-27kvn4ewpldsngbu.jpg')`; const imgTarget = p.image ? `url('${p.image}')` : `url('https://wallpapers.com/images/featured/fundo-abstrato-escuro-27kvn4ewpldsngbu.jpg')`;
return ` return `
@@ -479,67 +812,87 @@ async function abrirItensComanda(comandaIdOrObj) {
} }
// ─── Modal Nova Comanda ─────────────────────────────────────────────────────── // ─── Modal Comanda (Nova / Editar) ──────────────────────────────────────────
function abrirModalNovaComanda(mesas) { function abrirModalNovaComanda(mesas, comandaExistente = null) {
const isEdit = !!comandaExistente;
openModal({ openModal({
title: 'Nova Comanda', title: isEdit ? `Editar Comanda #${comandaExistente.id}` : 'Nova Comanda',
body: ` body: `
<form id="form-nova-comanda" class="form-grid"> <form id="form-comanda" class="form-grid">
<div class="form-group"> <div class="form-group">
<label>Nome do Cliente / Identificação</label> <label>Nome do Cliente / Identificação</label>
<input type="text" id="comanda-nome" class="form-control" placeholder="Ex: João, Mesa do fundo..." autofocus required /> <input type="text" id="comanda-nome" class="form-control" placeholder="Ex: João, Mesa do fundo..." value="${isEdit ? (comandaExistente.name || '') : ''}" autofocus required />
</div> </div>
<div class="form-group"> <div class="form-group">
<label>Mesa</label> <label>Mesa</label>
<select id="comanda-mesa" class="form-control"> <select id="comanda-mesa" class="form-control">
${mesas.map(m => `<option value="${m.id}">${m.nome || m.name || `Mesa ${m.numero || m.number || m.id}`}</option>`).join('')} ${mesas.map(m => `<option value="${m.id}" ${isEdit && String(comandaExistente.mesa) === String(m.id) ? 'selected' : ''}>${m.nome || m.name || `Mesa ${m.numero || m.number || m.id}`}</option>`).join('')}
</select> </select>
</div> </div>
<button type="submit" style="display:none"></button> <!-- Invisível para permitir Enter --> <div class="form-group">
<label>Relacionar Cliente (Opcional)</label>
<select id="comanda-cliente" class="form-control">
<option value="">Nenhum Cliente</option>
${_clients.filter(cl => cl.active !== false).map(cl => `
<option value="${cl.id}" ${isEdit && String(comandaExistente.client) === String(cl.id) ? 'selected' : ''}>
${cl.name}
</option>
`).join('')}
</select>
</div>
<button type="submit" style="display:none"></button>
</form>`, </form>`,
footer: ` footer: `
<button class="btn btn-secondary btn-md" onclick="closeModal()">Cancelar</button> <button class="btn btn-secondary btn-md" onclick="closeModal()">Cancelar</button>
<button class="btn btn-primary btn-md" id="btn-criar-comanda">Criar e Adicionar Itens</button>`, <button class="btn btn-primary btn-md" id="btn-salvar-comanda">${isEdit ? 'Salvar Alterações' : 'Criar e Adicionar Itens'}</button>`,
}); });
// Foco manual caso autofocus falhe em algum navegador
setTimeout(() => document.getElementById('comanda-nome')?.focus(), 100); setTimeout(() => document.getElementById('comanda-nome')?.focus(), 100);
const submeter = async (e) => { const submeter = async (e) => {
if (e) e.preventDefault(); if (e) e.preventDefault();
const mesaId = parseInt(document.getElementById('comanda-mesa').value); const mesaId = parseInt(document.getElementById('comanda-mesa').value);
const clientId = document.getElementById('comanda-cliente').value ? parseInt(document.getElementById('comanda-cliente').value) : null;
const nome = document.getElementById('comanda-nome').value.trim(); const nome = document.getElementById('comanda-nome').value.trim();
if (!nome) return showToast('Informe o nome ou identificação.', 'error'); if (!nome) return showToast('Informe o nome ou identificação.', 'error');
const btn = document.getElementById('btn-salvar-comanda');
btn.disabled = true;
btn.textContent = isEdit ? 'Salvando...' : 'Criando...';
const loggedUser = await window.electronAPI.getUser(); const loggedUser = await window.electronAPI.getUser();
const payload = { const payload = {
name: nome,
mesa: mesaId, mesa: mesaId,
user: loggedUser?.id || 1, user: loggedUser?.id || 1,
status: 'OPEN' client: clientId,
name: nome,
}; };
const btn = document.getElementById('btn-criar-comanda'); let r;
btn.disabled = true; if (isEdit) {
btn.textContent = 'Criando...'; r = await window.electronAPI.patch(`/comandas/${comandaExistente.id}`, payload);
} else {
r = await window.electronAPI.post('/comandas', payload);
}
const r = await window.electronAPI.post('/comandas/', payload);
if (r.ok) { if (r.ok) {
showToast('Comanda criada!', 'success'); showToast(isEdit ? 'Comanda atualizada!' : 'Comanda criada!', 'success');
closeModal(); closeModal();
loadComandas(_mesasRef); loadComandas(_mesasRef);
// Abre direto a modal de itens da comanda recém criada if (!isEdit) {
setTimeout(() => abrirItensComanda(r.data), 300); setTimeout(() => abrirItensComanda(r.data), 300);
}
} else { } else {
showToast(r.error, 'error'); showToast(r.error, 'error');
btn.disabled = false; btn.disabled = false;
btn.textContent = 'Criar e Adicionar Itens'; btn.textContent = isEdit ? 'Salvar Alterações' : 'Criar e Adicionar Itens';
} }
}; };
document.getElementById('form-nova-comanda').onsubmit = submeter; document.getElementById('form-comanda').onsubmit = submeter;
document.getElementById('btn-criar-comanda').onclick = submeter; document.getElementById('btn-salvar-comanda').onclick = submeter;
} }
// ─── Helpers ────────────────────────────────────────────────────────────────── // ─── Helpers ──────────────────────────────────────────────────────────────────

View File

@@ -1,5 +1,6 @@
export async function renderConfig(container) { export async function renderConfig(container) {
const currentUrl = await window.electronAPI.getConfigUrl(); const currentUrl = await window.electronAPI.getConfigUrl();
const printSilent = await window.electronAPI.getPrintSilent();
container.innerHTML = ` container.innerHTML = `
<div class="page-header"> <div class="page-header">
@@ -26,6 +27,24 @@ export async function renderConfig(container) {
</div> </div>
</div> </div>
<div class="card" style="max-width: 600px; margin-top: 20px;">
<h3 style="margin-bottom: 20px; color: var(--text-primary);">🖨️ Impressão</h3>
<div class="form-group">
<label style="display: flex; align-items: center; gap: 10px; cursor: pointer;">
<input type="checkbox" id="config-print-silent" ${printSilent ? 'checked' : ''} style="width: 18px; height: 18px;" />
<span><strong>Impressão direta (sem diálogo)</strong></span>
</label>
<p style="font-size: 0.75rem; color: var(--text-muted); margin-top: 8px;">
Quando ativado, a impressão é enviada diretamente para a impressora padrão sem mostrar o diálogo do sistema.
</p>
</div>
<div style="margin-top: 15px; display: flex; gap: 10px;">
<button class="btn btn-primary btn-md" id="btn-save-print-config">💾 Salvar</button>
</div>
</div>
<div class="card" style="max-width: 600px; margin-top: 20px; border-left: 4px solid var(--primary);"> <div class="card" style="max-width: 600px; margin-top: 20px; border-left: 4px solid var(--primary);">
<h3 style="margin-bottom: 10px; color: var(--text-primary);"> Sobre o Sistema</h3> <h3 style="margin-bottom: 10px; color: var(--text-primary);"> Sobre o Sistema</h3>
<p style="color: var(--text-secondary); font-size: 0.85rem;"> <p style="color: var(--text-secondary); font-size: 0.85rem;">
@@ -36,7 +55,7 @@ export async function renderConfig(container) {
</div> </div>
`; `;
// Salvar // Salvar config de API
document.getElementById('btn-save-config').addEventListener('click', async () => { document.getElementById('btn-save-config').addEventListener('click', async () => {
const newUrl = document.getElementById('config-api-url').value.trim(); const newUrl = document.getElementById('config-api-url').value.trim();
if (!newUrl.startsWith('http')) { if (!newUrl.startsWith('http')) {
@@ -51,11 +70,22 @@ export async function renderConfig(container) {
} }
}); });
// Restaurar // Restaurar URL
document.getElementById('btn-reset-url').addEventListener('click', async () => { document.getElementById('btn-reset-url').addEventListener('click', async () => {
const defaultUrl = 'http://localhost:8000/api/v1'; const defaultUrl = 'http://localhost:8000/api/v1';
document.getElementById('config-api-url').value = defaultUrl; document.getElementById('config-api-url').value = defaultUrl;
await window.electronAPI.setConfigUrl(defaultUrl); await window.electronAPI.setConfigUrl(defaultUrl);
showToast('URL restaurada para o padrão localhost.', 'info'); showToast('URL restaurada para o padrão localhost.', 'info');
}); });
// Salvar config de impressão
document.getElementById('btn-save-print-config').addEventListener('click', async () => {
const silent = document.getElementById('config-print-silent').checked;
const r = await window.electronAPI.setPrintSilent(silent);
if (r.ok) {
showToast(silent ? 'Impressão direta ativada!' : 'Impressão direta desativada.', 'success');
} else {
showToast('Erro ao salvar configuração.', 'error');
}
});
} }

View File

@@ -28,8 +28,8 @@ async function loadMesas() {
// Carrega mesas e comandas em paralelo para determinar ocupação // Carrega mesas e comandas em paralelo para determinar ocupação
const [mesasRes, comandasRes] = await Promise.all([ const [mesasRes, comandasRes] = await Promise.all([
window.electronAPI.get('/mesas/'), window.electronAPI.get('/mesas'),
window.electronAPI.get('/comandas/'), window.electronAPI.get('/comandas'),
]); ]);
if (!mesasRes.ok) { if (!mesasRes.ok) {
@@ -103,7 +103,7 @@ function abrirDetalheMesa(mesa, ocupada) {
}); });
document.getElementById('btn-del-mesa').addEventListener('click', async () => { document.getElementById('btn-del-mesa').addEventListener('click', async () => {
const r = await window.electronAPI.delete(`/mesas/${mesa.id}/`); const r = await window.electronAPI.delete(`/mesas/${mesa.id}`);
if (r.ok) { showToast('Mesa excluída!', 'success'); closeModal(); loadMesas(); } if (r.ok) { showToast('Mesa excluída!', 'success'); closeModal(); loadMesas(); }
else showToast(r.error, 'error'); else showToast(r.error, 'error');
}); });
@@ -145,8 +145,8 @@ function abrirModalMesa(mesa = null) {
if (!data.name) return showToast('Informe o nome da mesa.', 'error'); if (!data.name) return showToast('Informe o nome da mesa.', 'error');
const r = isEdit const r = isEdit
? await window.electronAPI.put(`/mesas/${mesa.id}/`, data) ? await window.electronAPI.put(`/mesas/${mesa.id}`, data)
: await window.electronAPI.post('/mesas/', data); : await window.electronAPI.post('/mesas', data);
if (r.ok) { showToast(isEdit ? 'Mesa atualizada!' : 'Mesa criada!', 'success'); closeModal(); loadMesas(); } if (r.ok) { showToast(isEdit ? 'Mesa atualizada!' : 'Mesa criada!', 'success'); closeModal(); loadMesas(); }
else showToast(r.error, 'error'); else showToast(r.error, 'error');

View File

@@ -1,12 +1,11 @@
export async function renderPagamentos(container) { export async function renderPagamentos(container) {
// Carrega tipos de pagamento para o formulário de novo registro // Carrega tipos de pagamento para o formulário de novo registro
let tiposPag = [], comandas = [];
const [tRes, cRes] = await Promise.all([ const [tRes, cRes] = await Promise.all([
window.electronAPI.get('/payment-types/'), window.electronAPI.get('/payment-types'),
window.electronAPI.get('/comandas/'), window.electronAPI.get('/comandas'),
]); ]);
if (tRes.ok) tiposPag = tRes.data; const tiposPag = tRes.ok ? tRes.data : [];
if (cRes.ok) comandas = cRes.data; const comandas = cRes.ok ? cRes.data : [];
container.innerHTML = ` container.innerHTML = `
<div class="page-header"> <div class="page-header">
@@ -42,13 +41,22 @@ async function loadPagamentos(tiposPag, comandas) {
const wrap = document.getElementById('pagamentos-table'); const wrap = document.getElementById('pagamentos-table');
if (!wrap) return; if (!wrap) return;
wrap.innerHTML = `<div class="loading-screen"><div class="spinner"></div></div>`; wrap.innerHTML = `<div class="loading-screen"><div class="spinner"></div></div>`;
const res = await window.electronAPI.get('/payments/'); const res = await window.electronAPI.get('/payments');
if (!res.ok) { wrap.innerHTML = `<div class="table-empty">Erro ao carregar pagamentos.</div>`; return; } if (!res.ok) { wrap.innerHTML = `<div class="table-empty">Erro ao carregar pagamentos.</div>`; return; }
_pagsData = res.data;
renderPagsTable(_pagsData); // Ordena decrescente (mais novos primeiro)
_pagsData = (res.data || []).sort((a, b) => b.id - a.id);
// Cria mapa de comandas para consulta rápida
const cmdMap = (comandas || []).reduce((acc, c) => {
acc[String(c.id)] = `${c.name || 'Sem nome'} (${c.mesa_name || `Mesa ${c.mesa}`})`;
return acc;
}, {});
renderPagsTable(_pagsData, cmdMap);
} }
function renderPagsTable(data) { function renderPagsTable(data, cmdMap = {}) {
const wrap = document.getElementById('pagamentos-table'); const wrap = document.getElementById('pagamentos-table');
if (!wrap) return; if (!wrap) return;
@@ -73,25 +81,29 @@ function renderPagsTable(data) {
<th>Ações</th> <th>Ações</th>
</tr></thead> </tr></thead>
<tbody> <tbody>
${data.map(p => `<tr> ${data.map(p => {
<td style="color:var(--text-muted)">#${p.id}</td> const cDesc = cmdMap[String(p.comanda)] || p.comanda_name || '';
<td>${p.client_name || ''}</td> return `
<td> <tr>
${p.comanda ? `<span style="font-size:0.8rem"> <td style="color:var(--text-muted)">#${p.id}</td>
<span style="color:var(--text-muted)">#${p.comanda}</span> <td>${p.client_name || ''}</td>
${p.comanda_name ? `<span style="color:var(--text-secondary)"> ${p.comanda_name}</span>` : ''} <td>
</span>` : ''} ${p.comanda ? `<span style="font-size:0.8rem">
</td> <span style="color:var(--text-muted)">#${p.comanda}</span>
<td><span class="badge badge-info">${p.type_pay_name || ''}</span></td> <span style="color:var(--text-secondary)"> ${cDesc}</span>
<td><strong style="color:var(--success)">R$ ${parseFloat(p.value || 0).toFixed(2)}</strong></td> </span>` : ''}
<td style="max-width:180px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;color:var(--text-secondary);font-size:0.82rem"> </td>
${p.description || ''} <td><span class="badge badge-info">${p.type_pay_name || ''}</span></td>
</td> <td><strong style="color:var(--success)">R$ ${parseFloat(p.value || 0).toFixed(2)}</strong></td>
<td style="white-space:nowrap;font-size:0.82rem">${formatDate(p.datetime)}</td> <td style="max-width:180px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;color:var(--text-secondary);font-size:0.82rem">
<td> ${p.description || ''}
<button class="btn btn-danger btn-sm btn-del-pag" data-id="${p.id}">Excluir</button> </td>
</td> <td style="white-space:nowrap;font-size:0.82rem">${formatDate(p.datetime)}</td>
</tr>`).join('')} <td>
<button class="btn btn-danger btn-sm btn-del-pag" data-id="${p.id}">Excluir</button>
</td>
</tr>`;
}).join('')}
</tbody> </tbody>
</table> </table>
<div style="padding:14px 20px;border-top:1px solid var(--border);display:flex;justify-content:flex-end;align-items:center;gap:8px"> <div style="padding:14px 20px;border-top:1px solid var(--border);display:flex;justify-content:flex-end;align-items:center;gap:8px">
@@ -101,7 +113,7 @@ function renderPagsTable(data) {
wrap.querySelectorAll('.btn-del-pag').forEach(btn => wrap.querySelectorAll('.btn-del-pag').forEach(btn =>
btn.addEventListener('click', async () => { btn.addEventListener('click', async () => {
const r = await window.electronAPI.delete(`/payments/${btn.dataset.id}/`); const r = await window.electronAPI.delete(`/payments/${btn.dataset.id}`);
if (r.ok) { showToast('Pagamento excluído!', 'success'); loadPagamentos([], []); } if (r.ok) { showToast('Pagamento excluído!', 'success'); loadPagamentos([], []); }
else showToast(r.error, 'error'); else showToast(r.error, 'error');
}) })
@@ -172,7 +184,7 @@ function abrirModalPagamento(tiposPag, comandas) {
description: document.getElementById('pag-desc').value.trim(), description: document.getElementById('pag-desc').value.trim(),
}; };
const r = await window.electronAPI.post('/payments/', data); const r = await window.electronAPI.post('/payments', data);
if (r.ok) { showToast('Pagamento registrado!', 'success'); closeModal(); loadPagamentos(tiposPag, comandas); } if (r.ok) { showToast('Pagamento registrado!', 'success'); closeModal(); loadPagamentos(tiposPag, comandas); }
else showToast(r.error, 'error'); else showToast(r.error, 'error');
}); });

View File

@@ -2,13 +2,21 @@
// Cada "order" representa um item individual na fila, com pipeline de status por timestamps // Cada "order" representa um item individual na fila, com pipeline de status por timestamps
const STATUS_CONFIG = { const STATUS_CONFIG = {
'Em espera': { badge: 'badge-warning', icon: '⏳', next: 'preparing', nextLabel: '▶ Preparando' }, 'Na fila': { badge: 'badge-warning', icon: '⏳', next: 'preparing', nextLabel: '▶ Preparando' },
'Preparando': { badge: 'badge-info', icon: '🍳', next: 'finished', nextLabel: '✅ Pronto' }, 'Preparando': { badge: 'badge-info', icon: '🍳', next: 'finished', nextLabel: '✅ Pronto' },
'Pronto': { badge: 'badge-success', icon: '✅', next: 'delivered', nextLabel: '🚀 Entregue' }, 'Pronto': { badge: 'badge-success', icon: '✅', next: 'delivered', nextLabel: '🚀 Entregue' },
'Entregue': { badge: 'badge-muted', icon: '🚀', next: null, nextLabel: null }, 'Entregue': { badge: 'badge-muted', icon: '🚀', next: null, nextLabel: null },
'Cancelado': { badge: 'badge-danger', icon: '❌', next: null, nextLabel: null }, 'Cancelado': { badge: 'badge-danger', icon: '❌', next: null, nextLabel: null },
}; };
function getOrderStatus(o) {
if (o.canceled && o.canceled !== '0001-01-01T00:00:00Z') return 'Cancelado';
if (o.delivered && o.delivered !== '0001-01-01T00:00:00Z') return 'Entregue';
if (o.finished && o.finished !== '0001-01-01T00:00:00Z') return 'Pronto';
if (o.preparing && o.preparing !== '0001-01-01T00:00:00Z') return 'Preparando';
return 'Na fila';
}
export async function renderPedidos(container) { export async function renderPedidos(container) {
container.innerHTML = ` container.innerHTML = `
<div class="page-header"> <div class="page-header">
@@ -25,7 +33,7 @@ export async function renderPedidos(container) {
<input type="text" class="search-input" id="search-order" placeholder="🔍 Buscar produto, comanda ou mesa..." /> <input type="text" class="search-input" id="search-order" placeholder="🔍 Buscar produto, comanda ou mesa..." />
<select id="filter-status-order" class="form-control" style="width:160px"> <select id="filter-status-order" class="form-control" style="width:160px">
<option value="">Todos os status</option> <option value="">Todos os status</option>
<option value="Em espera">⏳ Em espera</option> <option value="Na fila">⏳ Na fila</option>
<option value="Preparando">🍳 Preparando</option> <option value="Preparando">🍳 Preparando</option>
<option value="Pronto">✅ Pronto</option> <option value="Pronto">✅ Pronto</option>
<option value="Entregue">🚀 Entregue</option> <option value="Entregue">🚀 Entregue</option>
@@ -43,19 +51,42 @@ export async function renderPedidos(container) {
} }
let _ordersData = []; let _ordersData = [];
let _productsMap = {};
let _comandasMap = {};
let _mesasMap = {};
async function loadOrders() { async function loadOrders() {
const wrap = document.getElementById('orders-table'); const wrap = document.getElementById('orders-table');
if (!wrap) return; if (!wrap) return;
wrap.innerHTML = `<div class="loading-screen"><div class="spinner"></div></div>`; wrap.innerHTML = `<div class="loading-screen"><div class="spinner"></div></div>`;
const res = await window.electronAPI.get('/orders/');
// Busca tudo em paralelo para resolver as referências (IDs)
const [res, pRes, cRes, mRes] = await Promise.all([
window.electronAPI.get('/orders'),
window.electronAPI.get('/products'),
window.electronAPI.get('/comandas'),
window.electronAPI.get('/mesas')
]);
if (!res.ok) { wrap.innerHTML = `<div class="table-empty">Erro ao carregar pedidos.</div>`; return; } if (!res.ok) { wrap.innerHTML = `<div class="table-empty">Erro ao carregar pedidos.</div>`; return; }
// Constrói mapas para consulta rápida (IDs como string para segurança)
if (pRes.ok) _productsMap = pRes.data.reduce((acc, p) => { acc[String(p.id)] = p.name; return acc; }, {});
if (mRes.ok) _mesasMap = mRes.data.reduce((acc, m) => { acc[String(m.id)] = m.name; return acc; }, {});
if (cRes.ok) _comandasMap = cRes.data.reduce((acc, c) => {
acc[String(c.id)] = {
name: c.name || '',
mesa: _mesasMap[String(c.mesa)] || `Mesa ${c.mesa}` || ''
};
return acc;
}, {});
_ordersData = res.data; _ordersData = res.data;
// Por padrão, filtra para mostrar só os não entregues/cancelados // Por padrão, filtra para mostrar só os não entregues/cancelados
const filtroInicial = document.getElementById('filter-status-order'); const filtroInicial = document.getElementById('filter-status-order');
if (filtroInicial && !filtroInicial.value) { if (filtroInicial && !filtroInicial.value) {
// Mantém filtro vazio mas é renderizado completo // Mantém o filtro se necessário
} }
renderOrdersTable(_ordersData); renderOrdersTable(_ordersData);
} }
@@ -79,13 +110,19 @@ function renderOrdersTable(data) {
</tr></thead> </tr></thead>
<tbody> <tbody>
${data.map(o => { ${data.map(o => {
const cfg = STATUS_CONFIG[o.status] || { badge: 'badge-muted', icon: '?', next: null }; const status = getOrderStatus(o);
const cfg = STATUS_CONFIG[status] || { badge: 'badge-muted', icon: '?', next: null };
// Resolve nomes com base nos IDs
const prodName = _productsMap[String(o.id_product)] || o.product_name || `ID #${o.id_product}`;
const comandaInfo = _comandasMap[String(o.id_comanda)] || { name: `Comanda #${o.id_comanda}`, mesa: '' };
return `<tr> return `<tr>
<td style="color:var(--text-muted)">#${o.id}</td> <td style="color:var(--text-muted)">#${o.id}</td>
<td><strong>${o.product_name || ''}</strong></td> <td><strong>${prodName}</strong></td>
<td style="font-size:0.82rem;color:var(--text-secondary)">${o.comanda_name || ''}</td> <td style="font-size:0.82rem;color:var(--text-secondary)">${comandaInfo.name}</td>
<td style="font-size:0.82rem">${o.mesa_name || ''}</td> <td style="font-size:0.82rem">${comandaInfo.mesa}</td>
<td><span class="badge ${cfg.badge}">${cfg.icon} ${o.status}</span></td> <td><span class="badge ${cfg.badge}">${cfg.icon} ${status}</span></td>
<td style="font-size:0.78rem;color:var(--text-muted);white-space:nowrap">${formatTime(o.queue)}</td> <td style="font-size:0.78rem;color:var(--text-muted);white-space:nowrap">${formatTime(o.queue)}</td>
<td style="font-size:0.8rem;color:var(--text-secondary);max-width:140px;"> <td style="font-size:0.8rem;color:var(--text-secondary);max-width:140px;">
<div style="display:flex;align-items:center;gap:4px"> <div style="display:flex;align-items:center;gap:4px">
@@ -107,9 +144,9 @@ function renderOrdersTable(data) {
</table> </table>
<div style="padding:12px 20px;border-top:1px solid var(--border);font-size:0.8rem;color:var(--text-muted)"> <div style="padding:12px 20px;border-top:1px solid var(--border);font-size:0.8rem;color:var(--text-muted)">
${data.length} ${data.length === 1 ? 'pedido' : 'pedidos'} exibidos ${data.length} ${data.length === 1 ? 'pedido' : 'pedidos'} exibidos
&nbsp;·&nbsp; ${data.filter(o => o.status === 'Em espera').length} em espera &nbsp;·&nbsp; ${data.filter(o => getOrderStatus(o) === 'Na fila').length} na fila
&nbsp;·&nbsp; 🍳 ${data.filter(o => o.status === 'Preparando').length} preparando &nbsp;·&nbsp; 🍳 ${data.filter(o => getOrderStatus(o) === 'Preparando').length} preparando
&nbsp;·&nbsp; ${data.filter(o => o.status === 'Pronto').length} prontos &nbsp;·&nbsp; ${data.filter(o => getOrderStatus(o) === 'Pronto').length} prontos
</div>`; </div>`;
// Avançar status para próxima etapa // Avançar status para próxima etapa
@@ -117,7 +154,7 @@ function renderOrdersTable(data) {
btn.addEventListener('click', async () => { btn.addEventListener('click', async () => {
const nowISO = new Date().toISOString(); const nowISO = new Date().toISOString();
const patch = { [btn.dataset.next]: nowISO }; const patch = { [btn.dataset.next]: nowISO };
const r = await window.electronAPI.patch(`/orders/${btn.dataset.id}/`, patch); const r = await window.electronAPI.patch(`/orders/${btn.dataset.id}`, patch);
if (r.ok) { showToast('Status atualizado!', 'success'); loadOrders(); } if (r.ok) { showToast('Status atualizado!', 'success'); loadOrders(); }
else showToast(r.error, 'error'); else showToast(r.error, 'error');
}) })
@@ -126,7 +163,7 @@ function renderOrdersTable(data) {
// Cancelar pedido // Cancelar pedido
wrap.querySelectorAll('.btn-cancela').forEach(btn => wrap.querySelectorAll('.btn-cancela').forEach(btn =>
btn.addEventListener('click', async () => { btn.addEventListener('click', async () => {
const r = await window.electronAPI.patch(`/orders/${btn.dataset.id}/`, { canceled: new Date().toISOString() }); const r = await window.electronAPI.patch(`/orders/${btn.dataset.id}`, { canceled: new Date().toISOString() });
if (r.ok) { showToast('Pedido cancelado.', 'info'); loadOrders(); } if (r.ok) { showToast('Pedido cancelado.', 'info'); loadOrders(); }
else showToast(r.error, 'error'); else showToast(r.error, 'error');
}) })
@@ -144,7 +181,7 @@ function renderOrdersTable(data) {
window.abrirModalObsCozinhaGlobal(productName, currentObs, async (novaObs) => { window.abrirModalObsCozinhaGlobal(productName, currentObs, async (novaObs) => {
if (novaObs === null || novaObs === currentObs) return; if (novaObs === null || novaObs === currentObs) return;
const r = await window.electronAPI.patch(`/orders/${orderId}/`, { obs: novaObs }); const r = await window.electronAPI.patch(`/orders/${orderId}`, { obs: novaObs });
if (r.ok) { if (r.ok) {
showToast('Observação atualizada!', 'success'); showToast('Observação atualizada!', 'success');
loadOrders(); loadOrders();
@@ -167,7 +204,7 @@ function filtrarOrders() {
(o.comanda_name || '').toLowerCase().includes(q) || (o.comanda_name || '').toLowerCase().includes(q) ||
(o.mesa_name || '').toLowerCase().includes(q) || (o.mesa_name || '').toLowerCase().includes(q) ||
(o.obs || '').toLowerCase().includes(q); (o.obs || '').toLowerCase().includes(q);
const matchStatus = !status || o.status === status; const matchStatus = !status || getOrderStatus(o) === status;
return matchQ && matchStatus; return matchQ && matchStatus;
}); });
renderOrdersTable(filtered); renderOrdersTable(filtered);

View File

@@ -1,7 +1,12 @@
export async function renderProdutos(container) { export async function renderProdutos(container) {
let categorias = []; let categorias = [];
const catRes = await window.electronAPI.get('/categories/'); let unidades = [];
const [catRes, unRes] = await Promise.all([
window.electronAPI.get('/categories'),
window.electronAPI.get('/unit-of-measurements')
]);
if (catRes.ok) categorias = catRes.data; if (catRes.ok) categorias = catRes.data;
if (unRes.ok) unidades = unRes.data;
container.innerHTML = ` container.innerHTML = `
<div class="page-header"> <div class="page-header">
@@ -10,7 +15,7 @@ export async function renderProdutos(container) {
<div class="page-subtitle">Cardápio e categorias</div> <div class="page-subtitle">Cardápio e categorias</div>
</div> </div>
<div class="page-actions"> <div class="page-actions">
<button class="btn btn-secondary btn-md" id="btn-nova-cat">+ Categoria</button> <button class="btn btn-secondary btn-md" id="btn-gerenciar-cat">📁 Categorias</button>
<button class="btn btn-primary btn-md" id="btn-novo-prod">+ Produto</button> <button class="btn btn-primary btn-md" id="btn-novo-prod">+ Produto</button>
</div> </div>
</div> </div>
@@ -30,28 +35,28 @@ export async function renderProdutos(container) {
<div id="produtos-table"></div> <div id="produtos-table"></div>
</div>`; </div>`;
await loadProdutos(categorias); await loadProdutos(categorias, unidades);
document.getElementById('btn-novo-prod').addEventListener('click', () => abrirModalProduto(null, categorias)); document.getElementById('btn-novo-prod').addEventListener('click', () => abrirModalProduto(null, categorias, unidades));
document.getElementById('btn-nova-cat').addEventListener('click', () => abrirModalCategoria()); document.getElementById('btn-gerenciar-cat').addEventListener('click', () => abrirModalGerenciarCategorias(categorias));
document.getElementById('search-produto').addEventListener('input', () => filtrarProdutos(categorias)); document.getElementById('search-produto').addEventListener('input', () => filtrarProdutos(categorias, unidades));
document.getElementById('filter-cat').addEventListener('change', () => filtrarProdutos(categorias)); document.getElementById('filter-cat').addEventListener('change', () => filtrarProdutos(categorias, unidades));
document.getElementById('filter-ativo').addEventListener('change', () => filtrarProdutos(categorias)); document.getElementById('filter-ativo').addEventListener('change', () => filtrarProdutos(categorias, unidades));
} }
let _produtosData = []; let _produtosData = [];
async function loadProdutos(categorias) { async function loadProdutos(categorias, unidades) {
const wrap = document.getElementById('produtos-table'); const wrap = document.getElementById('produtos-table');
if (!wrap) return; if (!wrap) return;
wrap.innerHTML = `<div class="loading-screen"><div class="spinner"></div></div>`; wrap.innerHTML = `<div class="loading-screen"><div class="spinner"></div></div>`;
const res = await window.electronAPI.get('/products/'); const res = await window.electronAPI.get('/products');
if (!res.ok) { wrap.innerHTML = `<div class="table-empty">Erro ao carregar produtos.</div>`; return; } if (!res.ok) { wrap.innerHTML = `<div class="table-empty">Erro ao carregar produtos.</div>`; return; }
_produtosData = res.data; _produtosData = res.data;
renderProdutosTable(_produtosData, categorias); renderProdutosTable(_produtosData, categorias, unidades);
} }
function renderProdutosTable(data, categorias) { function renderProdutosTable(data, categorias, unidades) {
const wrap = document.getElementById('produtos-table'); const wrap = document.getElementById('produtos-table');
if (!wrap) return; if (!wrap) return;
if (!data.length) { wrap.innerHTML = `<div class="table-empty">Nenhum produto encontrado.</div>`; return; } if (!data.length) { wrap.innerHTML = `<div class="table-empty">Nenhum produto encontrado.</div>`; return; }
@@ -87,14 +92,16 @@ function renderProdutosTable(data, categorias) {
</span> </span>
</td> </td>
<td> <td>
<span class="badge ${p.active ? 'badge-success' : 'badge-danger'}"> <button class="badge ${p.active ? 'badge-success' : 'badge-danger'} btn-status-prod"
data-id="${p.id}" data-active="${p.active}"
style="cursor:pointer; border:none; outline:none; transition: transform 0.1s active"
title="Clique para alterar status">
${p.active ? 'Ativo' : 'Inativo'} ${p.active ? 'Ativo' : 'Inativo'}
</span> </button>
</td> </td>
<td> <td>
<div style="display:flex;gap:6px"> <div style="display:flex;gap:6px">
<button class="btn btn-secondary btn-sm btn-edit-prod" data-id="${p.id}">Editar</button> <button class="btn btn-secondary btn-sm btn-edit-prod" data-id="${p.id}">Editar</button>
<button class="btn btn-danger btn-sm btn-del-prod" data-id="${p.id}">Excluir</button>
</div> </div>
</td> </td>
</tr>`).join('')} </tr>`).join('')}
@@ -103,21 +110,27 @@ function renderProdutosTable(data, categorias) {
wrap.querySelectorAll('.btn-edit-prod').forEach(btn => { wrap.querySelectorAll('.btn-edit-prod').forEach(btn => {
btn.addEventListener('click', () => { btn.addEventListener('click', () => {
const p = _produtosData.find(x => x.id === parseInt(btn.dataset.id)); const p = _produtosData.find(x => String(x.id) === String(btn.dataset.id));
if (p) abrirModalProduto(p, categorias); if (p) abrirModalProduto(p, categorias, unidades);
}); });
}); });
wrap.querySelectorAll('.btn-del-prod').forEach(btn => wrap.querySelectorAll('.btn-status-prod').forEach(btn => {
btn.addEventListener('click', async () => { btn.addEventListener('click', async () => {
const r = await window.electronAPI.delete(`/products/${btn.dataset.id}/`); const id = btn.dataset.id;
if (r.ok) { showToast('Produto excluído!', 'success'); loadProdutos(categorias); } const currentActive = btn.dataset.active === 'true';
else showToast(r.error, 'error'); const r = await window.electronAPI.patch(`/products/${id}`, { active: !currentActive });
}) if (r.ok) {
); showToast(`Produto ${!currentActive ? 'ativado' : 'inativado'}!`, 'success');
loadProdutos(categorias, unidades);
} else {
showToast(r.error, 'error');
}
});
});
} }
function filtrarProdutos(categorias) { function filtrarProdutos(categorias, unidades) {
const q = document.getElementById('search-produto')?.value.toLowerCase() || ''; const q = document.getElementById('search-produto')?.value.toLowerCase() || '';
const catId = parseInt(document.getElementById('filter-cat')?.value) || null; const catId = parseInt(document.getElementById('filter-cat')?.value) || null;
const ativo = document.getElementById('filter-ativo')?.value; const ativo = document.getElementById('filter-ativo')?.value;
@@ -128,10 +141,10 @@ function filtrarProdutos(categorias) {
const matchAtiv = !ativo || String(p.active) === ativo; const matchAtiv = !ativo || String(p.active) === ativo;
return matchQ && matchCat && matchAtiv; return matchQ && matchCat && matchAtiv;
}); });
renderProdutosTable(filtered, categorias); renderProdutosTable(filtered, categorias, unidades);
} }
function abrirModalProduto(produto, categorias) { function abrirModalProduto(produto, categorias, unidades) {
const isEdit = !!produto; const isEdit = !!produto;
openModal({ openModal({
title: isEdit ? `Editar: ${produto.name}` : 'Novo Produto', title: isEdit ? `Editar: ${produto.name}` : 'Novo Produto',
@@ -156,6 +169,13 @@ function abrirModalProduto(produto, categorias) {
${categorias.map(c => `<option value="${c.id}" ${produto?.category === c.id ? 'selected' : ''}>${c.nome || c.name}</option>`).join('')} ${categorias.map(c => `<option value="${c.id}" ${produto?.category === c.id ? 'selected' : ''}>${c.nome || c.name}</option>`).join('')}
</select> </select>
</div> </div>
<div class="form-group">
<label>Unidade de Medida</label>
<select id="prod-unit" class="form-control">
<option value=""> Selecione </option>
${unidades.map(u => `<option value="${u.id}" ${produto?.unit_of_measure === u.id ? 'selected' : ''}>${u.acronym || u.name}</option>`).join('')}
</select>
</div>
<div class="form-group"> <div class="form-group">
<label>Ativo</label> <label>Ativo</label>
<select id="prod-ativo" class="form-control"> <select id="prod-ativo" class="form-control">
@@ -175,52 +195,168 @@ function abrirModalProduto(produto, categorias) {
<input type="text" id="prod-desc" class="form-control" value="${produto?.description || ''}" placeholder="Descrição opcional" /> <input type="text" id="prod-desc" class="form-control" value="${produto?.description || ''}" placeholder="Descrição opcional" />
</div> </div>
<div class="form-group" style="grid-column:1/-1"> <div class="form-group" style="grid-column:1/-1">
<label>Imagem</label> <label>URL da Imagem</label>
<input type="file" id="prod-img" accept="image/*" class="form-control" /> <input type="text" id="prod-img" class="form-control" value="${produto?.image || ''}" placeholder="http://..." />
</div> </div>
</div>`, </div>`,
footer: ` footer: `
<button class="btn btn-secondary btn-md" onclick="closeModal()">Cancelar</button> <button class="btn btn-secondary btn-md" onclick="closeModal()">Cancelar</button>
<button class="btn btn-primary btn-md" id="btn-salvar-prod">${isEdit ? 'Salvar' : 'Criar'}</button>`, <button class="btn btn-primary btn-md" id="btn-salvar-prod">${isEdit ? 'Salvar Alterações' : 'Criar Produto'}</button>`,
}); });
document.getElementById('btn-salvar-prod').addEventListener('click', async () => { document.getElementById('btn-salvar-prod').addEventListener('click', async () => {
const catVal = parseInt(document.getElementById('prod-cat').value) || null; const btn = document.getElementById('btn-salvar-prod');
btn.disabled = true;
btn.textContent = 'Processando...';
const catVal = parseInt(document.getElementById('prod-cat').value);
const unitVal = parseInt(document.getElementById('prod-unit').value);
// Constrói o payload enviando apenas o que é necessário/preenchido
const data = { const data = {
name: document.getElementById('prod-nome').value, name: document.getElementById('prod-nome').value.trim(),
description: document.getElementById('prod-desc').value, description: document.getElementById('prod-desc').value.trim(),
//image: document.getElementById('prod-img').value,
price: parseFloat(document.getElementById('prod-preco').value) || 0, price: parseFloat(document.getElementById('prod-preco').value) || 0,
quantity: parseInt(document.getElementById('prod-qty').value) || 0, quantity: parseInt(document.getElementById('prod-qty').value) || 0,
category: catVal,
active: document.getElementById('prod-ativo').value === 'true', active: document.getElementById('prod-ativo').value === 'true',
cuisine: document.getElementById('prod-cuisine').value === 'true', cuisine: document.getElementById('prod-cuisine').value === 'true',
image: document.getElementById('prod-img').value.trim(),
}; };
if (catVal) data.category = catVal;
if (unitVal) data.unit_of_measure = unitVal;
if (!data.name) {
btn.disabled = false;
btn.textContent = isEdit ? 'Salvar Alterações' : 'Criar Produto';
return showToast('O nome do produto é obrigatório.', 'warning');
}
const r = isEdit const r = isEdit
? await window.electronAPI.put(`/products/${produto.id}/`, data) ? await window.electronAPI.patch(`/products/${produto.id}`, data)
: await window.electronAPI.post('/products/', data); : await window.electronAPI.post('/products', data);
if (r.ok) { showToast(isEdit ? 'Produto atualizado!' : 'Produto criado!', 'success'); closeModal(); loadProdutos(categorias); }
else showToast(r.error, 'error'); if (r.ok) {
showToast(isEdit ? 'Produto atualizado!' : 'Produto criado!', 'success');
closeModal();
loadProdutos(categorias, unidades);
} else {
showToast(r.error, 'error');
btn.disabled = false;
btn.textContent = isEdit ? 'Salvar Alterações' : 'Criar Produto';
}
}); });
} }
function abrirModalCategoria() { // ─── Modal Gerenciar Categorias ──────────────────────────────────────────────
async function abrirModalGerenciarCategorias(categoriasArr) {
let categorias = Array.isArray(categoriasArr) ? categoriasArr : [];
const updateTable = () => {
const listWrap = document.getElementById('cat-list-items');
if (!listWrap) return;
listWrap.innerHTML = categorias.map(c => `
<div style="display:flex; justify-content:space-between; align-items:center; padding:10px; border-bottom:1px solid var(--border)">
<span style="${!c.active ? 'text-decoration:line-through; color:var(--text-muted)' : ''}">
${c.nome || c.name} ${!c.active ? '(Inativa)' : ''}
</span>
<button class="btn btn-ghost btn-sm btn-edit-cat" data-id="${c.id}" title="Editar Categoria">✏️</button>
</div>`).join('');
listWrap.querySelectorAll('.btn-edit-cat').forEach(btn => {
btn.addEventListener('click', () => {
const cat = categorias.find(x => String(x.id) === String(btn.dataset.id));
if (cat) abrirFormularioCategoria(cat, updateTable, async (novasCats) => {
categorias = novasCats;
updateTable();
});
});
});
};
openModal({ openModal({
title: 'Nova Categoria', title: '📁 Gerenciar Categorias',
body: ` body: `
<div class="form-group"> <div style="margin-bottom:20px">
<label>Nome</label> <button class="btn btn-primary btn-sm" id="btn-cat-add-new">+ Adicionar Nova</button>
<input type="text" id="cat-nome" class="form-control" placeholder="Ex: Bebidas, Lanches..." /> </div>
<div id="cat-list-items" style="max-height:400px; overflow-y:auto; border:1px solid var(--border); border-radius:var(--radius-sm)">
<div class="table-empty">Limpando...</div>
</div>`, </div>`,
footer: ` footer: `<button class="btn btn-secondary btn-md" onclick="closeModal()">Fechar</button>`,
<button class="btn btn-secondary btn-md" onclick="closeModal()">Cancelar</button>
<button class="btn btn-primary btn-md" id="btn-criar-cat">Criar</button>`,
}); });
document.getElementById('btn-criar-cat').addEventListener('click', async () => {
const nome = document.getElementById('cat-nome').value.trim(); updateTable();
if (!nome) return showToast('Informe um nome.', 'error');
const r = await window.electronAPI.post('/categories/', { nome, name: nome }); document.getElementById('btn-cat-add-new').addEventListener('click', () => {
if (r.ok) { showToast('Categoria criada!', 'success'); closeModal(); } abrirFormularioCategoria(null, updateTable, async (novasCats) => {
else showToast(r.error, 'error'); categorias = novasCats;
updateTable();
});
});
}
// Abre formulário pequeno para criar ou editar uma única categoria
function abrirFormularioCategoria(cat, onSuccess, onListUpdate) {
const isEdit = !!cat;
// Criamos uma mini modal ou sobrepomos a atual com uma de confirmação simples de formulário
// Para simplicidade, vamos usar o openModal mesmo (ele sobrepõe)
openModal({
title: isEdit ? `Editar Categoria: ${cat.nome || cat.name}` : 'Nova Categoria',
body: `
<div class="form-grid">
<div class="form-group">
<label>Nome da Categoria</label>
<input type="text" id="cat-field-name" class="form-control" value="${isEdit ? (cat.nome || cat.name) : ''}" placeholder="Ex: Bebidas, Sobremesas..." />
</div>
<div class="form-group">
<label>Status</label>
<select id="cat-field-active" class="form-control">
<option value="true" ${cat?.active !== false ? 'selected' : ''}>Ativa</option>
<option value="false" ${cat?.active === false ? 'selected' : ''}>Inativa</option>
</select>
</div>
</div>`,
footer: `
<button class="btn btn-secondary btn-md" id="btn-cat-form-cancel">Voltar</button>
<button class="btn btn-primary btn-md" id="btn-cat-form-save">${isEdit ? 'Salvar Alterações' : 'Criar Categoria'}</button>`,
});
document.getElementById('btn-cat-form-cancel').onclick = () => {
closeModal();
// Reabre o gerenciador
setTimeout(() => {
// Recarregar categorias para garantir sync
window.electronAPI.get('/categories').then(res => {
if (res.ok) onListUpdate(res.data);
});
}, 300);
};
document.getElementById('btn-cat-form-save').addEventListener('click', async () => {
const nome = document.getElementById('cat-field-name').value.trim();
const active = document.getElementById('cat-field-active').value === 'true';
if (!nome) return showToast('O nome é obrigatório.', 'warning');
const data = { name: nome, active: active };
const r = isEdit
? await window.electronAPI.patch(`/categories/${cat.id}`, { name: nome }) // Use PATCH as requested
: await window.electronAPI.post('/categories', data);
if (r.ok) {
showToast(isEdit ? 'Categoria atualizada!' : 'Categoria criada!', 'success');
const res = await window.electronAPI.get('/categories');
if (res.ok) {
onListUpdate(res.data);
closeModal();
// Não reabre o gerenciador imediatamente para dar tempo do toast sumir,
// mas aqui vamos reabrir para manter o fluxo
setTimeout(() => abrirModalGerenciarCategorias(res.data), 300);
}
} else {
showToast(r.error, 'error');
}
}); });
} }

View File

@@ -979,3 +979,80 @@ select.form-control option {
color: var(--primary); color: var(--primary);
font-weight: 600; font-weight: 600;
} }
/* ─── Print Styles ────────────────────────────────────────────────────────── */
@media print {
@page {
size: 80mm auto;
margin: 5mm;
}
body {
background: white !important;
color: black !important;
font-size: 12px;
margin: 0;
padding: 0;
}
body > *:not(.print-comanda) {
display: none !important;
}
.print-comanda {
display: block !important;
width: 100%;
max-width: 80mm;
background: white;
color: black;
font-family: 'Courier New', monospace;
font-size: 11px;
line-height: 1.3;
}
.print-comanda * {
color: black !important;
background: transparent !important;
border: none !important;
box-shadow: none !important;
text-shadow: none !important;
}
.print-header {
text-align: center;
border-bottom: 1px dashed #000;
padding-bottom: 8px;
margin-bottom: 10px;
}
.print-title {
font-size: 14px;
font-weight: bold;
}
.print-info {
margin: 4px 0;
}
.print-items {
border-bottom: 1px dashed #000;
padding-bottom: 8px;
margin-bottom: 8px;
}
.print-item {
display: flex;
justify-content: space-between;
padding: 2px 0;
}
.print-totals {
font-weight: bold;
}
.print-footer {
text-align: center;
margin-top: 10px;
font-size: 10px;
}
}