feat: Release version 1.5.0 with major UI improvements and advanced anticipos

- Add sales subtabs: Ventas and Tickets for better organization
- Implement manual anticipos with confirmation checkbox system
- Add automatic "Público General" for sales without specific client
- Reorganize service order: Clean Girl → Elegant → Mystery → Seduction
- Replace gradient with solid black header background
- Improve price alignment with grid layout
- Add sort_order field to products for consistent ordering
- Move movements section from dashboard to Tickets subtab
- Enhanced JavaScript functionality for subtab navigation
- Remove TASKS.md documentation file

🤖 Generated with [Claude Code](https://claude.ai/code)

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
Marco Gallegos
2025-09-08 18:18:48 -06:00
parent 65837e59b1
commit 02f5775223
7 changed files with 2267 additions and 247 deletions

View File

@@ -94,33 +94,31 @@ El sistema está diseñado para ser desplegado fácilmente utilizando Docker y D
- Los datos se mantienen entre reinicios y actualizaciones del contenedor
- Para más información sobre Docker, consulta [DOCKER.md](./DOCKER.md)
## Novedades de la Versión 1.3.5
## Novedades de la Versión 1.5.0
### 🚀 **Nueva Interfaz de Ventas**
- **Formulario modernizado**: Diseño más intuitivo y profesional
- **Múltiples productos**: Agrega varios servicios/cursos en una sola venta
- **Sistema de cantidades**: Especifica la cantidad de cada producto
### 🎫 **Reorganización de Interface**
- **Subpestañas en Ventas**: Nueva estructura con "💰 Ventas" y "🎫 Tickets"
- **Dashboard limpio**: Movida sección de movimientos a subpestaña de Tickets
- **Navegación mejorada**: Interfaz más organizada y lógica
### 💰 **Sistema de Descuentos Avanzado**
- **Interfaz colapsable**: Sección de descuentos elegante y fácil de usar
- **Dos tipos de descuento**: Por porcentaje (%) o monto fijo ($)
- **Motivo del descuento**: Registro del motivo para auditoría
- **Preview en tiempo real**: Ve el descuento aplicado instantáneamente
### 💳 **Sistema de Anticipos Avanzado**
- **Anticipos manuales**: Aplicar anticipos no registrados con confirmación
- **Checkbox de seguridad**: Confirmación obligatoria para anticipos manuales
- **Integración completa**: Anticipos se aplican como descuentos automáticamente
- **Control de duplicación**: Sistema previene aplicar el mismo anticipo múltiples veces
### 📅 **Gestión de Citas Mejorada**
- **Campos de fecha intuitivos**: DD/MM/AAAA más fácil de usar
- **Horarios preconfigurados**: Selección rápida de horas disponibles
- **Integración con ventas**: Cita programada directamente al crear la venta
### 👥 **Gestión de Clientes Mejorada**
- **Público General**: Sistema automático para ventas sin cliente específico
- **Campo opcional**: Cliente ya no es obligatorio en ventas
- **Tickets genéricos**: Soporte para ventas a público general
### 🧾 **Tickets Optimizados**
- **Formato térmico 58mm**: Diseño específico para impresoras térmicas
- **Información completa**: Productos, cantidades, descuentos y totales
- **QR Code**: Para feedback de clientes
- **Fechas corregidas**: Formato DD/MM/YYYY HH:MM sin errores de "undefined"
- **Etiquetas en negrita**: Folio y Fecha destacados visualmente
### 🎨 **Mejoras Visuales**
- **Header sólido**: Eliminado gradiente por color sólido negro
- **Precios alineados**: Grid layout mejorado para mejor presentación
- **Orden de servicios**: Clean Girl → Elegant → Mystery → Seduction con sus retoques
- **Interfaz consistente**: Colores y estilos uniformes
### ⚡ **Mejoras Técnicas**
- **Cálculos en tiempo real**: Totales actualizados automáticamente
- **Validaciones mejoradas**: Mejor control de errores
- **Base de datos optimizada**: Persistencia de datos mejorada
- **API REST**: Migración completa de localStorage a servidor
### ⚡ **Optimizaciones Técnicas**
- **Base de datos mejorada**: Campo sort_order para control de ordenamiento
- **Subpestañas funcionales**: JavaScript para navegación entre secciones
- **Validaciones reforzadas**: Mejor control de formularios y datos

980
app.js

File diff suppressed because it is too large Load Diff

View File

@@ -12,7 +12,7 @@
<link href="https://fonts.googleapis.com/css2?family=Montserrat:wght@500;600&family=Open+Sans:wght@400;600&display=swap" rel="stylesheet">
<link href="https://fonts.googleapis.com/css2?family=Material+Icons+Outlined" rel="stylesheet">
<!-- Styles -->
<link rel="stylesheet" href="styles.css?v=1757039067" />
<link rel="stylesheet" href="styles.css?v=1757039804" />
<script src="https://cdn.jsdelivr.net/npm/chart.js"></script>
</head>
<body>
@@ -33,6 +33,9 @@
<button type="button" class="tab-link" data-tab="tab-products">
<span class="material-icons-outlined">inventory_2</span><span>Productos</span>
</button>
<button type="button" class="tab-link" data-tab="tab-cancellation-requests" id="tab-cancellation-requests-btn" style="display: none;">
<span class="material-icons-outlined">cancel_presentation</span><span>Solicitudes</span>
</button>
<button type="button" class="tab-link" data-tab="tab-settings">
<span class="material-icons-outlined">settings</span><span>Configuración</span>
</button>
@@ -77,30 +80,159 @@
<!-- Pestaña de Movimientos/Recibos -->
<div id="tab-movements" class="tab-content">
<div class="section">
<h2>Nuevo Movimiento</h2>
<form id="formMove" class="form-modern">
<!-- Cliente -->
<div class="form-row">
<div class="form-group">
<label>Cliente</label>
<input type="text" id="m-cliente" list="client-list" required autocomplete="off" placeholder="Nombre del cliente" />
<datalist id="client-list"></datalist>
<div class="sub-tabs">
<button type="button" class="sub-tab-link active" data-subtab="sub-tab-ventas">💰 Ventas</button>
<button type="button" class="sub-tab-link" data-subtab="sub-tab-tickets">🎫 Tickets</button>
</div>
<!-- Sub-Pestaña de Ventas -->
<div id="sub-tab-ventas" class="sub-tab-content active">
<div class="sales-container">
<!-- Header -->
<div class="sales-header">
<h2>💰 Nueva Venta</h2>
<div class="sales-summary">
<span class="cart-count" id="cart-count">0 productos</span>
<span class="cart-total" id="cart-total">$0.00</span>
</div>
</div>
<div class="sales-layout">
<!-- Panel de Productos (Izquierda) -->
<div class="products-panel">
<!-- Cliente -->
<div class="client-selector">
<div class="form-group modern-input">
<label>👤 Cliente</label>
<input type="text" id="m-cliente" list="client-list" autocomplete="off" placeholder="Buscar o crear cliente... (opcional)" />
<datalist id="client-list"></datalist>
</div>
</div>
<!-- Categorías de Productos -->
<div class="categories-container">
<h3>Selecciona tus servicios</h3>
<!-- Vanity Lashes -->
<div class="category-section" data-category="Vanity Lashes">
<div class="category-header">
<span class="category-icon">👁️</span>
<h4>Vanity Lashes</h4>
<button class="category-toggle"></button>
</div>
<div class="products-grid" id="pestanas-products">
<!-- Los productos se cargarán dinámicamente aquí -->
</div>
</div>
<!-- PMU Services -->
<div class="category-section" data-category="PMU Services">
<div class="category-header">
<span class="category-icon">✏️</span>
<h4>PMU Services</h4>
<button class="category-toggle"></button>
</div>
<div class="products-grid" id="microblading-products">
<!-- Los productos se cargarán dinámicamente aquí -->
</div>
</div>
<!-- Uñas -->
<div class="category-section" data-category="Uñas">
<div class="category-header">
<span class="category-icon">💅</span>
<h4>Nail Art</h4>
<button class="category-toggle"></button>
</div>
<div class="products-grid" id="unas-products">
<!-- Los productos se cargarán dinámicamente aquí -->
</div>
</div>
<!-- Anticipos -->
<div class="category-section" data-category="Anticipos">
<div class="category-header">
<span class="category-icon">💰</span>
<h4>Anticipos</h4>
<button class="category-toggle"></button>
</div>
<div class="anticipos-grid" id="anticipos-products">
<div class="anticipo-form">
<div class="anticipo-input-group">
<input type="number" id="anticipo-amount" min="1" placeholder="Cantidad" />
<input type="text" id="anticipo-comment" placeholder="Comentario (opcional)" />
<button class="btn-add-anticipo" onclick="addAnticipo()">Agregar</button>
</div>
</div>
</div>
</div>
</div>
</div>
<!-- Cita -->
<div class="form-section" id="appointment-section">
<h3>Datos de la Cita</h3>
<div class="form-row">
<div class="form-group">
<label>Fecha de la Cita</label>
<input type="date" id="m-fecha-cita" class="date-picker" />
<!-- Panel de Carrito y Checkout (Derecha) -->
<div class="checkout-panel">
<!-- Carrito de Productos -->
<div class="cart-section">
<h3>🛒 Carrito de Compras</h3>
<div id="selected-products-container" class="cart-items">
<div class="empty-cart">
<span class="empty-icon">🛒</span>
<p>Selecciona servicios para comenzar</p>
</div>
</div>
<div class="form-group">
<label>Hora</label>
<select id="m-hora-cita" class="time-select">
<option value="">-- Seleccionar hora --</option>
</div>
<!-- Descuentos y Anticipos -->
<div class="discount-section modern-card">
<div class="discount-header">
<input type="checkbox" id="discount-toggle" class="modern-checkbox">
<label for="discount-toggle" class="discount-label">
<span class="discount-icon">🏷️</span>
<span>Descuentos y Anticipos</span>
</label>
</div>
<div class="discount-container" id="discount-container" style="display: none;">
<div class="discount-options">
<select id="discount-type" class="modern-select">
<option value="">Sin descuento</option>
<option value="percentage">💯 Porcentaje (%)</option>
<option value="amount">💰 Cantidad fija ($)</option>
<option value="anticipo">💳 Aplicar Anticipo</option>
<option value="warrior">🎗️ Vanity (100%)</option>
</select>
<div class="discount-input-group">
<input type="number" id="discount-value" min="0" step="0.01" placeholder="0" disabled />
<span class="input-symbol" id="discount-symbol">%</span>
</div>
</div>
<input type="text" id="discount-reason" placeholder="Motivo del descuento (opcional)" disabled class="modern-input" />
<!-- Checkbox de confirmación para anticipo manual -->
<div id="anticipo-manual-confirmation" style="display: none; margin-top: 10px;">
<label class="checkbox-container">
<input type="checkbox" id="confirm-anticipo-manual">
<span class="checkmark"></span>
<span class="checkbox-text">✅ Confirmo que el anticipo existe y fue dado por la cliente</span>
</label>
</div>
</div>
</div>
<!-- Anticipos Disponibles -->
<div id="anticipos-section" class="anticipos-section modern-card" style="display: none;">
<h4>💰 Anticipos Disponibles</h4>
<div id="anticipos-disponibles" class="anticipos-list">
<!-- Se llenan dinámicamente -->
</div>
</div>
<!-- Datos de Cita -->
<div class="appointment-section modern-card">
<h4>📅 Datos de la Cita</h4>
<div class="appointment-inputs">
<input type="date" id="m-fecha-cita" class="modern-input" placeholder="Fecha" />
<select id="m-hora-cita" class="modern-select">
<option value="">Seleccionar hora</option>
<option value="10:00">10:00 AM</option>
<option value="10:30">10:30 AM</option>
<option value="11:00">11:00 AM</option>
@@ -119,157 +251,108 @@
<option value="17:30">5:30 PM</option>
<option value="18:00">6:00 PM</option>
</select>
<div id="time-availability-info" class="time-availability-info" style="display: none;">
<span id="available-slots-count"></span>
</div>
</div>
<!-- Resumen y Totales -->
<div class="totals-section modern-card">
<div class="totals-breakdown">
<div class="total-row">
<span>Subtotal:</span>
<span id="subtotal-display">$0.00</span>
</div>
<div class="total-row discount-row" id="discount-display-row" style="display: none;">
<span>Descuento:</span>
<span id="discount-display" class="discount-amount">-$0.00</span>
</div>
<div class="total-row final-total">
<span><strong>TOTAL:</strong></span>
<span id="total-display" class="final-amount"><strong>$0.00</strong></span>
</div>
</div>
</div>
</div>
<!-- Venta -->
<div class="form-section">
<h3>Venta</h3>
<div class="products-container">
<div class="product-selector">
<select id="m-categoria" required>
<option value="">-- Seleccione tipo --</option>
<option value="service">Servicio</option>
<option value="course">Curso</option>
<option value="anticipo">Anticipo</option>
</select>
<select id="m-articulo" class="product-select">
<option value="">-- Primero seleccione tipo --</option>
</select>
<input type="number" id="product-quantity" min="1" value="1" class="quantity-input" placeholder="Cant." />
<button type="button" id="add-product-btn" class="btn-add">Agregar</button>
</div>
<div id="selected-products" class="selected-products"></div>
</div>
</div>
<!-- Descuentos -->
<div class="form-section discount-section">
<div class="discount-header">
<input type="checkbox" id="discount-toggle" class="discount-checkbox">
<label for="discount-toggle" class="discount-label">
<span class="material-icons-outlined discount-icon">percent</span>
Aplicar descuento
</label>
</div>
<div class="discount-container" id="discount-container" style="display: none;">
<div class="discount-grid">
<div class="form-group">
<label>Tipo de descuento</label>
<select id="discount-type">
<option value="">Sin descuento</option>
<option value="percentage">Porcentaje (%)</option>
<option value="amount">Cantidad fija ($)</option>
<option value="warrior">🎗️ Vanity (100%)</option>
</select>
</div>
<div class="form-group">
<label>Valor del descuento</label>
<div class="input-with-symbol">
<input type="number" id="discount-value" min="0" step="0.01" placeholder="0" disabled />
<span class="input-symbol" id="discount-symbol">%</span>
<!-- Método de Pago -->
<div class="payment-section modern-card">
<h4>💳 Método de Pago</h4>
<div class="payment-grid">
<label class="payment-option">
<input type="radio" name="m-metodo" value="Efectivo" checked />
<div class="payment-card">
<span class="payment-icon">💵</span>
<span>Efectivo</span>
</div>
</div>
<div class="form-group full-width-discount">
<label>Motivo del descuento</label>
<input type="text" id="discount-reason" placeholder="Ej: Cliente frecuente, promoción especial..." disabled />
</div>
</div>
<div class="discount-preview" id="discount-preview" style="display: none;">
<div class="discount-preview-item">
<span>Descuento aplicado:</span>
<span id="discount-preview-amount" class="discount-amount">$0.00</span>
</div>
</label>
<label class="payment-option">
<input type="radio" name="m-metodo" value="Tarjeta" />
<div class="payment-card">
<span class="payment-icon">💳</span>
<span>Tarjeta</span>
</div>
</label>
<label class="payment-option">
<input type="radio" name="m-metodo" value="Transferencia" />
<div class="payment-card">
<span class="payment-icon">🏦</span>
<span>Transferencia</span>
</div>
</label>
</div>
</div>
</div>
<!-- Anticipos Disponibles -->
<div class="form-section anticipos-section" id="anticipos-section" style="display: none;">
<h4>💰 Anticipos Disponibles</h4>
<div id="anticipos-disponibles" class="anticipos-container">
<!-- Los anticipos se cargarán dinámicamente -->
<!-- Notas -->
<div class="notes-section modern-card">
<h4>📝 Notas Adicionales</h4>
<textarea id="m-notas" placeholder="Comentarios especiales sobre la venta..." class="modern-textarea"></textarea>
</div>
<!-- Botones de Acción -->
<form id="formMove" class="checkout-form">
<input type="hidden" id="m-monto" />
<input type="hidden" id="m-concepto" />
<input type="hidden" id="m-staff" />
<input type="hidden" id="m-metodo" value="Efectivo" />
<div class="action-buttons">
<button type="submit" class="btn-checkout">
<span class="btn-icon">🎫</span>
<span>Generar Venta</span>
</button>
<button type="reset" class="btn-clear">
<span class="btn-icon">🗑️</span>
<span>Limpiar</span>
</button>
</div>
</form>
</div> <!-- fin checkout-panel -->
</div> <!-- fin sales-layout -->
</div> <!-- fin sales-container -->
</div> <!-- fin sub-tab-ventas -->
<!-- Sub-Pestaña de Tickets -->
<div id="sub-tab-tickets" class="sub-tab-content">
<div class="section">
<h2>🎫 Historial de Tickets y Movimientos</h2>
<button id="btnExport" class="btn-secondary">Exportar a CSV</button>
<div class="table-wrapper">
<table id="tblMoves">
<thead>
<tr>
<th>Folio</th>
<th>Fecha</th>
<th>Cita</th>
<th>Cliente</th>
<th>Servicio</th>
<th>Monto</th>
<th>Acciones</th>
</tr>
</thead>
<tbody></tbody>
</table>
</div>
<!-- Método de Pago y Detalles -->
<div class="form-row">
<div class="form-group">
<label>Método de Pago *</label>
<select id="m-metodo" required>
<option value="">-- Seleccione método de pago --</option>
<option value="Efectivo">Efectivo</option>
<option value="Tarjeta">Tarjeta</option>
<option value="Transferencia">Transferencia</option>
<option value="Depósito">Depósito</option>
<option value="Giftcard">Giftcard</option>
<option value="Interno">Interno</option>
<option value="Otros">Otros</option>
</select>
</div>
<div class="form-group">
<label>Atendió</label>
<input type="text" id="m-staff" readonly />
</div>
</div>
<div class="form-group full-width">
<label>Notas</label>
<textarea id="m-notas" rows="2" placeholder="Notas adicionales..."></textarea>
</div>
<!-- Totales -->
<div class="totals-section">
<div class="totals-row">
<span>Subtotal:</span>
<span id="subtotal-display">$0.00</span>
</div>
<div class="totals-row" id="discount-display" style="display: none;">
<span>Descuento:</span>
<span id="discount-amount-display">-$0.00</span>
</div>
<div class="totals-row total-final">
<span>Total:</span>
<span id="total-display">$0.00</span>
</div>
</div>
<!-- Campos ocultos para compatibilidad -->
<input type="hidden" id="m-monto" />
<input type="hidden" id="m-concepto" />
<div class="form-actions-modern">
<button type="submit" class="btn-primary-large">Generar Venta y Ticket</button>
<button type="reset" class="btn-secondary-large">Limpiar Formulario</button>
</div>
</form>
</div>
<div class="section">
<h2>Movimientos Recientes</h2>
<button id="btnExport" class="btn-secondary">Exportar a CSV</button>
<div class="table-wrapper">
<table id="tblMoves">
<thead>
<tr>
<th>Folio</th>
<th>Fecha</th>
<th>Cita</th>
<th>Cliente</th>
<th>Servicio</th>
<th>Monto</th>
<th>Acciones</th>
</tr>
</thead>
<tbody></tbody>
</table>
</div>
</div>
</div> <!-- fin sub-tab-tickets -->
</div>
<!-- Pestaña de Clientes -->
@@ -438,6 +521,9 @@
<div class="form-actions">
<button type="submit">Guardar</button>
<button type="reset" id="btnCancelEditProduct" class="btn-danger">Cancelar</button>
<button type="button" id="btnImportProducts" class="btn-secondary" onclick="importProductsFromJSON()">
📥 Importar Productos JSON
</button>
</div>
</form>
</div>
@@ -528,6 +614,38 @@
</div>
</div>
<!-- Pestaña de Solicitudes de Cancelación (Solo Admins) -->
<div id="tab-cancellation-requests" class="tab-content">
<div class="section">
<h2>Solicitudes de Cancelación</h2>
<p class="section-description">Aquí puedes revisar y gestionar las solicitudes de cancelación de ventas enviadas por los usuarios.</p>
<div class="table-wrapper">
<table id="tblCancellationRequests">
<thead>
<tr>
<th>Folio</th>
<th>Cliente</th>
<th>Monto</th>
<th>Solicitado por</th>
<th>Fecha Solicitud</th>
<th>Motivo</th>
<th>Estado</th>
<th>Acciones</th>
</tr>
</thead>
<tbody></tbody>
</table>
</div>
<div id="no-cancellation-requests" style="display: none;">
<p style="text-align: center; color: #6c757d; font-style: italic; margin-top: 2rem;">
No hay solicitudes de cancelación pendientes.
</p>
</div>
</div>
</div>
<!-- Pestaña de Configuración -->
<div id="tab-settings" class="tab-content">
<div class="section">
@@ -629,6 +747,6 @@
<div id="printArea" class="no-print"></div>
<script src="https://cdn.jsdelivr.net/npm/qrcode@1/build/qrcode.min.js"></script>
<script type="module" src="app.js?v=1757039801"></script>
<script type="module" src="app.js?v=1757039803"></script>
</body>
</html>

View File

@@ -1,6 +1,6 @@
{
"name": "ap-pos",
"version": "1.4.1",
"version": "1.5.0",
"main": "app.js",
"scripts": {
"start": "node server.js"

View File

@@ -97,7 +97,41 @@ function templateTicket(mov, settings) {
// DESCUENTOS SI EXISTEN
if (mov.descuento && mov.descuento > 0) {
lines.push(`<div class="t-row t-small"><span>Descuento ${mov.motivoDescuento ? '(' + esc(mov.motivoDescuento) + ')' : ''}:</span><span>-$${Number(mov.descuento).toFixed(2)}</span></div>`);
if (mov.discountInfo) {
// Mostrar información detallada del descuento
const discountInfo = mov.discountInfo;
let descriptionText = '';
let amountText = `-$${Number(mov.descuento).toFixed(2)}`;
if (discountInfo.type === 'anticipo') {
// Distinguir entre anticipo registrado y manual
if (discountInfo.anticipo && discountInfo.anticipo.manual) {
descriptionText = `Anticipo manual aplicado (${discountInfo.anticipo.comentario})`;
} else {
descriptionText = `Anticipo aplicado ${discountInfo.anticipo ? `(${discountInfo.anticipo.folio})` : ''}`;
}
amountText = `-$${Number(mov.descuento).toFixed(2)} (${discountInfo.percentage.toFixed(1)}%)`;
} else if (discountInfo.type === 'warrior') {
descriptionText = 'Descuento Vanity (100%)';
amountText = `-$${Number(mov.descuento).toFixed(2)} (${discountInfo.percentage.toFixed(1)}%)`;
} else if (discountInfo.type === 'percentage') {
descriptionText = `Descuento (${discountInfo.value}%)`;
amountText = `-$${Number(mov.descuento).toFixed(2)} (${discountInfo.percentage.toFixed(1)}%)`;
} else if (discountInfo.type === 'amount') {
descriptionText = `Descuento ($${discountInfo.value})`;
amountText = `-$${Number(mov.descuento).toFixed(2)} (${discountInfo.percentage.toFixed(1)}%)`;
}
lines.push(`<div class="t-row t-small"><span>${descriptionText}:</span><span>${amountText}</span></div>`);
// Mostrar comentario del descuento si existe
if (discountInfo.reason && discountInfo.reason.trim()) {
lines.push(`<div class="t-center t-small t-service-detail"><b>Motivo:</b> ${esc(discountInfo.reason)}</div>`);
}
} else {
// Fallback para formato anterior
lines.push(`<div class="t-row t-small"><span>Descuento ${mov.motivoDescuento ? '(' + esc(mov.motivoDescuento) + ')' : ''}:</span><span>-$${Number(mov.descuento).toFixed(2)}</span></div>`);
}
}
if (mov.staff) lines.push(`<div class="t-center t-small t-service-detail"><b>Te atendió:</b> ${esc(mov.staff)}</div>`);

215
server.js
View File

@@ -4,6 +4,7 @@ const sqlite3 = require('sqlite3').verbose();
const cors = require('cors');
const path = require('path');
const bcrypt = require('bcryptjs');
const crypto = require('crypto');
const app = express();
const port = 3111;
@@ -135,6 +136,55 @@ function initializeApplication() {
FOREIGN KEY (course_id) REFERENCES products (id) ON DELETE CASCADE
)`);
// --- Tabla de Solicitudes de Cancelación ---
db.run(`CREATE TABLE IF NOT EXISTS cancellation_requests (
id TEXT PRIMARY KEY,
movement_id TEXT NOT NULL,
requested_by TEXT NOT NULL, -- user id
reason TEXT NOT NULL,
status TEXT DEFAULT 'pending', -- 'pending', 'approved', 'denied'
created_at TEXT NOT NULL,
reviewed_by TEXT,
reviewed_at TEXT,
admin_notes TEXT,
FOREIGN KEY (movement_id) REFERENCES movements (id),
FOREIGN KEY (requested_by) REFERENCES users (id),
FOREIGN KEY (reviewed_by) REFERENCES users (id)
)`);
// Agregar columna de estado temporal a movements
db.run("ALTER TABLE movements ADD COLUMN temp_cancelled INTEGER DEFAULT 0", (err) => {
if (err && !err.message.includes('duplicate column name')) {
console.error("Error adding temp_cancelled column:", err.message);
}
});
// Agregar columnas para categorización de productos
db.run("ALTER TABLE products ADD COLUMN category TEXT", (err) => {
if (err && !err.message.includes('duplicate column name')) {
console.error("Error adding category column:", err.message);
}
});
db.run("ALTER TABLE products ADD COLUMN subcategory TEXT", (err) => {
if (err && !err.message.includes('duplicate column name')) {
console.error("Error adding subcategory column:", err.message);
}
});
db.run("ALTER TABLE products ADD COLUMN custom_price INTEGER DEFAULT 0", (err) => {
if (err && !err.message.includes('duplicate column name')) {
console.error("Error adding custom_price column:", err.message);
}
});
// Agregar campo de orden para control de secuencia
db.run("ALTER TABLE products ADD COLUMN sort_order INTEGER DEFAULT 0", (err) => {
if (err && !err.message.includes('duplicate column name')) {
console.error("Error adding sort_order column:", err.message);
}
});
// Una vez completada toda la inicialización de la DB, iniciar el servidor
startServer();
});
@@ -318,7 +368,7 @@ function startServer() {
// --- Movements ---
apiRouter.get('/movements', (req, res) => {
db.all("SELECT * FROM movements ORDER BY fechaISO DESC", [], (err, rows) => {
db.all("SELECT * FROM movements WHERE temp_cancelled = 0 OR temp_cancelled IS NULL ORDER BY fechaISO DESC", [], (err, rows) => {
if (err) return res.status(500).json({ error: err.message });
res.json(rows);
});
@@ -333,13 +383,107 @@ function startServer() {
});
});
apiRouter.delete('/movements/:id', (req, res) => {
apiRouter.delete('/movements/:id', isAdmin, (req, res) => {
db.run(`DELETE FROM movements WHERE id = ?`, req.params.id, function(err) {
if (err) return res.status(500).json({ error: err.message });
res.json({ message: 'Movement deleted' });
});
});
// --- Cancellation Requests ---
apiRouter.post('/movements/:id/cancel-request', (req, res) => {
const { reason } = req.body;
const movementId = req.params.id;
if (!reason || reason.trim().length === 0) {
return res.status(400).json({ error: 'Reason is required' });
}
const requestId = crypto.randomUUID();
const createdAt = new Date().toISOString();
// First, check if movement exists and is not already cancelled
db.get("SELECT * FROM movements WHERE id = ?", [movementId], (err, movement) => {
if (err) return res.status(500).json({ error: err.message });
if (!movement) return res.status(404).json({ error: 'Movement not found' });
if (movement.temp_cancelled) return res.status(400).json({ error: 'Movement already has a pending cancellation request' });
// Check if there's already a pending request
db.get("SELECT * FROM cancellation_requests WHERE movement_id = ? AND status = 'pending'", [movementId], (err, existingRequest) => {
if (err) return res.status(500).json({ error: err.message });
if (existingRequest) return res.status(400).json({ error: 'There is already a pending cancellation request for this sale' });
// Create cancellation request
db.run(`INSERT INTO cancellation_requests (id, movement_id, requested_by, reason, status, created_at)
VALUES (?, ?, ?, ?, 'pending', ?)`,
[requestId, movementId, req.session.userId, reason.trim(), createdAt], function(err) {
if (err) return res.status(500).json({ error: err.message });
// Mark movement as temporarily cancelled
db.run("UPDATE movements SET temp_cancelled = 1 WHERE id = ?", [movementId], function(err) {
if (err) return res.status(500).json({ error: err.message });
res.status(201).json({
message: 'Cancellation request created successfully',
requestId: requestId
});
});
});
});
});
});
apiRouter.get('/cancellation-requests', isAdmin, (req, res) => {
const sql = `
SELECT cr.*, u.name as requested_by_name, m.folio, m.monto, m.concepto, c.nombre as client_name
FROM cancellation_requests cr
LEFT JOIN users u ON cr.requested_by = u.id
LEFT JOIN movements m ON cr.movement_id = m.id
LEFT JOIN clients c ON m.clienteId = c.id
ORDER BY cr.created_at DESC
`;
db.all(sql, [], (err, rows) => {
if (err) return res.status(500).json({ error: err.message });
res.json(rows);
});
});
apiRouter.put('/cancellation-requests/:id', isAdmin, (req, res) => {
const { status, admin_notes } = req.body;
const requestId = req.params.id;
if (!status || !['approved', 'denied'].includes(status)) {
return res.status(400).json({ error: 'Invalid status. Must be approved or denied' });
}
const reviewedAt = new Date().toISOString();
db.get("SELECT * FROM cancellation_requests WHERE id = ?", [requestId], (err, request) => {
if (err) return res.status(500).json({ error: err.message });
if (!request) return res.status(404).json({ error: 'Cancellation request not found' });
if (request.status !== 'pending') return res.status(400).json({ error: 'Request already processed' });
// Update request status
db.run(`UPDATE cancellation_requests SET status = ?, reviewed_by = ?, reviewed_at = ?, admin_notes = ? WHERE id = ?`,
[status, req.session.userId, reviewedAt, admin_notes || '', requestId], function(err) {
if (err) return res.status(500).json({ error: err.message });
if (status === 'approved') {
// If approved, delete the movement
db.run("DELETE FROM movements WHERE id = ?", [request.movement_id], function(err) {
if (err) return res.status(500).json({ error: err.message });
res.json({ message: 'Cancellation approved and sale deleted' });
});
} else {
// If denied, remove temporary cancellation
db.run("UPDATE movements SET temp_cancelled = 0 WHERE id = ?", [request.movement_id], function(err) {
if (err) return res.status(500).json({ error: err.message });
res.json({ message: 'Cancellation request denied and sale restored' });
});
}
});
});
});
// --- Client History ---
apiRouter.get('/clients/:id/history', (req, res) => {
db.all("SELECT * FROM movements WHERE clienteId = ? ORDER BY fechaISO DESC", [req.params.id], (err, rows) => {
@@ -350,32 +494,69 @@ function startServer() {
// --- Product/Course Management ---
apiRouter.get('/products', (req, res) => {
db.all("SELECT * FROM products ORDER BY type, name", [], (err, rows) => {
db.all("SELECT * FROM products ORDER BY type, sort_order, name", [], (err, rows) => {
if (err) return res.status(500).json({ error: err.message });
res.json(rows);
});
});
apiRouter.post('/products', isAdmin, (req, res) => {
const { name, type, price } = req.body;
apiRouter.post('/products', (req, res) => {
const { name, type, price, category, subcategory, custom_price } = req.body;
if (!name || !type) return res.status(400).json({ error: 'Name and type are required' });
db.run(`INSERT INTO products (name, type, price) VALUES (?, ?, ?)`,
[name, type, price || 0], function(err) {
db.run(`INSERT INTO products (name, type, price, category, subcategory, custom_price, sort_order) VALUES (?, ?, ?, ?, ?, ?, ?)`,
[name, type, price || 0, category, subcategory, custom_price ? 1 : 0, 0], function(err) {
if (err) return res.status(500).json({ error: err.message });
res.status(201).json({ id: this.lastID, name, type, price });
res.status(201).json({ id: this.lastID, name, type, price, category, subcategory, custom_price, sort_order: 0 });
});
});
apiRouter.put('/products/:id', isAdmin, (req, res) => {
const { name, type, price } = req.body;
apiRouter.put('/products/:id', (req, res) => {
const { name, type, price, category, subcategory, custom_price } = req.body;
if (!name || !type) return res.status(400).json({ error: 'Name and type are required' });
db.run(`UPDATE products SET name = ?, type = ?, price = ? WHERE id = ?`,
[name, type, price || 0, req.params.id], function(err) {
db.run(`UPDATE products SET name = ?, type = ?, price = ?, category = ?, subcategory = ?, custom_price = ? WHERE id = ?`,
[name, type, price || 0, category, subcategory, custom_price ? 1 : 0, req.params.id], function(err) {
if (err) return res.status(500).json({ error: err.message });
res.json({ message: 'Product updated' });
});
});
// Bulk update products from JSON
apiRouter.post('/products/bulk-import', isAdmin, (req, res) => {
const { products } = req.body;
if (!products || !Array.isArray(products)) {
return res.status(400).json({ error: 'Products array is required' });
}
db.serialize(() => {
db.run("BEGIN TRANSACTION");
const stmt = db.prepare(`INSERT OR REPLACE INTO products
(name, type, price, category, subcategory, custom_price, sort_order) VALUES (?, ?, ?, ?, ?, ?, ?)`);
products.forEach(product => {
stmt.run([
product.name,
product.type || 'service',
product.price || null,
product.category || null,
product.subcategory || null,
product.custom_price ? 1 : 0,
product.sort_order || 0
]);
});
stmt.finalize();
db.run("COMMIT", function(err) {
if (err) {
db.run("ROLLBACK");
return res.status(500).json({ error: err.message });
}
res.json({ message: 'Products imported successfully', count: products.length });
});
});
});
apiRouter.delete('/products/:id', isAdmin, (req, res) => {
db.run(`DELETE FROM products WHERE id = ?`, req.params.id, function(err) {
if (err) return res.status(500).json({ error: err.message });
@@ -490,15 +671,15 @@ function startServer() {
// --- Dashboard Route (Authenticated Users) ---
apiRouter.get('/dashboard', isAuthenticated, (req, res) => {
const queries = {
totalIncome: "SELECT SUM(monto) as total FROM movements",
totalMovements: "SELECT COUNT(*) as total FROM movements",
incomeByService: "SELECT tipo, SUM(monto) as total FROM movements GROUP BY tipo",
incomeByPaymentMethod: "SELECT metodo, SUM(monto) as total FROM movements WHERE metodo IS NOT NULL AND metodo != '''' GROUP BY metodo",
totalIncome: "SELECT SUM(monto) as total FROM movements WHERE temp_cancelled = 0 OR temp_cancelled IS NULL",
totalMovements: "SELECT COUNT(*) as total FROM movements WHERE temp_cancelled = 0 OR temp_cancelled IS NULL",
incomeByService: "SELECT tipo, SUM(monto) as total FROM movements WHERE temp_cancelled = 0 OR temp_cancelled IS NULL GROUP BY tipo",
incomeByPaymentMethod: "SELECT metodo, SUM(monto) as total FROM movements WHERE (temp_cancelled = 0 OR temp_cancelled IS NULL) AND metodo IS NOT NULL AND metodo != '''' GROUP BY metodo",
upcomingAppointments: `
SELECT m.id, m.folio, m.fechaCita, m.horaCita, c.nombre as clienteNombre
FROM movements m
JOIN clients c ON m.clienteId = c.id
WHERE m.fechaCita IS NOT NULL AND m.fechaCita >= date('now')
WHERE (m.temp_cancelled = 0 OR m.temp_cancelled IS NULL) AND m.fechaCita IS NOT NULL AND m.fechaCita >= date('now')
ORDER BY m.fechaCita ASC, m.horaCita ASC
LIMIT 5`
};

View File

@@ -1460,4 +1460,795 @@ table tbody tr:hover {
.btn-aplicar-anticipo:hover {
background: #218838;
}
/* --- Cancellation Request Styles --- */
.modal {
display: flex;
position: fixed;
z-index: 1000;
left: 0;
top: 0;
width: 100%;
height: 100%;
background-color: rgba(0, 0, 0, 0.4);
justify-content: center;
align-items: center;
}
.modal-content {
background-color: #fefefe;
margin: 15% auto;
padding: 20px;
border-radius: 8px;
width: 80%;
max-width: 600px;
position: relative;
box-shadow: 0 4px 6px rgba(0, 0, 0, 0.1);
max-height: 80vh;
overflow-y: auto;
}
.close-button {
color: #aaa;
position: absolute;
top: 10px;
right: 20px;
font-size: 28px;
font-weight: bold;
cursor: pointer;
}
.close-button:hover,
.close-button:focus {
color: #000;
text-decoration: none;
}
.cancellation-info {
background: #f8f9fa;
border: 1px solid #dee2e6;
border-radius: 6px;
padding: 15px;
margin: 15px 0;
}
.cancellation-info p {
margin: 8px 0;
}
.status-badge {
padding: 4px 8px;
border-radius: 12px;
font-size: 12px;
font-weight: 500;
text-transform: uppercase;
}
.status-pending {
background-color: #fff3cd;
color: #856404;
border: 1px solid #ffeaa7;
}
.status-approved {
background-color: #d4edda;
color: #155724;
border: 1px solid #c3e6cb;
}
.status-denied {
background-color: #f8d7da;
color: #721c24;
border: 1px solid #f5c6cb;
}
.btn-warning {
background-color: #ffc107;
color: #212529;
border: 1px solid #ffc107;
}
.btn-warning:hover {
background-color: #e0a800;
border-color: #d39e00;
}
.btn-success {
background-color: #28a745;
color: white;
border: 1px solid #28a745;
margin-right: 5px;
}
.btn-success:hover {
background-color: #218838;
border-color: #1e7e34;
}
.reason-cell {
max-width: 200px;
word-wrap: break-word;
cursor: help;
}
.processed-info {
font-style: italic;
color: #6c757d;
}
.processed-info small {
font-size: 10px;
color: #868e96;
}
.section-description {
color: #6c757d;
margin-bottom: 20px;
font-style: italic;
}
/* --- Modern Sales Interface --- */
.sales-container {
padding: 0;
max-width: none;
background: transparent;
}
.sales-header {
background: #000000 !important;
background-image: none !important;
color: white;
padding: 20px 30px;
border-radius: 12px;
margin-bottom: 25px;
display: flex;
justify-content: space-between;
align-items: center;
box-shadow: 0 4px 15px rgba(44, 62, 80, 0.2);
}
.sales-header h2 {
margin: 0;
font-size: 28px;
font-weight: 600;
border: none;
padding: 0;
color: white !important;
}
.sales-summary {
display: flex;
gap: 20px;
align-items: center;
}
.cart-count, .cart-total {
background: rgba(255, 255, 255, 0.2);
padding: 8px 16px;
border-radius: 20px;
font-weight: 600;
backdrop-filter: blur(10px);
}
.sales-layout {
display: grid;
grid-template-columns: 2fr 1fr;
gap: 30px;
align-items: start;
}
/* --- Products Panel --- */
.products-panel {
background: #fff;
border-radius: 16px;
padding: 25px;
box-shadow: 0 4px 20px rgba(0, 0, 0, 0.08);
border: 1px solid #f0f0f0;
}
.client-selector {
margin-bottom: 25px;
}
.modern-input {
width: 100%;
padding: 14px 20px;
border: 2px solid #e1e5e9;
border-radius: 12px;
font-size: 16px;
transition: all 0.3s ease;
background: #fafbfc;
}
.modern-input:focus {
outline: none;
border-color: #667eea;
background: white;
box-shadow: 0 0 0 3px rgba(102, 126, 234, 0.1);
}
.modern-input::placeholder {
color: #a0a8b0;
}
/* --- Categories --- */
.categories-container h3 {
color: #2c3e50;
margin-bottom: 20px;
font-size: 20px;
}
.category-section {
margin-bottom: 20px;
border: 2px solid #f5f6fa;
border-radius: 12px;
overflow: hidden;
transition: all 0.3s ease;
}
.category-section:hover {
border-color: #e9ecef;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.05);
}
.category-header {
background: linear-gradient(135deg, #f8f9fa 0%, #e9ecef 100%);
padding: 15px 20px;
display: flex;
align-items: center;
cursor: pointer;
transition: background 0.3s ease;
}
.category-header:hover {
background: linear-gradient(135deg, #e9ecef 0%, #dee2e6 100%);
}
.category-icon {
font-size: 24px;
margin-right: 12px;
}
.category-header h4 {
margin: 0;
flex: 1;
font-size: 18px;
color: #2c3e50;
font-weight: 600;
}
.category-toggle {
background: none;
border: none;
font-size: 18px;
cursor: pointer;
color: #6c757d;
transition: transform 0.3s ease;
}
.category-section.collapsed .category-toggle {
transform: rotate(-90deg);
}
.products-grid {
padding: 20px;
display: grid;
grid-template-columns: repeat(auto-fill, minmax(280px, 1fr));
gap: 15px;
background: white;
}
.product-card {
display: grid;
grid-template-columns: 1fr auto auto auto;
align-items: center;
gap: 10px;
padding: 12px 16px;
border-bottom: 1px solid #e0e0e0;
cursor: pointer;
transition: background-color 0.2s ease;
background: white;
}
.product-card:nth-child(even) {
background: #f8f9fa;
}
.product-card:hover {
background: #e3f2fd;
}
.product-card.selected {
background: #e8f5e9;
border-left: 3px solid #4caf50;
}
.product-info {
display: flex;
flex-direction: column;
flex: 1;
}
.product-name {
font-weight: 500;
color: #333;
font-size: 14px;
margin: 0;
}
.product-price {
color: #2c3e50;
font-size: 14px;
font-weight: 600;
margin: 0;
text-align: right;
min-width: 90px;
display: block;
justify-self: end;
}
.product-price.custom {
color: #e74c3c;
font-style: italic;
}
.product-actions {
display: flex;
gap: 8px;
align-items: center;
justify-self: end;
}
.btn-select-product {
background: #2c3e50;
color: white;
border: none;
padding: 6px 12px;
border-radius: 4px;
font-size: 12px;
font-weight: 500;
cursor: pointer;
transition: background-color 0.2s ease;
}
.btn-select-product:hover {
background: #34495e;
}
/* Anticipos section */
.anticipos-grid {
padding: 16px;
background: #f8f9fa;
border-radius: 8px;
margin-top: 8px;
}
.anticipo-form {
background: white;
padding: 16px;
border-radius: 6px;
border: 1px solid #e0e0e0;
}
.anticipo-input-group {
display: flex;
gap: 8px;
align-items: center;
}
.anticipo-input-group input {
padding: 8px 12px;
border: 1px solid #ddd;
border-radius: 4px;
font-size: 14px;
}
#anticipo-amount {
width: 100px;
}
#anticipo-comment {
flex: 1;
}
.btn-add-anticipo {
background: #28a745;
color: white;
border: none;
padding: 8px 16px;
border-radius: 4px;
font-size: 14px;
cursor: pointer;
white-space: nowrap;
}
.btn-add-anticipo:hover {
background: #218838;
}
/* Estilos para checkbox de confirmación de anticipo manual */
.checkbox-container {
display: flex;
align-items: center;
cursor: pointer;
margin: 10px 0;
padding: 8px;
border-radius: 6px;
background: #fff8dc;
border: 1px solid #f0ad4e;
}
.checkbox-container input[type="checkbox"] {
margin-right: 8px;
transform: scale(1.2);
}
.checkbox-text {
font-size: 14px;
color: #856404;
font-weight: 500;
}
.checkmark {
margin-right: 5px;
}
.quantity-input {
width: 60px;
padding: 8px;
border: 2px solid #e1e5e9;
border-radius: 6px;
text-align: center;
font-size: 14px;
}
/* --- Checkout Panel --- */
.checkout-panel {
background: #fff;
border-radius: 16px;
padding: 25px;
box-shadow: 0 4px 20px rgba(0, 0, 0, 0.08);
border: 1px solid #f0f0f0;
position: sticky;
top: 20px;
height: fit-content;
}
.modern-card {
background: #fafbfc;
border: 1px solid #e1e5e9;
border-radius: 12px;
padding: 20px;
margin-bottom: 20px;
transition: all 0.3s ease;
}
.modern-card:hover {
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.05);
border-color: #d1d9e0;
}
.modern-card h3, .modern-card h4 {
margin: 0 0 15px 0;
color: #2c3e50;
font-size: 16px;
font-weight: 600;
display: flex;
align-items: center;
gap: 8px;
}
/* --- Cart Section --- */
.cart-section h3 {
border-bottom: 2px solid #e9ecef;
padding-bottom: 10px;
margin-bottom: 20px;
}
.cart-items {
max-height: 300px;
overflow-y: auto;
}
.empty-cart {
text-align: center;
padding: 40px 20px;
color: #6c757d;
}
.empty-icon {
font-size: 48px;
opacity: 0.5;
display: block;
margin-bottom: 10px;
}
.cart-item {
display: flex;
justify-content: space-between;
align-items: center;
padding: 12px 0;
border-bottom: 1px solid #f1f3f4;
}
.cart-item:last-child {
border-bottom: none;
}
.item-info {
flex: 1;
}
.item-name {
font-weight: 600;
color: #2c3e50;
margin-bottom: 4px;
}
.item-details {
font-size: 12px;
color: #6c757d;
}
.item-price {
font-weight: 600;
color: #667eea;
margin-left: 10px;
}
.btn-remove-item {
background: #ff6b6b;
color: white;
border: none;
width: 24px;
height: 24px;
border-radius: 50%;
cursor: pointer;
font-size: 12px;
margin-left: 8px;
}
/* --- Discount Section --- */
.modern-checkbox {
display: none;
}
.discount-label {
display: flex;
align-items: center;
gap: 10px;
cursor: pointer;
font-weight: 600;
color: #2c3e50;
}
.discount-icon {
font-size: 20px;
}
.modern-checkbox:checked + .discount-label .discount-icon {
color: #ffc107;
}
.discount-options {
display: flex;
gap: 10px;
margin-bottom: 10px;
}
.modern-select {
flex: 1;
padding: 10px 12px;
border: 2px solid #e1e5e9;
border-radius: 8px;
background: white;
font-size: 14px;
}
.discount-input-group {
display: flex;
align-items: center;
background: white;
border: 2px solid #e1e5e9;
border-radius: 8px;
padding: 2px;
width: 120px;
}
.discount-input-group input {
border: none;
background: none;
padding: 8px 10px;
font-size: 14px;
width: 80px;
}
.discount-input-group .input-symbol {
background: #f8f9fa;
padding: 8px;
font-size: 12px;
font-weight: 600;
color: #667eea;
border-radius: 6px;
margin-right: 2px;
}
/* --- Payment Section --- */
.payment-grid {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 10px;
}
.payment-option {
cursor: pointer;
}
.payment-option input {
display: none;
}
.payment-card {
display: flex;
flex-direction: column;
align-items: center;
padding: 15px 10px;
border: 2px solid #e1e5e9;
border-radius: 12px;
transition: all 0.3s ease;
background: white;
}
.payment-option input:checked + .payment-card {
border-color: #28a745;
background: linear-gradient(135deg, #f8fff9 0%, #e8f5e8 100%);
}
.payment-icon {
font-size: 24px;
margin-bottom: 8px;
}
/* --- Totals Section --- */
.totals-breakdown {
background: white;
border-radius: 8px;
padding: 20px;
}
.total-row {
display: flex;
justify-content: space-between;
padding: 8px 0;
border-bottom: 1px solid #f1f3f4;
}
.total-row:last-child {
border-bottom: none;
}
.final-total {
border-top: 2px solid #e9ecef;
margin-top: 10px;
padding-top: 15px;
font-size: 18px;
}
.final-amount {
color: #28a745;
font-size: 20px;
}
.discount-amount {
color: #ff6b6b;
}
/* --- Action Buttons --- */
.action-buttons {
display: flex;
gap: 15px;
margin-top: 20px;
}
.btn-checkout {
background: linear-gradient(135deg, #28a745 0%, #20c997 100%);
color: white;
border: none;
padding: 15px 25px;
border-radius: 12px;
font-size: 16px;
font-weight: 600;
cursor: pointer;
flex: 1;
display: flex;
align-items: center;
justify-content: center;
gap: 8px;
transition: all 0.3s ease;
}
.btn-checkout:hover {
transform: translateY(-2px);
box-shadow: 0 6px 20px rgba(40, 167, 69, 0.3);
}
.btn-clear {
background: #6c757d;
color: white;
border: none;
padding: 15px 20px;
border-radius: 12px;
font-size: 16px;
font-weight: 600;
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
gap: 8px;
transition: all 0.3s ease;
}
.btn-clear:hover {
background: #5a6268;
transform: translateY(-1px);
}
.btn-icon {
font-size: 18px;
}
/* --- Notes Section --- */
.modern-textarea {
width: 100%;
padding: 12px 16px;
border: 2px solid #e1e5e9;
border-radius: 8px;
font-size: 14px;
font-family: inherit;
resize: vertical;
min-height: 80px;
background: white;
}
.modern-textarea:focus {
outline: none;
border-color: #667eea;
box-shadow: 0 0 0 3px rgba(102, 126, 234, 0.1);
}
/* --- Responsive Design --- */
@media (max-width: 1200px) {
.sales-layout {
grid-template-columns: 1fr;
gap: 20px;
}
.checkout-panel {
position: static;
}
}
@media (max-width: 768px) {
.sales-header {
flex-direction: column;
gap: 15px;
text-align: center;
}
.products-grid {
grid-template-columns: 1fr;
}
.payment-grid {
grid-template-columns: 1fr;
}
.discount-options {
flex-direction: column;
}
.action-buttons {
flex-direction: column;
}
}