feat: Update observation tracking in PDV and Kitchen

This commit is contained in:
Welton Silva
2026-02-23 18:31:38 -03:00
commit d1319c39ba
18 changed files with 7983 additions and 0 deletions

4
.gitignore vendored Normal file
View File

@@ -0,0 +1,4 @@
node_modules/
dist/
.env
*.log

56
GUIA_POSTMAN_API.md Normal file
View File

@@ -0,0 +1,56 @@
# Guia de Teste da API com Postman
Este documento explica como testar os endpoints da API do sistema **Gestão Raul** utilizando o Postman.
---
## 1. Passo 1: Obter o Token de Acesso (Login)
Como a API é protegida, você precisa primeiro de um token de autorização.
1. Abra o **Postman** e crie uma nova aba de requisição.
2. Mude o método para **POST**.
3. Coloque a URL: `http://localhost:8000/api/v1/token/`
4. Vá na aba **Body**, selecione a opção **raw** e escolha **JSON** no menu à direita.
5. Insira suas credenciais no formato abaixo:
```json
{
"username": "seu_usuario_django",
"password": "sua_senha_django"
}
```
6. Clique em **Send**.
7. No resultado (JSON), copie o código que aparece no campo `"access"`.
---
## 2. Passo 2: Acessar os Endpoints da API
Agora que você tem o token, pode acessar qualquer endpoint protegido (ex: Pedidos).
1. Crie uma nova aba de requisição no Postman.
2. Mude o método para **GET**.
3. Coloque a URL do endpoint que deseja testar, por exemplo:
`http://localhost:8000/api/v1/orders/`
4. Vá na aba **Authorization** (fica logo abaixo da URL).
5. No campo **Type**, selecione **Bearer Token**.
6. No campo **Token** (à direita), cole o código `"access"` que você copiou no Passo 1.
7. Clique em **Send**.
Você deverá ver a lista de dados em formato JSON.
---
## Dicas Rápidas
- **Erro 401 Unauthorized:** Significa que o token não foi enviado corretamente ou expirou. Obtenha um novo no Passo 1.
- **CORS:** O backend está configurado para aceitar requisições de outras origens (CORS), permitindo que seu frontend se conecte normalmente.
- **Endpoints Disponíveis:**
- `/api/v1/orders/` (Pedidos)
- `/api/v1/products/` (Produtos)
- `/api/v1/clients/` (Clientes)
- `/api/v1/mesas/` (Mesas)
- `/api/v1/comandas/` (Comandas)
- `/api/v1/items-comanda/` (Itens de Comanda)
- `/api/v1/categories/` (Categorias)
- `/api/v1/payment-types/` (Tipos de Pagamento)
- `/api/v1/payments/` (Registro de Pagamentos)

4507
package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

41
package.json Normal file
View File

@@ -0,0 +1,41 @@
{
"name": "rrbec-gestao-bar",
"version": "1.0.0",
"description": "Sistema de Gestão de Bar e Restaurante - RRBEC",
"main": "src/main/main.js",
"scripts": {
"start": "electron .",
"dev": "electron . --inspect",
"build:win": "electron-builder --win"
},
"keywords": [
"electron",
"bar",
"restaurante",
"gestao"
],
"author": "RRBEC",
"license": "MIT",
"devDependencies": {
"electron": "^28.0.0",
"electron-builder": "^24.9.1"
},
"dependencies": {
"axios": "^1.6.2",
"electron-store": "^8.1.0"
},
"build": {
"appId": "com.rrbec.gestao",
"productName": "RRBEC Gestão",
"directories": {
"output": "dist"
},
"win": {
"target": [
"nsis",
"portable"
],
"icon": "assets/icon.png"
}
}
}

173
src/main/main.js Normal file
View File

@@ -0,0 +1,173 @@
const { app, BrowserWindow, ipcMain } = require('electron');
const path = require('path');
const Store = require('electron-store');
const axios = require('axios');
const store = new Store();
function getBaseUrl() {
return store.get('api_url', 'http://localhost:8000/api/v1');
}
// ─── Janela Principal ────────────────────────────────────────────────────────
let mainWindow;
function createWindow() {
mainWindow = new BrowserWindow({
width: 1280,
height: 800,
minWidth: 1024,
minHeight: 680,
frame: true,
autoHideMenuBar: true,
titleBarStyle: 'default',
backgroundColor: '#0f1117',
webPreferences: {
preload: path.join(__dirname, 'preload.js'),
contextIsolation: true,
nodeIntegration: false,
},
icon: path.join(__dirname, '../../assets/icon.png'),
});
mainWindow.loadFile(path.join(__dirname, '../renderer/index.html'));
mainWindow.webContents.openDevTools();
}
// ─── Helpers de Token ────────────────────────────────────────────────────────
let isRefreshing = false;
let refreshPromise = null;
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;
}
async function requestWithRetry(method, endpoint, data) {
const url = `${getBaseUrl()}${endpoint}`;
console.log(`[API] ${method.toUpperCase()} ${url}`);
try {
const res = await axios({ method, url, data, headers: getHeaders() });
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);
return { ok: false, error: typeof msg === 'object' ? JSON.stringify(msg) : msg };
}
}
// ─── 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);
return { ok: true };
} catch (err) {
console.error('[MAIN] Login Failed:', err.response?.data || err.message);
return { ok: false, error: 'Credenciais inválidas.' };
}
});
ipcMain.handle('auth:logout', () => {
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:user', () => {
console.log('[MAIN] IPC auth:user requested.');
return store.get('user');
});
ipcMain.handle('api:get', (_, endpoint) => requestWithRetry('get', endpoint));
ipcMain.handle('api:post', (_, endpoint, data) => requestWithRetry('post', endpoint, data));
ipcMain.handle('api:put', (_, endpoint, data) => requestWithRetry('put', endpoint, data));
ipcMain.handle('api:patch', (_, endpoint, data) => requestWithRetry('patch', endpoint, data));
ipcMain.handle('api:delete', (_, endpoint) => requestWithRetry('delete', endpoint));
ipcMain.handle('config:get-url', () => getBaseUrl());
ipcMain.handle('config:set-url', (_, url) => {
store.set('api_url', url);
console.log('[MAIN] API URL updated to:', url);
return { ok: true };
});
// ─── Lifecycle ──────────────────────────────────────────────────────────────
app.whenReady().then(() => {
createWindow();
app.on('activate', () => {
if (BrowserWindow.getAllWindows().length === 0) createWindow();
});
});
app.on('window-all-closed', () => {
if (process.platform !== 'darwin') app.quit();
});

23
src/main/preload.js Normal file
View File

@@ -0,0 +1,23 @@
const { contextBridge, ipcRenderer } = require('electron');
contextBridge.exposeInMainWorld('electronAPI', {
// Auth
login: (credentials) => ipcRenderer.invoke('auth:login', credentials),
logout: () => ipcRenderer.invoke('auth:logout'),
check: () => ipcRenderer.invoke('auth:check'),
getUser: () => ipcRenderer.invoke('auth:user'),
// Config
getConfigUrl: () => ipcRenderer.invoke('config:get-url'),
setConfigUrl: (url) => ipcRenderer.invoke('config:set-url', url),
// API CRUD
get: (endpoint) => ipcRenderer.invoke('api:get', endpoint),
post: (endpoint, data) => ipcRenderer.invoke('api:post', endpoint, data),
put: (endpoint, data) => ipcRenderer.invoke('api:put', endpoint, data),
patch: (endpoint, data) => ipcRenderer.invoke('api:patch', endpoint, data),
delete: (endpoint) => ipcRenderer.invoke('api:delete', endpoint),
// Eventos
onAuthExpired: (callback) => ipcRenderer.on('auth:expired', () => callback()),
});

221
src/renderer/app.js Normal file
View File

@@ -0,0 +1,221 @@
// ─── Imports de Páginas ───────────────────────────────────────────────────────
// Cada página exporta uma função render() que retorna uma string HTML
// e opcionalmente uma função init() chamada após inserção no DOM.
import { renderDashboard } from './pages/dashboard.js';
import { renderMesas } from './pages/mesas.js';
import { renderComandas } from './pages/comandas.js';
import { renderPedidos } from './pages/pedidos.js';
import { renderProdutos } from './pages/produtos.js';
import { renderClientes } from './pages/clientes.js';
import { renderPagamentos } from './pages/pagamentos.js';
import { renderLogin } from './pages/login.js';
import { renderConfig } from './pages/config.js';
// ─── Roteador ────────────────────────────────────────────────────────────────
const PAGES = {
dashboard: renderDashboard,
mesas: renderMesas,
comandas: renderComandas,
pedidos: renderPedidos,
produtos: renderProdutos,
clientes: renderClientes,
pagamentos: renderPagamentos,
config: renderConfig,
};
let currentPage = null;
let currentUser = null;
async function navigate(page) {
if (currentPage === page) return;
currentPage = page;
// Highlight sidebar
document.querySelectorAll('.nav-item').forEach(el => {
el.classList.toggle('active', el.dataset.page === page);
});
const container = document.getElementById('page-container');
container.innerHTML = `<div class="loading-screen"><div class="spinner"></div> Carregando...</div>`;
const renderer = PAGES[page];
if (renderer) await renderer(container);
}
// ─── Auth ─────────────────────────────────────────────────────────────────────
async function checkAuthAndRender() {
const { authenticated } = await window.electronAPI.check();
if (!authenticated) {
showLogin();
} else {
// Carrega dados do usuário
currentUser = await window.electronAPI.getUser();
if (currentUser) {
document.getElementById('user-display-name').textContent = currentUser.first_name || currentUser.username;
document.getElementById('user-initials').textContent = (currentUser.first_name?.[0] || currentUser.username?.[0] || '?').toUpperCase();
}
document.getElementById('login-screen').classList.add('hidden');
document.getElementById('app').classList.remove('hidden');
if (!currentPage) navigate('dashboard');
}
}
function showLogin() {
document.getElementById('login-screen').classList.remove('hidden');
document.getElementById('app').classList.add('hidden');
renderLogin();
}
function showApp() {
document.getElementById('login-screen').classList.add('hidden');
document.getElementById('app').classList.remove('hidden');
}
// ─── Toast Global ─────────────────────────────────────────────────────────────
window.showToast = function (message, type = 'info', duration = 3000) {
const toast = document.getElementById('toast');
toast.textContent = message;
toast.className = `toast ${type}`;
toast.classList.remove('hidden');
clearTimeout(window._toastTimer);
window._toastTimer = setTimeout(() => toast.classList.add('hidden'), duration);
};
// ─── Modal Global ─────────────────────────────────────────────────────────────
window.openModal = function ({ title, body, footer = '', full = false }) {
const box = document.querySelector('.modal-box');
box.classList.toggle('modal-full', full);
document.getElementById('modal-title').textContent = title;
document.getElementById('modal-body').innerHTML = body;
document.getElementById('modal-footer').innerHTML = footer;
document.getElementById('modal-overlay').classList.remove('hidden');
};
window.closeModal = function () {
document.getElementById('modal-overlay').classList.add('hidden');
};
// ─── Init ─────────────────────────────────────────────────────────────────────
document.addEventListener('DOMContentLoaded', async () => {
// Debug: Captura erros globais
window.onerror = (msg, url, line) => {
console.error(`[RENDERER ERROR] ${msg} at ${url}:${line}`);
window.showToast?.('Erro interno detectado. Verifique o console.', 'error');
};
window.onunhandledrejection = (event) => {
console.error('[RENDERER UNHANDLED REJECTION]', event.reason);
};
console.log('[APP] DOMContentLoaded - Initializing...');
// Sidebar navigation
document.querySelectorAll('.nav-item').forEach(item => {
item.addEventListener('click', (e) => {
e.preventDefault();
console.log(`[APP] Navigating to: ${item.dataset.page}`);
navigate(item.dataset.page);
});
});
// Modal close
document.getElementById('modal-close').addEventListener('click', closeModal);
document.getElementById('modal-overlay').addEventListener('click', (e) => {
if (e.target === document.getElementById('modal-overlay')) closeModal();
});
// Logout
document.getElementById('logout-btn').addEventListener('click', async () => {
console.log('[APP] Logout requested');
await window.electronAPI.logout();
currentPage = null;
showLogin();
});
await checkAuthAndRender();
window.electronAPI.onAuthExpired(() => {
console.warn('[APP] Session expired. Redirecting to login...');
showToast('Sessão expirada. Por favor, entre novamente.', 'error');
currentUser = null;
showLogin();
});
});
// ─── Modals Compartilhados ────────────────────────────────────────────────────
window.abrirModalObsCozinhaGlobal = function (nomeProduto, currentObs, callback) {
const tags = ['Para viagem', 'Meia porção', 'Com ovo', 'Com leite', 'Sem cebola'];
let selectedTags = new Set();
const initialObs = currentObs || '';
// Se houver observação inicial, tenta extrair as tags
let initialText = initialObs;
if (initialObs.includes(' | ')) {
const parts = initialObs.split(' | ');
initialText = parts[0];
const tagsPart = parts[1] || '';
tagsPart.split(', ').forEach(t => { if (t) selectedTags.add(t); });
} else if (tags.some(t => initialObs.includes(t))) {
// Fallback simples se não houver o divisor |
tags.forEach(t => { if (initialObs.includes(t)) selectedTags.add(t); });
}
openModal({
title: `📝 Observações: ${nomeProduto}`,
body: `
<div class="form-group">
<label>Instruções Especiais</label>
<textarea id="obs-text" class="form-control" rows="3" placeholder="Ex: Sem sal, ponto da carne...">${initialText}</textarea>
</div>
<div class="obs-tags">
${tags.map(tag => `<div class="tag-item ${selectedTags.has(tag) ? 'active' : ''}" data-tag="${tag}">${tag}</div>`).join('')}
</div>
`,
footer: `
<button class="btn btn-secondary btn-md" id="btn-obs-cancelar">Pular / Cancelar</button>
<button class="btn btn-primary btn-md" id="btn-obs-confirmar">Confirmar</button>
`
});
const textInput = document.getElementById('obs-text');
setTimeout(() => textInput.focus(), 100);
document.querySelectorAll('.tag-item').forEach(tagEl => {
tagEl.addEventListener('click', () => {
const tag = tagEl.dataset.tag;
if (selectedTags.has(tag)) {
selectedTags.delete(tag);
tagEl.classList.remove('active');
} else {
selectedTags.add(tag);
tagEl.classList.add('active');
}
});
});
const finalizar = () => {
const texto = textInput.value.trim();
const tagsStr = Array.from(selectedTags).join(', ');
const finalObs = [texto, tagsStr].filter(x => x).join(' | ');
closeModal();
callback(finalObs);
};
document.getElementById('btn-obs-confirmar').onclick = finalizar;
document.getElementById('btn-obs-cancelar').onclick = () => {
closeModal();
callback(null); // Return null instead of empty string on cancel to preserve original
};
textInput.addEventListener('keydown', (e) => {
if (e.key === 'Enter' && !e.shiftKey) {
e.preventDefault();
finalizar();
}
});
};
export { navigate, showApp, showLogin };

