feat: Update observation tracking in PDV and Kitchen
This commit is contained in:
4
.gitignore
vendored
Normal file
4
.gitignore
vendored
Normal file
@@ -0,0 +1,4 @@
|
||||
node_modules/
|
||||
dist/
|
||||
.env
|
||||
*.log
|
||||
56
GUIA_POSTMAN_API.md
Normal file
56
GUIA_POSTMAN_API.md
Normal 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
4507
package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
41
package.json
Normal file
41
package.json
Normal 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
173
src/main/main.js
Normal 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
23
src/main/preload.js
Normal 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
221
src/renderer/app.js
Normal 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
132
src/renderer/index.html
Normal 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>
|
||||
344
src/renderer/pages/clientes.js
Normal file
344
src/renderer/pages/clientes.js
Normal 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'
|
||||
});
|
||||
}
|
||||
541
src/renderer/pages/comandas.js
Normal file
541
src/renderer/pages/comandas.js
Normal 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'
|
||||
});
|
||||
}
|
||||
61
src/renderer/pages/config.js
Normal file
61
src/renderer/pages/config.js
Normal 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');
|
||||
});
|
||||
}
|
||||
98
src/renderer/pages/dashboard.js
Normal file
98
src/renderer/pages/dashboard.js
Normal 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;
|
||||
}
|
||||
}
|
||||
61
src/renderer/pages/login.js
Normal file
61
src/renderer/pages/login.js
Normal 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
154
src/renderer/pages/mesas.js
Normal 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 🔴 Ocupada (tem comanda aberta) ⚫ 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');
|
||||
});
|
||||
}
|
||||
187
src/renderer/pages/pagamentos.js
Normal file
187
src/renderer/pages/pagamentos.js
Normal 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'
|
||||
});
|
||||
}
|
||||
179
src/renderer/pages/pedidos.js
Normal file
179
src/renderer/pages/pedidos.js
Normal 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
|
||||
· ⏳ ${data.filter(o => o.status === 'Em espera').length} em espera
|
||||
· 🍳 ${data.filter(o => o.status === 'Preparando').length} preparando
|
||||
· ✅ ${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' });
|
||||
}
|
||||
221
src/renderer/pages/produtos.js
Normal file
221
src/renderer/pages/produtos.js
Normal 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
980
src/renderer/style.css
Normal 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;
|
||||
}
|
||||
Reference in New Issue
Block a user