feat: add PDV Fichas navigation item to the sidebar

This commit is contained in:
Welton Silva
2026-04-28 19:13:23 -03:00
parent d6174da594
commit b022d09a71
4 changed files with 614 additions and 1 deletions

View File

@@ -11,12 +11,14 @@ import { renderClientes } from './pages/clientes.js';
import { renderPagamentos } from './pages/pagamentos.js'; import { renderPagamentos } from './pages/pagamentos.js';
import { renderLogin } from './pages/login.js'; import { renderLogin } from './pages/login.js';
import { renderConfig } from './pages/config.js'; import { renderConfig } from './pages/config.js';
import { renderPdv } from './pages/pdv.js';
// ─── Roteador ──────────────────────────────────────────────────────────────── // ─── Roteador ────────────────────────────────────────────────────────────────
const PAGES = { const PAGES = {
dashboard: renderDashboard, dashboard: renderDashboard,
mesas: renderMesas, mesas: renderMesas,
comandas: renderComandas, comandas: renderComandas,
pdv: renderPdv,
pedidos: renderPedidos, pedidos: renderPedidos,
produtos: renderProdutos, produtos: renderProdutos,
clientes: renderClientes, clientes: renderClientes,

View File

@@ -74,6 +74,9 @@
<a href="#" class="nav-item" data-page="comandas"> <a href="#" class="nav-item" data-page="comandas">
<span class="nav-icon">📋</span><span class="nav-label">Comandas</span> <span class="nav-icon">📋</span><span class="nav-label">Comandas</span>
</a> </a>
<a href="#" class="nav-item" data-page="pdv">
<span class="nav-icon">🖨️</span><span class="nav-label">PDV Fichas</span>
</a>
<a href="#" class="nav-item" data-page="pedidos"> <a href="#" class="nav-item" data-page="pedidos">
<span class="nav-icon">🛒</span><span class="nav-label">Pedidos</span> <span class="nav-icon">🛒</span><span class="nav-label">Pedidos</span>
</a> </a>

587
src/renderer/pages/pdv.js Normal file
View File

@@ -0,0 +1,587 @@
let _productsMap = {};
let _productsNames = {};
let _paymentTypes = [];
let _clients = [];
let _mesas = [];
let _pdvComanda = null;
let _pdvItems = [];
let _loggedUser = null;
export async function renderPdv(container) {
container.innerHTML = `
<div class="page-header">
<div>
<div class="page-title">🖨️ PDV - Impressão de Fichas</div>
<div class="page-subtitle">Adicione itens para imprimir fichas na cozinha</div>
</div>
</div>
<div class="pdv-container">
<div class="pdv-left" id="pdv-ficha-items">
<div class="loading-screen"><div class="spinner"></div></div>
</div>
<div class="pdv-right">
<div class="pdv-header">
<input type="text" class="search-input" id="pdv-search" placeholder="🔍 Buscar produto..." style="width:100%" />
</div>
<div class="pdv-products-grid" id="pdv-products-grid"></div>
</div>
</div>
<div class="pdv-footer-bar">
<div class="pdv-footer-info">
<div style="display:flex;gap:20px">
<div>
<span style="color:var(--text-muted);font-size:0.8rem">Total:</span>
<strong id="pdv-total-all" style="font-size:1.1rem;color:var(--success)">R$ 0,00</strong>
</div>
<div>
<span style="color:var(--text-muted);font-size:0.8rem">Selecionado:</span>
<strong id="pdv-total-selected" style="font-size:1.1rem;color:var(--warning)">R$ 0,00</strong>
</div>
</div>
</div>
<div class="pdv-footer-actions">
<button class="btn btn-secondary btn-lg" id="btn-pdv-limpar">
🗑️ Limpar Tudo
</button>
<button class="btn btn-warning btn-lg" id="btn-pdv-imprimir-fichas" disabled>
🖨️ Imprimir Fichas
</button>
<button class="btn btn-success btn-lg" id="btn-pdv-pagamento">
💰 Pagamento
</button>
</div>
</div>`;
_loggedUser = await window.electronAPI.getUser();
await criarComandaPdv();
await loadPdvData();
setTimeout(() => {
const btnLimpar = document.getElementById('btn-pdv-limpar');
const btnImprimir = document.getElementById('btn-pdv-imprimir-fichas');
const btnPagamento = document.getElementById('btn-pdv-pagamento');
const searchInput = document.getElementById('pdv-search');
if (btnLimpar) {
btnLimpar.onclick = async () => {
if (!_pdvItems.length) return;
if (confirm('Deseja limpar todos os itens do PDV?')) {
await limparPdv();
}
};
}
if (btnImprimir) {
btnImprimir.onclick = () => {
imprimirFichasSelecionadas();
};
}
if (btnPagamento) {
btnPagamento.onclick = () => {
abrirModalPagamentoPdv();
};
}
if (searchInput) {
searchInput.oninput = async (e) => {
const pRes = await window.electronAPI.get('/products');
const todosProdutos = pRes.ok ? pRes.data.filter(p => p.active) : [];
renderRight(todosProdutos, e.target.value);
};
}
}, 150);
}
async function criarComandaPdv() {
const mesasRes = await window.electronAPI.get('/mesas');
if (mesasRes.ok) {
_mesas = mesasRes.data;
}
const comandasRes = await window.electronAPI.get('/comandas');
if (comandasRes.ok) {
const existing = comandasRes.data.find(c => c.name === 'PDV-BALCAO' && c.status === 'OPEN');
if (existing) {
_pdvComanda = existing;
console.log('[PDV] ComandaPDV encontrada:', _pdvComanda);
await recarregarComanda();
return;
}
}
const now = new Date();
const dataStr = now.toLocaleDateString('pt-BR', { day: '2-digit', month: '2-digit' });
const horaStr = now.toLocaleTimeString('pt-BR', { hour: '2-digit', minute: '2-digit' }).replace(':', '');
const userId = _loggedUser?.id || 1;
const nomeComanda = 'PDV-BALCAO';
const mesaId = _mesas[0]?.id || 1;
const r = await window.electronAPI.post('/comandas', {
user: userId,
mesa: mesaId,
name: nomeComanda,
status: 'OPEN'
});
if (r.ok) {
_pdvComanda = r.data;
console.log('[PDV] ComandaPDV criada:', _pdvComanda);
} else {
console.error('[PDV] Erro ao criar comanda:', r.error);
}
}
async function loadPdvData() {
const [pRes, ptRes, cRes] = await Promise.all([
window.electronAPI.get('/products'),
window.electronAPI.get('/payment-types'),
window.electronAPI.get('/clients')
]);
if (pRes.ok) {
_productsMap = pRes.data.reduce((acc, p) => {
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;
}, {});
}
if (ptRes.ok) _paymentTypes = ptRes.data;
if (cRes.ok) _clients = cRes.data;
const todosProdutos = pRes.ok ? pRes.data.filter(p => p.active) : [];
renderRight(todosProdutos);
renderLeft();
}
function renderLeft() {
const container = document.getElementById('pdv-ficha-items');
if (!container) return;
if (!_pdvComanda || !_pdvComanda.items || _pdvComanda.items.length === 0) {
container.innerHTML = `
<div style="padding:40px 20px;text-align:center;color:var(--text-muted)">
<div style="font-size:3rem;margin-bottom:16px">📋</div>
<div>Nenhum item adicionado</div>
<div style="font-size:0.85rem;margin-top:8px">Clique nos produtos para adicionar</div>
</div>`;
updateFooterCounts();
return;
}
_pdvItems = _pdvComanda.items || [];
container.innerHTML = `
<div style="flex:1;overflow-y:auto">
<div style="display:flex;align-items:center;gap:10px;margin-bottom:16px;padding-bottom:12px;border-bottom:1px solid var(--border)">
<input type="checkbox" id="check-all" checked style="width:20px;height:20px;cursor:pointer" />
<label for="check-all" style="cursor:pointer;font-weight:500">Selecionar Todos</label>
</div>
<table style="width:100%;font-size:0.9rem">
<thead>
<tr style="color:var(--text-muted);border-bottom:1px solid var(--border)">
<th style="text-align:left;padding:8px 4px;width:40px"></th>
<th style="text-align:left;padding:8px">Produto</th>
<th style="text-align:right;padding:8px">Preço</th>
<th style="text-align:center;padding:8px;width:50px">Ações</th>
</tr>
</thead>
<tbody>
${_pdvItems.map((it, index) => {
const price = _productsMap[String(it.product)] || 0;
const name = _productsNames[String(it.product)] || it.product_name || `Produto #${it.product}`;
return `
<tr data-index="${index}">
<td style="padding:10px 4px">
<input type="checkbox" class="item-checkbox" data-index="${index}" ${it.selected !== false ? 'checked' : ''} style="width:18px;height:18px;cursor:pointer" />
</td>
<td style="padding:10px 0;border-bottom:1px solid var(--border)">
${it.product_name || name}
${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$ ${price.toFixed(2)}
</td>
<td style="padding:10px 0;text-align:center;border-bottom:1px solid var(--border)">
<button class="btn btn-ghost btn-sm btn-remove-item" data-id="${it.id}" title="Remover" style="color:var(--danger);font-size:0.9rem">
🗑️
</button>
</td>
</tr>`;
}).join('')}
</tbody>
</table>
</div>`;
container.querySelectorAll('.item-checkbox').forEach(cb => {
cb.addEventListener('change', () => {
const idx = parseInt(cb.dataset.index);
if (!_pdvItems[idx]) return;
_pdvItems[idx].selected = cb.checked;
updateFooterCounts();
});
});
container.querySelector('#check-all')?.addEventListener('change', (e) => {
const checked = e.target.checked;
_pdvItems.forEach(it => it.selected = checked);
container.querySelectorAll('.item-checkbox').forEach(cb => cb.checked = checked);
updateFooterCounts();
});
container.querySelectorAll('.btn-remove-item').forEach(btn => {
btn.addEventListener('click', async () => {
const itemId = btn.dataset.id;
if (confirm('Deseja realmente excluir este item?')) {
const r = await window.electronAPI.delete(`/items-comanda/${itemId}`);
if (r.ok) {
showToast('Item excluído!', 'success');
await recarregarComanda();
} else {
showToast(r.error, 'error');
}
}
});
});
updateFooterCounts();
}
async function recarregarComanda() {
if (!_pdvComanda) return;
const r = await window.electronAPI.get(`/comandas/${_pdvComanda.id}`);
if (r.ok) {
_pdvComanda = r.data;
renderLeft();
}
}
function updateFooterCounts() {
const totalAll = (_pdvItems || []).reduce((acc, it) => acc + (_productsMap[String(it.product)] || 0), 0);
const selected = (_pdvItems || []).filter(it => it.selected !== false);
const totalSelected = selected.reduce((acc, it) => acc + (_productsMap[String(it.product)] || 0), 0);
const totalAllEl = document.getElementById('pdv-total-all');
const totalSelectedEl = document.getElementById('pdv-total-selected');
const printBtn = document.getElementById('btn-pdv-imprimir-fichas');
if (totalAllEl) totalAllEl.textContent = `R$ ${totalAll.toFixed(2)}`;
if (totalSelectedEl) totalSelectedEl.textContent = `R$ ${totalSelected.toFixed(2)}`;
if (printBtn) {
printBtn.disabled = selected.length === 0;
printBtn.innerHTML = selected.length > 0
? `🖨️ Imprimir ${selected.length} Ficha${selected.length > 1 ? 's' : ''}`
: '🖨️ Imprimir Fichas';
}
}
function renderRight(todosProdutos, filtro = '') {
const container = document.getElementById('pdv-products-grid');
if (!container) return;
const filtrados = todosProdutos.filter(p => !filtro || p.name.toLowerCase().includes(filtro.toLowerCase())).slice(0, 20);
container.innerHTML = filtrados.map(p => {
const imgUrl = p.image
? (p.image.startsWith('data:') || p.image.startsWith('http')
? p.image
: `http://localhost:8080${p.image}`)
: 'https://wallpapers.com/images/featured/fundo-abstrato-escuro-27kvn4ewpldsngbu.jpg';
return `
<div class="pdv-product-card" data-id="${p.id}" data-cuisine="${p.cuisine || false}">
<div class="pdv-product-bg" style="background-image: url('${imgUrl}')"></div>
<div class="pdv-product-info">
<div class="pdv-product-name">${p.cuisine ? '🍳 ' : ''}${p.name}</div>
<div class="pdv-product-price">R$ ${parseFloat(p.price || 0).toFixed(2)}</div>
</div>
</div>`;
}).join('');
container.querySelectorAll('.pdv-product-card').forEach(card => {
card.addEventListener('click', async () => {
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 (!_pdvComanda) return showToast('Erro: Comanda PDV não criada.', 'error');
if (prod.cuisine) {
window.abrirModalObsCozinhaGlobal(prod.name, '', async (obs) => {
if (obs === null) return;
const loggedUser = await window.electronAPI.getUser();
const r = await window.electronAPI.post('/items-comanda', {
comanda: _pdvComanda.id,
product: prod.id,
obs: obs,
applicant: loggedUser?.username || 'Sistema'
});
await processarResultadoAdd(r, prod, obs, loggedUser);
});
} else {
const loggedUser = await window.electronAPI.getUser();
const r = await window.electronAPI.post('/items-comanda', {
comanda: _pdvComanda.id,
product: prod.id,
applicant: loggedUser?.username || 'Sistema'
});
await processarResultadoAdd(r, prod);
}
});
});
}
async function processarResultadoAdd(r, prod, obs = '', loggedUser = null) {
if (r.ok) {
await recarregarComanda();
if (prod && prod.cuisine) {
const orderPayload = {
productComanda: r.data.id,
id_product: prod.id,
id_comanda: _pdvComanda.id,
obs: obs || r.data.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');
} else {
showToast('Item adicionado, mas falhou ao enviar para cozinha.', 'warning');
}
}
} else {
showToast(r.error, 'error');
}
}
async function imprimirFichasSelecionadas() {
const selected = _pdvItems.filter(it => it.selected !== false);
if (!selected.length) return showToast('Nenhum item selecionado para imprimir.', 'warning');
const loggedUser = await window.electronAPI.getUser();
for (const item of selected) {
const prod = { name: _productsNames[String(item.product)] || item.product_name };
const htmlTicket = gerarHtmlTicket(item, prod, loggedUser);
window.electronAPI.printDirect(htmlTicket).then(r => {
if (r.ok) {
// OK
} else if (r.error === 'NO_PRINTER') {
showToast('Nenhuma impressora configurada. Abra as configuracoes 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);
}
});
await new Promise(r => setTimeout(r, 200));
}
showToast(`${selected.length} ficha${selected.length > 1 ? 's' : ''} enviada(s) para impressao!`, 'success');
}
function gerarHtmlTicket(item, product, loggedUser) {
const dataAtual = new Date().toLocaleString('pt-BR');
const nomeEstabelecimento = 'Raul Rock Bar & Café';
const nomeProduto = product?.name || item.product_name || `Produto #${item.product}`;
const observacao = item.obs || '';
const htmlTicket = `
<!DOCTYPE html>
<html>
<head>
<meta charset="UTF-8">
<title>Ticket PDV - ${nomeProduto}</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">${nomeEstabelecimento}</div>
</div>
<div class="info" style="text-align:center">
<strong>${_pdvComanda?.name || 'PDV'}</strong>
</div>
<div class="info" style="display:flex;justify-content:space-between">
<span>PDV</span>
<span>${dataAtual}</span>
</div>
<div class="product">
${nomeProduto}
</div>
${observacao ? `<div class="obs">OBS: ${observacao}</div>` : ''}
<div class="footer">
Válido somente por essa noite
</div>
<div class="header">
</div>
</div>
</body>
</html>
`;
return htmlTicket;
}
function calcularTotal() {
return _pdvItems.reduce((acc, it) => {
if (it.selected !== false) {
return acc + (_productsMap[String(it.product)] || 0);
}
return acc;
}, 0);
}
function abrirModalPagamentoPdv() {
if (!_pdvComanda || !_pdvItems.length) return showToast('Adicione itens primeiro.', 'warning');
const total = calcularTotal();
const valorRestante = total;
openModal({
title: `💰 Pagamento - ${_pdvComanda.name}`,
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" />
</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}">${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 PDV..." value="Pagamento PDV" />
</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>`
});
const btnConfirmar = document.getElementById('btn-confirmar-pagamento');
btnConfirmar.addEventListener('click', async () => {
const payTypeId = parseInt(document.getElementById('pay-type').value);
const clientId = document.getElementById('pay-client').value || null;
const valor = parseFloat(document.getElementById('pay-value').value);
if (isNaN(valor) || valor <= 0) {
return showToast('Informe um valor válido.', 'error');
}
try {
btnConfirmar.disabled = true;
btnConfirmar.textContent = 'Processando...';
const rPay = await window.electronAPI.post(`/comandas/${_pdvComanda.id}/pagar`, {
value: valor,
type_pay: payTypeId,
client: clientId ? parseInt(clientId) : null,
description: document.getElementById('pay-desc').value || 'Pagamento PDV',
status: 'CLOSED'
});
if (rPay.ok) {
showToast('Pagamento realizado com sucesso!', 'success');
closeModal();
await window.electronAPI.patch(`/comandas/${_pdvComanda.id}`, {
status: 'CLOSED'
});
_pdvItems = [];
_pdvComanda = null;
await criarComandaPdv();
renderLeft();
updateFooterCounts();
} else {
throw new Error(rPay.error || 'Erro ao registrar pagamento.');
}
} catch (err) {
showToast(err.message, 'error');
btnConfirmar.disabled = false;
btnConfirmar.textContent = 'Confirmar Recebimento';
}
});
}
async function excluirComandaPdv() {
if (!_pdvComanda) return;
const itensToDelete = _pdvComanda.items || [];
for (const item of itensToDelete) {
await window.electronAPI.delete(`/items-comanda/${item.id}`);
}
_pdvItems = [];
await recarregarComanda();
showToast('PDV limpo!', 'info');
}
async function limparPdv() {
if (!_pdvComanda || !_pdvComanda.items?.length) {
_pdvItems = [];
renderLeft();
return;
}
for (const item of _pdvComanda.items) {
await window.electronAPI.delete(`/items-comanda/${item.id}`);
}
_pdvItems = [];
await recarregarComanda();
showToast('PDV limpo!', 'info');
}

View File

@@ -756,7 +756,7 @@ select.form-control option {
.pdv-container { .pdv-container {
display: grid; display: grid;
grid-template-columns: 480px 1fr; grid-template-columns: 480px 1fr;
height: 100%; height: calc(100vh - 180px);
gap: 1px; gap: 1px;
background: var(--border); background: var(--border);
} }
@@ -1056,3 +1056,24 @@ select.form-control option {
font-size: 10px; font-size: 10px;
} }
} }
/* PDV Footer Bar */
.pdv-footer-bar {
display: flex;
align-items: center;
justify-content: space-between;
padding: 16px 24px;
background: var(--bg-surface);
border-top: 1px solid var(--border);
}
.pdv-footer-info {
display: flex;
gap: 16px;
font-size: 0.9rem;
}
.pdv-footer-actions {
display: flex;
gap: 12px;
}