132
src/renderer/index.html Normal file
View File

@@ -0,0 +1,132 @@
<!DOCTYPE html>
<html lang="pt-BR">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta http-equiv="Content-Security-Policy"
content="default-src 'self'; style-src 'self' 'unsafe-inline' https://fonts.googleapis.com; font-src https://fonts.gstatic.com; img-src * data: blob:; script-src 'self' 'unsafe-inline';" />
<title>Gestão RRBEC</title>
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700&display=swap" rel="stylesheet">
<link rel="stylesheet" href="style.css" />
</head>
<body>
<!-- LOGIN SCREEN -->
<div id="login-screen" class="login-screen hidden">
<div class="login-card">
<div class="login-logo">
<div class="logo-icon">🍺</div>
<h1>RRBEC</h1>
<p>Gestão de Bar & Restaurante</p>
</div>
<form id="login-form" class="login-form">
<div class="field-group">
<label for="username">Usuário</label>
<input type="text" id="username" placeholder="Digite seu usuário" autocomplete="username" required />
</div>
<div class="field-group">
<label for="password">Senha</label>
<input type="password" id="password" placeholder="••••••••" autocomplete="current-password" required />
</div>
<div id="login-error" class="login-error hidden"></div>
<button type="submit" id="login-btn" class="btn btn-primary btn-lg">
<span id="login-btn-text">Entrar</span>
<span id="login-spinner" class="spinner hidden"></span>
</button>
</form>
<div class="login-config">
<a href="#" id="login-config-toggle"
style="font-size: 0.75rem; color: var(--text-muted); text-decoration: none;">⚙️ Configurações de Conexão</a>
<div id="login-config-container" class="hidden"
style="margin-top: 15px; text-align: left; border-top: 1px solid var(--border); padding-top: 15px;">
<div class="field-group">
<label style="font-size: 0.7rem; color: var(--text-secondary);">URL da API</label>
<input type="text" id="login-api-url" placeholder="http://localhost:8000/api/v1"
style="font-size: 0.8rem; padding: 8px;" />
<button class="btn btn-secondary btn-sm" id="btn-save-login-config"
style="width: 100%; margin-top: 8px; font-size: 0.75rem;">💾 Salvar URL</button>
</div>
</div>
</div>
</div>
</div>
<!-- MAIN APP -->
<div id="app" class="app-layout hidden">
<!-- Sidebar -->
<aside class="sidebar" id="sidebar">
<div class="sidebar-header">
<div class="logo-icon-sm">🍺</div>
<span class="brand-name">RRBEC</span>
</div>
<nav class="sidebar-nav">
<a href="#" class="nav-item active" data-page="dashboard">
<span class="nav-icon">📊</span><span class="nav-label">Dashboard</span>
</a>
<a href="#" class="nav-item" data-page="mesas">
<span class="nav-icon">🪑</span><span class="nav-label">Mesas</span>
</a>
<a href="#" class="nav-item" data-page="comandas">
<span class="nav-icon">📋</span><span class="nav-label">Comandas</span>
</a>
<a href="#" class="nav-item" data-page="pedidos">
<span class="nav-icon">🛒</span><span class="nav-label">Pedidos</span>
</a>
<a href="#" class="nav-item" data-page="produtos">
<span class="nav-icon">🍔</span><span class="nav-label">Produtos</span>
</a>
<a href="#" class="nav-item" data-page="clientes">
<span class="nav-icon">👥</span><span class="nav-label">Clientes</span>
</a>
<a href="#" class="nav-item" data-page="pagamentos">
<span class="nav-icon">💳</span><span class="nav-label">Pagamentos</span>
</a>
<a href="#" class="nav-item" data-page="config">
<span class="nav-icon">🎛️</span><span class="nav-label">Configurações</span>
</a>
</nav>
<div class="sidebar-footer">
<div id="user-profile"
style="margin-bottom: 8px; padding: 0 10px; font-size: 0.82rem; color: var(--text-secondary); display: flex; align-items: center; gap: 8px;">
<div
style="width: 28px; height: 28px; background: var(--primary); border-radius: 50%; display: flex; align-items: center; justify-content: center; color: white; font-weight: bold; font-size: 0.7rem;"
id="user-initials">?</div>
<div style="flex: 1; overflow: hidden; text-overflow: ellipsis; white-space: nowrap;" id="user-display-name">
Usuário</div>
</div>
<button id="logout-btn" class="btn btn-ghost btn-sm">
<span>⬅️</span> Sair
</button>
</div>
</aside>
<!-- Main Content -->
<main class="main-content">
<div id="page-container"></div>
</main>
</div>
<!-- TOAST NOTIFICATION -->
<div id="toast" class="toast hidden"></div>
<!-- MODAL -->
<div id="modal-overlay" class="modal-overlay hidden">
<div class="modal-box" id="modal-box">
<div class="modal-header">
<h3 id="modal-title">Título</h3>
<button id="modal-close" class="modal-close"></button>
</div>
<div id="modal-body" class="modal-body"></div>
<div id="modal-footer" class="modal-footer"></div>
</div>
</div>
<script type="module" src="app.js"></script>
</body>
</html>

View File

