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:
181
web/src/views/kitchen.ts
Normal file
181
web/src/views/kitchen.ts
Normal file
@@ -0,0 +1,181 @@
|
||||
import { api } from '../api';
|
||||
import { renderNavbar, toast } from '../utils';
|
||||
|
||||
let previousItemIds: Set<string> = new Set<string>();
|
||||
let audioCtx: AudioContext | null = null;
|
||||
|
||||
function playNotificationSound() {
|
||||
try {
|
||||
if (!audioCtx) audioCtx = new AudioContext();
|
||||
const osc = audioCtx.createOscillator();
|
||||
const gain = audioCtx.createGain();
|
||||
osc.connect(gain);
|
||||
gain.connect(audioCtx.destination);
|
||||
osc.type = 'sine';
|
||||
osc.frequency.setValueAtTime(800, audioCtx.currentTime);
|
||||
osc.frequency.setValueAtTime(1000, audioCtx.currentTime + 0.1);
|
||||
osc.frequency.setValueAtTime(800, audioCtx.currentTime + 0.2);
|
||||
gain.gain.setValueAtTime(0.3, audioCtx.currentTime);
|
||||
gain.gain.exponentialRampToValueAtTime(0.01, audioCtx.currentTime + 0.4);
|
||||
osc.start(audioCtx.currentTime);
|
||||
osc.stop(audioCtx.currentTime + 0.4);
|
||||
} catch (e) {
|
||||
// Audio not supported, silently fail
|
||||
}
|
||||
}
|
||||
|
||||
export async function renderKitchen(container: HTMLElement) {
|
||||
container.innerHTML = `
|
||||
${renderNavbar('/kitchen')}
|
||||
<div class="container animate-fade-in">
|
||||
<div class="page-header">
|
||||
<h1>Painel de Preparo 👨🍳</h1>
|
||||
<div style="display:flex;gap:0.75rem;align-items:center;flex-wrap:wrap;">
|
||||
<label style="color:var(--text-secondary);font-size:0.875rem;">Filtrar:</label>
|
||||
<button class="btn btn-secondary btn-sm filter-btn active" data-filter="">Todos</button>
|
||||
<button class="btn btn-secondary btn-sm filter-btn" data-filter="true">🍳 Cozinha</button>
|
||||
<button class="btn btn-secondary btn-sm filter-btn" data-filter="false">🍹 Bar</button>
|
||||
<button class="btn btn-secondary btn-sm" id="btn-refresh">🔄 Atualizar</button>
|
||||
<label style="display:flex;align-items:center;gap:0.4rem;cursor:pointer;color:var(--text-secondary);font-size:0.875rem;">
|
||||
<input type="checkbox" id="auto-refresh-toggle" checked> Auto-refresh
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="tabs">
|
||||
<button class="tab-btn active" data-status="PENDENTE">🔴 Pendente</button>
|
||||
<button class="tab-btn" data-status="PREPARANDO">🟡 Preparando</button>
|
||||
<button class="tab-btn" data-status="PRONTO">🟢 Pronto</button>
|
||||
<button class="tab-btn" data-status="ENTREGUE">✅ Entregue</button>
|
||||
</div>
|
||||
|
||||
<div class="grid" id="items-grid">
|
||||
<p>Carregando pedidos...</p>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
|
||||
let currentStatus = 'PENDENTE';
|
||||
let currentFilter = '';
|
||||
let autoRefreshInterval: ReturnType<typeof setInterval> | null = null;
|
||||
|
||||
const loadItems = async () => {
|
||||
const grid = document.getElementById('items-grid')!;
|
||||
grid.innerHTML = '<p class="card-subtitle">Carregando...</p>';
|
||||
try {
|
||||
let url = `/painel/itens?status=${currentStatus}`;
|
||||
if (currentFilter !== '') url += `&itemCozinha=${currentFilter}`;
|
||||
const items = await api.get(url);
|
||||
|
||||
const currentIds = new Set<string>(items.map((i: any) => i.id));
|
||||
|
||||
if (previousItemIds.size > 0) {
|
||||
const newItems = items.filter((i: any) => !previousItemIds.has(i.id));
|
||||
if (newItems.length > 0) {
|
||||
playNotificationSound();
|
||||
toast(`${newItems.length} novo(s) pedido(s) recebido(s)!`, 'success');
|
||||
}
|
||||
}
|
||||
previousItemIds = currentIds;
|
||||
|
||||
if (!items.length) {
|
||||
grid.innerHTML = `<p class="card-subtitle" style="grid-column:1/-1;text-align:center;padding:3rem 0;">
|
||||
Nenhum pedido com status <strong>${currentStatus}</strong> 🎉
|
||||
</p>`;
|
||||
return;
|
||||
}
|
||||
|
||||
const statusActions: Record<string, { label: string; next: string }> = {
|
||||
PENDENTE: { label: '▶ Iniciar Preparo', next: 'PREPARANDO' },
|
||||
PREPARANDO: { label: '✔ Marcar Pronto', next: 'PRONTO' },
|
||||
PRONTO: { label: '🚀 Marcar Entregue', next: 'ENTREGUE' },
|
||||
ENTREGUE: { label: '', next: '' },
|
||||
};
|
||||
|
||||
grid.innerHTML = items.map((item: any) => {
|
||||
const action = statusActions[item.status];
|
||||
return `
|
||||
<div class="card animate-fade-in">
|
||||
<div style="display:flex;justify-content:space-between;align-items:flex-start;margin-bottom:1rem;flex-wrap:wrap;gap:0.5rem;">
|
||||
<span class="badge ${item.produto.itemCozinha ? 'badge-cozinha' : 'badge-barman'}">
|
||||
${item.produto.itemCozinha ? '🍳 Cozinha' : '🍹 Bar'}
|
||||
</span>
|
||||
<span class="card-subtitle" style="margin:0;">Mesa: <strong>${item.comanda.identificador}</strong></span>
|
||||
</div>
|
||||
<h3 class="card-title">${item.quantidade}× ${item.produto.nome}</h3>
|
||||
${item.observacao ? `<p style="color:var(--warning);font-size:0.875rem;margin-bottom:1rem;">⚠️ ${item.observacao}</p>` : ''}
|
||||
<p class="card-subtitle" style="margin-bottom:1.5rem;">
|
||||
Pedido às ${new Date(item.criadoEm).toLocaleTimeString('pt-BR', { hour: '2-digit', minute: '2-digit' })}
|
||||
</p>
|
||||
${action.label ? `
|
||||
<button class="btn" style="width:100%;" onclick="window.advanceStatus('${item.id}','${action.next}')">
|
||||
${action.label}
|
||||
</button>
|
||||
` : `<p class="card-subtitle" style="text-align:center;">✅ Entregue</p>`}
|
||||
</div>
|
||||
`;
|
||||
}).join('');
|
||||
} catch (err: any) {
|
||||
grid.innerHTML = `<p style="color:var(--accent-primary)">Erro: ${err.message}</p>`;
|
||||
}
|
||||
};
|
||||
|
||||
const startAutoRefresh = () => {
|
||||
stopAutoRefresh();
|
||||
autoRefreshInterval = setInterval(loadItems, 10000);
|
||||
};
|
||||
|
||||
const stopAutoRefresh = () => {
|
||||
if (autoRefreshInterval) {
|
||||
clearInterval(autoRefreshInterval);
|
||||
autoRefreshInterval = null;
|
||||
}
|
||||
};
|
||||
|
||||
// Tab switching
|
||||
document.querySelectorAll('.tab-btn').forEach(btn => {
|
||||
btn.addEventListener('click', () => {
|
||||
document.querySelectorAll('.tab-btn').forEach(b => b.classList.remove('active'));
|
||||
btn.classList.add('active');
|
||||
currentStatus = (btn as HTMLElement).dataset.status!;
|
||||
loadItems();
|
||||
});
|
||||
});
|
||||
|
||||
// Filter buttons
|
||||
document.querySelectorAll('.filter-btn').forEach(btn => {
|
||||
btn.addEventListener('click', () => {
|
||||
document.querySelectorAll('.filter-btn').forEach(b => b.classList.remove('active'));
|
||||
btn.classList.add('active');
|
||||
currentFilter = (btn as HTMLElement).dataset.filter!;
|
||||
loadItems();
|
||||
});
|
||||
});
|
||||
|
||||
document.getElementById('btn-refresh')?.addEventListener('click', loadItems);
|
||||
|
||||
document.getElementById('auto-refresh-toggle')?.addEventListener('change', (e) => {
|
||||
if ((e.target as HTMLInputElement).checked) startAutoRefresh();
|
||||
else stopAutoRefresh();
|
||||
});
|
||||
|
||||
(window as any).advanceStatus = async (id: string, status: string) => {
|
||||
try {
|
||||
await api.patch(`/painel/itens/${id}/status`, { status });
|
||||
toast('Status atualizado!', 'success');
|
||||
loadItems();
|
||||
} catch (err: any) {
|
||||
toast(err.message, 'error');
|
||||
}
|
||||
};
|
||||
|
||||
// Cleanup on navigation
|
||||
const cleanup = () => {
|
||||
stopAutoRefresh();
|
||||
previousItemIds.clear();
|
||||
};
|
||||
window.addEventListener('popstate', cleanup);
|
||||
|
||||
await loadItems();
|
||||
startAutoRefresh();
|
||||
}
|
||||
Reference in New Issue
Block a user