feat: Implement persistence and initial project setup

This commit is contained in:
Marco Gallegos
2025-08-12 20:06:49 -06:00
parent 26a4051c32
commit 8b9f5afe96
11 changed files with 3381 additions and 0 deletions

15
ap-pos/.gitignore vendored Normal file
View 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
View 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
View 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
View 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

File diff suppressed because it is too large Load Diff

17
ap-pos/package.json Normal file
View 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
View 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 => ({
"&": "&amp;",
"<": "&lt;",
">": "&gt;",
"\"": "&quot;",
"'": "&#39;"
}[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
View 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

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.7 KiB

71
ap-pos/storage.js Normal file
View 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
View 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;
}