@@ -0,0 +1,344 @@
export async function renderClientes(container) {
container.innerHTML = `
<div class="page-header">
<div>
<div class="page-title">👥 Clientes</div>
<div class="page-subtitle">Cadastro de clientes e controle de débitos</div>
</div>
<div class="page-actions">
<button class="btn btn-ghost btn-md" id="btn-refresh-clientes">↺ Atualizar</button>
<button class="btn btn-primary btn-md" id="btn-novo-cliente">+ Novo Cliente</button>
</div>
</div>
<div class="table-wrap">
<div class="table-toolbar">
<input type="text" class="search-input" id="search-cliente" placeholder="🔍 Buscar por nome ou contato..." />
<select id="filter-debt" class="form-control" style="width:160px">
<option value="">Todos os clientes</option>
<option value="has-debt">Com débito</option>
<option value="no-debt">Sem débito</option>
</select>
</div>
<div id="clientes-table"></div>
</div>`;
await loadClientes();
document.getElementById('btn-novo-cliente').addEventListener('click', () => abrirModalCliente());
document.getElementById('btn-refresh-clientes').addEventListener('click', loadClientes);
document.getElementById('search-cliente').addEventListener('input', () => filtrarClientes());
document.getElementById('filter-debt').addEventListener('change', () => filtrarClientes());
}
let _clientesData = [];
let _productsMap = {};
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 e produtos em paralelo para ter os preços
const [res, pRes] = await Promise.all([
window.electronAPI.get('/clients/'),
window.electronAPI.get('/products/')
]);
if (pRes.ok) {
_productsMap = pRes.data.reduce((acc, p) => {
acc[p.id] = parseFloat(p.price || 0);
return acc;
}, {});
}
if (!res.ok) { wrap.innerHTML = `<div class="table-empty">Erro ao carregar clientes.</div>`; return; }
_clientesData = res.data;
renderClientesTable(_clientesData);
}
function renderClientesTable(data) {
const wrap = document.getElementById('clientes-table');
if (!wrap) return;
if (!data.length) { wrap.innerHTML = `<div class="table-empty">Nenhum cliente encontrado.</div>`; return; }
// Ordena por maior débito por padrão
const sorted = [...data].sort((a, b) => parseFloat(b.debt || 0) - parseFloat(a.debt || 0));
wrap.innerHTML = `
<table>
<thead><tr>
<th>#</th>
<th>Nome</th>
<th>Contato</th>
<th>Débito</th>
<th>Status</th>
<th>Cadastrado em</th>
<th>Ações</th>
</tr></thead>
<tbody>
${sorted.map(c => {
const debt = parseFloat(c.debt || 0);
return `<tr>
<td style="color:var(--text-muted)">#${c.id}</td>
<td><strong>${c.name}</strong></td>
<td>${c.contact || ''}</td>
<td>
<span style="font-weight:600; color: ${debt > 0 ? 'var(--danger)' : 'var(--text-secondary)'}">
R$ ${debt.toFixed(2)}
</span>
</td>
<td>
<span class="badge ${c.active ? 'badge-success' : 'badge-muted'}">
${c.active ? 'Ativo' : 'Inativo'}
</span>
</td>
<td style="font-size:0.82rem;color:var(--text-muted)">${formatDate(c.created_at)}</td>
<td>
<div style="display:flex;gap:6px">
<button class="btn btn-info btn-sm btn-hist-cli" data-id="${c.id}" title="Ver Fiados">📜</button>
<button class="btn btn-secondary btn-sm btn-edit-cli" data-id="${c.id}">Editar</button>
<button class="btn btn-danger btn-sm btn-del-cli" data-id="${c.id}">Excluir</button>
</div>
</td>
</tr>`;
}).join('')}
</tbody>
</table>`;
wrap.querySelectorAll('.btn-hist-cli').forEach(btn => {
btn.addEventListener('click', () => {
const c = _clientesData.find(x => x.id === parseInt(btn.dataset.id));
if (c) abrirHistoricoFiados(c);
});
});
wrap.querySelectorAll('.btn-edit-cli').forEach(btn => {
btn.addEventListener('click', () => {
const c = _clientesData.find(x => x.id === parseInt(btn.dataset.id));
if (c) abrirModalCliente(c);
});
});
wrap.querySelectorAll('.btn-del-cli').forEach(btn =>
btn.addEventListener('click', async () => {
const r = await window.electronAPI.delete(`/clients/${btn.dataset.id}/`);
if (r.ok) { showToast('Cliente excluído!', 'success'); loadClientes(); }
else showToast(r.error, 'error');
})
);
}
function filtrarClientes() {
const q = document.getElementById('search-cliente')?.value.toLowerCase() || '';
const debtFltr = document.getElementById('filter-debt')?.value || '';
const filtered = _clientesData.filter(c => {
const matchQ = !q ||
(c.name || '').toLowerCase().includes(q) ||
(c.contact || '').toLowerCase().includes(q) ||
String(c.id).includes(q);
const debtValue = parseFloat(c.debt || 0);
const matchDebt = !debtFltr ||
(debtFltr === 'has-debt' ? debtValue > 0 : debtValue === 0);
return matchQ && matchDebt;
});
renderClientesTable(filtered);
}
function abrirModalCliente(cliente = null) {
const isEdit = !!cliente;
openModal({
title: isEdit ? `Editar: ${cliente.name}` : 'Novo Cliente',
body: `
<div class="form-grid">
<div class="form-group" style="grid-column:1/-1">
<label>Nome Completo</label>
<input type="text" id="cli-nome" class="form-control" value="${cliente?.name || ''}" placeholder="Ex: Belchior de Oliveira" />
</div>
<div class="form-group">
<label>Contato</label>
<input type="text" id="cli-contact" class="form-control" value="${cliente?.contact || ''}" placeholder="(00) 0000-0000" />
</div>
<div class="form-group">
<label>Débito Inicial (R$)</label>
<input type="number" id="cli-debt" class="form-control" value="${cliente?.debt || '0.00'}" step="0.01" min="0" ${isEdit ? 'disabled' : ''} />
${isEdit ? '<small style="color:var(--text-muted)">Ajuste via pagamentos/comandas</small>' : ''}
</div>
<div class="form-group">
<label>Ativo</label>
<select id="cli-active" class="form-control">
<option value="true" ${cliente?.active !== false ? 'selected' : ''}>Sim</option>
<option value="false" ${cliente?.active === false ? 'selected' : ''}>Não</option>
</select>
</div>
</div>`,
footer: `
<button class="btn btn-secondary btn-md" onclick="closeModal()">Cancelar</button>
<button class="btn btn-primary btn-md" id="btn-salvar-cli">${isEdit ? 'Salvar' : 'Criar'}</button>`,
});
document.getElementById('btn-salvar-cli').addEventListener('click', async () => {
const data = {
name: document.getElementById('cli-nome').value.trim(),
contact: document.getElementById('cli-contact').value.trim(),
active: document.getElementById('cli-active').value === 'true',
};
// Só envia débito na criação se for o caso da API suportar
if (!isEdit) {
data.debt = parseFloat(document.getElementById('cli-debt').value || 0).toFixed(2);
}
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);
if (r.ok) { showToast(isEdit ? 'Cliente atualizado!' : 'Cliente criado!', 'success'); closeModal(); loadClientes(); }
else showToast(r.error, 'error');
});
}
async function abrirHistoricoFiados(cliente) {
openModal({
title: `📜 Fiados: ${cliente.name}`,
body: `
<div id="fiados-modal-content">
<div id="fiados-list" class="loading-screen"><div class="spinner"></div></div>
<div id="fiados-summary" class="hidden" style="margin-top:20px; padding-top:15px; border-top:2px solid var(--border); display:flex; justify-content:space-between; align-items:center;">
<div>
<div style="font-size:0.85rem; color:var(--text-secondary)">Selecionados: <span id="selected-count">0</span></div>
<div style="font-size:1.2rem; font-weight:700; color:var(--success)">Total: R$ <span id="selected-total">0.00</span></div>
</div>
<button class="btn btn-primary btn-md" id="btn-pagar-selecionados" disabled>💳 Pagar Selecionados</button>
</div>
</div>`,
footer: `<button class="btn btn-secondary btn-md" onclick="closeModal()">Sair</button>`
});
const res = await window.electronAPI.get(`/clients/${cliente.id}/fiados/`);
const listContainer = document.getElementById('fiados-list');
const summary = document.getElementById('fiados-summary');
if (!listContainer) return;
if (!res.ok) {
listContainer.innerHTML = `<div class="table-empty">Erro ao carregar fiados: ${res.error}</div>`;
return;
}
const fiados = res.data;
if (!fiados.length) {
listContainer.innerHTML = `<div class="table-empty">Nenhuma comanda pendente para este cliente.</div>`;
return;
}
listContainer.classList.remove('loading-screen');
listContainer.style.maxHeight = '400px';
listContainer.style.overflowY = 'auto';
summary.classList.remove('hidden');
listContainer.innerHTML = fiados.map(f => {
const totalComanda = (f.items || []).reduce((acc, it) => acc + (_productsMap[it.product] || 0), 0);
return `
<div class="card card-fiado" style="margin-bottom:15px; border-left: 4px solid var(--warning); 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" />
</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>
</div>
<div style="text-align:right">
<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>
</div>
<div style="font-size:0.85rem;">
<div style="margin-bottom:5px; color:var(--text-secondary)">Mesa: ${f.mesa_name || ''} | Lançado por: ${f.user_name || ''}</div>
</div>
<div style="margin-top:10px; border-top:1px solid var(--border); padding-top:10px;">
<details>
<summary style="font-weight:600; font-size:0.8rem; text-transform:uppercase; color:var(--text-muted); cursor:pointer; outline:none">
Ver Itens (${(f.items || []).length})
</summary>
<ul style="list-style:none; padding:10px 0 0 0; margin:0; font-size:0.85rem;">
${(f.items || []).map(it => `
<li style="display:flex; justify-content:space-between; padding:4px 0; border-bottom:1px dashed var(--border)">
<span>• ${it.product_name}</span>
<div style="text-align:right">
<span>R$ ${(_productsMap[it.product] || 0).toFixed(2)}</span>
<div style="font-size:0.7rem; color:var(--text-muted)">${formatDateShort(it.data_time)}</div>
</div>
</li>
`).join('')}
</ul>
</details>
</div>
</div>
`;
}).join('');
// Lógica de Soma Dinâmica
const updateSum = () => {
const checks = listContainer.querySelectorAll('.fiado-check:checked');
let sum = 0;
checks.forEach(c => sum += parseFloat(c.dataset.total));
document.getElementById('selected-count').textContent = checks.length;
document.getElementById('selected-total').textContent = sum.toFixed(2);
const btnPagar = document.getElementById('btn-pagar-selecionados');
btnPagar.disabled = checks.length === 0;
};
listContainer.querySelectorAll('.fiado-check').forEach(chk => {
chk.addEventListener('change', updateSum);
});
document.getElementById('btn-pagar-selecionados').addEventListener('click', async () => {
const selecionados = Array.from(listContainer.querySelectorAll('.fiado-check:checked')).map(c => parseInt(c.dataset.id));
const totalPrompt = document.getElementById('selected-total').textContent;
if (confirm(`Deseja confirmar o pagamento de R$ ${totalPrompt} referente a ${selecionados.length} comanda(s)?`)) {
const btn = document.getElementById('btn-pagar-selecionados');
btn.disabled = true;
btn.textContent = 'Processando...';
const r = await window.electronAPI.post('/clients/pagar_fiados/', { ids: selecionados });
if (r.ok) {
showToast('Pagamento realizado com sucesso!', 'success');
closeModal();
loadClientes(); // Recarrega a lista para atualizar os débitos
} else {
showToast(r.error || 'Erro ao processar pagamento.', 'error');
btn.disabled = false;
btn.textContent = '💳 Pagar Selecionados';
}
}
});
updateSum();
}
function formatDateShort(str) {
if (!str) return '';
const d = new Date(str);
return d.toLocaleTimeString('pt-BR', { hour: '2-digit', minute: '2-digit' });
}
function formatDate(str) {
if (!str) return '';
return new Date(str).toLocaleDateString('pt-BR', {
day: '2-digit', month: '2-digit', year: 'numeric'
});
}

View File

