mirror of
https://github.com/marcogll/ap_pos.git
synced 2026-01-13 21:25:16 +00:00
feat: Implement persistence and initial project setup
This commit is contained in:
15
ap-pos/.gitignore
vendored
Normal file
15
ap-pos/.gitignore
vendored
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
# Dependencies
|
||||||
|
/node_modules
|
||||||
|
|
||||||
|
# Local Database
|
||||||
|
ap-pos.db
|
||||||
|
|
||||||
|
# Environment variables
|
||||||
|
.env
|
||||||
|
*.env.local
|
||||||
|
*.env.*.local
|
||||||
|
|
||||||
|
# Log files
|
||||||
|
npm-debug.log*
|
||||||
|
yarn-debug.log*
|
||||||
|
yarn-error.log*
|
||||||
21
ap-pos/LICENSE
Normal file
21
ap-pos/LICENSE
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
MIT License
|
||||||
|
|
||||||
|
Copyright (c) 2025 marcogll
|
||||||
|
|
||||||
|
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||||
|
of this software and associated documentation files (the "Software"), to deal
|
||||||
|
in the Software without restriction, including without limitation the rights
|
||||||
|
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||||
|
copies of the Software, and to permit persons to whom the Software is
|
||||||
|
furnished to do so, subject to the following conditions:
|
||||||
|
|
||||||
|
The above copyright notice and this permission notice shall be included in all
|
||||||
|
copies or substantial portions of the Software.
|
||||||
|
|
||||||
|
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||||
|
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||||
|
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||||
|
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||||
|
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||||
|
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||||
|
SOFTWARE.
|
||||||
359
ap-pos/app.js
Normal file
359
ap-pos/app.js
Normal file
@@ -0,0 +1,359 @@
|
|||||||
|
import { load, save, remove, KEY_DATA, KEY_SETTINGS, KEY_CLIENTS } from './storage.js';
|
||||||
|
import { renderTicketAndPrint } from './print.js';
|
||||||
|
|
||||||
|
// --- ESTADO Y DATOS ---
|
||||||
|
const DEFAULT_SETTINGS = {
|
||||||
|
negocio: 'Ale Ponce',
|
||||||
|
tagline: 'beauty expert',
|
||||||
|
calle: 'Benito Juarez 246',
|
||||||
|
colonia: 'Col. Los Pinos',
|
||||||
|
cp: '252 pinos',
|
||||||
|
rfc: '',
|
||||||
|
tel: '8443555108',
|
||||||
|
leyenda: '¡Gracias por tu preferencia!',
|
||||||
|
folioPrefix: 'AP-',
|
||||||
|
folioSeq: 1
|
||||||
|
};
|
||||||
|
|
||||||
|
let settings = {};
|
||||||
|
let movements = [];
|
||||||
|
let clients = [];
|
||||||
|
|
||||||
|
// --- DOM ELEMENTS ---
|
||||||
|
const formSettings = document.getElementById('formSettings');
|
||||||
|
const formMove = document.getElementById('formMove');
|
||||||
|
const tblMovesBody = document.getElementById('tblMoves')?.querySelector('tbody');
|
||||||
|
const btnExport = document.getElementById('btnExport');
|
||||||
|
const btnTestTicket = document.getElementById('btnTestTicket');
|
||||||
|
const formClient = document.getElementById('formClient');
|
||||||
|
const tblClientsBody = document.getElementById('tblClients')?.querySelector('tbody');
|
||||||
|
const clientDatalist = document.getElementById('client-list');
|
||||||
|
|
||||||
|
// --- LÓGICA DE NEGOCIO ---
|
||||||
|
|
||||||
|
async function getNextFolio() {
|
||||||
|
const folio = `${settings.folioPrefix || ''}${String(settings.folioSeq).padStart(6, '0')}`;
|
||||||
|
settings.folioSeq += 1;
|
||||||
|
await save(KEY_SETTINGS, settings);
|
||||||
|
return folio;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function addMovement(mov) {
|
||||||
|
await save('movements', { movement: mov });
|
||||||
|
movements.unshift(mov);
|
||||||
|
renderTable();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function deleteMovement(id) {
|
||||||
|
if (confirm('¿Estás seguro de que quieres eliminar este movimiento?')) {
|
||||||
|
await remove(KEY_DATA, id);
|
||||||
|
movements = movements.filter(m => m.id !== id);
|
||||||
|
renderTable();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function saveClient(clientData) {
|
||||||
|
let clientToSave;
|
||||||
|
let isUpdate = false;
|
||||||
|
|
||||||
|
if (clientData) {
|
||||||
|
// Data is passed directly (e.g., from new movement creation)
|
||||||
|
clientToSave = clientData;
|
||||||
|
} else {
|
||||||
|
// Read from the client form
|
||||||
|
isUpdate = !!document.getElementById('c-id').value;
|
||||||
|
const id = isUpdate ? document.getElementById('c-id').value : crypto.randomUUID();
|
||||||
|
clientToSave = {
|
||||||
|
id: id,
|
||||||
|
nombre: document.getElementById('c-nombre').value,
|
||||||
|
telefono: document.getElementById('c-telefono').value,
|
||||||
|
cumpleaños: document.getElementById('c-cumple').value,
|
||||||
|
consentimiento: document.getElementById('c-consent').checked,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
await save('clients', { client: clientToSave });
|
||||||
|
|
||||||
|
if (isUpdate) {
|
||||||
|
const index = clients.findIndex(c => c.id === clientToSave.id);
|
||||||
|
if (index > -1) {
|
||||||
|
clients[index] = clientToSave;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// Avoid duplicates if client was already added optimistically
|
||||||
|
if (!clients.some(c => c.id === clientToSave.id)) {
|
||||||
|
clients.push(clientToSave);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
renderClientsTable();
|
||||||
|
updateClientDatalist();
|
||||||
|
|
||||||
|
// Only reset the form if we were using it
|
||||||
|
if (!clientData) {
|
||||||
|
document.getElementById('formClient').reset();
|
||||||
|
document.getElementById('c-id').value = '';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function deleteClient(id) {
|
||||||
|
if (confirm('¿Estás seguro de que quieres eliminar este cliente? Se conservarán sus recibos históricos.')) {
|
||||||
|
await remove(KEY_CLIENTS, id);
|
||||||
|
clients = clients.filter(c => c.id !== id);
|
||||||
|
renderClientsTable();
|
||||||
|
updateClientDatalist();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function exportCSV() {
|
||||||
|
const headers = 'folio,fechaISO,cliente,tipo,monto,metodo,concepto,staff,notas';
|
||||||
|
const rows = movements.map(m => {
|
||||||
|
const client = clients.find(c => c.id === m.clienteId);
|
||||||
|
return [
|
||||||
|
m.folio, m.fechaISO, client ? client.nombre : 'N/A', m.tipo, m.monto,
|
||||||
|
m.metodo || '', m.concepto || '', m.staff || '', m.notas || ''
|
||||||
|
].map(val => `"${String(val).replace(/"/g, '""')}"`).join(',');
|
||||||
|
});
|
||||||
|
|
||||||
|
const csvContent = `data:text/csv;charset=utf-8,${headers}\n${rows.join('\n')}`;
|
||||||
|
const encodedUri = encodeURI(csvContent);
|
||||||
|
const link = document.createElement('a');
|
||||||
|
link.setAttribute('href', encodedUri);
|
||||||
|
link.setAttribute('download', 'movimientos.csv');
|
||||||
|
document.body.appendChild(link);
|
||||||
|
link.click();
|
||||||
|
document.body.removeChild(link);
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- RENDERIZADO ---
|
||||||
|
|
||||||
|
function renderSettings() {
|
||||||
|
document.getElementById('s-negocio').value = settings.negocio || '';
|
||||||
|
document.getElementById('s-tagline').value = settings.tagline || '';
|
||||||
|
document.getElementById('s-calle').value = settings.calle || '';
|
||||||
|
document.getElementById('s-colonia-cp').value = settings.colonia && settings.cp ? `${settings.colonia}, ${settings.cp}` : '';
|
||||||
|
document.getElementById('s-rfc').value = settings.rfc || '';
|
||||||
|
document.getElementById('s-tel').value = settings.tel || '';
|
||||||
|
document.getElementById('s-leyenda').value = settings.leyenda || '';
|
||||||
|
document.getElementById('s-folioPrefix').value = settings.folioPrefix || '';
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderTable() {
|
||||||
|
if (!tblMovesBody) return;
|
||||||
|
tblMovesBody.innerHTML = '';
|
||||||
|
movements.forEach(mov => {
|
||||||
|
const client = clients.find(c => c.id === mov.clienteId);
|
||||||
|
const tr = document.createElement('tr');
|
||||||
|
tr.innerHTML = `
|
||||||
|
<td><a href="#" class="action-btn" data-id="${mov.id}" data-action="reprint">${mov.folio}</a></td>
|
||||||
|
<td>${new Date(mov.fechaISO).toLocaleDateString('es-MX')}</td>
|
||||||
|
<td>${client ? client.nombre : 'Cliente Eliminado'}</td>
|
||||||
|
<td>${mov.tipo}</td>
|
||||||
|
<td>${Number(mov.monto).toFixed(2)}</td>
|
||||||
|
<td><button class="action-btn" data-id="${mov.id}" data-action="delete">Eliminar</button></td>
|
||||||
|
`;
|
||||||
|
tblMovesBody.appendChild(tr);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderClientsTable() {
|
||||||
|
if (!tblClientsBody) return;
|
||||||
|
tblClientsBody.innerHTML = '';
|
||||||
|
clients.forEach(c => {
|
||||||
|
const tr = document.createElement('tr');
|
||||||
|
tr.innerHTML = `
|
||||||
|
<td>${c.nombre}</td>
|
||||||
|
<td>${c.telefono || ''}</td>
|
||||||
|
<td>${c.cumpleaños ? new Date(c.cumpleaños).toLocaleDateString('es-MX') : ''}</td>
|
||||||
|
<td>${c.consentimiento ? 'Sí' : 'No'}</td>
|
||||||
|
<td>
|
||||||
|
<button class="action-btn" data-id="${c.id}" data-action="edit-client">Editar</button>
|
||||||
|
<button class="action-btn" data-id="${c.id}" data-action="delete-client">Eliminar</button>
|
||||||
|
</td>
|
||||||
|
`;
|
||||||
|
tblClientsBody.appendChild(tr);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateClientDatalist() {
|
||||||
|
if (!clientDatalist) return;
|
||||||
|
clientDatalist.innerHTML = '';
|
||||||
|
clients.forEach(c => {
|
||||||
|
const option = document.createElement('option');
|
||||||
|
option.value = c.nombre;
|
||||||
|
clientDatalist.appendChild(option);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- MANEJADORES DE EVENTOS ---
|
||||||
|
|
||||||
|
async function handleSaveSettings(e) {
|
||||||
|
e.preventDefault();
|
||||||
|
settings.negocio = document.getElementById('s-negocio').value;
|
||||||
|
settings.tagline = document.getElementById('s-tagline').value;
|
||||||
|
settings.calle = document.getElementById('s-calle').value;
|
||||||
|
|
||||||
|
const coloniaCp = document.getElementById('s-colonia-cp').value.split(',');
|
||||||
|
settings.colonia = coloniaCp[0]?.trim() || '';
|
||||||
|
settings.cp = coloniaCp[1]?.trim() || '';
|
||||||
|
|
||||||
|
settings.rfc = document.getElementById('s-rfc').value;
|
||||||
|
settings.tel = document.getElementById('s-tel').value;
|
||||||
|
settings.leyenda = document.getElementById('s-leyenda').value;
|
||||||
|
settings.folioPrefix = document.getElementById('s-folioPrefix').value;
|
||||||
|
await save(KEY_SETTINGS, { settings });
|
||||||
|
alert('Configuración guardada.');
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleNewMovement(e) {
|
||||||
|
e.preventDefault();
|
||||||
|
const form = e.target;
|
||||||
|
const monto = parseFloat(document.getElementById('m-monto').value || 0);
|
||||||
|
const clienteNombre = document.getElementById('m-cliente').value;
|
||||||
|
|
||||||
|
let client = clients.find(c => c.nombre.toLowerCase() === clienteNombre.toLowerCase());
|
||||||
|
if (!client) {
|
||||||
|
if (confirm(`El cliente "${clienteNombre}" no existe. ¿Deseas crearlo?`)) {
|
||||||
|
const newClient = {
|
||||||
|
id: crypto.randomUUID(),
|
||||||
|
nombre: clienteNombre,
|
||||||
|
telefono: '',
|
||||||
|
cumpleaños: '',
|
||||||
|
consentimiento: false
|
||||||
|
};
|
||||||
|
await saveClient(newClient); // This now works correctly
|
||||||
|
client = newClient;
|
||||||
|
} else {
|
||||||
|
return; // Do not create movement if client is not created
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const newMovement = {
|
||||||
|
id: crypto.randomUUID(),
|
||||||
|
folio: await getNextFolio(),
|
||||||
|
fechaISO: new Date().toISOString(),
|
||||||
|
clienteId: client.id,
|
||||||
|
tipo: document.getElementById('m-tipo').value,
|
||||||
|
monto: Number(monto.toFixed(2)),
|
||||||
|
metodo: document.getElementById('m-metodo').value,
|
||||||
|
concepto: document.getElementById('m-concepto').value,
|
||||||
|
staff: document.getElementById('m-staff').value,
|
||||||
|
notas: document.getElementById('m-notas').value,
|
||||||
|
};
|
||||||
|
|
||||||
|
await addMovement(newMovement);
|
||||||
|
const movementForTicket = { ...newMovement, cliente: client.nombre };
|
||||||
|
renderTicketAndPrint(movementForTicket, settings);
|
||||||
|
form.reset();
|
||||||
|
document.getElementById('m-cliente').focus(); // Poner el foco en el campo de cliente
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleTableClick(e) {
|
||||||
|
if (e.target.classList.contains('action-btn')) {
|
||||||
|
e.preventDefault();
|
||||||
|
const id = e.target.dataset.id;
|
||||||
|
const action = e.target.dataset.action;
|
||||||
|
|
||||||
|
if (action === 'reprint' || action === 'delete') {
|
||||||
|
const movement = movements.find(m => m.id === id);
|
||||||
|
if (movement) {
|
||||||
|
if (action === 'reprint') {
|
||||||
|
const client = clients.find(c => c.id === movement.clienteId);
|
||||||
|
const movementForTicket = { ...movement, cliente: client ? client.nombre : 'N/A' };
|
||||||
|
renderTicketAndPrint(movementForTicket, settings);
|
||||||
|
} else if (action === 'delete') {
|
||||||
|
deleteMovement(id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else if (action === 'edit-client' || action === 'delete-client') {
|
||||||
|
const client = clients.find(c => c.id === id);
|
||||||
|
if (client) {
|
||||||
|
if (action === 'edit-client') {
|
||||||
|
document.getElementById('c-id').value = client.id;
|
||||||
|
document.getElementById('c-nombre').value = client.nombre;
|
||||||
|
document.getElementById('c-telefono').value = client.telefono;
|
||||||
|
document.getElementById('c-cumple').value = client.cumpleaños;
|
||||||
|
document.getElementById('c-consent').checked = client.consentimiento;
|
||||||
|
} else if (action === 'delete-client') {
|
||||||
|
deleteClient(id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleClientForm(e) {
|
||||||
|
e.preventDefault();
|
||||||
|
await saveClient();
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleTabChange(e) {
|
||||||
|
const tabButton = e.target.closest('.tab-link');
|
||||||
|
if (!tabButton) return; // Si el clic no fue en un botón de pestaña, no hacer nada.
|
||||||
|
|
||||||
|
e.preventDefault();
|
||||||
|
|
||||||
|
// Quitar la clase 'active' de todas las pestañas y contenidos.
|
||||||
|
document.querySelectorAll('.tab-link').forEach(tab => tab.classList.remove('active'));
|
||||||
|
document.querySelectorAll('.tab-content').forEach(content => content.classList.remove('active'));
|
||||||
|
|
||||||
|
// Activar la pestaña correcta y su contenido.
|
||||||
|
const tabId = tabButton.dataset.tab;
|
||||||
|
tabButton.classList.add('active');
|
||||||
|
document.getElementById(tabId)?.classList.add('active');
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleTestTicket() {
|
||||||
|
const demoMovement = {
|
||||||
|
id: 'demo',
|
||||||
|
folio: 'DEMO-000001',
|
||||||
|
fechaISO: new Date().toISOString(),
|
||||||
|
cliente: 'Cliente de Prueba',
|
||||||
|
tipo: 'Pago',
|
||||||
|
monto: 123.45,
|
||||||
|
metodo: 'Efectivo',
|
||||||
|
concepto: 'Producto de demostración',
|
||||||
|
staff: 'Admin',
|
||||||
|
notas: 'Esta es una impresión de prueba.'
|
||||||
|
};
|
||||||
|
renderTicketAndPrint(demoMovement, settings);
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- INICIALIZACIÓN ---
|
||||||
|
|
||||||
|
function initializeApp() {
|
||||||
|
const tabs = document.querySelector('.tabs');
|
||||||
|
|
||||||
|
// Conectar eventos
|
||||||
|
formSettings?.addEventListener('submit', handleSaveSettings);
|
||||||
|
formMove?.addEventListener('submit', handleNewMovement);
|
||||||
|
tblMovesBody?.addEventListener('click', handleTableClick);
|
||||||
|
tblClientsBody?.addEventListener('click', handleTableClick);
|
||||||
|
btnExport?.addEventListener('click', exportCSV);
|
||||||
|
btnTestTicket?.addEventListener('click', handleTestTicket);
|
||||||
|
formClient?.addEventListener('submit', handleClientForm);
|
||||||
|
tabs?.addEventListener('click', handleTabChange);
|
||||||
|
document.getElementById('btnCancelEditClient')?.addEventListener('click', () => {
|
||||||
|
formClient.reset();
|
||||||
|
document.getElementById('c-id').value = '';
|
||||||
|
});
|
||||||
|
|
||||||
|
// Cargar datos y renderizar
|
||||||
|
Promise.all([
|
||||||
|
load(KEY_SETTINGS, DEFAULT_SETTINGS),
|
||||||
|
load(KEY_DATA, []),
|
||||||
|
load(KEY_CLIENTS, [])
|
||||||
|
]).then(values => {
|
||||||
|
[settings, movements, clients] = values;
|
||||||
|
renderSettings();
|
||||||
|
renderTable();
|
||||||
|
renderClientsTable();
|
||||||
|
updateClientDatalist();
|
||||||
|
}).catch(error => {
|
||||||
|
console.error('CRITICAL: Failed to load initial data. The app may not function correctly.', error);
|
||||||
|
alert('Error Crítico: No se pudieron cargar los datos del servidor. Asegúrate de que el servidor (npm start) esté corriendo y que no haya errores en la terminal del servidor.');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Esperar a que el DOM esté completamente cargado para iniciar la app
|
||||||
|
document.addEventListener('DOMContentLoaded', initializeApp);
|
||||||
158
ap-pos/index.html
Normal file
158
ap-pos/index.html
Normal file
@@ -0,0 +1,158 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html lang="es">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||||
|
<title>AP-POS — v0.2.1</title>
|
||||||
|
<link rel="stylesheet" href="styles.css?v=1.1" />
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<main class="container">
|
||||||
|
<header class="main-header">
|
||||||
|
<!-- Logo del negocio en lugar de texto -->
|
||||||
|
<img src="src/logo.png" alt="Ale Ponce" class="header-logo">
|
||||||
|
<nav class="tabs">
|
||||||
|
<button type="button" class="tab-link active" data-tab="tab-movements">Recibos</button>
|
||||||
|
<button type="button" class="tab-link" data-tab="tab-clients">Clientes</button>
|
||||||
|
<button type="button" class="tab-link" data-tab="tab-settings">Configuración</button>
|
||||||
|
</nav>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<!-- Pestaña de Movimientos/Recibos -->
|
||||||
|
<div id="tab-movements" class="tab-content active">
|
||||||
|
<div class="section">
|
||||||
|
<h2>Nuevo Movimiento</h2>
|
||||||
|
<form id="formMove">
|
||||||
|
<div class="form-grid">
|
||||||
|
<label>Cliente:</label>
|
||||||
|
<div>
|
||||||
|
<input type="text" id="m-cliente" list="client-list" required autocomplete="off" />
|
||||||
|
<datalist id="client-list"></datalist>
|
||||||
|
</div>
|
||||||
|
<label>Tipo:</label>
|
||||||
|
<select id="m-tipo" required>
|
||||||
|
<option value="Pago">Pago</option>
|
||||||
|
<option value="Anticipo">Anticipo</option>
|
||||||
|
</select>
|
||||||
|
<label>Monto (MXN):</label><input type="number" id="m-monto" step="0.01" min="0" required />
|
||||||
|
<label>Método:</label>
|
||||||
|
<select id="m-metodo">
|
||||||
|
<option value="">-- Opcional --</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="Otros">Otros</option>
|
||||||
|
</select>
|
||||||
|
<label>Concepto:</label><input type="text" id="m-concepto" />
|
||||||
|
<label>Atendió:</label><input type="text" id="m-staff" />
|
||||||
|
<label>Notas:</label><textarea id="m-notas" rows="2"></textarea>
|
||||||
|
</div>
|
||||||
|
<div class="form-actions">
|
||||||
|
<button type="submit">Guardar y Generar Recibo</button>
|
||||||
|
<button type="reset" class="btn-danger">Limpiar</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>Cliente</th>
|
||||||
|
<th>Tipo</th>
|
||||||
|
<th>Monto</th>
|
||||||
|
<th>Acciones</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody></tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Pestaña de Clientes -->
|
||||||
|
<div id="tab-clients" class="tab-content">
|
||||||
|
<div class="section">
|
||||||
|
<h2>Administrar Clientes</h2>
|
||||||
|
<form id="formClient">
|
||||||
|
<input type="hidden" id="c-id" />
|
||||||
|
<div class="form-grid client-grid">
|
||||||
|
<label>Nombre:</label>
|
||||||
|
<input type="text" id="c-nombre" required />
|
||||||
|
<label>Teléfono:</label>
|
||||||
|
<input type="tel" id="c-telefono" />
|
||||||
|
<label>Cumpleaños:</label>
|
||||||
|
<input type="date" id="c-cumple" />
|
||||||
|
<label></label> <!-- Etiqueta vacía para alinear el checkbox -->
|
||||||
|
<div class="checkbox-container">
|
||||||
|
<input type="checkbox" id="c-consent" />
|
||||||
|
<label for="c-consent">Consentimiento médico</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="form-actions">
|
||||||
|
<button type="submit">Guardar Cliente</button>
|
||||||
|
<button type="reset" id="btnCancelEditClient" class="btn-danger">Limpiar</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
<div class="section">
|
||||||
|
<h2>Lista de Clientes</h2>
|
||||||
|
<div class="table-wrapper">
|
||||||
|
<table id="tblClients">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Nombre</th>
|
||||||
|
<th>Teléfono</th>
|
||||||
|
<th>Cumpleaños</th>
|
||||||
|
<th>Consentimiento</th>
|
||||||
|
<th>Acciones</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody></tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Pestaña de Configuración -->
|
||||||
|
<div id="tab-settings" class="tab-content">
|
||||||
|
<div class="section">
|
||||||
|
<h2>Configuración del Negocio</h2>
|
||||||
|
<form id="formSettings">
|
||||||
|
<div class="form-grid">
|
||||||
|
<label>Nombre del negocio:</label><input type="text" id="s-negocio" required />
|
||||||
|
<label>Eslogan (opcional):</label><input type="text" id="s-tagline" />
|
||||||
|
<label>Calle y Número:</label><input type="text" id="s-calle" placeholder="Ej: Av. Siempre Viva 123" />
|
||||||
|
<label>Colonia y C.P.:</label><input type="text" id="s-colonia-cp" placeholder="Ej: Centro, 25000" />
|
||||||
|
<label>Teléfono:</label><input type="text" id="s-tel" />
|
||||||
|
<label>RFC:</label><input type="text" id="s-rfc" />
|
||||||
|
<label>Leyenda pie de ticket:</label><input type="text" id="s-leyenda" />
|
||||||
|
<label>Prefijo de folio:</label><input type="text"id="s-folioPrefix" />
|
||||||
|
</div>
|
||||||
|
<div class="form-actions">
|
||||||
|
<button type="submit">Guardar Configuración</button>
|
||||||
|
<button type="button" id="btnTestTicket" class="btn-secondary">Probar Ticket</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
<div class="section">
|
||||||
|
<h2>Ubicación de los Datos</h2>
|
||||||
|
<p class="data-location-info">
|
||||||
|
Toda la información de tu negocio (clientes, recibos y configuración) se guarda de forma segura en el archivo <strong>ap-pos.db</strong>, ubicado en la misma carpeta que la aplicación. Para hacer un respaldo, simplemente copia este archivo.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
|
||||||
|
<!-- Área de impresión oculta -->
|
||||||
|
<div id="printArea" class="no-print"></div>
|
||||||
|
|
||||||
|
<script type="module" src="app.js?v=1.3"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
2201
ap-pos/package-lock.json
generated
Normal file
2201
ap-pos/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
17
ap-pos/package.json
Normal file
17
ap-pos/package.json
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
{
|
||||||
|
"name": "ap-pos",
|
||||||
|
"version": "1.0.0",
|
||||||
|
"main": "app.js",
|
||||||
|
"scripts": {
|
||||||
|
"start": "node server.js"
|
||||||
|
},
|
||||||
|
"keywords": [],
|
||||||
|
"author": "",
|
||||||
|
"license": "ISC",
|
||||||
|
"description": "",
|
||||||
|
"dependencies": {
|
||||||
|
"cors": "^2.8.5",
|
||||||
|
"express": "^5.1.0",
|
||||||
|
"sqlite3": "^5.1.7"
|
||||||
|
}
|
||||||
|
}
|
||||||
71
ap-pos/print.js
Normal file
71
ap-pos/print.js
Normal file
@@ -0,0 +1,71 @@
|
|||||||
|
/**
|
||||||
|
* Escapa caracteres HTML para prevenir XSS.
|
||||||
|
* @param {string} str El string a escapar.
|
||||||
|
* @returns {string} El string escapado.
|
||||||
|
*/
|
||||||
|
function esc(str) {
|
||||||
|
return String(str || '').replace(/[&<>"']/g, c => ({
|
||||||
|
"&": "&",
|
||||||
|
"<": "<",
|
||||||
|
">": ">",
|
||||||
|
"\"": """,
|
||||||
|
"'": "'"
|
||||||
|
}[c]));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Genera el HTML para un ticket de movimiento.
|
||||||
|
* @param {object} mov El objeto del movimiento.
|
||||||
|
* @param {object} settings El objeto de configuración.
|
||||||
|
* @returns {string} El HTML del ticket.
|
||||||
|
*/
|
||||||
|
function templateTicket(mov, settings) {
|
||||||
|
const dt = new Date(mov.fechaISO || Date.now());
|
||||||
|
const fechaLocal = dt.toLocaleString('es-MX', { dateStyle: 'medium', timeStyle: 'short' });
|
||||||
|
const montoFormateado = Number(mov.monto).toFixed(2);
|
||||||
|
|
||||||
|
const lines = [];
|
||||||
|
lines.push('<div class="ticket">');
|
||||||
|
lines.push('<img src="src/logo.png" alt="Logo" class="t-logo">');
|
||||||
|
|
||||||
|
if (settings.negocio) lines.push(`<div class="t-center t-bold">${esc(settings.negocio)}</div>`);
|
||||||
|
if (settings.tagline) lines.push(`<div class="t-center t-tagline">${esc(settings.tagline)}</div>`);
|
||||||
|
if (settings.rfc) lines.push(`<div class="t-center t-small">RFC: ${esc(settings.rfc)}</div>`);
|
||||||
|
if (settings.sucursal) lines.push(`<div class="t-center t-small">${esc(settings.sucursal)}</div>`);
|
||||||
|
if (settings.tel) lines.push(`<div class="t-center t-small">Tel: ${esc(settings.tel)}</div>`);
|
||||||
|
|
||||||
|
lines.push('<div class="t-divider"></div>');
|
||||||
|
lines.push(`<div class="t-row t-small"><span>Folio:</span><span>${esc(mov.folio)}</span></div>`);
|
||||||
|
lines.push(`<div class="t-row t-small"><span>Fecha:</span><span>${esc(fechaLocal)}</span></div>`);
|
||||||
|
|
||||||
|
lines.push('<div class="t-divider"></div>');
|
||||||
|
lines.push(`<div><span class="t-bold">${esc(mov.tipo)}</span></div>`);
|
||||||
|
if (mov.cliente) lines.push(`<div class="t-small">Cliente: ${esc(mov.cliente)}</div>`);
|
||||||
|
if (mov.concepto) lines.push(`<div class="t-small">Concepto: ${esc(mov.concepto)}</div>`);
|
||||||
|
if (mov.staff) lines.push(`<div class="t-small">Atendió: ${esc(mov.staff)}</div>`);
|
||||||
|
if (mov.metodo) lines.push(`<div class="t-small">Método: ${esc(mov.metodo)}</div>`);
|
||||||
|
if (mov.notas) lines.push(`<div class="t-small">Notas: ${esc(mov.notas)}</div>`);
|
||||||
|
|
||||||
|
lines.push('<div class="t-divider"></div>');
|
||||||
|
lines.push(`<div class="t-row t-bold"><span>Total</span><span>$${montoFormateado}</span></div>`);
|
||||||
|
|
||||||
|
if (settings.leyenda) lines.push(`<div class="t-footer t-center t-small">${esc(settings.leyenda)}</div>`);
|
||||||
|
|
||||||
|
lines.push('</div>');
|
||||||
|
return lines.join('');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Renderiza el ticket en el DOM y llama a la función de impresión del navegador.
|
||||||
|
* @param {object} mov El objeto del movimiento.
|
||||||
|
* @param {object} settings El objeto de configuración.
|
||||||
|
*/
|
||||||
|
export function renderTicketAndPrint(mov, settings) {
|
||||||
|
const printArea = document.getElementById('printArea');
|
||||||
|
if (printArea) {
|
||||||
|
printArea.innerHTML = templateTicket(mov, settings);
|
||||||
|
window.print();
|
||||||
|
} else {
|
||||||
|
console.error("El área de impresión #printArea no se encontró.");
|
||||||
|
}
|
||||||
|
}
|
||||||
158
ap-pos/server.js
Normal file
158
ap-pos/server.js
Normal file
@@ -0,0 +1,158 @@
|
|||||||
|
|
||||||
|
const express = require('express');
|
||||||
|
const sqlite3 = require('sqlite3').verbose();
|
||||||
|
const cors = require('cors');
|
||||||
|
const path = require('path');
|
||||||
|
|
||||||
|
const app = express();
|
||||||
|
const port = 3000;
|
||||||
|
|
||||||
|
app.use(cors());
|
||||||
|
app.use(express.json());
|
||||||
|
|
||||||
|
// Servir archivos estáticos (CSS, JS, imágenes)
|
||||||
|
app.use(express.static(__dirname));
|
||||||
|
|
||||||
|
// Ruta principal para servir el index.html
|
||||||
|
app.get('/', (req, res) => {
|
||||||
|
res.sendFile(path.join(__dirname, 'index.html'));
|
||||||
|
});
|
||||||
|
|
||||||
|
// Initialize SQLite database
|
||||||
|
const db = new sqlite3.Database('./ap-pos.db', (err) => {
|
||||||
|
if (err) {
|
||||||
|
console.error(err.message);
|
||||||
|
}
|
||||||
|
console.log('Connected to the ap-pos.db database.');
|
||||||
|
});
|
||||||
|
|
||||||
|
// Create tables if they don't exist
|
||||||
|
db.serialize(() => {
|
||||||
|
db.run(`CREATE TABLE IF NOT EXISTS settings (
|
||||||
|
key TEXT PRIMARY KEY,
|
||||||
|
value TEXT
|
||||||
|
)`);
|
||||||
|
|
||||||
|
db.run(`CREATE TABLE IF NOT EXISTS clients (
|
||||||
|
id TEXT PRIMARY KEY,
|
||||||
|
nombre TEXT,
|
||||||
|
telefono TEXT,
|
||||||
|
cumpleaños TEXT,
|
||||||
|
consentimiento INTEGER
|
||||||
|
)`);
|
||||||
|
|
||||||
|
db.run(`CREATE TABLE IF NOT EXISTS movements (
|
||||||
|
id TEXT PRIMARY KEY,
|
||||||
|
folio TEXT,
|
||||||
|
fechaISO TEXT,
|
||||||
|
clienteId TEXT,
|
||||||
|
tipo TEXT,
|
||||||
|
monto REAL,
|
||||||
|
metodo TEXT,
|
||||||
|
concepto TEXT,
|
||||||
|
staff TEXT,
|
||||||
|
notas TEXT,
|
||||||
|
FOREIGN KEY (clienteId) REFERENCES clients (id)
|
||||||
|
)`);
|
||||||
|
});
|
||||||
|
|
||||||
|
// API routes will go here
|
||||||
|
|
||||||
|
// --- Settings ---
|
||||||
|
app.get('/api/settings', (req, res) => {
|
||||||
|
db.get("SELECT value FROM settings WHERE key = 'settings'", (err, row) => {
|
||||||
|
if (err) {
|
||||||
|
res.status(500).json({ error: err.message });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
res.json(row ? JSON.parse(row.value) : {});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
app.post('/api/settings', (req, res) => {
|
||||||
|
const { settings } = req.body;
|
||||||
|
const value = JSON.stringify(settings);
|
||||||
|
db.run(`INSERT OR REPLACE INTO settings (key, value) VALUES ('settings', ?)`, [value], function(err) {
|
||||||
|
if (err) {
|
||||||
|
res.status(500).json({ error: err.message });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
res.json({ message: 'Settings saved' });
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// --- Clients ---
|
||||||
|
app.get('/api/clients', (req, res) => {
|
||||||
|
db.all("SELECT * FROM clients", [], (err, rows) => {
|
||||||
|
if (err) {
|
||||||
|
res.status(500).json({ error: err.message });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
res.json(rows);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
app.post('/api/clients', (req, res) => {
|
||||||
|
const { client } = req.body;
|
||||||
|
const { id, nombre, telefono, cumpleaños, consentimiento } = client;
|
||||||
|
db.run(`INSERT OR REPLACE INTO clients (id, nombre, telefono, cumpleaños, consentimiento) VALUES (?, ?, ?, ?, ?)`,
|
||||||
|
[id, nombre, telefono, cumpleaños, consentimiento], function(err) {
|
||||||
|
if (err) {
|
||||||
|
res.status(500).json({ error: err.message });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
res.json({ id });
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
app.delete('/api/clients/:id', (req, res) => {
|
||||||
|
const { id } = req.params;
|
||||||
|
db.run(`DELETE FROM clients WHERE id = ?`, id, function(err) {
|
||||||
|
if (err) {
|
||||||
|
res.status(500).json({ error: err.message });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
res.json({ message: 'Client deleted' });
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
|
||||||
|
// --- Movements ---
|
||||||
|
app.get('/api/movements', (req, res) => {
|
||||||
|
db.all("SELECT * FROM movements ORDER BY fechaISO DESC", [], (err, rows) => {
|
||||||
|
if (err) {
|
||||||
|
res.status(500).json({ error: err.message });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
res.json(rows);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
app.post('/api/movements', (req, res) => {
|
||||||
|
const { movement } = req.body;
|
||||||
|
const { id, folio, fechaISO, clienteId, tipo, monto, metodo, concepto, staff, notas } = movement;
|
||||||
|
db.run(`INSERT INTO movements (id, folio, fechaISO, clienteId, tipo, monto, metodo, concepto, staff, notas)
|
||||||
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
||||||
|
[id, folio, fechaISO, clienteId, tipo, monto, metodo, concepto, staff, notas], function(err) {
|
||||||
|
if (err) {
|
||||||
|
res.status(500).json({ error: err.message });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
res.json({ id });
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
app.delete('/api/movements/:id', (req, res) => {
|
||||||
|
const { id } = req.params;
|
||||||
|
db.run(`DELETE FROM movements WHERE id = ?`, id, function(err) {
|
||||||
|
if (err) {
|
||||||
|
res.status(500).json({ error: err.message });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
res.json({ message: 'Movement deleted' });
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
app.listen(port, () => {
|
||||||
|
console.log(`Server running at http://localhost:${port}`);
|
||||||
|
});
|
||||||
BIN
ap-pos/src/logo.png
Normal file
BIN
ap-pos/src/logo.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 3.7 KiB |
71
ap-pos/storage.js
Normal file
71
ap-pos/storage.js
Normal file
@@ -0,0 +1,71 @@
|
|||||||
|
const API_URL = 'http://localhost:3000/api';
|
||||||
|
|
||||||
|
export const KEY_DATA = 'movements';
|
||||||
|
export const KEY_SETTINGS = 'settings';
|
||||||
|
export const KEY_CLIENTS = 'clients';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Carga datos desde el servidor.
|
||||||
|
* @param {string} key La clave que representa el endpoint (e.g., 'clients').
|
||||||
|
* @param {any} defaultValue El valor a devolver si la carga falla.
|
||||||
|
* @returns {Promise<any>} Los datos parseados o el valor por defecto.
|
||||||
|
*/
|
||||||
|
export async function load(key, defaultValue) {
|
||||||
|
try {
|
||||||
|
const response = await fetch(`${API_URL}/${key}`);
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(`Network response was not ok for key: ${key}`);
|
||||||
|
}
|
||||||
|
const data = await response.json();
|
||||||
|
// Si el servidor devuelve nulo, usar el valor por defecto para evitar errores.
|
||||||
|
if (data === null) {
|
||||||
|
console.warn(`Server returned null for key "${key}", using default value.`);
|
||||||
|
return defaultValue;
|
||||||
|
}
|
||||||
|
return data;
|
||||||
|
} catch (error) {
|
||||||
|
console.error(`Error al cargar datos desde el servidor para la clave "${key}"`, error);
|
||||||
|
return defaultValue;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Guarda datos en el servidor.
|
||||||
|
* @param {string} key La clave que representa el endpoint.
|
||||||
|
* @param {any} data Los datos a guardar.
|
||||||
|
*/
|
||||||
|
export async function save(key, data) {
|
||||||
|
try {
|
||||||
|
const response = await fetch(`${API_URL}/${key}`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
},
|
||||||
|
body: JSON.stringify(data), // Envuelve los datos en un objeto con la clave principal
|
||||||
|
});
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(`Network response was not ok for key: ${key}`);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error(`Error al guardar datos en el servidor para la clave "${key}"`, error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Elimina un item por su ID.
|
||||||
|
* @param {string} key La clave que representa el endpoint (e.g., 'clients').
|
||||||
|
* @param {string} id El ID del item a eliminar.
|
||||||
|
*/
|
||||||
|
export async function remove(key, id) {
|
||||||
|
try {
|
||||||
|
const response = await fetch(`${API_URL}/${key}/${id}`, {
|
||||||
|
method: 'DELETE',
|
||||||
|
});
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(`Network response was not ok for key: ${key} and id: ${id}`);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error(`Error al eliminar el item con id "${id}" desde "${key}"`, error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
310
ap-pos/styles.css
Normal file
310
ap-pos/styles.css
Normal file
@@ -0,0 +1,310 @@
|
|||||||
|
/* --- Estilos Minimalistas y Elegantes --- */
|
||||||
|
|
||||||
|
/* Paleta de colores:
|
||||||
|
- Fondo: #f8f9fa (gris muy claro)
|
||||||
|
- Texto principal: #212529 (casi negro)
|
||||||
|
- Bordes/Divisores: #dee2e6 (gris claro)
|
||||||
|
- Botón primario: #343a40 (gris oscuro)
|
||||||
|
- Botón secundario: #6c757d (gris medio)
|
||||||
|
*/
|
||||||
|
|
||||||
|
/* Estilos generales */
|
||||||
|
body {
|
||||||
|
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif;
|
||||||
|
margin: 0;
|
||||||
|
background-color: #f8f9fa;
|
||||||
|
color: #212529;
|
||||||
|
-webkit-font-smoothing: antialiased;
|
||||||
|
-moz-osx-font-smoothing: grayscale;
|
||||||
|
}
|
||||||
|
|
||||||
|
.container {
|
||||||
|
max-width: 800px;
|
||||||
|
margin: 30px auto;
|
||||||
|
padding: 25px 30px;
|
||||||
|
background: #fff;
|
||||||
|
border-radius: 8px;
|
||||||
|
box-shadow: 0 4px 20px rgba(0,0,0,0.05);
|
||||||
|
}
|
||||||
|
|
||||||
|
h1, h2 {
|
||||||
|
color: #212529;
|
||||||
|
border-bottom: 1px solid #dee2e6;
|
||||||
|
padding-bottom: 12px;
|
||||||
|
margin-top: 0;
|
||||||
|
margin-bottom: 25px;
|
||||||
|
}
|
||||||
|
|
||||||
|
h1 {
|
||||||
|
font-size: 28px;
|
||||||
|
}
|
||||||
|
|
||||||
|
h2 {
|
||||||
|
font-size: 22px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.section {
|
||||||
|
margin-bottom: 40px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Formularios */
|
||||||
|
.form-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 160px 1fr;
|
||||||
|
gap: 18px;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
label {
|
||||||
|
font-weight: 500;
|
||||||
|
text-align: right;
|
||||||
|
color: #495057;
|
||||||
|
}
|
||||||
|
|
||||||
|
input[type="text"],
|
||||||
|
input[type="number"],
|
||||||
|
select,
|
||||||
|
textarea {
|
||||||
|
width: 100%;
|
||||||
|
padding: 10px;
|
||||||
|
border: 1px solid #ced4da;
|
||||||
|
border-radius: 5px;
|
||||||
|
box-sizing: border-box;
|
||||||
|
transition: border-color 0.15s ease-in-out, box-shadow 0.15s ease-in-out;
|
||||||
|
}
|
||||||
|
input:focus, select:focus, textarea:focus {
|
||||||
|
border-color: #80bdff;
|
||||||
|
outline: 0;
|
||||||
|
box-shadow: 0 0 0 0.2rem rgba(0,123,255,.25);
|
||||||
|
}
|
||||||
|
|
||||||
|
button {
|
||||||
|
background-color: #343a40;
|
||||||
|
color: white;
|
||||||
|
padding: 10px 18px;
|
||||||
|
border: none;
|
||||||
|
border-radius: 5px;
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: 500;
|
||||||
|
margin-right: 10px;
|
||||||
|
transition: background-color 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
button:hover {
|
||||||
|
background-color: #212529;
|
||||||
|
}
|
||||||
|
|
||||||
|
button[type="reset"], button#btnTestTicket, #btnExport {
|
||||||
|
background-color: #6c757d;
|
||||||
|
}
|
||||||
|
button[type="reset"]:hover, button#btnTestTicket:hover, #btnExport:hover, .btn-secondary:hover {
|
||||||
|
background-color: #5a6268;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-danger {
|
||||||
|
background-color: #dc3545;
|
||||||
|
}
|
||||||
|
.btn-danger:hover {
|
||||||
|
background-color: #c82333;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-actions button {
|
||||||
|
margin-right: 0; /* Remove right margin */
|
||||||
|
margin-left: 10px; /* Add left margin for spacing */
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-actions {
|
||||||
|
/* Empuja los botones a la derecha en el grid */
|
||||||
|
grid-column-start: 2;
|
||||||
|
margin-top: 20px; /* Aumentar espacio superior */
|
||||||
|
display: flex;
|
||||||
|
justify-content: flex-end; /* Alinea botones a la derecha */
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Tabla */
|
||||||
|
.table-wrapper {
|
||||||
|
overflow-x: auto;
|
||||||
|
border: 1px solid #dee2e6;
|
||||||
|
border-radius: 5px;
|
||||||
|
}
|
||||||
|
|
||||||
|
#tblMoves, #tblClients {
|
||||||
|
width: 100%;
|
||||||
|
border-collapse: collapse;
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
#tblMoves th, #tblMoves td,
|
||||||
|
#tblClients th, #tblClients td {
|
||||||
|
border-bottom: 1px solid #dee2e6;
|
||||||
|
padding: 12px 15px;
|
||||||
|
text-align: left;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
#tblMoves td:last-child, #tblMoves th:last-child,
|
||||||
|
#tblClients td:last-child, #tblClients th:last-child {
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
#tblMoves th, #tblClients th {
|
||||||
|
background-color: #f8f9fa;
|
||||||
|
font-weight: 600;
|
||||||
|
border-bottom-width: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
#tblMoves tbody tr:last-child td,
|
||||||
|
#tblClients tbody tr:last-child td {
|
||||||
|
border-bottom: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.action-btn {
|
||||||
|
background: none;
|
||||||
|
border: none;
|
||||||
|
color: #007bff;
|
||||||
|
cursor: pointer;
|
||||||
|
padding: 5px;
|
||||||
|
font-size: 13px;
|
||||||
|
text-decoration: none;
|
||||||
|
}
|
||||||
|
.action-btn:hover {
|
||||||
|
text-decoration: underline;
|
||||||
|
}
|
||||||
|
button.action-btn {
|
||||||
|
color: #dc3545;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Estilos del Ticket */
|
||||||
|
.ticket {
|
||||||
|
width: 58mm;
|
||||||
|
max-width: 58mm;
|
||||||
|
background: #fff;
|
||||||
|
color: #000;
|
||||||
|
padding: 10px;
|
||||||
|
box-sizing: border-box;
|
||||||
|
font: 12px/1.3 ui-monospace, SFMono-Regular, Menlo, Consolas, "Liberation Mono", monospace;
|
||||||
|
border: 1px solid #ccc; /* Visible en pantalla, no en impresión */
|
||||||
|
}
|
||||||
|
.t-logo {
|
||||||
|
display: block;
|
||||||
|
margin: 0 auto 8px auto;
|
||||||
|
max-width: 75%;
|
||||||
|
height: auto;
|
||||||
|
}
|
||||||
|
.t-center { text-align: center; }
|
||||||
|
.t-bold { font-weight: bold; }
|
||||||
|
.t-tagline { font-size: 11px; margin-bottom: 6px; }
|
||||||
|
.t-small { font-size: 10px; }
|
||||||
|
.t-divider { border-top: 1px dashed #000; margin: 8px 0; }
|
||||||
|
.t-row { display: flex; justify-content: space-between; }
|
||||||
|
.t-footer { margin-top: 10px; }
|
||||||
|
|
||||||
|
|
||||||
|
/***** MODO IMPRESIÓN *****/
|
||||||
|
@media print {
|
||||||
|
body {
|
||||||
|
background: #fff;
|
||||||
|
color: #000;
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.no-print, .container {
|
||||||
|
display: none !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
#printArea {
|
||||||
|
display: block !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
@page {
|
||||||
|
size: 58mm auto;
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ticket {
|
||||||
|
border: none;
|
||||||
|
width: 100%;
|
||||||
|
max-width: 100%;
|
||||||
|
padding: 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* --- Estilos de Pestañas --- */
|
||||||
|
.main-header {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
margin-bottom: 25px;
|
||||||
|
border-bottom: 1px solid #dee2e6;
|
||||||
|
}
|
||||||
|
|
||||||
|
.main-header h1 {
|
||||||
|
border-bottom: none;
|
||||||
|
margin-bottom: 0;
|
||||||
|
padding-bottom: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Logo en header */
|
||||||
|
.main-header .header-logo {
|
||||||
|
max-height: 50px;
|
||||||
|
width: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tabs {
|
||||||
|
display: flex;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tab-link {
|
||||||
|
padding: 10px 20px;
|
||||||
|
cursor: pointer;
|
||||||
|
background: none;
|
||||||
|
border: none;
|
||||||
|
font-size: 16px;
|
||||||
|
color: #6c757d;
|
||||||
|
border-bottom: 2px solid transparent;
|
||||||
|
margin-bottom: -1px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tab-link.active {
|
||||||
|
color: #343a40;
|
||||||
|
border-bottom-color: #343a40;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tab-content {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tab-content.active {
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* --- Estilos específicos de Clientes --- */
|
||||||
|
.client-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 120px 1fr 120px 1fr; /* Dos columnas de etiquetas y campos */
|
||||||
|
gap: 18px 12px; /* Espacio vertical y horizontal */
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.client-grid .checkbox-container {
|
||||||
|
grid-column: 2 / span 3; /* El checkbox ocupa las columnas de la derecha */
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.checkbox-container input[type="checkbox"] {
|
||||||
|
width: auto;
|
||||||
|
margin-right: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* --- Estilos de Configuración --- */
|
||||||
|
.data-location-info {
|
||||||
|
background-color: #e9ecef;
|
||||||
|
padding: 15px;
|
||||||
|
border-radius: 5px;
|
||||||
|
font-size: 14px;
|
||||||
|
line-height: 1.5;
|
||||||
|
}
|
||||||
|
.data-location-info strong {
|
||||||
|
font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, "Liberation Mono", monospace;
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user