mirror of
https://github.com/marcogll/ap_pos.git
synced 2026-01-13 13:15: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