@@ -0,0 +1,541 @@
export async function renderComandas(container) {
container.innerHTML = `
<div class="page-header">
<div>
<div class="page-title">📋 Comandas</div>
<div class="page-subtitle">Gerencie as comandas por mesa</div>
</div>
<div class="page-actions">
<button class="btn btn-primary btn-md" id="btn-nova-comanda">+ Nova Comanda</button>
</div>
</div>
<div class="table-wrap">
<div class="table-toolbar">
<input type="text" class="search-input" id="search-comanda" placeholder="🔍 Buscar por nome ou mesa..." />
<select id="filter-status" class="form-control" style="width:180px">
<option value="">Todos os status</option>
<option value="ACTIVE" selected>Ativas (Abertas/Pagando)</option>
<option value="OPEN">Somente Abertas</option>
<option value="PAYING">Pagando</option>
<option value="CLOSED">Fechadas</option>
</select>
</div>
<div id="comandas-table"></div>
</div>`;
let mesas = [];
const mesasRes = await window.electronAPI.get('/mesas/');
if (mesasRes.ok) mesas = mesasRes.data;
await loadComandas(mesas);
document.getElementById('btn-nova-comanda').addEventListener('click', () => abrirModalNovaComanda(mesas));
document.getElementById('search-comanda').addEventListener('input', () => filtrarComandas());
document.getElementById('filter-status').addEventListener('change', () => filtrarComandas());
}
let _comandasData = [];
let _mesasRef = [];
let _productsMap = {}; // Cache de preços {id: price}
let _paymentTypes = [];
let _clients = [];
async function loadComandas(mesas) {
_mesasRef = mesas;
const wrap = document.getElementById('comandas-table');
if (!wrap) return;
wrap.innerHTML = `<div class="loading-screen"><div class="spinner"></div></div>`;
// Carrega dados necessários em paralelo
const [res, pRes, ptRes, cRes] = await Promise.all([
window.electronAPI.get('/comandas/'),
window.electronAPI.get('/products/'),
window.electronAPI.get('/payment-types/'),
window.electronAPI.get('/clients/')
]);
if (ptRes.ok) _paymentTypes = ptRes.data;
if (cRes.ok) _clients = cRes.data;
if (pRes.ok) {
_productsMap = pRes.data.reduce((acc, p) => {
acc[p.id] = parseFloat(p.price || 0);
return acc;
}, {});
}
if (!res.ok) { wrap.innerHTML = `<div class="table-empty">Erro ao carregar comandas.</div>`; return; }
_comandasData = res.data;
// Aplica o filtro padrão (Ativas) logo no carregamento
filtrarComandas();
}
function renderComandasTable(data) {
const wrap = document.getElementById('comandas-table');
if (!wrap) return;
if (!data.length) { wrap.innerHTML = `<div class="table-empty">Nenhuma comanda encontrada.</div>`; return; }
// Limita a exibição às primeiras 100 comandas
const limitedData = data.slice(0, 100);
wrap.innerHTML = `
<table>
<thead><tr>
<th>#</th>
<th>Nome</th>
<th>Mesa</th>
<th>Status</th>
<th>Total</th>
<th>Aberta em</th>
<th>Itens</th>
<th>Ações</th>
</tr></thead>
<tbody>
${data.map(c => {
const statusCfg = {
'OPEN': { label: 'Aberta', badge: 'badge-success' },
'PAYING': { label: 'Pagando', badge: 'badge-warning' },
'CLOSED': { label: 'Fechada', badge: 'badge-muted' }
};
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);
return `<tr>
<td><strong>#${c.id}</strong></td>
<td>${c.name || ''}</td>
<td>${c.mesa_name || `Mesa ${c.mesa}` || ''}</td>
<td><span class="badge ${cfg.badge}">${cfg.label}</span></td>
<td><strong style="color:var(--success)">R$ ${totalComanda.toFixed(2)}</strong></td>
<td>${formatDate(c.dt_open)}</td>
<td>
<span class="badge badge-info" style="cursor:pointer" data-id="${c.id}" title="Ver itens">
${(c.items || []).length} ${(c.items || []).length === 1 ? 'item' : 'itens'}
</span>
</td>
<td>
<div style="display:flex;gap:6px">
<button class="btn btn-secondary btn-sm btn-itens" data-id="${c.id}" title="Itens">🛒</button>
${ativa ? `<button class="btn btn-success btn-sm btn-receber" data-id="${c.id}" title="Receber">💰</button>` : ''}
${ativa && c.status !== 'PAYING' ? `<button class="btn btn-warning btn-sm btn-pagar" data-id="${c.id}" title="Avisar Pagamento">⏳</button>` : ''}
${ativa ? `<button class="btn btn-danger btn-sm btn-excluir" data-id="${c.id}" title="Excluir">🗑️</button>` : ''}
</div>
</td>
</tr>`;
}).join('')}
</tbody>
</table>
${data.length > 100 ? `<div style="padding:10px; text-align:center; color:var(--text-muted); font-size:0.8rem;">Exibindo apenas as últimas 100 de ${data.length} comandas.</div>` : ''}
`;
// Listener para Receber
wrap.querySelectorAll('.btn-receber').forEach(btn => {
btn.addEventListener('click', () => {
const comanda = _comandasData.find(c => c.id === parseInt(btn.dataset.id));
if (comanda) abrirModalReceber(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' });
if (r.ok) { showToast('Comanda em fase de pagamento!', 'info'); loadComandas(_mesasRef); }
else showToast(r.error, 'error');
});
});
// 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);
});
});
// 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);
});
});
// 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);
} else {
showToast(r.error, 'error');
}
}
});
});
}
function abrirModalReceber(comanda) {
const total = (comanda.items || []).reduce((acc, it) => acc + (_productsMap[it.product] || 0), 0);
openModal({
title: `💰 Receber Pago - Comanda #${comanda.id}`,
body: `
<div class="form-grid">
<div class="form-group">
<label>Valor Total (R$)</label>
<input type="number" id="pay-value" class="form-control" value="${total.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="">Cliente não identificado</option>
${_clients.map(cl => `<option value="${cl.id}" ${comanda.client === cl.id ? 'selected' : ''}>${cl.name || cl.nome}</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 total..." value="Pagamento total" />
</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>`
});
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');
}
});
}
function filtrarComandas() {
const q = document.getElementById('search-comanda')?.value.toLowerCase() || '';
const status = document.getElementById('filter-status')?.value || '';
const filtered = _comandasData.filter(c => {
const matchQ = !q ||
(c.name || '').toLowerCase().includes(q) ||
(c.mesa_name || '').toLowerCase().includes(q) ||
String(c.id).includes(q);
const matchStatus = !status ||
(status === 'ACTIVE' ? (c.status === 'OPEN' || c.status === 'PAYING') : (c.status === status));
return matchQ && matchStatus;
});
renderComandasTable(filtered);
}
// ─── Modal de Itens (Novo Layout PDV Split) ───────────────────────────────────
async function abrirItensComanda(comandaIdOrObj) {
let comanda = typeof comandaIdOrObj === 'object' ? comandaIdOrObj : _comandasData.find(c => c.id === comandaIdOrObj);
const ativa = comanda.status === 'OPEN' || comanda.status === 'PAYING';
const podeAdd = comanda.status === 'OPEN'; // Só permite add se ainda não estiver pagando?
// Carrega produtos (ativos)
const pRes = await window.electronAPI.get('/products/');
let todosProdutos = pRes.ok ? pRes.data.filter(p => p.active) : [];
openModal({
full: true,
title: `🛒 PDV: Comanda #${comanda.id}${comanda.name || ''} (${comanda.mesa_name || ''})`,
body: `
<div class="pdv-container">
<!-- Lado Esquerdo: Itens da Comanda -->
<div class="pdv-left" id="pdv-items-list">
<div class="loading-screen"><div class="spinner"></div></div>
</div>
<!-- Lado Direito: Catálogo de Produtos -->
<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">
<!-- Cards aqui -->
</div>
</div>
</div>`,
footer: `<button class="btn btn-secondary btn-md" onclick="closeModal()">Sair do PDV</button>`,
});
// Funções internas de renderização
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);
container.innerHTML = `
<div style="flex:1; overflow-y: auto;">
<h4 style="margin-bottom:12px;color:var(--text-secondary);font-size:0.8rem;text-transform:uppercase">Itens na Comanda</h4>
${itens.length ? `
<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 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>' : ''}
</tr></thead>
<tbody>
${itens.map(it => {
const prod = todosProdutos.find(p => p.id === it.product);
const isCuisine = prod?.cuisine || false;
const tooltip = it.obs ? `title="${it.obs}"` : '';
return `
<tr data-item-id="${it.id}">
<td style="padding:10px 0;border-bottom:1px solid var(--border)" ${tooltip}>
${it.product_name}
</td>
<td style="padding:10px 0;text-align:right;border-bottom:1px solid var(--border)">
R$ ${(_productsMap[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>
` : ''}
<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>
</div>
</td>` : ''}
</tr>`;
}).join('')}
</tbody>
</table>
` : `<p style="padding:40px 0;text-align:center;color:var(--text-muted)">Nenhum item adicionado.</p>`}
</div>
<div style="padding-top:20px;margin-top:auto;border-top:2px solid var(--border)">
<div style="display:flex;justify-content:space-between;margin-bottom:6px">
<span style="color:var(--text-secondary)">Total de Itens:</span>
<strong>${itens.length}</strong>
</div>
<div style="display:flex;justify-content:space-between;margin-bottom:10px">
<span style="color:var(--text-secondary);font-size:1.1rem">Total da Conta:</span>
<strong style="color:var(--success);font-size:1.3rem">R$ ${totalComanda.toFixed(2)}</strong>
</div>
${ativa ? `
<div style="display:grid; grid-template-columns: 1fr 1fr; gap: 10px; margin-top: 10px;">
<button class="btn btn-success btn-lg" id="btn-pdv-receber">💰 Receber</button>
<button class="btn btn-danger btn-lg" id="btn-pdv-excluir">🗑️ Excluir</button>
</div>
` : ''}
</div>`;
// Listeners de exclusão de item individual
container.querySelectorAll('.btn-del-item').forEach(btn => {
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}/`);
if (r.ok) {
showToast('Item excluído!', 'success');
comanda.items = comanda.items.filter(it => it.id !== parseInt(itemId));
renderLeft();
loadComandas(_mesasRef);
} else {
showToast(r.error, 'error');
}
}
});
});
// 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);
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');
}
});
}
});
});
};
const processarResultadoAdd = (r) => {
if (r.ok) {
if (!comanda.items) comanda.items = [];
comanda.items.push(r.data);
renderLeft();
loadComandas(_mesasRef);
} else {
showToast(r.error, 'error');
}
};
const bindProductClicks = (container, filtrados) => {
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);
card.style.transform = 'scale(0.95)';
setTimeout(() => card.style.transform = '', 100);
if (prod.cuisine) {
window.abrirModalObsCozinhaGlobal(prod.name, '', async (obs) => {
if (obs === null) return;
const r = await window.electronAPI.post('/items-comanda/', {
comanda: comanda.id,
product: pId,
obs: obs
});
processarResultadoAdd(r);
});
} else {
const r = await window.electronAPI.post('/items-comanda/', { comanda: comanda.id, product: pId });
processarResultadoAdd(r);
}
});
});
};
const renderRight = (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);
console.log('Produtos carregados no PDV:', todosProdutos);
container.innerHTML = filtrados.map(p => {
const imgTarget = p.image ? `url('${p.image}')` : 'none';
return `
<div class="pdv-product-card" data-id="${p.id}">
<div class="pdv-product-bg" style="background-image: ${imgTarget}"></div>
<div class="pdv-product-info">
<div class="pdv-product-name">${p.name}</div>
<div class="pdv-product-price">R$ ${parseFloat(p.price || 0).toFixed(2)}</div>
</div>
</div>
`;
}).join('');
bindProductClicks(container, filtrados);
};
// Inicializa
renderLeft();
renderRight();
document.getElementById('pdv-search')?.addEventListener('input', (e) => renderRight(e.target.value));
// Foco no campo de busca ao abrir o PDV
setTimeout(() => document.getElementById('pdv-search')?.focus(), 200);
}
// ─── Modal Nova Comanda ───────────────────────────────────────────────────────
function abrirModalNovaComanda(mesas) {
openModal({
title: 'Nova Comanda',
body: `
<form id="form-nova-comanda" class="form-grid">
<div class="form-group">
<label>Nome do Cliente / Identificação</label>
<input type="text" id="comanda-nome" class="form-control" placeholder="Ex: João, Mesa do fundo..." autofocus required />
</div>
<div class="form-group">
<label>Mesa</label>
<select id="comanda-mesa" class="form-control">
${mesas.map(m => `<option value="${m.id}">${m.nome || m.name || `Mesa ${m.numero || m.number || m.id}`}</option>`).join('')}
</select>
</div>
<button type="submit" style="display:none"></button> <!-- Invisível para permitir Enter -->
</form>`,
footer: `
<button class="btn btn-secondary btn-md" onclick="closeModal()">Cancelar</button>
<button class="btn btn-primary btn-md" id="btn-criar-comanda">Criar e Adicionar Itens</button>`,
});
// 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 nome = document.getElementById('comanda-nome').value.trim();
if (!nome) return showToast('Informe o nome ou identificação.', 'error');
const loggedUser = await window.electronAPI.getUser();
const payload = {
name: nome,
mesa: mesaId,
user: loggedUser?.id || 1,
status: 'OPEN'
};
const btn = document.getElementById('btn-criar-comanda');
btn.disabled = true;
btn.textContent = 'Criando...';
const r = await window.electronAPI.post('/comandas/', payload);
if (r.ok) {
showToast('Comanda criada!', 'success');
closeModal();
loadComandas(_mesasRef);
// Abre direto a modal de itens da comanda recém criada
setTimeout(() => abrirItensComanda(r.data), 300);
} else {
showToast(r.error, 'error');
btn.disabled = false;
btn.textContent = 'Criar e Adicionar Itens';
}
};
document.getElementById('form-nova-comanda').onsubmit = submeter;
document.getElementById('btn-criar-comanda').onclick = submeter;
}
// ─── Helpers ──────────────────────────────────────────────────────────────────
function formatDate(str) {
if (!str) return '';
return new Date(str).toLocaleDateString('pt-BR', {
day: '2-digit', month: '2-digit', year: 'numeric',
hour: '2-digit', minute: '2-digit'
});
}

View File

@@ -0,0 +1,61 @@
export async function renderConfig(container) {
const currentUrl = await window.electronAPI.getConfigUrl();
container.innerHTML = `
<div class="page-header">
<div>
<div class="page-title">🎛️ Configurações</div>
<div class="page-subtitle">Ajustes técnicos do aplicativo</div>
</div>
</div>
<div class="card" style="max-width: 600px; margin-top: 20px;">
<h3 style="margin-bottom: 20px; color: var(--text-primary);">🌐 API e Conectividade</h3>
<div class="form-group">
<label>URL Base da API (Django)</label>
<input type="text" id="config-api-url" class="form-control" value="${currentUrl}" placeholder="Ex: http://192.168.1.100:8000/api/v1" />
<p style="font-size: 0.75rem; color: var(--text-muted); margin-top: 8px;">
⚠️ <strong>Atenção:</strong> Alterar esta URL fará com que o app tente se conectar a um novo servidor. Certifique-se de que a URL termina em <code>/api/v1</code> e está acessível.
</p>
</div>
<div style="margin-top: 30px; display: flex; gap: 10px;">
<button class="btn btn-primary btn-md" id="btn-save-config">💾 Salvar Configurações</button>
<button class="btn btn-secondary btn-md" id="btn-reset-url">↺ Restaurar Localhost</button>
</div>
</div>
<div class="card" style="max-width: 600px; margin-top: 20px; border-left: 4px solid var(--primary);">
<h3 style="margin-bottom: 10px; color: var(--text-primary);"> Sobre o Sistema</h3>
<p style="color: var(--text-secondary); font-size: 0.85rem;">
<strong>Versão:</strong> 1.0.0 (Desenvolvedor)<br>
<strong>Ambiente:</strong> Produção / Local<br>
<strong>Sessão:</strong> Ativa (Tokens persistidos)
</p>
</div>
`;
// Salvar
document.getElementById('btn-save-config').addEventListener('click', async () => {
const newUrl = document.getElementById('config-api-url').value.trim();
if (!newUrl.startsWith('http')) {
return showToast('A URL deve começar com http:// ou https://', 'error');
}
const r = await window.electronAPI.setConfigUrl(newUrl);
if (r.ok) {
showToast('Configurações salvas! Reiniciando conexões...', 'success');
} else {
showToast('Erro ao salvar configurações.', 'error');
}
});
// Restaurar
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');
});
}

View File

@@ -0,0 +1,98 @@
export async function renderDashboard(container) {
container.innerHTML = `
<div class="page-header">
<div>
<div class="page-title">Dashboard</div>
<div class="page-subtitle">Visão geral do seu estabelecimento</div>
</div>
</div>
<div class="cards-grid cards-grid-4" id="dash-stats">
<div class="card stat-card">
<div class="stat-icon purple">🪑</div>
<div><div class="stat-value" id="stat-mesas"></div><div class="stat-label">Mesas Abertas</div></div>
</div>
<div class="card stat-card">
<div class="stat-icon amber">📋</div>
<div><div class="stat-value" id="stat-comandas"></div><div class="stat-label">Comandas Ativas</div></div>
</div>
<div class="card stat-card">
<div class="stat-icon green">🛒</div>
<div><div class="stat-value" id="stat-pedidos"></div><div class="stat-label">Pedidos Hoje</div></div>
</div>
<div class="card stat-card">
<div class="stat-icon blue">👥</div>
<div><div class="stat-value" id="stat-clientes"></div><div class="stat-label">Clientes Cad.</div></div>
</div>
</div>
<div class="cards-grid cards-grid-2" style="padding-top:0">
<div class="card">
<h3 style="font-size:1rem;margin-bottom:16px;">🪑 Status das Mesas</h3>
<div id="dash-mesas-preview" style="display:flex;flex-wrap:wrap;gap:8px;"></div>
</div>
<div class="card">
<h3 style="font-size:1rem;margin-bottom:16px;">📋 Últimas Comandas</h3>
<div id="dash-comandas-preview"></div>
</div>
</div>`;
// Carrega dados em paralelo
const [mesas, comandas, pedidos, clientes] = await Promise.all([
window.electronAPI.get('/mesas/'),
window.electronAPI.get('/comandas/'),
window.electronAPI.get('/orders/'),
window.electronAPI.get('/clients/'),
]);
if (mesas.ok) {
// Cruza com comandas para verificar ocupação
const mesasOcupadas = new Set();
if (comandas.ok) {
comandas.data.forEach(c => {
if ((c.status === 'OPEN' || c.status === 'PAYING') && c.mesa) mesasOcupadas.add(c.mesa);
});
}
const abertas = mesas.data.filter(m => m.active && mesasOcupadas.has(m.id)).length;
document.getElementById('stat-mesas').textContent = abertas;
const preview = document.getElementById('dash-mesas-preview');
preview.innerHTML = mesas.data.slice(0, 12).map(m => {
const ocupada = mesasOcupadas.has(m.id);
return `<span style="padding:4px 10px;border-radius:6px;font-size:0.78rem;font-weight:600;
background:${ocupada ? 'rgba(239,68,68,0.15)' : 'rgba(34,197,94,0.12)'};
color:${ocupada ? '#fca5a5' : '#86efac'}">
${m.name}
</span>`;
}).join('');
}
if (comandas.ok) {
document.getElementById('stat-comandas').textContent = comandas.data.filter(c => c.status === 'OPEN' || c.status === 'PAYING').length;
const preview = document.getElementById('dash-comandas-preview');
preview.innerHTML = `<table style="width:100%;font-size:0.85rem">
<thead><tr style="color:var(--text-muted)"><th style="text-align:left;padding:4px 0">ID</th><th style="text-align:left">Nome/Mesa</th><th style="text-align:left">Status</th></tr></thead>
<tbody>
${comandas.data.slice(0, 5).map(c => {
const statusLabel = c.status === 'OPEN' ? 'Aberta' : (c.status === 'PAYING' ? 'Pagando' : 'Fechada');
const badgeClass = c.status === 'OPEN' ? 'badge-success' : (c.status === 'PAYING' ? 'badge-warning' : 'badge-muted');
return `
<tr>
<td style="padding:6px 0">#${c.id}</td>
<td>${c.name || c.mesa_name || ''}</td>
<td><span class="badge ${badgeClass}">${statusLabel}</span></td>
</tr>`;
}).join('')}
</tbody>
</table>`;
}
if (pedidos.ok) {
const hoje = new Date().toISOString().slice(0, 10);
const pedidosHoje = pedidos.data.filter(p => (p.created_at || p.data || '').startsWith(hoje)).length;
document.getElementById('stat-pedidos').textContent = pedidosHoje || pedidos.data.length;
}
if (clientes.ok) {
document.getElementById('stat-clientes').textContent = clientes.data.length;
}
}

View File

@@ -0,0 +1,61 @@
import { showApp, navigate } from '../app.js';
export function renderLogin() {
const form = document.getElementById('login-form');
if (!form) return;
// Configuração de URL na tela de Login
const configToggle = document.getElementById('login-config-toggle');
const configContainer = document.getElementById('login-config-container');
const apiUrlInput = document.getElementById('login-api-url');
const btnSaveUrl = document.getElementById('btn-save-login-config');
// Carrega a URL atual
window.electronAPI.getConfigUrl().then(url => {
if (apiUrlInput) apiUrlInput.value = url;
});
configToggle?.addEventListener('click', (e) => {
e.preventDefault();
configContainer?.classList.toggle('hidden');
});
btnSaveUrl?.addEventListener('click', async () => {
const newUrl = apiUrlInput.value.trim();
if (!newUrl.startsWith('http')) {
return alert('A URL deve começar com http:// ou https://');
}
await window.electronAPI.setConfigUrl(newUrl);
alert('URL da API atualizada com sucesso!');
configContainer?.classList.add('hidden');
});
form.onsubmit = async (e) => {
e.preventDefault();
const username = document.getElementById('username').value.trim();
const password = document.getElementById('password').value;
const btn = document.getElementById('login-btn');
const btnText = document.getElementById('login-btn-text');
const spinner = document.getElementById('login-spinner');
const errBox = document.getElementById('login-error');
btn.disabled = true;
btnText.textContent = 'Entrando...';
spinner.classList.remove('hidden');
errBox.classList.add('hidden');
const res = await window.electronAPI.login({ username, password });
btn.disabled = false;
btnText.textContent = 'Entrar';
spinner.classList.add('hidden');
if (res.ok) {
showApp();
navigate('dashboard');
} else {
errBox.textContent = res.error || 'Erro ao fazer login.';
errBox.classList.remove('hidden');
}
};
}

154
src/renderer/pages/mesas.js Normal file
View File

@@ -0,0 +1,154 @@
export async function renderMesas(container) {
container.innerHTML = `
<div class="page-header">
<div>
<div class="page-title">🪑 Mesas</div>
<div class="page-subtitle">Status em tempo real — cruzando com comandas abertas</div>
</div>
<div class="page-actions">
<button class="btn btn-ghost btn-md" id="btn-refresh-mesas">↺ Atualizar</button>
<button class="btn btn-primary btn-md" id="btn-nova-mesa">+ Nova Mesa</button>
</div>
</div>
<div id="mesas-legenda" style="display:flex;gap:12px;padding:0 32px 8px;font-size:0.8rem;color:var(--text-secondary)">
<span>🟢 Livre &nbsp; 🔴 Ocupada (tem comanda aberta) &nbsp; ⚫ Inativa</span>
</div>
<div id="mesas-container" class="mesa-grid"></div>`;
await loadMesas();
document.getElementById('btn-nova-mesa').addEventListener('click', () => abrirModalMesa());
document.getElementById('btn-refresh-mesas').addEventListener('click', loadMesas);
}
async function loadMesas() {
const grid = document.getElementById('mesas-container');
if (!grid) return;
grid.innerHTML = `<div class="loading-screen"><div class="spinner"></div></div>`;
// Carrega mesas e comandas em paralelo para determinar ocupação
const [mesasRes, comandasRes] = await Promise.all([
window.electronAPI.get('/mesas/'),
window.electronAPI.get('/comandas/'),
]);
if (!mesasRes.ok) {
grid.innerHTML = `<div class="table-empty">Erro ao carregar mesas.</div>`;
return;
}
const mesas = mesasRes.data;
// IDs de mesas com pelo menos uma comanda ativa (OPEN ou PAYING)
const mesasOcupadas = new Set();
if (comandasRes.ok) {
comandasRes.data.forEach(c => {
if ((c.status === 'OPEN' || c.status === 'PAYING') && c.mesa) mesasOcupadas.add(c.mesa);
});
}
if (!mesas.length) {
grid.innerHTML = `<div class="table-empty">Nenhuma mesa cadastrada.</div>`;
return;
}
grid.innerHTML = mesas.map(mesa => {
const inativa = !mesa.active;
const ocupada = !inativa && mesasOcupadas.has(mesa.id);
const classe = inativa ? 'inativa' : (ocupada ? 'ocupada' : 'livre');
const icone = inativa ? '⚫' : (ocupada ? '🔴' : '🟢');
const statusTxt = inativa ? 'Inativa' : (ocupada ? 'Ocupada' : 'Livre');
return `
<div class="mesa-card ${classe}" data-id="${mesa.id}" data-ocupada="${ocupada}" data-inativa="${inativa}"
title="Localização: ${mesa.location || ''}">
<div class="mesa-num">${mesa.name}</div>
<div class="mesa-status">${icone} ${statusTxt}</div>
${mesa.location ? `<div style="font-size:0.65rem;color:var(--text-muted);margin-top:4px">${mesa.location}</div>` : ''}
</div>`;
}).join('');
// Click em cada mesa abre detalhes/ações
grid.querySelectorAll('.mesa-card').forEach(card => {
card.addEventListener('click', () => {
const mesa = mesas.find(m => m.id === parseInt(card.dataset.id));
if (mesa) abrirDetalheMesa(mesa, mesasOcupadas.has(mesa.id));
});
});
}
function abrirDetalheMesa(mesa, ocupada) {
const inativa = !mesa.active;
openModal({
title: mesa.name,
body: `
<div style="display:flex;flex-direction:column;gap:12px">
<div style="display:flex;gap:10px;align-items:center">
<span class="badge ${inativa ? 'badge-muted' : (ocupada ? 'badge-danger' : 'badge-success')}">
${inativa ? 'Inativa' : (ocupada ? 'Ocupada' : 'Livre')}
</span>
${mesa.active ? '<span class="badge badge-info">Ativa no sistema</span>' : ''}
</div>
${mesa.location ? `<div style="font-size:0.82rem;color:var(--text-secondary)">📍 Localização: <strong>${mesa.location}</strong></div>` : ''}
</div>`,
footer: `
<button class="btn btn-ghost btn-md" onclick="closeModal()">Fechar</button>
<button class="btn btn-secondary btn-md" id="btn-edit-mesa">✏️ Editar</button>
<button class="btn btn-danger btn-md" id="btn-del-mesa">Excluir</button>`,
});
document.getElementById('btn-edit-mesa').addEventListener('click', () => {
closeModal();
abrirModalMesa(mesa);
});
document.getElementById('btn-del-mesa').addEventListener('click', async () => {
const r = await window.electronAPI.delete(`/mesas/${mesa.id}/`);
if (r.ok) { showToast('Mesa excluída!', 'success'); closeModal(); loadMesas(); }
else showToast(r.error, 'error');
});
}
function abrirModalMesa(mesa = null) {
const isEdit = !!mesa;
openModal({
title: isEdit ? `Editar: ${mesa.name}` : 'Nova Mesa',
body: `
<div class="form-grid">
<div class="form-group">
<label>Nome</label>
<input type="text" id="mesa-nome" class="form-control" value="${mesa?.name || ''}" placeholder="Ex: BALCÃO, Mesa 01..." />
</div>
<div class="form-group">
<label>Localização (x-y)</label>
<input type="text" id="mesa-loc" class="form-control" value="${mesa?.location || ''}" placeholder="Ex: 350-850" />
</div>
<div class="form-group">
<label>Ativa no sistema</label>
<select id="mesa-active" class="form-control">
<option value="true" ${mesa?.active ? 'selected' : ''}>Sim</option>
<option value="false" ${mesa?.active === false ? 'selected' : ''}>Não</option>
</select>
</div>
</div>`,
footer: `
<button class="btn btn-secondary btn-md" onclick="closeModal()">Cancelar</button>
<button class="btn btn-primary btn-md" id="btn-salvar-mesa">${isEdit ? 'Salvar' : 'Criar'}</button>`,
});
document.getElementById('btn-salvar-mesa').addEventListener('click', async () => {
const data = {
name: document.getElementById('mesa-nome').value.trim(),
location: document.getElementById('mesa-loc').value.trim(),
active: document.getElementById('mesa-active').value === 'true',
};
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);
if (r.ok) { showToast(isEdit ? 'Mesa atualizada!' : 'Mesa criada!', 'success'); closeModal(); loadMesas(); }
else showToast(r.error, 'error');
});
}

View File

@@ -0,0 +1,187 @@
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/'),
]);
if (tRes.ok) tiposPag = tRes.data;
if (cRes.ok) comandas = cRes.data;
container.innerHTML = `
<div class="page-header">
<div>
<div class="page-title">💳 Pagamentos</div>
<div class="page-subtitle">Histórico financeiro do estabelecimento</div>
</div>
<div class="page-actions">
<button class="btn btn-primary btn-md" id="btn-novo-pag">+ Registrar Pagamento</button>
</div>
</div>
<div class="table-wrap">
<div class="table-toolbar">
<input type="text" class="search-input" id="search-pag" placeholder="🔍 Buscar por cliente, comanda ou descrição..." />
<select id="filter-tipo" class="form-control" style="width:160px">
<option value="">Todos os tipos</option>
${tiposPag.map(t => `<option value="${t.id}">${t.nome || t.name}</option>`).join('')}
</select>
</div>
<div id="pagamentos-table"></div>
</div>`;
await loadPagamentos(tiposPag, comandas);
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));
}
let _pagsData = [];
async function loadPagamentos(tiposPag, comandas) {
const wrap = document.getElementById('pagamentos-table');
if (!wrap) return;
wrap.innerHTML = `<div class="loading-screen"><div class="spinner"></div></div>`;
const res = await window.electronAPI.get('/payments/');
if (!res.ok) { wrap.innerHTML = `<div class="table-empty">Erro ao carregar pagamentos.</div>`; return; }
_pagsData = res.data;
renderPagsTable(_pagsData);
}
function renderPagsTable(data) {
const wrap = document.getElementById('pagamentos-table');
if (!wrap) return;
if (!data.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);
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>
${data.map(p => `<tr>
<td style="color:var(--text-muted)">#${p.id}</td>
<td>${p.client_name || ''}</td>
<td>
${p.comanda ? `<span style="font-size:0.8rem">
<span style="color:var(--text-muted)">#${p.comanda}</span>
${p.comanda_name ? `<span style="color:var(--text-secondary)"> ${p.comanda_name}</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-danger btn-sm btn-del-pag" data-id="${p.id}">Excluir</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-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');
})
);
}
function filtrarPagamentos(tiposPag) {
const q = document.getElementById('search-pag')?.value.toLowerCase() || '';
const tipo = parseInt(document.getElementById('filter-tipo')?.value) || null;
const filtered = _pagsData.filter(p => {
const matchQ = !q ||
(p.client_name || '').toLowerCase().includes(q) ||
(p.comanda_name || '').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);
}
function abrirModalPagamento(tiposPag, comandas) {
const comandasAbertas = comandas.filter(c => c.status === 'OPEN' || c.status === 'PAYING');
openModal({
title: 'Registrar Pagamento',
body: `
<div class="form-grid">
<div class="form-group">
<label>Tipo de Pagamento</label>
<select id="pag-tipo" class="form-control">
${tiposPag.map(t => `<option value="${t.id}">${t.nome || t.name}</option>`).join('')}
</select>
</div>
<div class="form-group">
<label>Valor (R$)</label>
<input type="number" id="pag-valor" class="form-control" step="0.01" min="0" placeholder="0.00" />
</div>
<div class="form-group" style="grid-column:1/-1">
<label>Comanda (opcional)</label>
<select id="pag-comanda" class="form-control">
<option value=""> Sem comanda </option>
${comandasAbertas.map(c => `<option value="${c.id}">#${c.id}${c.name || ''} (${c.mesa_name || ''})</option>`).join('')}
</select>
</div>
<div class="form-group" style="grid-column:1/-1">
<label>Descrição</label>
<input type="text" id="pag-desc" class="form-control" placeholder="Ex: PAGAMENTO DE FIADO" />
</div>
</div>`,
footer: `
<button class="btn btn-secondary btn-md" onclick="closeModal()">Cancelar</button>
<button class="btn btn-primary btn-md" id="btn-salvar-pag">Registrar</button>`,
});
document.getElementById('btn-salvar-pag').addEventListener('click', async () => {
const tipoVal = parseInt(document.getElementById('pag-tipo').value) || null;
const comandaVal = parseInt(document.getElementById('pag-comanda').value) || null;
const valor = parseFloat(document.getElementById('pag-valor').value);
if (!valor || valor <= 0) return showToast('Informe um valor válido.', 'error');
const data = {
type_pay: tipoVal,
value: valor.toFixed(2),
comanda: comandaVal,
description: document.getElementById('pag-desc').value.trim(),
};
const r = await window.electronAPI.post('/payments/', data);
if (r.ok) { showToast('Pagamento registrado!', 'success'); closeModal(); loadPagamentos(tiposPag, comandas); }
else showToast(r.error, 'error');
});
}
function formatDate(str) {
if (!str) return '';
return new Date(str).toLocaleDateString('pt-BR', {
day: '2-digit', month: '2-digit', year: 'numeric',
hour: '2-digit', minute: '2-digit'
});
}

View File

@@ -0,0 +1,179 @@
// Pedidos = Fila de Cozinha (KDS - Kitchen Display System)
// Cada "order" representa um item individual na fila, com pipeline de status por timestamps
const STATUS_CONFIG = {
'Em espera': { badge: 'badge-warning', icon: '⏳', next: 'preparing', nextLabel: '▶ Preparando' },
'Preparando': { badge: 'badge-info', icon: '🍳', next: 'finished', nextLabel: '✅ Pronto' },
'Pronto': { badge: 'badge-success', icon: '✅', next: 'delivered', nextLabel: '🚀 Entregue' },
'Entregue': { badge: 'badge-muted', icon: '🚀', next: null, nextLabel: null },
'Cancelado': { badge: 'badge-danger', icon: '❌', next: null, nextLabel: null },
};
export async function renderPedidos(container) {
container.innerHTML = `
<div class="page-header">
<div>
<div class="page-title">🛒 Pedidos</div>
<div class="page-subtitle">Fila de cozinha em tempo real</div>
</div>
<div class="page-actions">
<button class="btn btn-ghost btn-md" id="btn-refresh-orders">↺ Atualizar</button>
</div>
</div>
<div class="table-wrap">
<div class="table-toolbar">
<input type="text" class="search-input" id="search-order" placeholder="🔍 Buscar produto, comanda ou mesa..." />
<select id="filter-status-order" class="form-control" style="width:160px">
<option value="">Todos os status</option>
<option value="Em espera">⏳ Em espera</option>
<option value="Preparando">🍳 Preparando</option>
<option value="Pronto">✅ Pronto</option>
<option value="Entregue">🚀 Entregue</option>
<option value="Cancelado">❌ Cancelado</option>
</select>
</div>
<div id="orders-table"></div>
</div>`;
await loadOrders();
document.getElementById('btn-refresh-orders').addEventListener('click', loadOrders);
document.getElementById('search-order').addEventListener('input', () => filtrarOrders());
document.getElementById('filter-status-order').addEventListener('change', () => filtrarOrders());
}
let _ordersData = [];
async function loadOrders() {
const wrap = document.getElementById('orders-table');
if (!wrap) return;
wrap.innerHTML = `<div class="loading-screen"><div class="spinner"></div></div>`;
const res = await window.electronAPI.get('/orders/');
if (!res.ok) { wrap.innerHTML = `<div class="table-empty">Erro ao carregar pedidos.</div>`; return; }
_ordersData = res.data;
// Por padrão, filtra para mostrar só os não entregues/cancelados
const filtroInicial = document.getElementById('filter-status-order');
if (filtroInicial && !filtroInicial.value) {
// Mantém filtro vazio mas é renderizado completo
}
renderOrdersTable(_ordersData);
}
function renderOrdersTable(data) {
const wrap = document.getElementById('orders-table');
if (!wrap) return;
if (!data.length) { wrap.innerHTML = `<div class="table-empty">Nenhum pedido encontrado.</div>`; return; }
wrap.innerHTML = `
<table>
<thead><tr>
<th>#</th>
<th>Produto</th>
<th>Comanda</th>
<th>Mesa</th>
<th>Status</th>
<th>Na fila</th>
<th>Obs.</th>
<th>Ações</th>
</tr></thead>
<tbody>
${data.map(o => {
const cfg = STATUS_CONFIG[o.status] || { badge: 'badge-muted', icon: '?', next: null };
return `<tr>
<td style="color:var(--text-muted)">#${o.id}</td>
<td><strong>${o.product_name || ''}</strong></td>
<td style="font-size:0.82rem;color:var(--text-secondary)">${o.comanda_name || ''}</td>
<td style="font-size:0.82rem">${o.mesa_name || ''}</td>
<td><span class="badge ${cfg.badge}">${cfg.icon} ${o.status}</span></td>
<td style="font-size:0.78rem;color:var(--text-muted);white-space:nowrap">${formatTime(o.queue)}</td>
<td style="font-size:0.8rem;color:var(--text-secondary);max-width:140px;">
<div style="display:flex;align-items:center;gap:4px">
<span style="flex:1;overflow:hidden;text-overflow:ellipsis;white-space:nowrap" title="${o.obs || ''}">${o.obs || ''}</span>
<button class="btn btn-ghost btn-sm btn-edit-obs" data-id="${o.id}" data-obs="${o.obs || ''}" title="Editar observação" style="padding:0;color:var(--primary);font-size:1rem;min-height:unset;height:auto"></button>
</div>
</td>
<td>
<div style="display:flex;gap:6px;flex-wrap:wrap">
${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>`
: ''}
</div>
</td>
</tr>`;
}).join('')}
</tbody>
</table>
<div style="padding:12px 20px;border-top:1px solid var(--border);font-size:0.8rem;color:var(--text-muted)">
${data.length} ${data.length === 1 ? 'pedido' : 'pedidos'} exibidos
&nbsp;·&nbsp; ${data.filter(o => o.status === 'Em espera').length} em espera
&nbsp;·&nbsp; 🍳 ${data.filter(o => o.status === 'Preparando').length} preparando
&nbsp;·&nbsp; ${data.filter(o => o.status === 'Pronto').length} prontos
</div>`;
// Avançar status para próxima etapa
wrap.querySelectorAll('.btn-avanca').forEach(btn =>
btn.addEventListener('click', async () => {
const nowISO = new Date().toISOString();
const patch = { [btn.dataset.next]: nowISO };
const r = await window.electronAPI.patch(`/orders/${btn.dataset.id}/`, patch);
if (r.ok) { showToast('Status atualizado!', 'success'); loadOrders(); }
else showToast(r.error, 'error');
})
);
// Cancelar pedido
wrap.querySelectorAll('.btn-cancela').forEach(btn =>
btn.addEventListener('click', async () => {
const r = await window.electronAPI.patch(`/orders/${btn.dataset.id}/`, { canceled: new Date().toISOString() });
if (r.ok) { showToast('Pedido cancelado.', 'info'); loadOrders(); }
else showToast(r.error, 'error');
})
);
// Editar observação
wrap.querySelectorAll('.btn-edit-obs').forEach(btn =>
btn.addEventListener('click', () => {
const currentObs = btn.dataset.obs || '';
const orderId = btn.dataset.id;
// Para o nome do produto, usamos o valor mais próximo ou apenas "Pedido #"
const row = btn.closest('tr');
const productName = row ? row.querySelector('td:nth-child(2) strong').innerText : `Pedido #${orderId}`;
window.abrirModalObsCozinhaGlobal(productName, currentObs, async (novaObs) => {
if (novaObs === null || novaObs === currentObs) return;
const r = await window.electronAPI.patch(`/orders/${orderId}/`, { obs: novaObs });
if (r.ok) {
showToast('Observação atualizada!', 'success');
loadOrders();
} else {
showToast(r.error, 'error');
}
});
})
);
}
function filtrarOrders() {
const q = document.getElementById('search-order')?.value.toLowerCase() || '';
const status = document.getElementById('filter-status-order')?.value || '';
const filtered = _ordersData.filter(o => {
const matchQ = !q ||
(o.product_name || '').toLowerCase().includes(q) ||
(o.comanda_name || '').toLowerCase().includes(q) ||
(o.mesa_name || '').toLowerCase().includes(q) ||
(o.obs || '').toLowerCase().includes(q);
const matchStatus = !status || o.status === status;
return matchQ && matchStatus;
});
renderOrdersTable(filtered);
}
function formatTime(str) {
if (!str) return '';
return new Date(str).toLocaleTimeString('pt-BR', { hour: '2-digit', minute: '2-digit', second: '2-digit' });
}

View File

@@ -0,0 +1,221 @@
export async function renderProdutos(container) {
let categorias = [];
const catRes = await window.electronAPI.get('/categories/');
if (catRes.ok) categorias = catRes.data;
container.innerHTML = `
<div class="page-header">
<div>
<div class="page-title">🍔 Produtos</div>
<div class="page-subtitle">Cardápio e categorias</div>
</div>
<div class="page-actions">
<button class="btn btn-secondary btn-md" id="btn-nova-cat">+ Categoria</button>
<button class="btn btn-primary btn-md" id="btn-novo-prod">+ Produto</button>
</div>
</div>
<div class="table-wrap">
<div class="table-toolbar">
<input type="text" class="search-input" id="search-produto" placeholder="🔍 Buscar produto..." />
<select id="filter-cat" class="form-control" style="width:180px">
<option value="">Todas as categorias</option>
${categorias.map(c => `<option value="${c.id}">${c.nome || c.name}</option>`).join('')}
</select>
<select id="filter-ativo" class="form-control" style="width:140px">
<option value="">Todos</option>
<option value="true">Ativos</option>
<option value="false">Inativos</option>
</select>
</div>
<div id="produtos-table"></div>
</div>`;
await loadProdutos(categorias);
document.getElementById('btn-novo-prod').addEventListener('click', () => abrirModalProduto(null, categorias));
document.getElementById('btn-nova-cat').addEventListener('click', () => abrirModalCategoria());
document.getElementById('search-produto').addEventListener('input', () => filtrarProdutos(categorias));
document.getElementById('filter-cat').addEventListener('change', () => filtrarProdutos(categorias));
document.getElementById('filter-ativo').addEventListener('change', () => filtrarProdutos(categorias));
}
let _produtosData = [];
async function loadProdutos(categorias) {
const wrap = document.getElementById('produtos-table');
if (!wrap) return;
wrap.innerHTML = `<div class="loading-screen"><div class="spinner"></div></div>`;
const res = await window.electronAPI.get('/products/');
if (!res.ok) { wrap.innerHTML = `<div class="table-empty">Erro ao carregar produtos.</div>`; return; }
_produtosData = res.data;
renderProdutosTable(_produtosData, categorias);
}
function renderProdutosTable(data, categorias) {
const wrap = document.getElementById('produtos-table');
if (!wrap) return;
if (!data.length) { wrap.innerHTML = `<div class="table-empty">Nenhum produto encontrado.</div>`; return; }
wrap.innerHTML = `
<table>
<thead><tr>
<th>#</th>
<th>Nome</th>
<th>Categoria</th>
<th>Preço</th>
<th>Estoque</th>
<th>Unid.</th>
<th>Cozinha</th>
<th>Ativo</th>
<th>Ações</th>
</tr></thead>
<tbody>
${data.map(p => `<tr>
<td style="color:var(--text-muted)">#${p.id}</td>
<td><strong>${p.name}</strong></td>
<td>${p.category_name || ''}</td>
<td>R$ ${parseFloat(p.price || 0).toFixed(2)}</td>
<td>
<span class="badge ${p.quantity > 0 ? 'badge-info' : 'badge-warning'}">
${p.quantity ?? ''}
</span>
</td>
<td style="font-size:0.8rem;color:var(--text-secondary)">${p.unit_of_measure_name || ''}</td>
<td>
<span class="badge ${p.cuisine ? 'badge-warning' : 'badge-muted'}">
${p.cuisine ? 'Sim' : 'Não'}
</span>
</td>
<td>
<span class="badge ${p.active ? 'badge-success' : 'badge-danger'}">
${p.active ? 'Ativo' : 'Inativo'}
</span>
</td>
<td>
<div style="display:flex;gap:6px">
<button class="btn btn-secondary btn-sm btn-edit-prod" data-id="${p.id}">Editar</button>
<button class="btn btn-danger btn-sm btn-del-prod" data-id="${p.id}">Excluir</button>
</div>
</td>
</tr>`).join('')}
</tbody>
</table>`;
wrap.querySelectorAll('.btn-edit-prod').forEach(btn => {
btn.addEventListener('click', () => {
const p = _produtosData.find(x => x.id === parseInt(btn.dataset.id));
if (p) abrirModalProduto(p, categorias);
});
});
wrap.querySelectorAll('.btn-del-prod').forEach(btn =>
btn.addEventListener('click', async () => {
const r = await window.electronAPI.delete(`/products/${btn.dataset.id}/`);
if (r.ok) { showToast('Produto excluído!', 'success'); loadProdutos(categorias); }
else showToast(r.error, 'error');
})
);
}
function filtrarProdutos(categorias) {
const q = document.getElementById('search-produto')?.value.toLowerCase() || '';
const catId = parseInt(document.getElementById('filter-cat')?.value) || null;
const ativo = document.getElementById('filter-ativo')?.value;
const filtered = _produtosData.filter(p => {
const matchQ = !q || p.name.toLowerCase().includes(q) || (p.description || '').toLowerCase().includes(q);
const matchCat = !catId || p.category === catId;
const matchAtiv = !ativo || String(p.active) === ativo;
return matchQ && matchCat && matchAtiv;
});
renderProdutosTable(filtered, categorias);
}
function abrirModalProduto(produto, categorias) {
const isEdit = !!produto;
openModal({
title: isEdit ? `Editar: ${produto.name}` : 'Novo Produto',
body: `
<div class="form-grid">
<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" />
</div>
<div class="form-group">
<label>Preço (R$)</label>
<input type="number" id="prod-preco" class="form-control" value="${produto?.price || ''}" step="0.01" min="0" placeholder="0.00" />
</div>
<div class="form-group">
<label>Estoque</label>
<input type="number" id="prod-qty" class="form-control" value="${produto?.quantity ?? ''}" min="0" placeholder="0" />
</div>
<div class="form-group">
<label>Categoria</label>
<select id="prod-cat" class="form-control">
<option value=""> Sem categoria </option>
${categorias.map(c => `<option value="${c.id}" ${produto?.category === c.id ? 'selected' : ''}>${c.nome || c.name}</option>`).join('')}
</select>
</div>
<div class="form-group">
<label>Ativo</label>
<select id="prod-ativo" class="form-control">
<option value="true" ${produto?.active !== false ? 'selected' : ''}>Sim</option>
<option value="false" ${produto?.active === false ? 'selected' : ''}>Não</option>
</select>
</div>
<div class="form-group">
<label>Cozinha (cuisine)</label>
<select id="prod-cuisine" class="form-control">
<option value="false" ${!produto?.cuisine ? 'selected' : ''}>Não</option>
<option value="true" ${produto?.cuisine ? 'selected' : ''}>Sim</option>
</select>
</div>
<div class="form-group" style="grid-column:1/-1">
<label>Descrição</label>
<input type="text" id="prod-desc" class="form-control" value="${produto?.description || ''}" placeholder="Descrição opcional" />
</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' : 'Criar'}</button>`,
});
document.getElementById('btn-salvar-prod').addEventListener('click', async () => {
const catVal = parseInt(document.getElementById('prod-cat').value) || null;
const data = {
name: document.getElementById('prod-nome').value,
description: document.getElementById('prod-desc').value,
price: parseFloat(document.getElementById('prod-preco').value) || 0,
quantity: parseInt(document.getElementById('prod-qty').value) || 0,
category: catVal,
active: document.getElementById('prod-ativo').value === 'true',
cuisine: document.getElementById('prod-cuisine').value === 'true',
};
const r = isEdit
? await window.electronAPI.put(`/products/${produto.id}/`, data)
: await window.electronAPI.post('/products/', data);
if (r.ok) { showToast(isEdit ? 'Produto atualizado!' : 'Produto criado!', 'success'); closeModal(); loadProdutos(categorias); }
else showToast(r.error, 'error');
});
}
function abrirModalCategoria() {
openModal({
title: 'Nova Categoria',
body: `
<div class="form-group">
<label>Nome</label>
<input type="text" id="cat-nome" class="form-control" placeholder="Ex: Bebidas, Lanches..." />
</div>`,
footer: `
<button class="btn btn-secondary btn-md" onclick="closeModal()">Cancelar</button>
<button class="btn btn-primary btn-md" id="btn-criar-cat">Criar</button>`,
});
document.getElementById('btn-criar-cat').addEventListener('click', async () => {
const nome = document.getElementById('cat-nome').value.trim();
if (!nome) return showToast('Informe um nome.', 'error');
const r = await window.electronAPI.post('/categories/', { nome, name: nome });
if (r.ok) { showToast('Categoria criada!', 'success'); closeModal(); }
else showToast(r.error, 'error');
});
}

980
src/renderer/style.css Normal file
View File

@@ -0,0 +1,980 @@
/* ─── Reset & Base ────────────────────────────────────────────────────────── */
*,
*::before,
*::after {
box-sizing: border-box;
margin: 0;
padding: 0;
}
:root {
--bg-base: #0f1117;
--bg-surface: #161b27;
--bg-elevated: #1e2536;
--bg-card: #232b3e;
--border: rgba(255, 255, 255, 0.07);
--primary: #6c63ff;
--primary-dark: #5a52e0;
--primary-glow: rgba(108, 99, 255, 0.25);
--success: #22c55e;
--warning: #f59e0b;
--danger: #ef4444;
--info: #38bdf8;
--text-primary: #f0f4ff;
--text-secondary: #8b95b0;
--text-muted: #4e5875;
--sidebar-w: 230px;
--radius: 12px;
--radius-sm: 8px;
--transition: 0.2s ease;
--shadow: 0 4px 24px rgba(0, 0, 0, 0.4);
}
html,
body {
height: 100%;
font-family: 'Inter', system-ui, sans-serif;
background: var(--bg-base);
color: var(--text-primary);
overflow: hidden;
}
/* ─── Utilities ───────────────────────────────────────────────────────────── */
.hidden {
display: none !important;
}
.flex {
display: flex;
}
.flex-col {
flex-direction: column;
}
.items-center {
align-items: center;
}
.justify-between {
justify-content: space-between;
}
.gap-sm {
gap: 8px;
}
.gap-md {
gap: 16px;
}
.gap-lg {
gap: 24px;
}
/* ─── Login Screen ────────────────────────────────────────────────────────── */
.login-screen {
display: flex;
height: 100vh;
align-items: center;
justify-content: center;
background: radial-gradient(ellipse at 60% 40%, rgba(108, 99, 255, 0.15) 0%, transparent 70%),
radial-gradient(ellipse at 20% 80%, rgba(56, 189, 248, 0.08) 0%, transparent 60%),
var(--bg-base);
}
.login-card {
background: var(--bg-surface);
border: 1px solid var(--border);
border-radius: 20px;
padding: 48px 40px;
width: 420px;
box-shadow: 0 24px 64px rgba(0, 0, 0, 0.5), 0 0 0 1px var(--border);
animation: fadeSlideUp 0.4s ease;
}
.login-logo {
text-align: center;
margin-bottom: 36px;
}
.logo-icon {
font-size: 3rem;
margin-bottom: 12px;
filter: drop-shadow(0 0 16px rgba(108, 99, 255, 0.5));
}
.login-logo h1 {
font-size: 1.8rem;
font-weight: 700;
letter-spacing: 2px;
background: linear-gradient(135deg, var(--primary), var(--info));
-webkit-background-clip: text;
background-clip: text;
-webkit-text-fill-color: transparent;
}
.login-logo p {
color: var(--text-secondary);
font-size: 0.85rem;
margin-top: 4px;
}
.login-form {
display: flex;
flex-direction: column;
gap: 18px;
}
.field-group {
display: flex;
flex-direction: column;
gap: 6px;
}
.field-group label {
font-size: 0.8rem;
font-weight: 500;
color: var(--text-secondary);
text-transform: uppercase;
letter-spacing: 0.5px;
}
.field-group input {
background: var(--bg-elevated);
border: 1px solid var(--border);
border-radius: var(--radius-sm);
padding: 12px 16px;
color: var(--text-primary);
font-size: 0.95rem;
font-family: inherit;
outline: none;
transition: border-color var(--transition), box-shadow var(--transition);
}
.field-group input:focus {
border-color: var(--primary);
box-shadow: 0 0 0 3px var(--primary-glow);
}
.field-group input::placeholder {
color: var(--text-muted);
}
.login-error {
background: rgba(239, 68, 68, 0.12);
border: 1px solid rgba(239, 68, 68, 0.3);
border-radius: var(--radius-sm);
padding: 10px 14px;
color: #fca5a5;
font-size: 0.85rem;
text-align: center;
}
/* ─── Buttons ─────────────────────────────────────────────────────────────── */
.btn {
display: inline-flex;
align-items: center;
justify-content: center;
gap: 8px;
border: none;
border-radius: var(--radius-sm);
cursor: pointer;
font-family: inherit;
font-weight: 500;
transition: all var(--transition);
}
.btn:active {
transform: scale(0.97);
}
.btn-primary {
background: linear-gradient(135deg, var(--primary), var(--primary-dark));
color: #fff;
box-shadow: 0 4px 16px var(--primary-glow);
}
.btn-primary:hover {
filter: brightness(1.12);
box-shadow: 0 6px 24px var(--primary-glow);
}
.btn-secondary {
background: var(--bg-elevated);
border: 1px solid var(--border);
color: var(--text-primary);
}
.btn-secondary:hover {
background: var(--bg-card);
border-color: var(--primary);
}
.btn-success {
background: rgba(34, 197, 94, 0.15);
border: 1px solid rgba(34, 197, 94, 0.3);
color: var(--success);
}
.btn-success:hover {
background: rgba(34, 197, 94, 0.25);
}
.btn-danger {
background: rgba(239, 68, 68, 0.12);
border: 1px solid rgba(239, 68, 68, 0.3);
color: var(--danger);
}
.btn-danger:hover {
background: rgba(239, 68, 68, 0.22);
}
.btn-ghost {
background: transparent;
color: var(--text-secondary);
}
.btn-ghost:hover {
background: var(--bg-elevated);
color: var(--text-primary);
}
.btn-sm {
padding: 6px 12px;
font-size: 0.8rem;
}
.btn-md {
padding: 9px 18px;
font-size: 0.88rem;
}
.btn-lg {
padding: 13px 24px;
font-size: 1rem;
width: 100%;
}
.btn:disabled {
opacity: 0.5;
cursor: not-allowed;
pointer-events: none;
}
/* ─── App Layout ──────────────────────────────────────────────────────────── */
.app-layout {
display: flex;
height: 100vh;
overflow: hidden;
}
/* Sidebar */
.sidebar {
width: var(--sidebar-w);
background: var(--bg-surface);
border-right: 1px solid var(--border);
display: flex;
flex-direction: column;
flex-shrink: 0;
}
.sidebar-header {
padding: 20px 18px;
display: flex;
align-items: center;
gap: 10px;
border-bottom: 1px solid var(--border);
}
.logo-icon-sm {
font-size: 1.4rem;
filter: drop-shadow(0 0 8px rgba(108, 99, 255, 0.5));
}
.brand-name {
font-size: 1.1rem;
font-weight: 700;
letter-spacing: 1px;
background: linear-gradient(135deg, var(--primary), var(--info));
-webkit-background-clip: text;
background-clip: text;
-webkit-text-fill-color: transparent;
}
.sidebar-nav {
flex: 1;
padding: 12px 10px;
display: flex;
flex-direction: column;
gap: 4px;
overflow-y: auto;
}
.nav-item {
display: flex;
align-items: center;
gap: 12px;
padding: 10px 12px;
border-radius: var(--radius-sm);
color: var(--text-secondary);
text-decoration: none;
font-size: 0.9rem;
font-weight: 500;
transition: all var(--transition);
}
.nav-item:hover {
background: var(--bg-elevated);
color: var(--text-primary);
}
.nav-item.active {
background: var(--primary-glow);
color: var(--primary);
border-left: 3px solid var(--primary);
}
.nav-icon {
font-size: 1.1rem;
width: 20px;
text-align: center;
}
.sidebar-footer {
padding: 14px 10px;
border-top: 1px solid var(--border);
}
/* Main Content */
.main-content {
flex: 1;
overflow-y: auto;
background: var(--bg-base);
}
/* ─── Page Header ─────────────────────────────────────────────────────────── */
.page-header {
padding: 28px 32px 0;
display: flex;
align-items: center;
justify-content: space-between;
flex-wrap: wrap;
gap: 12px;
}
.page-title {
font-size: 1.6rem;
font-weight: 700;
}
.page-subtitle {
font-size: 0.85rem;
color: var(--text-secondary);
margin-top: 3px;
}
.page-actions {
display: flex;
gap: 8px;
}
/* ─── Cards ───────────────────────────────────────────────────────────────── */
.cards-grid {
display: grid;
gap: 18px;
padding: 24px 32px;
}
.cards-grid-2 {
grid-template-columns: repeat(auto-fill, minmax(280px, 1fr));
}
.cards-grid-4 {
grid-template-columns: repeat(auto-fill, minmax(200px, 1fr));
}
.card {
background: var(--bg-card);
border: 1px solid var(--border);
border-radius: var(--radius);
padding: 22px;
transition: transform var(--transition), box-shadow var(--transition);
}
.card:hover {
transform: translateY(-2px);
box-shadow: var(--shadow);
}
.stat-card {
display: flex;
align-items: center;
gap: 16px;
}
.stat-icon {
font-size: 2rem;
width: 52px;
height: 52px;
border-radius: 14px;
display: flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
}
.stat-icon.purple {
background: rgba(108, 99, 255, 0.15);
}
.stat-icon.green {
background: rgba(34, 197, 94, 0.12);
}
.stat-icon.amber {
background: rgba(245, 158, 11, 0.12);
}
.stat-icon.blue {
background: rgba(56, 189, 248, 0.12);
}
.stat-value {
font-size: 1.8rem;
font-weight: 700;
line-height: 1;
}
.stat-label {
font-size: 0.78rem;
color: var(--text-secondary);
text-transform: uppercase;
letter-spacing: 0.5px;
margin-top: 4px;
}
/* ─── Tables ──────────────────────────────────────────────────────────────── */
.table-wrap {
margin: 0 32px 32px;
background: var(--bg-card);
border: 1px solid var(--border);
border-radius: var(--radius);
overflow: hidden;
}
.table-toolbar {
padding: 16px 20px;
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
border-bottom: 1px solid var(--border);
flex-wrap: wrap;
}
.search-input {
background: var(--bg-elevated);
border: 1px solid var(--border);
border-radius: var(--radius-sm);
padding: 8px 14px;
color: var(--text-primary);
font-size: 0.88rem;
font-family: inherit;
outline: none;
width: 240px;
}
.search-input:focus {
border-color: var(--primary);
}
.search-input::placeholder {
color: var(--text-muted);
}
table {
width: 100%;
border-collapse: collapse;
}
thead th {
padding: 12px 16px;
font-size: 0.75rem;
text-transform: uppercase;
letter-spacing: 0.6px;
color: var(--text-muted);
font-weight: 600;
text-align: left;
border-bottom: 1px solid var(--border);
white-space: nowrap;
}
tbody tr {
transition: background var(--transition);
}
tbody tr:hover {
background: var(--bg-elevated);
}
tbody td {
padding: 13px 16px;
font-size: 0.88rem;
border-bottom: 1px solid var(--border);
color: var(--text-primary);
}
tbody tr:last-child td {
border-bottom: none;
}
.table-empty {
padding: 48px;
text-align: center;
color: var(--text-muted);
}
/* ─── Badges ──────────────────────────────────────────────────────────────── */
.badge {
display: inline-flex;
align-items: center;
gap: 4px;
padding: 3px 10px;
border-radius: 20px;
font-size: 0.72rem;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.5px;
}
.badge-success {
background: rgba(34, 197, 94, 0.15);
color: var(--success);
}
.badge-warning {
background: rgba(245, 158, 11, 0.15);
color: var(--warning);
}
.badge-danger {
background: rgba(239, 68, 68, 0.15);
color: var(--danger);
}
.badge-info {
background: rgba(56, 189, 248, 0.15);
color: var(--info);
}
.badge-muted {
background: var(--bg-elevated);
color: var(--text-secondary);
}
/* ─── Mesa Grid ──────────────────────────────────────────────────────────── */
.mesa-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(150px, 1fr));
gap: 16px;
padding: 24px 32px;
}
.mesa-card {
background: var(--bg-card);
border: 2px solid var(--border);
border-radius: var(--radius);
padding: 20px 16px;
text-align: center;
cursor: pointer;
transition: all var(--transition);
position: relative;
}
.mesa-card:hover {
transform: translateY(-3px);
box-shadow: var(--shadow);
}
.mesa-card.ocupada {
border-color: rgba(239, 68, 68, 0.5);
background: rgba(239, 68, 68, 0.05);
}
.mesa-card.livre {
border-color: rgba(34, 197, 94, 0.4);
}
.mesa-num {
font-size: 2rem;
font-weight: 700;
margin-bottom: 6px;
}
.mesa-status {
font-size: 0.72rem;
text-transform: uppercase;
letter-spacing: 0.5px;
font-weight: 600;
}
.mesa-card.livre .mesa-status {
color: var(--success);
}
.mesa-card.ocupada .mesa-status {
color: var(--danger);
}
/* ─── Forms ───────────────────────────────────────────────────────────────── */
.form-grid {
display: grid;
gap: 14px;
}
.form-grid-2 {
grid-template-columns: 1fr 1fr;
}
.form-group {
display: flex;
flex-direction: column;
gap: 6px;
}
.form-group label {
font-size: 0.78rem;
font-weight: 500;
color: var(--text-secondary);
text-transform: uppercase;
letter-spacing: 0.4px;
}
.form-control {
background: var(--bg-elevated);
border: 1px solid var(--border);
border-radius: var(--radius-sm);
padding: 10px 14px;
color: var(--text-primary);
font-size: 0.9rem;
font-family: inherit;
outline: none;
transition: border-color var(--transition), box-shadow var(--transition);
width: 100%;
}
.form-control:focus {
border-color: var(--primary);
box-shadow: 0 0 0 3px var(--primary-glow);
}
.form-control::placeholder {
color: var(--text-muted);
}
select.form-control option {
background: var(--bg-elevated);
}
/* ─── Modal ───────────────────────────────────────────────────────────────── */
.modal-overlay {
position: fixed;
inset: 0;
background: rgba(0, 0, 0, 0.7);
backdrop-filter: blur(4px);
z-index: 1000;
display: flex;
align-items: center;
justify-content: center;
animation: fadeIn 0.2s ease;
}
.modal-box {
background: var(--bg-surface);
border: 1px solid var(--border);
border-radius: 16px;
width: 560px;
max-width: 95vw;
max-height: 90vh;
display: flex;
flex-direction: column;
box-shadow: 0 32px 80px rgba(0, 0, 0, 0.6);
animation: fadeSlideUp 0.25s ease;
}
.modal-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 20px 24px;
border-bottom: 1px solid var(--border);
}
.modal-header h3 {
font-size: 1.05rem;
font-weight: 600;
}
.modal-close {
background: none;
border: none;
color: var(--text-muted);
font-size: 1.1rem;
cursor: pointer;
padding: 4px;
border-radius: 6px;
transition: all var(--transition);
}
.modal-close:hover {
background: var(--bg-elevated);
color: var(--text-primary);
}
.modal-body {
padding: 24px;
overflow-y: auto;
}
.modal-footer {
padding: 16px 24px;
border-top: 1px solid var(--border);
display: flex;
justify-content: flex-end;
gap: 10px;
}
/* Modal Variants */
.modal-full {
width: 96vw;
height: 94vh;
}
/* PDV Layout for Comanda Items */
.pdv-container {
display: grid;
grid-template-columns: 380px 1fr;
height: 100%;
gap: 1px;
background: var(--border);
}
.pdv-left {
background: var(--bg-surface);
display: flex;
flex-direction: column;
padding: 20px;
overflow-y: auto;
}
.pdv-right {
background: var(--bg-base);
display: flex;
flex-direction: column;
padding: 20px;
overflow-y: hidden;
}
.pdv-header {
margin-bottom: 20px;
}
.pdv-products-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(160px, 1fr));
gap: 12px;
overflow-y: auto;
padding-right: 8px;
}
.pdv-product-card {
position: relative;
aspect-ratio: 1/1;
border-radius: var(--radius-sm);
overflow: hidden;
cursor: pointer;
border: 1px solid var(--border);
transition: all var(--transition);
display: flex;
flex-direction: column;
justify-content: flex-end;
padding: 12px;
}
.pdv-product-card:hover {
transform: scale(1.03);
border-color: var(--primary);
box-shadow: 0 8px 16px rgba(0, 0, 0, 0.4);
}
.pdv-product-bg {
position: absolute;
inset: 0;
background-size: cover;
background-position: center;
filter: brightness(0.4) grayscale(0.2);
z-index: 1;
transition: filter var(--transition);
}
.pdv-product-card:hover .pdv-product-bg {
filter: brightness(0.6) grayscale(0);
}
.pdv-product-info {
position: relative;
z-index: 2;
}
.pdv-product-name {
font-size: 0.9rem;
font-weight: 600;
color: #fff;
margin-bottom: 2px;
display: -webkit-box;
-webkit-line-clamp: 2;
line-clamp: 2;
-webkit-box-orient: vertical;
overflow: hidden;
text-shadow: 0 2px 4px rgba(0, 0, 0, 0.8);
}
.pdv-product-price {
font-size: 0.85rem;
color: var(--success);
font-weight: 700;
text-shadow: 0 2px 4px rgba(0, 0, 0, 0.8);
}
/* ─── Toast ───────────────────────────────────────────────────────────────── */
.toast {
position: fixed;
bottom: 28px;
right: 28px;
z-index: 2000;
padding: 14px 20px;
border-radius: var(--radius-sm);
font-size: 0.88rem;
font-weight: 500;
box-shadow: var(--shadow);
animation: fadeSlideUp 0.3s ease;
max-width: 360px;
}
.toast.success {
background: rgba(34, 197, 94, 0.15);
border: 1px solid rgba(34, 197, 94, 0.4);
color: #86efac;
}
.toast.error {
background: rgba(239, 68, 68, 0.15);
border: 1px solid rgba(239, 68, 68, 0.4);
color: #fca5a5;
}
.toast.info {
background: rgba(56, 189, 248, 0.15);
border: 1px solid rgba(56, 189, 248, 0.4);
color: var(--info);
}
/* ─── Loading ─────────────────────────────────────────────────────────────── */
.loading-screen {
display: flex;
align-items: center;
justify-content: center;
height: 300px;
color: var(--text-muted);
font-size: 0.9rem;
gap: 12px;
}
.spinner {
width: 18px;
height: 18px;
border: 2px solid rgba(255, 255, 255, 0.15);
border-top-color: var(--primary);
border-radius: 50%;
animation: spin 0.7s linear infinite;
display: inline-block;
}
/* ─── Scrollbar ───────────────────────────────────────────────────────────── */
::-webkit-scrollbar {
width: 6px;
height: 6px;
}
::-webkit-scrollbar-track {
background: transparent;
}
::-webkit-scrollbar-thumb {
background: var(--border);
border-radius: 99px;
}
/* ─── Animations ──────────────────────────────────────────────────────────── */
@keyframes fadeIn {
from {
opacity: 0;
}
to {
opacity: 1;
}
}
@keyframes fadeSlideUp {
from {
opacity: 0;
transform: translateY(16px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
@keyframes spin {
to {
transform: rotate(360deg);
}
}
/* ─── Tags de Observação (PDV) ────────────────────────────────────────────── */
.obs-tags {
display: flex;
flex-wrap: wrap;
gap: 10px;
margin-top: 12px;
}
.tag-item {
background: var(--bg-elevated);
border: 1px solid var(--border);
border-radius: 99px;
padding: 6px 14px;
font-size: 0.82rem;
color: var(--text-secondary);
cursor: pointer;
transition: all var(--transition);
user-select: none;
}
.tag-item:hover {
border-color: var(--primary);
color: var(--text-primary);
}
.tag-item.active {
background: var(--primary-glow);
border-color: var(--primary);
color: var(--primary);
font-weight: 600;
}