diff --git a/ELECTRON_INTEGRATION.md b/ELECTRON_INTEGRATION.md new file mode 100644 index 0000000..93945f4 --- /dev/null +++ b/ELECTRON_INTEGRATION.md @@ -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: ` +- **Body**: `{ "mesa_id": 1, "client_id": null }` + +### [PROTEGIDO] Lançar Pedido (Adicionar Item) +**POST** `/items-comanda` +- **Headers**: `X-User-ID: ` +- **Body**: `{ "comanda_id": 9, "product_id": 50, "applicant": "Nome do Garçom" }` + +### [PROTEGIDO] Deletar Item Individual +**DELETE** `/items-comanda/:id` +- **Headers**: `X-User-ID: ` + +### [PROTEGIDO] Limpar e Fechar Comanda (Apagar Inteira) +**POST** `/comandas/:id/apagar` +- **Headers**: `X-User-ID: ` +- **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: ` +- **Body**: + ```json + { + "value": 50.00, + "type_pay_id": 1, + "client_id": null + } + ``` +- **Efeito**: Registra o pagamento localmente e fecha a comanda. diff --git a/src/main/main.js b/src/main/main.js index e7c416c..8736fbf 100644 --- a/src/main/main.js +++ b/src/main/main.js @@ -6,7 +6,14 @@ const axios = require('axios'); const store = new Store(); 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 ──────────────────────────────────────────────────────── @@ -31,89 +38,45 @@ function createWindow() { }); mainWindow.loadFile(path.join(__dirname, '../renderer/index.html')); - mainWindow.webContents.openDevTools(); + // mainWindow.webContents.openDevTools(); } -// ─── Helpers de Token ──────────────────────────────────────────────────────── -let isRefreshing = false; -let refreshPromise = null; +// ─── Helpers de Autenticação (Middleware Go) ────────────────────────────────── function getHeaders() { - const token = store.get('access_token'); - // console.log('[MAIN] getHeaders - Token exists:', !!token); - 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; + const user = store.get('user'); + return user && user.id ? { 'X-User-ID': String(user.id) } : {}; } async function requestWithRetry(method, endpoint, data) { - const url = `${getBaseUrl()}${endpoint}`; - console.log(`[API] ${method.toUpperCase()} ${url}`); + const cleanEndpoint = endpoint.endsWith('/') ? endpoint.slice(0, -1) : endpoint; + 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 { - const res = await axios({ method, url, data, headers: getHeaders() }); + const res = await axios({ + method, + url, + data, + headers + }); return { ok: true, data: res.data }; } catch (err) { 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; - 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 }; } } @@ -121,29 +84,55 @@ async function requestWithRetry(method, endpoint, data) { // ─── IPC Handlers (Registrar IMEDIATAMENTE) ────────────────────────────────── ipcMain.handle('auth:login', async (_, { username, password }) => { try { - const res = await axios.post(`${getBaseUrl()}/token/`, { username, password }); - console.log('[MAIN] Login Successful. User:', res.data.user?.username); - store.set('access_token', res.data.access); - store.set('refresh_token', res.data.refresh); - store.set('user', res.data.user); + const url = `${getBaseUrl()}/login`; + console.log(`[DEBUG_LOGIN] >>> Tentando login em: ${url}`); + + const res = await axios.post(url, { username, password }, { + 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 }; } catch (err) { - console.error('[MAIN] Login Failed:', err.response?.data || err.message); - return { ok: false, error: 'Credenciais inválidas.' }; + console.error('[DEBUG_LOGIN] >>> Falha no Login:', err.response?.data || err.message); + return { ok: false, error: 'Erro de autenticação no servidor local.' }; } }); ipcMain.handle('auth:logout', () => { + store.delete('user'); store.delete('access_token'); store.delete('refresh_token'); - store.delete('user'); 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', () => { - console.log('[MAIN] IPC auth:user requested.'); return store.get('user'); }); @@ -160,6 +149,38 @@ ipcMain.handle('config:set-url', (_, url) => { 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 ────────────────────────────────────────────────────────────── app.whenReady().then(() => { createWindow(); diff --git a/src/main/preload.js b/src/main/preload.js index ca06813..b8d9527 100644 --- a/src/main/preload.js +++ b/src/main/preload.js @@ -10,6 +10,11 @@ contextBridge.exposeInMainWorld('electronAPI', { // Config getConfigUrl: () => ipcRenderer.invoke('config:get-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 get: (endpoint) => ipcRenderer.invoke('api:get', endpoint), diff --git a/src/renderer/pages/clientes.js b/src/renderer/pages/clientes.js index df295e0..453b30a 100644 --- a/src/renderer/pages/clientes.js +++ b/src/renderer/pages/clientes.js @@ -31,28 +31,63 @@ export async function renderClientes(container) { } let _clientesData = []; +let _comandasData = []; let _productsMap = {}; +let _paymentTypes = []; async function loadClientes() { const wrap = document.getElementById('clientes-table'); if (!wrap) return; wrap.innerHTML = `
`; - // Carrega clientes e produtos em paralelo para ter os preços - const [res, pRes] = await Promise.all([ - window.electronAPI.get('/clients/'), - window.electronAPI.get('/products/') + // Carrega clientes, produtos, comandas e tipos de pagamento em paralelo + const [res, pRes, cRes, ptRes] = await Promise.all([ + window.electronAPI.get('/clients'), + window.electronAPI.get('/products'), + window.electronAPI.get('/comandas'), + window.electronAPI.get('/payment-types') ]); + if (ptRes.ok) _paymentTypes = ptRes.data; + if (pRes.ok) { _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; }, {}); } + if (cRes.ok) _comandasData = cRes.data; + if (!res.ok) { wrap.innerHTML = `
Erro ao carregar clientes.
`; 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); } @@ -61,8 +96,8 @@ function renderClientesTable(data) { if (!wrap) return; if (!data.length) { wrap.innerHTML = `
Nenhum cliente encontrado.
`; return; } - // Ordena por maior débito por padrão - const sorted = [...data].sort((a, b) => parseFloat(b.debt || 0) - parseFloat(a.debt || 0)); + // Ordena por maior débito por padrão usando o cálculo dinâmico + const sorted = [...data].sort((a, b) => (b.real_debt || 0) - (a.real_debt || 0)); wrap.innerHTML = ` @@ -70,20 +105,20 @@ function renderClientesTable(data) { - + ${sorted.map(c => { - const debt = parseFloat(c.debt || 0); + const debt = c.real_debt || 0; return ` @@ -95,7 +130,7 @@ function renderClientesTable(data) { @@ -131,7 +166,7 @@ function filtrarClientes() { (c.contact || '').toLowerCase().includes(q) || String(c.id).includes(q); - const debtValue = parseFloat(c.debt || 0); + const debtValue = c.real_debt || 0; const matchDebt = !debtFltr || (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'); const r = isEdit - ? await window.electronAPI.put(`/clients/${cliente.id}/`, data) - : await window.electronAPI.post('/clients/', data); + ? await window.electronAPI.put(`/clients/${cliente.id}`, data) + : await window.electronAPI.post('/clients', data); if (r.ok) { showToast(isEdit ? 'Cliente atualizado!' : 'Cliente criado!', 'success'); closeModal(); loadClientes(); } else showToast(r.error, 'error'); @@ -200,42 +235,64 @@ async function abrirHistoricoFiados(cliente) { title: `📜 Fiados: ${cliente.name}`, body: `
-
+
-
- @@ -100,29 +236,33 @@ function renderComandasTable(data) { }; const cfg = statusCfg[c.status] || { label: c.status, badge: 'badge-muted' }; 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 ` + 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 ` - + - `; }).join('')} @@ -131,119 +271,177 @@ function renderComandasTable(data) { ${data.length > 100 ? `
Exibindo apenas as últimas 100 de ${data.length} comandas.
` : ''} `; + // 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 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)); - 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) wrap.querySelectorAll('.btn-pagar').forEach(btn => { - btn.addEventListener('click', async () => { - const r = await window.electronAPI.patch(`/comandas/${btn.dataset.id}/`, { status: 'PAYING' }); + btn.addEventListener('click', async (e) => { + 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); } 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 => { - btn.addEventListener('click', async () => { - const r = await window.electronAPI.patch(`/comandas/${btn.dataset.id}/`, { status: 'OPEN' }); + btn.addEventListener('click', async (e) => { + e.stopPropagation(); + const r = await window.electronAPI.patch(`/comandas/${btn.dataset.id}`, { status: 'OPEN' }); if (r.ok) { showToast('Comanda reaberta!', 'info'); loadComandas(_mesasRef); } 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); + + const pagamentosAtuais = _paymentsMap[comanda.id] || []; + const totalPago = pagamentosAtuais.reduce((acc, p) => acc + parseFloat(p.value || 0), 0); + const valorRestante = Math.max(0, totalBruto - totalPago); - // Listener para ver itens da comanda - wrap.querySelectorAll('.btn-itens').forEach(btn => { - btn.addEventListener('click', () => { - const comanda = _comandasData.find(c => c.id === parseInt(btn.dataset.id)); - if (comanda) abrirItensComanda(comanda); + openModal({ + title: `💰 Receber Pagamento - Comanda #${comanda.id}`, + body: ` +
+
+ + + ${totalPago > 0 ? `Valor restante (já pagos: R$ ${totalPago.toFixed(2)})` : ''} +
+
+ + +
+
+ + +
+
+ + +
+
`, + footer: ` + + ` }); - }); - // Badge de itens também abre o modal - wrap.querySelectorAll('.badge[data-id]').forEach(badge => { - badge.addEventListener('click', () => { - const comanda = _comandasData.find(c => c.id === parseInt(badge.dataset.id)); - if (comanda) abrirItensComanda(comanda); + const btnConfirmar = document.getElementById('btn-confirmar-pagamento'); + + document.getElementById('pay-value').addEventListener('input', () => { + const valorInformado = parseFloat(document.getElementById('pay-value').value) || 0; + 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) - wrap.querySelectorAll('.btn-excluir').forEach(btn => { - btn.addEventListener('click', async () => { - if (confirm('Deseja realmente EXCLUIR/APAGAR esta comanda?')) { - const r = await window.electronAPI.post(`/comandas/${btn.dataset.id}/apagar/`, {}); - if (r.ok) { - showToast('Comanda excluída!', 'success'); - loadComandas(_mesasRef); + btnConfirmar.addEventListener('click', async () => { + const payTypeId = parseInt(document.getElementById('pay-type').value); + const clientId = document.getElementById('pay-client').value || null; + const totalOriginal = parseFloat(document.getElementById('pay-value').value); + + const tipoPgto = document.getElementById('pay-type').options[document.getElementById('pay-type').selectedIndex].text; + const isVale = tipoPgto.toLowerCase().includes('vale'); + 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 { - 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'; } } }); - }); -} - -function abrirModalReceber(comanda) { - const total = (comanda.items || []).reduce((acc, it) => acc + (_productsMap[it.product] || 0), 0); - - openModal({ - title: `💰 Receber Pago - Comanda #${comanda.id}`, - body: ` -
-
- - -
-
- - -
-
- - -
-
- - -
-
`, - footer: ` - - ` - }); - - 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'); - } - }); + } catch (err) { + console.error('[PDV] Erro ao abrir modal de pagamento:', err); + showToast('Erro ao abrir tela de pagamento.', 'error'); + } } function filtrarComandas() { @@ -263,13 +461,19 @@ function filtrarComandas() { // ─── Modal de Itens (Novo Layout PDV Split) ─────────────────────────────────── 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 podeAdd = comanda.status === 'OPEN'; // Só permite add se ainda não estiver pagando? + const podeAdd = comanda.status === 'OPEN'; // 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) : []; openModal({ @@ -292,16 +496,32 @@ async function abrirItensComanda(comandaIdOrObj) { `, - footer: ``, + footer: ` + + + `, }); - // 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 container = document.getElementById('pdv-items-list'); if (!container) return; 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 = `
@@ -315,17 +535,17 @@ async function abrirItensComanda(comandaIdOrObj) {
${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 tooltip = it.obs ? `title="${it.obs}"` : ''; return ` ${podeAdd ? `
# Nome ContatoDébitoDébito Dinâmico Status Cadastrado em Ações
#${c.id} ${c.name} ${c.contact || '–'} - + R$ ${debt.toFixed(2)} ${formatDate(c.created_at)}
- +
Status Total Aberta emItens Ações
#${c.id} ${c.name || '–'} ${c.mesa_name || `Mesa ${c.mesa}` || '–'} ${cfg.label}R$ ${totalComanda.toFixed(2)} + ${temPagamentos ? `
+ R$ ${totalComanda.toFixed(2)} + R$ ${Math.max(0, valorRestante).toFixed(2)} + ${temPagamentos ? `Pago: R$ ${totalPago.toFixed(2)}` : ''} +
` : `R$ ${totalComanda.toFixed(2)}`} +
${formatDate(c.dt_open)} - - ${(c.items || []).length} ${(c.items || []).length === 1 ? 'item' : 'itens'} - -
- + ${ativa ? `` : ''} ${ativa && c.status === 'OPEN' ? `` : ''} ${ativa && c.status === 'PAYING' ? `` : ''} - - -
+
- ${it.product_name} + ${prod?.name || it.product_name || `Produto #${it.product}`} - R$ ${(_productsMap[it.product] || 0).toFixed(2)} + R$ ${(_productsMap[String(it.product)] || 0).toFixed(2)} @@ -344,16 +564,50 @@ async function abrirItensComanda(comandaIdOrObj) { }).join('')}
- ` : `

Nenhum item adicionado.

`} + ` : `

Nenhum item adicionado.

`} + + ${temPagamentos ? ` +

Pagamentos Recebidos

+ + + + + + + ${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 ` + + + + `; + }).join('')} + +
FormaValor
+ 💰 ${tipoNome} + + R$ ${parseFloat(p.value || 0).toFixed(2)} +
+ ` : ''}
Total de Itens: ${itens.length}
+
+ Total da Conta: + R$ ${totalComanda.toFixed(2)} +
+ ${temPagamentos ? ` +
+ Valor Pago: + R$ ${totalPago.toFixed(2)} +
+ ` : ''}
- Total da Conta: - R$ ${totalComanda.toFixed(2)} + ${temPagamentos ? 'Restante:' : 'Total:'} + R$ ${Math.max(0, valorRestante).toFixed(2)}
${ativa ? `
@@ -368,7 +622,7 @@ async function abrirItensComanda(comandaIdOrObj) { btn.addEventListener('click', async () => { const itemId = btn.dataset.id; 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) { showToast('Item excluído!', 'success'); 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 container.querySelectorAll('.btn-edit-obs').forEach(btn => { btn.addEventListener('click', async () => { - const itemId = parseInt(btn.dataset.id); - const item = comanda.items.find(it => it.id === itemId); - const prod = todosProdutos.find(p => p.id === item.product); + const itemId = String(btn.dataset.id); + const item = comanda.items.find(it => String(it.id) === itemId); + const prod = todosProdutos.find(p => String(p.id) === String(item?.product)); if (item && prod) { - window.abrirModalObsCozinhaGlobal(prod.name, item.obs, async (novaObs) => { - if (novaObs === null) return; - const r = await window.electronAPI.patch(`/items-comanda/${itemId}/`, { obs: novaObs }); - if (r.ok) { - showToast('Observação atualizada!', 'success'); - item.obs = novaObs; - renderLeft(); - loadComandas(_mesasRef); - } else { - showToast(r.error, 'error'); + window.abrirModalObsCozinhaGlobal(prod.name, item.obs || '', async (novaObs) => { + if (novaObs === null || novaObs === item.obs) return; + + // Busca os pedidos para encontrar o ID da order vinculada + const ordersRes = await window.electronAPI.get('/orders'); + if (ordersRes.ok) { + const linkedOrder = ordersRes.data.find(o => String(o.productComanda) === itemId); + + if (linkedOrder) { + // 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 (!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(); 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 { showToast(r.error, 'error'); } @@ -421,25 +745,34 @@ async function abrirItensComanda(comandaIdOrObj) { container.querySelectorAll('.pdv-product-card').forEach(card => { card.addEventListener('click', async () => { if (!podeAdd) return showToast('Comanda em fechamento ou fechada.', 'warning'); - const pId = parseInt(card.dataset.id); - const prod = todosProdutos.find(x => x.id === pId); + const pId = String(card.dataset.id); + const prod = todosProdutos.find(x => String(x.id) === pId); card.style.transform = 'scale(0.95)'; setTimeout(() => card.style.transform = '', 100); + if (!prod) return showToast('Erro: Produto não encontrado.', 'error'); + if (prod.cuisine) { window.abrirModalObsCozinhaGlobal(prod.name, '', async (obs) => { 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, - product: pId, - obs: obs + product: prod.id, + obs: obs, + applicant: loggedUser?.username || 'Sistema' }); - processarResultadoAdd(r); + processarResultadoAdd(r, prod, obs); }); } else { - const r = await window.electronAPI.post('/items-comanda/', { comanda: comanda.id, product: pId }); - processarResultadoAdd(r); + const loggedUser = await window.electronAPI.getUser(); + 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); - // console.log('Produtos carregados no PDV:', todosProdutos); + // console.log('Produtos carregados no PDV:', todosProdutos); container.innerHTML = filtrados.map(p => { const imgTarget = p.image ? `url('${p.image}')` : `url('https://wallpapers.com/images/featured/fundo-abstrato-escuro-27kvn4ewpldsngbu.jpg')`; return ` @@ -479,67 +812,87 @@ async function abrirItensComanda(comandaIdOrObj) { } -// ─── Modal Nova Comanda ─────────────────────────────────────────────────────── -function abrirModalNovaComanda(mesas) { +// ─── Modal Comanda (Nova / Editar) ────────────────────────────────────────── +function abrirModalNovaComanda(mesas, comandaExistente = null) { + const isEdit = !!comandaExistente; + openModal({ - title: 'Nova Comanda', + title: isEdit ? `Editar Comanda #${comandaExistente.id}` : 'Nova Comanda', body: ` -
+
- +
- +
+ + +
+
`, footer: ` - `, + `, }); - // Foco manual caso autofocus falhe em algum navegador setTimeout(() => document.getElementById('comanda-nome')?.focus(), 100); const submeter = async (e) => { if (e) e.preventDefault(); 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(); 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 payload = { - name: nome, mesa: mesaId, user: loggedUser?.id || 1, - status: 'OPEN' + client: clientId, + name: nome, }; - const btn = document.getElementById('btn-criar-comanda'); - btn.disabled = true; - btn.textContent = 'Criando...'; + let r; + if (isEdit) { + 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) { - showToast('Comanda criada!', 'success'); + showToast(isEdit ? 'Comanda atualizada!' : 'Comanda criada!', 'success'); closeModal(); loadComandas(_mesasRef); - // Abre direto a modal de itens da comanda recém criada - setTimeout(() => abrirItensComanda(r.data), 300); + if (!isEdit) { + setTimeout(() => abrirItensComanda(r.data), 300); + } } else { showToast(r.error, 'error'); 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('btn-criar-comanda').onclick = submeter; + document.getElementById('form-comanda').onsubmit = submeter; + document.getElementById('btn-salvar-comanda').onclick = submeter; } // ─── Helpers ────────────────────────────────────────────────────────────────── diff --git a/src/renderer/pages/config.js b/src/renderer/pages/config.js index 6f715ef..c3382d2 100644 --- a/src/renderer/pages/config.js +++ b/src/renderer/pages/config.js @@ -1,5 +1,6 @@ export async function renderConfig(container) { const currentUrl = await window.electronAPI.getConfigUrl(); + const printSilent = await window.electronAPI.getPrintSilent(); container.innerHTML = `
+
+

🖨️ Impressão

+ +
+ +

+ Quando ativado, a impressão é enviada diretamente para a impressora padrão sem mostrar o diálogo do sistema. +

+
+ +
+ +
+
+

ℹ️ Sobre o Sistema

@@ -36,7 +55,7 @@ export async function renderConfig(container) {

`; - // Salvar + // Salvar config de API document.getElementById('btn-save-config').addEventListener('click', async () => { const newUrl = document.getElementById('config-api-url').value.trim(); if (!newUrl.startsWith('http')) { @@ -51,11 +70,22 @@ export async function renderConfig(container) { } }); - // Restaurar + // Restaurar URL document.getElementById('btn-reset-url').addEventListener('click', async () => { const defaultUrl = 'http://localhost:8000/api/v1'; document.getElementById('config-api-url').value = defaultUrl; await window.electronAPI.setConfigUrl(defaultUrl); 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'); + } + }); } diff --git a/src/renderer/pages/mesas.js b/src/renderer/pages/mesas.js index 05292cf..1ea15d7 100644 --- a/src/renderer/pages/mesas.js +++ b/src/renderer/pages/mesas.js @@ -28,8 +28,8 @@ async function loadMesas() { // Carrega mesas e comandas em paralelo para determinar ocupação const [mesasRes, comandasRes] = await Promise.all([ - window.electronAPI.get('/mesas/'), - window.electronAPI.get('/comandas/'), + window.electronAPI.get('/mesas'), + window.electronAPI.get('/comandas'), ]); if (!mesasRes.ok) { @@ -103,7 +103,7 @@ function abrirDetalheMesa(mesa, ocupada) { }); 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(); } else showToast(r.error, 'error'); }); @@ -145,8 +145,8 @@ function abrirModalMesa(mesa = null) { if (!data.name) return showToast('Informe o nome da mesa.', 'error'); const r = isEdit - ? await window.electronAPI.put(`/mesas/${mesa.id}/`, data) - : await window.electronAPI.post('/mesas/', data); + ? await window.electronAPI.put(`/mesas/${mesa.id}`, data) + : await window.electronAPI.post('/mesas', data); if (r.ok) { showToast(isEdit ? 'Mesa atualizada!' : 'Mesa criada!', 'success'); closeModal(); loadMesas(); } else showToast(r.error, 'error'); diff --git a/src/renderer/pages/pagamentos.js b/src/renderer/pages/pagamentos.js index 2cfebce..025bfdd 100644 --- a/src/renderer/pages/pagamentos.js +++ b/src/renderer/pages/pagamentos.js @@ -1,12 +1,11 @@ export async function renderPagamentos(container) { // Carrega tipos de pagamento para o formulário de novo registro - let tiposPag = [], comandas = []; const [tRes, cRes] = await Promise.all([ - window.electronAPI.get('/payment-types/'), - window.electronAPI.get('/comandas/'), + window.electronAPI.get('/payment-types'), + window.electronAPI.get('/comandas'), ]); - if (tRes.ok) tiposPag = tRes.data; - if (cRes.ok) comandas = cRes.data; + const tiposPag = tRes.ok ? tRes.data : []; + const comandas = cRes.ok ? cRes.data : []; container.innerHTML = `