feat: sistema GastroBar PDV completo

- Backend: Fastify + Prisma/SQLite com autenticacao JWT
- CRUD completo: usuarios, categorias, produtos, comandas
- Sistema de comandas com itens, status, pagamento
- Controle de estoque (entrada/saida automatica)
- Relatorios e dashboard com KPIs
- Notificacoes sonoras na cozinha
- Frontend: SPA vanilla TS com tema dark glassmorphism
- Mobile-first com bottom-sheet e cards compactos
- Busca de comandas por periodo
- Modal de detalhes com secao editavel colapsavel
This commit is contained in:
2026-07-21 12:47:15 -03:00
commit 402ac776ac
55 changed files with 7403 additions and 0 deletions

200
web/src/views/dashboard.ts Normal file
View File

@@ -0,0 +1,200 @@
import { api } from '../api';
import { renderNavbar, toast, formatBRL } from '../utils';
export async function renderDashboard(container: HTMLElement) {
container.innerHTML = `
${renderNavbar('/dashboard')}
<div class="container animate-fade-in">
<div class="page-header">
<h1>Dashboard & Relatórios 📊</h1>
<button class="btn btn-secondary btn-sm" id="btn-refresh-dashboard">🔄 Atualizar</button>
</div>
<!-- KPI Cards -->
<div id="kpi-section" class="grid" style="grid-template-columns: repeat(auto-fit, minmax(200px, 1fr)); gap: 1rem; margin-bottom: 2rem;">
<div class="card" style="text-align:center;">
<p class="card-subtitle" style="margin-bottom:0.5rem;">Vendas Hoje</p>
<p style="font-size:1.75rem;font-weight:700;color:var(--success);" id="kpi-vendas-hoje">R$ 0,00</p>
<p class="card-subtitle" id="kpi-comandas-hoje">0 comandas</p>
</div>
<div class="card" style="text-align:center;">
<p class="card-subtitle" style="margin-bottom:0.5rem;">Vendas Semana</p>
<p style="font-size:1.75rem;font-weight:700;color:var(--accent-primary);" id="kpi-vendas-semana">R$ 0,00</p>
<p class="card-subtitle" id="kpi-comandas-semana">0 comandas</p>
</div>
<div class="card" style="text-align:center;">
<p class="card-subtitle" style="margin-bottom:0.5rem;">Comandas Abertas</p>
<p style="font-size:1.75rem;font-weight:700;color:var(--warning);" id="kpi-comandas-abertas">0</p>
</div>
<div class="card" style="text-align:center;">
<p class="card-subtitle" style="margin-bottom:0.5rem;">Estoque Baixo</p>
<p style="font-size:1.75rem;font-weight:700;color:var(--accent-primary);" id="kpi-estoque-baixo">0</p>
<p class="card-subtitle" id="kpi-total-produtos">0 produtos ativos</p>
</div>
</div>
<!-- Relatório de Vendas -->
<div class="glass-panel" style="margin-bottom:2rem;">
<div class="page-header" style="margin-bottom:1rem;">
<h3 style="margin:0;">Relatório de Vendas</h3>
<div style="display:flex;gap:0.5rem;align-items:center;flex-wrap:wrap;">
<label style="color:var(--text-secondary);font-size:0.85rem;">Período:</label>
<input type="date" id="rel-inicio" class="form-control" style="width:auto;">
<span style="color:var(--text-secondary);">até</span>
<input type="date" id="rel-fim" class="form-control" style="width:auto;">
<button class="btn btn-sm" id="btn-gerar-relatorio">Gerar</button>
</div>
</div>
<div id="relatorio-resumo" style="display:grid;grid-template-columns:repeat(auto-fit,minmax(150px,1fr));gap:1rem;margin-bottom:1.5rem;">
</div>
<div id="relatorio-top-produtos" style="margin-bottom:1.5rem;"></div>
<div id="relatorio-vendas-dia" style="margin-bottom:1.5rem;"></div>
<div id="relatorio-pagamento"></div>
</div>
</div>
`;
// Set default dates
const hoje = new Date().toISOString().split('T')[0];
const inicioMes = new Date(new Date().getFullYear(), new Date().getMonth(), 1).toISOString().split('T')[0];
(document.getElementById('rel-inicio') as HTMLInputElement).value = inicioMes;
(document.getElementById('rel-fim') as HTMLInputElement).value = hoje;
const loadDashboard = async () => {
try {
const data = await api.get('/relatorios/dashboard');
document.getElementById('kpi-vendas-hoje')!.textContent = formatBRL(data.vendasHoje.total);
document.getElementById('kpi-comandas-hoje')!.textContent = `${data.vendasHoje.comandas} comandas`;
document.getElementById('kpi-vendas-semana')!.textContent = formatBRL(data.vendasSemana.total);
document.getElementById('kpi-comandas-semana')!.textContent = `${data.vendasSemana.comandas} comandas`;
document.getElementById('kpi-comandas-abertas')!.textContent = `${data.comandasAbertas}`;
document.getElementById('kpi-estoque-baixo')!.textContent = `${data.produtosEstoqueBaixo}`;
document.getElementById('kpi-total-produtos')!.textContent = `${data.totalProdutos} produtos ativos`;
} catch (err: any) {
toast(err.message, 'error');
}
};
const loadRelatorio = async () => {
const inicio = (document.getElementById('rel-inicio') as HTMLInputElement).value;
const fim = (document.getElementById('rel-fim') as HTMLInputElement).value;
try {
const params = new URLSearchParams();
if (inicio) params.set('inicio', inicio);
if (fim) params.set('fim', fim);
const data = await api.get(`/relatorios/vendas?${params.toString()}`);
// Resumo
document.getElementById('relatorio-resumo')!.innerHTML = `
<div class="card" style="text-align:center;padding:1rem;">
<p class="card-subtitle" style="margin-bottom:0.25rem;">Total Vendido</p>
<p style="font-size:1.25rem;font-weight:700;color:var(--success);">${formatBRL(data.resumo.totalVendas)}</p>
</div>
<div class="card" style="text-align:center;padding:1rem;">
<p class="card-subtitle" style="margin-bottom:0.25rem;">Comandas</p>
<p style="font-size:1.25rem;font-weight:700;">${data.resumo.totalComandas}</p>
</div>
<div class="card" style="text-align:center;padding:1rem;">
<p class="card-subtitle" style="margin-bottom:0.25rem;">Ticket Médio</p>
<p style="font-size:1.25rem;font-weight:700;color:var(--accent-primary);">${formatBRL(data.resumo.ticketMedio)}</p>
</div>
<div class="card" style="text-align:center;padding:1rem;">
<p class="card-subtitle" style="margin-bottom:0.25rem;">Descontos</p>
<p style="font-size:1.25rem;font-weight:700;color:var(--warning);">${formatBRL(data.resumo.totalDescontos)}</p>
</div>
<div class="card" style="text-align:center;padding:1rem;">
<p class="card-subtitle" style="margin-bottom:0.25rem;">Acréscimos</p>
<p style="font-size:1.25rem;font-weight:700;color:var(--accent-secondary);">${formatBRL(data.resumo.totalAcrescimos)}</p>
</div>
`;
// Top produtos
if (data.topProdutos.length > 0) {
document.getElementById('relatorio-top-produtos')!.innerHTML = `
<h4 style="margin-bottom:0.75rem;">🏆 Mais Vendidos</h4>
<div class="table-wrap">
<table>
<thead><tr><th>#</th><th>Produto</th><th style="text-align:center;">Qtd</th><th style="text-align:right;">Total</th></tr></thead>
<tbody>
${data.topProdutos.map((p: any, i: number) => `
<tr>
<td>${i + 1}º</td>
<td><strong>${p.nome}</strong></td>
<td style="text-align:center;">${p.quantidade}</td>
<td style="text-align:right;">${formatBRL(p.total)}</td>
</tr>
`).join('')}
</tbody>
</table>
</div>
`;
} else {
document.getElementById('relatorio-top-produtos')!.innerHTML = '';
}
// Vendas por dia
if (data.vendasPorDia.length > 0) {
document.getElementById('relatorio-vendas-dia')!.innerHTML = `
<h4 style="margin-bottom:0.75rem;">📅 Vendas por Dia</h4>
<div class="table-wrap">
<table>
<thead><tr><th>Data</th><th style="text-align:center;">Comandas</th><th style="text-align:right;">Total</th></tr></thead>
<tbody>
${data.vendasPorDia.map((d: any) => `
<tr>
<td>${new Date(d.data + 'T12:00:00').toLocaleDateString('pt-BR')}</td>
<td style="text-align:center;">${d.comandas}</td>
<td style="text-align:right;">${formatBRL(d.vendas)}</td>
</tr>
`).join('')}
</tbody>
</table>
</div>
`;
} else {
document.getElementById('relatorio-vendas-dia')!.innerHTML = '';
}
// Vendas por pagamento
if (data.vendasPorPagamento.length > 0) {
const pagLabels: Record<string, string> = {
DINHEIRO: '💵 Dinheiro', CARTAO_CREDITO: '💳 Cartão Crédito',
CARTAO_DEBITO: '💳 Cartão Débito', PIX: '📱 PIX', NAO_INFORMADO: '❓ Não informado',
};
document.getElementById('relatorio-pagamento')!.innerHTML = `
<h4 style="margin-bottom:0.75rem;">💰 Por Forma de Pagamento</h4>
<div class="table-wrap">
<table>
<thead><tr><th>Forma</th><th style="text-align:right;">Total</th></tr></thead>
<tbody>
${data.vendasPorPagamento.map((p: any) => `
<tr>
<td>${pagLabels[p.forma] ?? p.forma}</td>
<td style="text-align:right;">${formatBRL(p.total)}</td>
</tr>
`).join('')}
</tbody>
</table>
</div>
`;
} else {
document.getElementById('relatorio-pagamento')!.innerHTML = '';
}
} catch (err: any) {
toast(err.message, 'error');
}
};
document.getElementById('btn-refresh-dashboard')?.addEventListener('click', () => {
loadDashboard();
loadRelatorio();
});
document.getElementById('btn-gerar-relatorio')?.addEventListener('click', loadRelatorio);
await Promise.all([loadDashboard(), loadRelatorio()]);
}