feat: Add kitchen ticket printing, client debit calculations, and payment details modal

- Add automatic ticket printing for kitchen items after observation modal
- Add reprint button for each kitchen item in comanda list
- Add reprint button in orders screen
- Include comanda name, table, user, observations in kitchen tickets
- Calculate remaining debt considering partial payments in clients screen
- Show payment history in fiados modal with remaining value
- Replace delete button with details modal in payments screen
- Fetch client name by ID in payments table
- Remove delete option from payment details modal
- Add printer error handling with fallback to system dialog
- Add product image upload with base64 encoding
This commit is contained in:
Welton Silva
2026-04-05 15:28:37 -03:00
parent 9ef245b9fe
commit d6174da594
6 changed files with 521 additions and 75 deletions

View File

@@ -172,7 +172,12 @@ ipcMain.handle('print:direct', async (_, html) => {
if (success) {
resolve({ ok: true });
} else {
resolve({ ok: false, error: 'Nenhuma impressora configurada ou disponível.' });
const errorMsg = errorType || '';
if (errorMsg.includes('No printer') || errorMsg.includes('failed to enumerate') || errorMsg.includes('Error getting default')) {
resolve({ ok: false, error: 'NO_PRINTER', message: 'Nenhuma impressora configurada. Configure uma impressora nas configurações do sistema.' });
} else {
resolve({ ok: false, error: errorMsg });
}
}
});
});

View File

@@ -34,18 +34,19 @@ let _clientesData = [];
let _comandasData = [];
let _productsMap = {};
let _paymentTypes = [];
let _paymentsMap = {};
async function loadClientes() {
const wrap = document.getElementById('clientes-table');
if (!wrap) return;
wrap.innerHTML = `<div class="loading-screen"><div class="spinner"></div></div>`;
// Carrega clientes, produtos, comandas e tipos de pagamento em paralelo
const [res, pRes, cRes, ptRes] = await Promise.all([
const [res, pRes, cRes, ptRes, pagsRes] = await Promise.all([
window.electronAPI.get('/clients'),
window.electronAPI.get('/products'),
window.electronAPI.get('/comandas'),
window.electronAPI.get('/payment-types')
window.electronAPI.get('/payment-types'),
window.electronAPI.get('/payments')
]);
if (ptRes.ok) _paymentTypes = ptRes.data;
@@ -60,17 +61,24 @@ async function loadClientes() {
}, {});
}
if (cRes.ok) _comandasData = cRes.data;
_comandasData = cRes.ok ? (cRes.data || []) : [];
_paymentsMap = {};
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 clientes.</div>`; return; }
_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';
});
@@ -81,7 +89,10 @@ async function loadClientes() {
const preco = pInfo ? pInfo.price : parseFloat(item.product_price || 0);
return sum + preco;
}, 0);
return acc + totalComanda;
const pagamentos = _paymentsMap[com.id] || [];
const totalPago = pagamentos.reduce((sum, p) => sum + parseFloat(p.value || 0), 0);
const restante = Math.max(0, totalComanda - totalPago);
return acc + restante;
}, 0);
});
@@ -293,21 +304,27 @@ async function abrirHistoricoFiados(cliente) {
const preco = pInfo ? pInfo.price : parseFloat(it.product_price || 0);
return acc + preco;
}, 0);
const pagamentos = _paymentsMap[f.id] || [];
const totalPago = pagamentos.reduce((acc, p) => acc + parseFloat(p.value || 0), 0);
const valorRestante = Math.max(0, totalComanda - totalPago);
const temPagamentos = pagamentos.length > 0;
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 ${valorRestante > 0 ? 'var(--warning)' : 'var(--success)'}; position:relative; padding-left:50px">
<div style="position:absolute; left:15px; top:50%; transform:translateY(-50%)">
<input type="checkbox" class="fiado-check" data-id="${f.id}" data-total="${totalComanda}" style="width:20px; height:20px; cursor:pointer" />
<input type="checkbox" class="fiado-check" data-id="${f.id}" data-total="${valorRestante}" style="width:20px; height:20px; cursor:pointer" ${valorRestante <= 0 ? 'disabled' : ''} />
</div>
<div style="display:flex; justify-content:space-between; align-items:flex-start; margin-bottom:10px;">
<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>
${temPagamentos ? `<div style="font-size:0.75rem; color:var(--success); margin-top:2px">Pago: R$ ${totalPago.toFixed(2)}</div>` : ''}
</div>
<div style="text-align:right">
${temPagamentos ? `<div style="font-size:0.85rem; text-decoration:line-through; color:var(--text-muted)">R$ ${totalComanda.toFixed(2)}</div>` : ''}
<div class="badge badge-warning" style="margin-bottom:5px">${f.status}</div>
<div style="font-weight:700; color:var(--danger); font-size:1.1rem">R$ ${totalComanda.toFixed(2)}</div>
<div style="font-weight:700; color:${valorRestante > 0 ? 'var(--warning)' : 'var(--success)'}; font-size:1.1rem">R$ ${valorRestante.toFixed(2)}</div>
</div>
</div>
@@ -333,6 +350,24 @@ async function abrirHistoricoFiados(cliente) {
`}).join('')}
</ul>
</details>
${temPagamentos ? `
<details style="margin-top:10px">
<summary style="font-weight:600; font-size:0.8rem; text-transform:uppercase; color:var(--success); cursor:pointer; outline:none">
Ver Pagamentos (${pagamentos.length})
</summary>
<ul style="list-style:none; padding:10px 0 0 0; margin:0; font-size:0.85rem;">
${pagamentos.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 `
<li style="display:flex; justify-content:space-between; padding:4px 0; border-bottom:1px dashed var(--border); color:var(--success)">
<span>💰 ${tipoNome}</span>
<span>R$ ${parseFloat(p.value || 0).toFixed(2)}</span>
</li>
`}).join('')}
</ul>
</details>
` : ''}
</div>
</div>
`;
@@ -342,13 +377,17 @@ async function abrirHistoricoFiados(cliente) {
const updateSum = () => {
const checks = listContainer.querySelectorAll('.fiado-check:checked');
let sum = 0;
checks.forEach(c => sum += parseFloat(c.dataset.total));
let count = 0;
checks.forEach(c => {
sum += parseFloat(c.dataset.total);
count++;
});
document.getElementById('selected-count').textContent = checks.length;
document.getElementById('selected-count').textContent = count;
document.getElementById('selected-total').textContent = sum.toFixed(2);
const btnPagar = document.getElementById('btn-pagar-selecionados');
btnPagar.disabled = checks.length === 0;
btnPagar.disabled = count === 0 || sum <= 0;
};
listContainer.querySelectorAll('.fiado-check').forEach(chk => {

View File

@@ -108,16 +108,113 @@ function imprimirComanda(comanda, pagamentosComanda, totalComanda) {
window.electronAPI.printDirect(htmlCompleto).then(r => {
if (r.ok) {
showToast('Impressão enviada!', 'success');
} else {
showToast('Nenhuma impressora configurada. Abrindo diálogo...', 'warning');
} else if (r.error === 'NO_PRINTER') {
showToast('⚠️ Nenhuma impressora configurada. Configure uma impressora nas configurações do sistema.', 'warning', 5000);
const printWindow = window.open('', '', 'width=300,height=600');
printWindow.document.write(htmlCompleto);
printWindow.document.close();
printWindow.onload = () => printWindow.print();
setTimeout(() => printWindow.print(), 300);
} else {
const printWindow = window.open('', '', 'width=300,height=600');
printWindow.document.write(htmlCompleto);
printWindow.document.close();
setTimeout(() => printWindow.print(), 300);
}
});
}
function imprimirTicketCozinha(comanda, item, product, obs = '', loggedUser = null) {
const dataAtual = new Date().toLocaleString('pt-BR');
const nomeEstabelecimento = 'RRBEC - Bar & Restaurante';
const nomeProduto = product?.name || item.product_name || `Produto #${item.product}`;
const observacao = obs || item.obs || '';
const usuario = loggedUser?.username || item.applicant || 'Sistema';
const nomeComanda = comanda.name || `Comanda #${comanda.id}`;
const nomeMesa = comanda.mesa_name || comanda.mesa || '';
const htmlTicket = `
<!DOCTYPE html>
<html>
<head>
<meta charset="UTF-8">
<title>Ticket Cozinha - ${nomeComanda}</title>
<style>
* { box-sizing: border-box; margin: 0; padding: 0; }
body { font-family: 'Courier New', monospace; font-size: 13px; padding: 8px; width: 80mm; }
.ticket { display: block; color: black; }
.ticket * { color: black !important; background: transparent !important; }
.header { text-align: center; border-bottom: 2px solid #000; padding-bottom: 8px; margin-bottom: 8px; }
.title { font-size: 16px; font-weight: bold; text-transform: uppercase; }
.subtitle { font-size: 12px; margin-top: 4px; }
.info { margin: 4px 0; font-size: 12px; }
.info strong { font-size: 14px; }
.product { font-size: 18px; font-weight: bold; text-align: center; margin: 12px 0; padding: 8px; border: 3px double #000; text-transform: uppercase; }
.obs { font-style: italic; font-size: 11px; text-align: center; margin-top: 8px; padding: 6px; border: 1px dashed #000; }
.footer { text-align: center; font-size: 10px; margin-top: 8px; color: #666; }
@media print {
@page { size: 80mm auto; margin: 0; }
}
</style>
</head>
<body>
<div class="ticket">
<div class="header">
<div class="title">🍳 COZINHA</div>
<div class="subtitle">${nomeEstabelecimento}</div>
</div>
<div class="info" style="text-align:center">
<strong>${nomeComanda}</strong>
</div>
<div class="info" style="display:flex;justify-content:space-between">
<span>Mesa: ${nomeMesa}</span>
<span>${dataAtual}</span>
</div>
<div class="product">
${nomeProduto}
</div>
${observacao ? `<div class="obs">OBS: ${observacao}</div>` : ''}
<div class="footer">
Atendido por: ${usuario}
</div>
</div>
</body>
</html>
`;
window.electronAPI.printDirect(htmlTicket).then(r => {
if (r.ok) {
// OK
} else if (r.error === 'NO_PRINTER') {
showToast('⚠️ Nenhuma impressora configurada. Abra as configurações do sistema para adicionar uma.', 'warning', 5000);
const printWindow = window.open('', '', 'width=300,height=400');
printWindow.document.write(htmlTicket);
printWindow.document.close();
setTimeout(() => printWindow.print(), 300);
} else {
const printWindow = window.open('', '', 'width=300,height=400');
printWindow.document.write(htmlTicket);
printWindow.document.close();
setTimeout(() => printWindow.print(), 300);
}
});
}
function getImageUrl(imagePath) {
if (!imagePath) return null;
if (imagePath.startsWith('data:')) return imagePath;
if (imagePath.startsWith('http://') || imagePath.startsWith('https://')) return imagePath;
return `http://localhost:8080${imagePath}`;
}
function getProductImage(product) {
const img = getImageUrl(product.image);
return img || 'https://wallpapers.com/images/featured/fundo-abstrato-escuro-27kvn4ewpldsngbu.jpg';
}
export async function renderComandas(container) {
container.innerHTML = `
<div class="page-header">
@@ -471,9 +568,11 @@ async function abrirItensComanda(comandaIdOrObj) {
const ativa = comanda.status === 'OPEN' || comanda.status === 'PAYING';
const podeAdd = comanda.status === 'OPEN';
// Carrega produtos (ativos)
const pRes = await window.electronAPI.get('/products');
// Carrega produtos (ativos) e usuário logado
const [pRes, loggedUser] = await Promise.all([
window.electronAPI.get('/products'),
window.electronAPI.getUser()
]);
let todosProdutos = pRes.ok ? pRes.data.filter(p => p.active) : [];
openModal({
@@ -497,7 +596,7 @@ async function abrirItensComanda(comandaIdOrObj) {
</div>
</div>`,
footer: `
<button class="btn btn-secondary btn-md" id="btn-pdv-imprimir">🖨️ Imprimir</button>
<button class="btn btn-secondary btn-md" id="btn-pdv-imprimir">🖨️ Imprimir Comanda</button>
<button class="btn btn-secondary btn-md" onclick="closeModal()">Sair do PDV</button>
`,
});
@@ -531,7 +630,7 @@ async function abrirItensComanda(comandaIdOrObj) {
<thead><tr style="color:var(--text-muted);border-bottom:1px solid var(--border)">
<th style="text-align:left;padding:8px 0">Produto</th>
<th style="text-align:right;padding:8px 0">Preço</th>
${podeAdd ? '<th style="text-align:center;padding:8px 0">Ação</th>' : ''}
<th style="text-align:center;padding:8px 0">Ações</th>
</tr></thead>
<tbody>
${itens.map(it => {
@@ -542,24 +641,29 @@ async function abrirItensComanda(comandaIdOrObj) {
return `
<tr data-item-id="${it.id}">
<td style="padding:10px 0;border-bottom:1px solid var(--border)" ${tooltip}>
${prod?.name || it.product_name || `Produto #${it.product}`}
${isCuisine ? '🍳 ' : ''}${prod?.name || it.product_name || `Produto #${it.product}`}
${it.obs ? `<br><small style="color:var(--text-muted);font-size:0.75rem">OBS: ${it.obs}</small>` : ''}
</td>
<td style="padding:10px 0;text-align:right;border-bottom:1px solid var(--border)">
R$ ${(_productsMap[String(it.product)] || 0).toFixed(2)}
</td>
${podeAdd ? `
<td style="padding:10px 0;text-align:center;border-bottom:1px solid var(--border)">
<div style="display:flex; gap:8px; justify-content:center">
${isCuisine ? `
<button class="btn btn-ghost btn-sm btn-edit-obs" data-id="${it.id}" title="Editar observação" style="color: var(--primary); font-size: 1rem;">
<button class="btn btn-ghost btn-sm btn-reprint-cozinha" data-id="${it.id}" title="Reimprimir Ticket" style="color: var(--warning); font-size: 0.9rem;">
🖨️
</button>
<button class="btn btn-ghost btn-sm btn-edit-obs" data-id="${it.id}" title="Editar observação" style="color: var(--primary); font-size: 0.9rem;">
📝
</button>
` : ''}
<button class="btn btn-ghost btn-sm btn-del-item" data-id="${it.id}" title="Excluir item" style="color: var(--danger); font-size: 1rem;">
🗑️
</button>
${podeAdd ? `
<button class="btn btn-ghost btn-sm btn-del-item" data-id="${it.id}" title="Excluir item" style="color: var(--danger); font-size: 0.9rem;">
🗑️
</button>
` : ''}
</div>
</td>` : ''}
</td>
</tr>`;
}).join('')}
</tbody>
@@ -635,6 +739,19 @@ async function abrirItensComanda(comandaIdOrObj) {
});
});
// Listeners de reimpressão de ticket de cozinha
container.querySelectorAll('.btn-reprint-cozinha').forEach(btn => {
btn.addEventListener('click', () => {
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) {
imprimirTicketCozinha(comanda, item, prod, item.obs, loggedUser);
}
});
});
// Listeners de edição de observação
container.querySelectorAll('.btn-edit-obs').forEach(btn => {
btn.addEventListener('click', async () => {
@@ -707,31 +824,33 @@ async function abrirItensComanda(comandaIdOrObj) {
}
};
const processarResultadoAdd = async (r, prod, obs = '') => {
const processarResultadoAdd = async (r, prod, obs = '', loggedUser = null) => {
if (r.ok) {
if (!comanda.items) comanda.items = [];
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
productComanda: novoItem.id,
id_product: prod.id,
id_comanda: comanda.id,
obs: obs || novoItem.obs || ''
obs: obs || novoItem.obs || '',
applicant: loggedUser?.username || 'Sistema'
};
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');
setTimeout(() => {
imprimirTicketCozinha(comanda, novoItem, prod, obs, loggedUser);
}, 300);
} else {
showToast('Item adicionado, mas falhou ao enviar para cozinha.', 'warning');
}
@@ -763,7 +882,7 @@ async function abrirItensComanda(comandaIdOrObj) {
obs: obs,
applicant: loggedUser?.username || 'Sistema'
});
processarResultadoAdd(r, prod, obs);
processarResultadoAdd(r, prod, obs, loggedUser);
});
} else {
const loggedUser = await window.electronAPI.getUser();
@@ -786,7 +905,7 @@ async function abrirItensComanda(comandaIdOrObj) {
// 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')`;
const imgTarget = `url('${getProductImage(p)}')`;
return `
<div class="pdv-product-card" data-id="${p.id}">
<div class="pdv-product-bg" style="background-image: ${imgTarget}"></div>

View File

@@ -1,11 +1,12 @@
export async function renderPagamentos(container) {
// Carrega tipos de pagamento para o formulário de novo registro
const [tRes, cRes] = await Promise.all([
const [tRes, cRes, cliRes] = await Promise.all([
window.electronAPI.get('/payment-types'),
window.electronAPI.get('/comandas'),
window.electronAPI.get('/clients'),
]);
const tiposPag = tRes.ok ? tRes.data : [];
const comandas = cRes.ok ? cRes.data : [];
const clientes = cliRes.ok ? cliRes.data : [];
container.innerHTML = `
<div class="page-header">
@@ -28,45 +29,62 @@ export async function renderPagamentos(container) {
<div id="pagamentos-table"></div>
</div>`;
await loadPagamentos(tiposPag, comandas);
await loadPagamentos(tiposPag, comandas, clientes);
document.getElementById('btn-novo-pag').addEventListener('click', () => abrirModalPagamento(tiposPag, comandas));
document.getElementById('search-pag').addEventListener('input', () => filtrarPagamentos(tiposPag));
document.getElementById('filter-tipo').addEventListener('change', () => filtrarPagamentos(tiposPag));
document.getElementById('search-pag').addEventListener('input', () => filtrarPagamentos());
document.getElementById('filter-tipo').addEventListener('change', () => filtrarPagamentos());
}
let _pagsData = [];
let _cmdMap = {};
let _tiposPag = [];
let _clientesMap = {};
async function loadPagamentos(tiposPag, comandas) {
async function loadPagamentos(tiposPag, comandas, clientes) {
const wrap = document.getElementById('pagamentos-table');
if (!wrap) return;
wrap.innerHTML = `<div class="loading-screen"><div class="spinner"></div></div>`;
_tiposPag = tiposPag;
const res = await window.electronAPI.get('/payments');
if (!res.ok) { wrap.innerHTML = `<div class="table-empty">Erro ao carregar pagamentos.</div>`; return; }
// 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}`})`;
_cmdMap = (comandas || []).reduce((acc, c) => {
acc[String(c.id)] = {
name: c.name || 'Sem nome',
mesa: c.mesa_name || `Mesa ${c.mesa}`,
mesa_name: c.mesa_name || '',
status: c.status,
dt_open: c.dt_open,
client: c.client
};
return acc;
}, {});
renderPagsTable(_pagsData, cmdMap);
if (clientes) {
_clientesMap = (clientes || []).reduce((acc, c) => {
acc[String(c.id)] = c.name || '';
return acc;
}, {});
}
renderPagsTable();
}
function renderPagsTable(data, cmdMap = {}) {
function renderPagsTable() {
const wrap = document.getElementById('pagamentos-table');
if (!wrap) return;
if (!data.length) {
if (!_pagsData.length) {
wrap.innerHTML = `<div class="table-empty">Nenhum pagamento registrado.</div>`;
return;
}
// Soma total dos pagamentos exibidos
const total = data.reduce((acc, p) => acc + parseFloat(p.value || 0), 0);
const total = _pagsData.reduce((acc, p) => acc + parseFloat(p.value || 0), 0);
wrap.innerHTML = `
<table>
@@ -81,12 +99,14 @@ function renderPagsTable(data, cmdMap = {}) {
<th>Ações</th>
</tr></thead>
<tbody>
${data.map(p => {
const cDesc = cmdMap[String(p.comanda)] || p.comanda_name || '';
${_pagsData.map(p => {
const cInfo = _cmdMap[String(p.comanda)];
const clienteNome = _clientesMap[String(p.client)] || p.client_name || '';
const cDesc = cInfo ? `${cInfo.name} (${cInfo.mesa})` : (p.comanda_name || '');
return `
<tr>
<td style="color:var(--text-muted)">#${p.id}</td>
<td>${p.client_name || ''}</td>
<td>${clienteNome}</td>
<td>
${p.comanda ? `<span style="font-size:0.8rem">
<span style="color:var(--text-muted)">#${p.comanda}</span>
@@ -100,7 +120,7 @@ function renderPagsTable(data, cmdMap = {}) {
</td>
<td style="white-space:nowrap;font-size:0.82rem">${formatDate(p.datetime)}</td>
<td>
<button class="btn btn-danger btn-sm btn-del-pag" data-id="${p.id}">Excluir</button>
<button class="btn btn-secondary btn-sm btn-view-pag" data-id="${p.id}">🔍 Ver</button>
</td>
</tr>`;
}).join('')}
@@ -111,29 +131,161 @@ function renderPagsTable(data, cmdMap = {}) {
<strong style="color:var(--success);font-size:1rem">R$ ${total.toFixed(2)}</strong>
</div>`;
wrap.querySelectorAll('.btn-del-pag').forEach(btn =>
btn.addEventListener('click', async () => {
const r = await window.electronAPI.delete(`/payments/${btn.dataset.id}`);
if (r.ok) { showToast('Pagamento excluído!', 'success'); loadPagamentos([], []); }
else showToast(r.error, 'error');
wrap.querySelectorAll('.btn-view-pag').forEach(btn =>
btn.addEventListener('click', () => {
const pag = _pagsData.find(p => String(p.id) === String(btn.dataset.id));
if (pag) abrirDetalhesPagamento(pag);
})
);
}
function filtrarPagamentos(tiposPag) {
function abrirDetalhesPagamento(pagamento) {
const cInfo = _cmdMap[String(pagamento.comanda)];
const tipoNome = _tiposPag.find(t => String(t.id) === String(pagamento.type_pay))?.name ||
_tiposPag.find(t => String(t.id) === String(pagamento.type_pay))?.nome ||
pagamento.type_pay_name || '';
const clienteNome = _clientesMap[String(pagamento.client)] || pagamento.client_name || '';
openModal({
title: `💳 Detalhes do Pagamento #${pagamento.id}`,
body: `
<div style="display:flex;flex-direction:column;gap:16px">
<div class="card" style="padding:16px;background:var(--bg-elevated)">
<div style="display:grid;grid-template-columns:1fr 1fr;gap:16px">
<div>
<div style="font-size:0.75rem;color:var(--text-muted);text-transform:uppercase;margin-bottom:4px">Valor</div>
<div style="font-size:1.5rem;font-weight:700;color:var(--success)">R$ ${parseFloat(pagamento.value || 0).toFixed(2)}</div>
</div>
<div>
<div style="font-size:0.75rem;color:var(--text-muted);text-transform:uppercase;margin-bottom:4px">Forma de Pagamento</div>
<div style="font-size:1rem;font-weight:600">${tipoNome}</div>
</div>
</div>
</div>
<div style="display:grid;grid-template-columns:1fr 1fr;gap:12px">
<div>
<div style="font-size:0.75rem;color:var(--text-muted);text-transform:uppercase;margin-bottom:4px">Cliente</div>
<div style="font-weight:500">${clienteNome}</div>
</div>
<div>
<div style="font-size:0.75rem;color:var(--text-muted);text-transform:uppercase;margin-bottom:4px">Data/Hora</div>
<div style="font-weight:500">${formatDate(pagamento.datetime)}</div>
</div>
</div>
${pagamento.comanda ? `
<div>
<div style="font-size:0.75rem;color:var(--text-muted);text-transform:uppercase;margin-bottom:8px">Comanda</div>
<div class="card" style="padding:12px;background:var(--bg-elevated)">
<div style="display:flex;justify-content:space-between;margin-bottom:8px">
<div>
<div style="font-weight:600">#${pagamento.comanda}${cInfo?.name || ''}</div>
<div style="font-size:0.8rem;color:var(--text-secondary)">${cInfo?.mesa || ''}</div>
</div>
<span class="badge badge-${cInfo?.status === 'FIADO' ? 'warning' : cInfo?.status === 'CLOSED' ? 'success' : 'info'}">
${cInfo?.status || ''}
</span>
</div>
${cInfo?.dt_open ? `
<div style="font-size:0.8rem;color:var(--text-muted)">
Abertura: ${formatDate(cInfo.dt_open)}
</div>
` : ''}
</div>
</div>
` : ''}
${pagamento.description ? `
<div>
<div style="font-size:0.75rem;color:var(--text-muted);text-transform:uppercase;margin-bottom:4px">Descrição</div>
<div style="padding:10px;background:var(--bg-elevated);border-radius:var(--radius-sm)">${pagamento.description}</div>
</div>
` : ''}
</div>
`,
footer: `<button class="btn btn-secondary btn-md" onclick="closeModal()">Fechar</button>`
});
}
function filtrarPagamentos() {
const q = document.getElementById('search-pag')?.value.toLowerCase() || '';
const tipo = parseInt(document.getElementById('filter-tipo')?.value) || null;
const filtered = _pagsData.filter(p => {
const clienteNome = _clientesMap[String(p.client)] || p.client_name || '';
const cInfo = _cmdMap[String(p.comanda)];
const comandaNome = cInfo?.name || p.comanda_name || '';
const matchQ = !q ||
(p.client_name || '').toLowerCase().includes(q) ||
(p.comanda_name || '').toLowerCase().includes(q) ||
clienteNome.toLowerCase().includes(q) ||
comandaNome.toLowerCase().includes(q) ||
(p.description || '').toLowerCase().includes(q) ||
String(p.id).includes(q);
const matchTipo = !tipo || p.type_pay === tipo;
return matchQ && matchTipo;
});
renderPagsTable(filtered);
const wrap = document.getElementById('pagamentos-table');
if (!wrap) return;
if (!filtered.length) {
wrap.innerHTML = `<div class="table-empty">Nenhum pagamento encontrado.</div>`;
return;
}
const total = filtered.reduce((acc, p) => acc + parseFloat(p.value || 0), 0);
wrap.innerHTML = `
<table>
<thead><tr>
<th>#</th>
<th>Cliente</th>
<th>Comanda</th>
<th>Tipo</th>
<th>Valor</th>
<th>Descrição</th>
<th>Data</th>
<th>Ações</th>
</tr></thead>
<tbody>
${filtered.map(p => {
const cInfo = _cmdMap[String(p.comanda)];
const clienteNome = _clientesMap[String(p.client)] || p.client_name || '';
const cDesc = cInfo ? `${cInfo.name} (${cInfo.mesa})` : (p.comanda_name || '');
return `
<tr>
<td style="color:var(--text-muted)">#${p.id}</td>
<td>${clienteNome}</td>
<td>
${p.comanda ? `<span style="font-size:0.8rem">
<span style="color:var(--text-muted)">#${p.comanda}</span>
<span style="color:var(--text-secondary)"> ${cDesc}</span>
</span>` : ''}
</td>
<td><span class="badge badge-info">${p.type_pay_name || ''}</span></td>
<td><strong style="color:var(--success)">R$ ${parseFloat(p.value || 0).toFixed(2)}</strong></td>
<td style="max-width:180px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;color:var(--text-secondary);font-size:0.82rem">
${p.description || ''}
</td>
<td style="white-space:nowrap;font-size:0.82rem">${formatDate(p.datetime)}</td>
<td>
<button class="btn btn-secondary btn-sm btn-view-pag" data-id="${p.id}">🔍 Ver</button>
</td>
</tr>`;
}).join('')}
</tbody>
</table>
<div style="padding:14px 20px;border-top:1px solid var(--border);display:flex;justify-content:flex-end;align-items:center;gap:8px">
<span style="font-size:0.82rem;color:var(--text-secondary)">Total exibido:</span>
<strong style="color:var(--success);font-size:1rem">R$ ${total.toFixed(2)}</strong>
</div>`;
wrap.querySelectorAll('.btn-view-pag').forEach(btn =>
btn.addEventListener('click', () => {
const pag = _pagsData.find(p => String(p.id) === String(btn.dataset.id));
if (pag) abrirDetalhesPagamento(pag);
})
);
}
function abrirModalPagamento(tiposPag, comandas) {
@@ -185,7 +337,7 @@ function abrirModalPagamento(tiposPag, comandas) {
};
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, [], null); }
else showToast(r.error, 'error');
});
}

View File

@@ -54,6 +54,7 @@ let _ordersData = [];
let _productsMap = {};
let _comandasMap = {};
let _mesasMap = {};
let _productsFullMap = {};
async function loadOrders() {
const wrap = document.getElementById('orders-table');
@@ -70,13 +71,17 @@ async function loadOrders() {
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 (pRes.ok) {
_productsMap = pRes.data.reduce((acc, p) => { acc[String(p.id)] = p.name; return acc; }, {});
_productsFullMap = pRes.data.reduce((acc, p) => { acc[String(p.id)] = p; 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)] = {
id: c.id,
name: c.name || '',
mesa: _mesasMap[String(c.mesa)] || `Mesa ${c.mesa}` || ''
mesa: _mesasMap[String(c.mesa)] || `Mesa ${c.mesa}` || '',
mesa_name: c.mesa_name || _mesasMap[String(c.mesa)] || ''
};
return acc;
}, {});
@@ -132,6 +137,7 @@ function renderOrdersTable(data) {
</td>
<td>
<div style="display:flex;gap:6px;flex-wrap:wrap">
<button class="btn btn-secondary btn-sm btn-reprint" data-id="${o.id}" title="Reimprimir Ticket">🖨</button>
${cfg.next ? `<button class="btn btn-success btn-sm btn-avanca" data-id="${o.id}" data-next="${cfg.next}">${cfg.nextLabel}</button>` : ''}
${o.status !== 'Cancelado' && o.status !== 'Entregue'
? `<button class="btn btn-danger btn-sm btn-cancela" data-id="${o.id}">✕</button>`
@@ -169,6 +175,92 @@ function renderOrdersTable(data) {
})
);
// Reimprimir ticket de cozinha
wrap.querySelectorAll('.btn-reprint').forEach(btn =>
btn.addEventListener('click', async () => {
const orderId = btn.dataset.id;
const order = _ordersData.find(o => String(o.id) === String(orderId));
if (!order) return showToast('Pedido não encontrado.', 'error');
const comanda = _comandasMap[String(order.id_comanda)];
const product = _productsFullMap[String(order.id_product)];
const dataAtual = new Date().toLocaleString('pt-BR');
const nomeEstabelecimento = 'RRBEC - Bar & Restaurante';
const nomeProduto = product?.name || order.product_name || `Produto #${order.id_product}`;
const obs = order.obs || '';
const usuario = order.applicant || 'Sistema';
const nomeComanda = comanda?.name || `Comanda #${comanda?.id || order.id_comanda}`;
const nomeMesa = comanda?.mesa_name || comanda?.mesa || '';
const htmlTicket = `
<!DOCTYPE html>
<html>
<head>
<meta charset="UTF-8">
<title>Ticket Cozinha - ${nomeComanda}</title>
<style>
* { box-sizing: border-box; margin: 0; padding: 0; }
body { font-family: 'Courier New', monospace; font-size: 13px; padding: 8px; width: 80mm; }
.ticket { display: block; color: black; }
.ticket * { color: black !important; background: transparent !important; }
.header { text-align: center; border-bottom: 2px solid #000; padding-bottom: 8px; margin-bottom: 8px; }
.title { font-size: 16px; font-weight: bold; text-transform: uppercase; }
.subtitle { font-size: 12px; margin-top: 4px; }
.info { margin: 4px 0; font-size: 12px; }
.info strong { font-size: 14px; }
.product { font-size: 18px; font-weight: bold; text-align: center; margin: 12px 0; padding: 8px; border: 3px double #000; text-transform: uppercase; }
.obs { font-style: italic; font-size: 11px; text-align: center; margin-top: 8px; padding: 6px; border: 1px dashed #000; }
.footer { text-align: center; font-size: 10px; margin-top: 8px; color: #666; }
@media print { @page { size: 80mm auto; margin: 0; } }
</style>
</head>
<body>
<div class="ticket">
<div class="header">
<div class="title">🍳 COZINHA - REIMPRESSÃO</div>
<div class="subtitle">${nomeEstabelecimento}</div>
</div>
<div class="info" style="text-align:center">
<strong>${nomeComanda}</strong>
</div>
<div class="info" style="display:flex;justify-content:space-between">
<span>Mesa: ${nomeMesa}</span>
<span>${dataAtual}</span>
</div>
<div class="product">${nomeProduto}</div>
${obs ? `<div class="obs">OBS: ${obs}</div>` : ''}
<div class="footer">
Atendido por: ${usuario}
</div>
</div>
</body>
</html>
`;
window.electronAPI.printDirect(htmlTicket).then(r => {
if (r.ok) {
showToast('Ticket reimpresso!', 'success');
} else if (r.error === 'NO_PRINTER') {
showToast('⚠️ Nenhuma impressora configurada. Configure uma impressora nas configurações do sistema.', 'warning', 5000);
const printWindow = window.open('', '', 'width=300,height=400');
printWindow.document.write(htmlTicket);
printWindow.document.close();
setTimeout(() => printWindow.print(), 300);
} else {
const printWindow = window.open('', '', 'width=300,height=400');
printWindow.document.write(htmlTicket);
printWindow.document.close();
setTimeout(() => printWindow.print(), 300);
}
});
})
);
// Editar observação
wrap.querySelectorAll('.btn-edit-obs').forEach(btn =>
btn.addEventListener('click', () => {

View File

@@ -146,10 +146,21 @@ function filtrarProdutos(categorias, unidades) {
function abrirModalProduto(produto, categorias, unidades) {
const isEdit = !!produto;
const imagemAtual = produto?.image || '';
openModal({
title: isEdit ? `Editar: ${produto.name}` : 'Novo Produto',
body: `
<div class="form-grid">
<div class="form-group" style="grid-column:1/-1; text-align:center">
<div id="prod-img-preview" style="width:120px;height:120px;border-radius:var(--radius-sm);border:2px dashed var(--border);margin:0 auto 10px;display:flex;align-items:center;justify-content:center;overflow:hidden;background:var(--bg-elevated);cursor:pointer">
${imagemAtual ? `<img src="${imagemAtual}" style="max-width:100%;max-height:100%;object-fit:cover" />` : '<span style="font-size:2rem;color:var(--text-muted)">📷</span>'}
</div>
<input type="file" id="prod-img-file" accept="image/*" style="display:none" />
<button type="button" class="btn btn-secondary btn-sm" id="btn-select-img">Selecionar Imagem</button>
${imagemAtual ? '<button type="button" class="btn btn-ghost btn-sm" id="btn-remove-img" style="color:var(--danger)">Remover</button>' : ''}
<input type="hidden" id="prod-img" value="${imagemAtual}" />
</div>
<div class="form-group" style="grid-column:1/-1">
<label>Nome</label>
<input type="text" id="prod-nome" class="form-control" value="${produto?.name || ''}" placeholder="Nome do produto" />
@@ -194,16 +205,43 @@ function abrirModalProduto(produto, categorias, unidades) {
<label>Descrição</label>
<input type="text" id="prod-desc" class="form-control" value="${produto?.description || ''}" placeholder="Descrição opcional" />
</div>
<div class="form-group" style="grid-column:1/-1">
<label>URL da Imagem</label>
<input type="text" id="prod-img" class="form-control" value="${produto?.image || ''}" placeholder="http://..." />
</div>
</div>`,
footer: `
<button class="btn btn-secondary btn-md" onclick="closeModal()">Cancelar</button>
<button class="btn btn-primary btn-md" id="btn-salvar-prod">${isEdit ? 'Salvar Alterações' : 'Criar Produto'}</button>`,
});
const previewEl = document.getElementById('prod-img-preview');
const fileInput = document.getElementById('prod-img-file');
const imgInput = document.getElementById('prod-img');
previewEl.addEventListener('click', () => fileInput.click());
document.getElementById('btn-select-img').addEventListener('click', () => fileInput.click());
fileInput.addEventListener('change', () => {
const file = fileInput.files[0];
if (!file) return;
if (file.size > 5 * 1024 * 1024) {
showToast('Imagem muito grande. Máximo 5MB.', 'warning');
return;
}
const reader = new FileReader();
reader.onload = (e) => {
const base64 = e.target.result;
imgInput.value = base64;
previewEl.innerHTML = `<img src="${base64}" style="max-width:100%;max-height:100%;object-fit:cover" />`;
};
reader.readAsDataURL(file);
});
const btnRemoveImg = document.getElementById('btn-remove-img');
if (btnRemoveImg) {
btnRemoveImg.addEventListener('click', () => {
imgInput.value = '';
previewEl.innerHTML = '<span style="font-size:2rem;color:var(--text-muted)">📷</span>';
});
}
document.getElementById('btn-salvar-prod').addEventListener('click', async () => {
const btn = document.getElementById('btn-salvar-prod');
btn.disabled = true;
@@ -212,7 +250,6 @@ function abrirModalProduto(produto, categorias, unidades) {
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 = {
name: document.getElementById('prod-nome').value.trim(),
description: document.getElementById('prod-desc').value.trim(),
@@ -220,9 +257,11 @@ function abrirModalProduto(produto, categorias, unidades) {
quantity: parseInt(document.getElementById('prod-qty').value) || 0,
active: document.getElementById('prod-ativo').value === 'true',
cuisine: document.getElementById('prod-cuisine').value === 'true',
image: document.getElementById('prod-img').value.trim(),
};
const imgValue = document.getElementById('prod-img').value.trim();
if (imgValue) data.image = imgValue;
if (catVal) data.category = catVal;
if (unitVal) data.unit_of_measure = unitVal;