feat: add captura, POS, cuentas, and tienda pages

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-03-18 22:25:32 +00:00
parent b1adf536f6
commit fe6542c45c
12 changed files with 4037 additions and 0 deletions

660
dashboard/captura.css Normal file
View File

@@ -0,0 +1,660 @@
/* ============================================================
captura.css -- Styles for Nexus Autoparts Data Entry
============================================================ */
/* --- Tabs --- */
.captura-tabs {
display: flex;
gap: 0;
border-bottom: 2px solid var(--border);
margin-bottom: 1.5rem;
}
.captura-tab {
padding: 0.8rem 1.8rem;
background: transparent;
border: none;
color: var(--text-secondary);
font-size: 0.95rem;
font-weight: 600;
cursor: pointer;
border-bottom: 3px solid transparent;
transition: all 0.2s;
position: relative;
bottom: -2px;
}
.captura-tab:hover {
color: var(--text-primary);
background: var(--bg-hover);
}
.captura-tab.active {
color: var(--accent);
border-bottom-color: var(--accent);
}
.captura-tab .tab-badge {
background: var(--accent);
color: #fff;
font-size: 0.7rem;
padding: 0.15rem 0.5rem;
border-radius: 10px;
margin-left: 0.5rem;
font-weight: 700;
}
.captura-section {
display: none;
}
.captura-section.active {
display: block;
}
/* --- Vehicle Selector (Section 1) --- */
.vehicle-filters {
display: flex;
gap: 1rem;
margin-bottom: 1rem;
flex-wrap: wrap;
align-items: flex-end;
}
.vehicle-filters .filter-group {
display: flex;
flex-direction: column;
gap: 0.3rem;
}
.vehicle-filters label {
font-size: 0.75rem;
color: var(--text-secondary);
text-transform: uppercase;
letter-spacing: 0.05em;
}
.vehicle-filters select,
.vehicle-filters input {
padding: 0.5rem 0.8rem;
background: var(--bg-secondary);
border: 1px solid var(--border);
border-radius: 8px;
color: var(--text-primary);
font-size: 0.9rem;
min-width: 160px;
}
.vehicle-filters select:focus,
.vehicle-filters input:focus {
outline: none;
border-color: var(--accent);
}
/* --- Vehicle List --- */
.vehicle-list {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(280px, 1fr));
gap: 0.8rem;
max-height: 400px;
overflow-y: auto;
padding-right: 0.5rem;
}
.vehicle-card {
background: var(--bg-card);
border: 1px solid var(--border);
border-radius: 10px;
padding: 1rem;
cursor: pointer;
transition: all 0.2s;
}
.vehicle-card:hover {
border-color: var(--accent);
background: var(--bg-hover);
}
.vehicle-card .vc-brand {
font-weight: 700;
font-size: 0.95rem;
color: var(--accent);
}
.vehicle-card .vc-model {
font-size: 1.1rem;
font-weight: 600;
margin: 0.2rem 0;
}
.vehicle-card .vc-details {
font-size: 0.8rem;
color: var(--text-secondary);
}
.vehicle-card .vc-parts-count {
margin-top: 0.5rem;
font-size: 0.75rem;
color: var(--success);
}
/* --- Vehicle Header (when editing) --- */
.vehicle-header {
background: linear-gradient(135deg, var(--bg-card) 0%, var(--bg-hover) 100%);
border: 1px solid var(--accent);
border-radius: 12px;
padding: 1rem 1.5rem;
margin-bottom: 1.5rem;
display: flex;
justify-content: space-between;
align-items: center;
}
.vehicle-header .vh-info {
display: flex;
gap: 1.5rem;
align-items: center;
flex-wrap: wrap;
}
.vehicle-header .vh-label {
font-size: 0.7rem;
color: var(--text-secondary);
text-transform: uppercase;
letter-spacing: 0.05em;
}
.vehicle-header .vh-value {
font-size: 1.1rem;
font-weight: 700;
}
.vehicle-header .vh-brand { color: var(--accent); }
.vehicle-header .vh-actions {
display: flex;
gap: 0.5rem;
}
/* --- Part Groups Table --- */
.category-section {
margin-bottom: 1.5rem;
}
.category-header {
background: var(--bg-secondary);
border: 1px solid var(--border);
border-radius: 8px 8px 0 0;
padding: 0.6rem 1rem;
display: flex;
justify-content: space-between;
align-items: center;
cursor: pointer;
user-select: none;
}
.category-header:hover {
background: var(--bg-hover);
}
.category-header h3 {
font-size: 0.9rem;
font-weight: 700;
color: var(--accent);
}
.category-header .cat-toggle {
font-size: 0.8rem;
color: var(--text-secondary);
transition: transform 0.2s;
}
.category-header.collapsed .cat-toggle {
transform: rotate(-90deg);
}
.category-body {
border: 1px solid var(--border);
border-top: none;
border-radius: 0 0 8px 8px;
}
.category-body.collapsed {
display: none;
}
.group-section {
border-bottom: 1px solid var(--border);
padding: 0.8rem 1rem;
}
.group-section:last-child {
border-bottom: none;
}
.group-name {
font-size: 0.85rem;
font-weight: 600;
color: var(--text-primary);
margin-bottom: 0.5rem;
}
/* --- Part Rows --- */
.part-rows {
display: flex;
flex-direction: column;
gap: 0.4rem;
margin-bottom: 0.4rem;
}
.part-row {
display: flex;
gap: 0.5rem;
align-items: center;
}
.part-row input {
padding: 0.4rem 0.6rem;
background: var(--bg-primary);
border: 1px solid var(--border);
border-radius: 6px;
color: var(--text-primary);
font-size: 0.85rem;
}
.part-row input:focus {
outline: none;
border-color: var(--accent);
}
.part-row .pr-oem {
width: 160px;
font-family: monospace;
}
.part-row .pr-name {
flex: 1;
min-width: 150px;
}
.part-row .pr-qty {
width: 50px;
text-align: center;
}
.part-row .pr-btn {
padding: 0.3rem 0.6rem;
border: none;
border-radius: 6px;
cursor: pointer;
font-size: 0.8rem;
font-weight: 600;
transition: all 0.2s;
}
.part-row .pr-save {
background: var(--success);
color: #fff;
}
.part-row .pr-save:hover { background: #1ea34e; }
.part-row .pr-delete {
background: var(--danger);
color: #fff;
}
.part-row .pr-delete:hover { background: #cc3333; }
.part-row.saved {
background: rgba(34, 197, 94, 0.08);
border-radius: 6px;
padding: 0.2rem 0.4rem;
}
.part-row.saved input {
background: transparent;
border-color: var(--success);
color: var(--success);
}
.btn-add-part {
background: transparent;
border: 1px dashed var(--border);
border-radius: 6px;
padding: 0.3rem 0.8rem;
color: var(--text-secondary);
font-size: 0.8rem;
cursor: pointer;
transition: all 0.2s;
}
.btn-add-part:hover {
border-color: var(--accent);
color: var(--accent);
}
/* --- Progress Bar --- */
.progress-bar {
background: var(--bg-secondary);
border-radius: 10px;
height: 8px;
overflow: hidden;
margin: 0.5rem 0;
}
.progress-bar .progress-fill {
height: 100%;
background: linear-gradient(90deg, var(--accent), var(--success));
border-radius: 10px;
transition: width 0.3s;
}
.progress-text {
font-size: 0.75rem;
color: var(--text-secondary);
}
/* --- Section 2: Intercambios --- */
.part-detail-card {
background: var(--bg-card);
border: 1px solid var(--border);
border-radius: 10px;
padding: 1rem;
margin-bottom: 1rem;
}
.part-detail-card .pdc-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 0.8rem;
}
.part-detail-card .pdc-oem {
font-family: monospace;
font-size: 1rem;
font-weight: 700;
color: var(--accent);
}
.part-detail-card .pdc-name {
font-size: 0.85rem;
color: var(--text-secondary);
}
.part-detail-card .pdc-group {
font-size: 0.75rem;
color: var(--text-secondary);
background: var(--bg-hover);
padding: 0.2rem 0.5rem;
border-radius: 4px;
}
.aftermarket-table {
width: 100%;
border-collapse: collapse;
font-size: 0.85rem;
}
.aftermarket-table th {
text-align: left;
padding: 0.5rem;
border-bottom: 1px solid var(--border);
color: var(--text-secondary);
font-size: 0.75rem;
text-transform: uppercase;
}
.aftermarket-table td {
padding: 0.4rem 0.5rem;
border-bottom: 1px solid rgba(42, 42, 58, 0.5);
}
.aftermarket-form {
display: flex;
gap: 0.5rem;
align-items: flex-end;
flex-wrap: wrap;
margin-top: 0.8rem;
padding-top: 0.8rem;
border-top: 1px dashed var(--border);
}
.aftermarket-form .af-field {
display: flex;
flex-direction: column;
gap: 0.2rem;
}
.aftermarket-form label {
font-size: 0.7rem;
color: var(--text-secondary);
text-transform: uppercase;
}
.aftermarket-form select,
.aftermarket-form input {
padding: 0.4rem 0.6rem;
background: var(--bg-primary);
border: 1px solid var(--border);
border-radius: 6px;
color: var(--text-primary);
font-size: 0.85rem;
}
.aftermarket-form select:focus,
.aftermarket-form input:focus {
outline: none;
border-color: var(--accent);
}
/* --- Section 3: Imágenes --- */
.image-card {
background: var(--bg-card);
border: 1px solid var(--border);
border-radius: 10px;
padding: 1rem;
display: flex;
gap: 1rem;
align-items: center;
margin-bottom: 0.8rem;
}
.image-card .ic-preview {
width: 100px;
height: 100px;
background: var(--bg-secondary);
border: 2px dashed var(--border);
border-radius: 8px;
display: flex;
align-items: center;
justify-content: center;
color: var(--text-secondary);
font-size: 0.75rem;
overflow: hidden;
flex-shrink: 0;
}
.image-card .ic-preview img {
width: 100%;
height: 100%;
object-fit: cover;
}
.image-card .ic-info {
flex: 1;
}
.image-card .ic-oem {
font-family: monospace;
font-weight: 700;
color: var(--accent);
}
.image-card .ic-name {
font-size: 0.85rem;
color: var(--text-secondary);
margin-bottom: 0.5rem;
}
.image-card .ic-upload {
display: flex;
gap: 0.5rem;
align-items: center;
}
.image-card .ic-upload input[type="file"] {
font-size: 0.8rem;
color: var(--text-secondary);
}
/* --- Search bar --- */
.captura-search {
display: flex;
gap: 0.8rem;
margin-bottom: 1rem;
align-items: center;
}
.captura-search input {
padding: 0.5rem 0.8rem;
background: var(--bg-secondary);
border: 1px solid var(--border);
border-radius: 8px;
color: var(--text-primary);
font-size: 0.9rem;
flex: 1;
max-width: 400px;
}
.captura-search input:focus {
outline: none;
border-color: var(--accent);
}
/* --- Pagination --- */
.captura-pagination {
display: flex;
justify-content: center;
align-items: center;
gap: 0.5rem;
margin-top: 1rem;
padding: 0.5rem;
}
.captura-pagination button {
padding: 0.4rem 0.8rem;
background: var(--bg-card);
border: 1px solid var(--border);
border-radius: 6px;
color: var(--text-primary);
cursor: pointer;
font-size: 0.85rem;
}
.captura-pagination button:hover {
border-color: var(--accent);
}
.captura-pagination button:disabled {
opacity: 0.4;
cursor: not-allowed;
}
.captura-pagination .page-info {
font-size: 0.8rem;
color: var(--text-secondary);
}
/* --- Empty state --- */
.empty-state {
text-align: center;
padding: 3rem;
color: var(--text-secondary);
}
.empty-state .es-icon {
font-size: 2.5rem;
margin-bottom: 0.5rem;
}
.empty-state .es-text {
font-size: 0.9rem;
}
/* --- Toast notifications --- */
.toast {
position: fixed;
bottom: 2rem;
right: 2rem;
padding: 0.8rem 1.5rem;
border-radius: 10px;
color: #fff;
font-weight: 600;
font-size: 0.9rem;
z-index: 9999;
animation: toastIn 0.3s ease;
box-shadow: 0 4px 20px rgba(0,0,0,0.3);
}
.toast.success { background: var(--success); }
.toast.error { background: var(--danger); }
@keyframes toastIn {
from { transform: translateY(20px); opacity: 0; }
to { transform: translateY(0); opacity: 1; }
}
/* --- Loading spinner --- */
.loading {
display: flex;
justify-content: center;
padding: 2rem;
}
.spinner {
width: 30px;
height: 30px;
border: 3px solid var(--border);
border-top-color: var(--accent);
border-radius: 50%;
animation: spin 0.8s linear infinite;
}
@keyframes spin {
to { transform: rotate(360deg); }
}
/* --- Layout --- */
.captura-container {
max-width: 1200px;
margin: 0 auto;
padding: 5rem 2rem 2rem;
}
/* --- Status tabs for vehicles --- */
.status-tabs {
display: flex;
gap: 0.5rem;
margin-bottom: 1rem;
}
.status-tab {
padding: 0.4rem 1rem;
background: var(--bg-card);
border: 1px solid var(--border);
border-radius: 20px;
color: var(--text-secondary);
font-size: 0.8rem;
cursor: pointer;
transition: all 0.2s;
}
.status-tab:hover { border-color: var(--accent); }
.status-tab.active {
background: var(--accent);
color: #fff;
border-color: var(--accent);
}

99
dashboard/captura.html Normal file
View File

@@ -0,0 +1,99 @@
<!DOCTYPE html>
<html lang="es">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Captura de Datos — NEXUS AUTOPARTS</title>
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700&family=Orbitron:wght@700&display=swap" rel="stylesheet">
<link rel="stylesheet" href="/shared.css">
<link rel="stylesheet" href="/captura.css">
</head>
<body>
<div id="shared-nav"></div>
<div class="captura-container">
<!-- Main Tabs -->
<div class="captura-tabs">
<button class="captura-tab active" data-tab="oem">Partes OEM</button>
<button class="captura-tab" data-tab="aftermarket">Intercambios</button>
<button class="captura-tab" data-tab="images">Imagenes</button>
</div>
<!-- ============================================ -->
<!-- SECTION 1: OEM Parts Entry -->
<!-- ============================================ -->
<div id="section-oem" class="captura-section active">
<!-- Vehicle selection view -->
<div id="oem-vehicle-select">
<div class="status-tabs">
<button class="status-tab active" data-status="pending">Pendientes</button>
<button class="status-tab" data-status="in_progress">En progreso</button>
</div>
<div class="vehicle-filters">
<div class="filter-group">
<label>Marca</label>
<select id="oem-brand-filter">
<option value="">Todas</option>
</select>
</div>
<div class="filter-group">
<label>Modelo</label>
<input id="oem-model-filter" type="text" placeholder="Buscar modelo...">
</div>
</div>
<div id="oem-vehicle-list" class="vehicle-list">
<div class="loading"><div class="spinner"></div></div>
</div>
<div id="oem-vehicle-pagination" class="captura-pagination"></div>
</div>
<!-- Part entry view (hidden until vehicle selected) -->
<div id="oem-part-entry" style="display: none;">
<div id="oem-vehicle-header" class="vehicle-header"></div>
<div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 1rem;">
<div>
<div class="progress-bar" style="width: 200px;">
<div id="oem-progress-fill" class="progress-fill" style="width: 0%"></div>
</div>
<span id="oem-progress-text" class="progress-text">0 partes registradas</span>
</div>
</div>
<div id="oem-groups-container"></div>
</div>
</div>
<!-- ============================================ -->
<!-- SECTION 2: Aftermarket / Interchange Entry -->
<!-- ============================================ -->
<div id="section-aftermarket" class="captura-section">
<div class="captura-search">
<input id="aftermarket-search" type="text" placeholder="Buscar por # OEM o nombre...">
<button class="btn btn-primary" onclick="loadPartsWithoutAftermarket()">Buscar</button>
</div>
<div id="aftermarket-list"></div>
<div id="aftermarket-pagination" class="captura-pagination"></div>
</div>
<!-- ============================================ -->
<!-- SECTION 3: Image Upload -->
<!-- ============================================ -->
<div id="section-images" class="captura-section">
<div class="captura-search">
<input id="image-search" type="text" placeholder="Buscar por # OEM o nombre...">
<button class="btn btn-primary" onclick="loadPartsWithoutImage()">Buscar</button>
</div>
<div id="image-list"></div>
<div id="image-pagination" class="captura-pagination"></div>
</div>
</div>
<script src="/nav.js"></script>
<script src="/captura.js"></script>
</body>
</html>

707
dashboard/captura.js Normal file
View File

@@ -0,0 +1,707 @@
/**
* captura.js — Data entry logic for Nexus Autoparts
* 3 sections: OEM Parts, Aftermarket/Interchange, Images
*/
(function () {
'use strict';
var API = '';
var currentMye = null; // selected vehicle MYE id
var currentVehicle = null; // vehicle info object
var vehicleParts = []; // existing parts for current vehicle
var manufacturers = []; // cached manufacturer list
var vehicleStatus = 'pending';
var vehiclePage = 1;
// ================================================================
// Utility
// ================================================================
function toast(msg, type) {
var el = document.createElement('div');
el.className = 'toast ' + (type || 'success');
el.textContent = msg;
document.body.appendChild(el);
setTimeout(function () { el.remove(); }, 3000);
}
function api(path, opts) {
opts = opts || {};
return fetch(API + path, opts).then(function (r) {
if (!r.ok) return r.json().then(function (d) { throw new Error(d.error || 'Error'); });
return r.json();
});
}
function esc(s) {
if (!s) return '';
var d = document.createElement('div');
d.textContent = s;
return d.innerHTML;
}
// ================================================================
// Tab Switching
// ================================================================
document.querySelectorAll('.captura-tab').forEach(function (tab) {
tab.addEventListener('click', function () {
document.querySelectorAll('.captura-tab').forEach(function (t) { t.classList.remove('active'); });
document.querySelectorAll('.captura-section').forEach(function (s) { s.classList.remove('active'); });
tab.classList.add('active');
var target = tab.getAttribute('data-tab');
document.getElementById('section-' + target).classList.add('active');
if (target === 'aftermarket') loadPartsWithoutAftermarket();
if (target === 'images') loadPartsWithoutImage();
});
});
// ================================================================
// SECTION 1: OEM Parts
// ================================================================
// --- Status tabs ---
document.querySelectorAll('.status-tab').forEach(function (tab) {
tab.addEventListener('click', function () {
document.querySelectorAll('.status-tab').forEach(function (t) { t.classList.remove('active'); });
tab.classList.add('active');
vehicleStatus = tab.getAttribute('data-status');
vehiclePage = 1;
loadVehicles();
});
});
// --- Brand filter ---
function loadBrands() {
api('/api/brands').then(function (brands) {
var sel = document.getElementById('oem-brand-filter');
brands.forEach(function (b) {
var opt = document.createElement('option');
opt.value = b;
opt.textContent = b;
sel.appendChild(opt);
});
});
}
document.getElementById('oem-brand-filter').addEventListener('change', function () {
vehiclePage = 1;
loadVehicles();
});
var modelTimer = null;
document.getElementById('oem-model-filter').addEventListener('input', function () {
clearTimeout(modelTimer);
modelTimer = setTimeout(function () {
vehiclePage = 1;
loadVehicles();
}, 400);
});
// --- Load vehicles ---
function loadVehicles() {
var brand = document.getElementById('oem-brand-filter').value;
var model = document.getElementById('oem-model-filter').value;
var list = document.getElementById('oem-vehicle-list');
list.innerHTML = '<div class="loading"><div class="spinner"></div></div>';
var endpoint = vehicleStatus === 'pending'
? '/api/captura/vehicles/pending'
: '/api/captura/vehicles/in-progress';
var params = '?page=' + vehiclePage + '&per_page=30';
if (brand) params += '&brand=' + encodeURIComponent(brand);
if (model) params += '&model=' + encodeURIComponent(model);
api(endpoint + params).then(function (res) {
var data = res.data || [];
if (data.length === 0) {
list.innerHTML = '<div class="empty-state"><div class="es-icon">&#128203;</div><div class="es-text">No hay vehiculos ' +
(vehicleStatus === 'pending' ? 'pendientes' : 'en progreso') + '</div></div>';
document.getElementById('oem-vehicle-pagination').innerHTML = '';
return;
}
list.innerHTML = data.map(function (v) {
return '<div class="vehicle-card" data-mye="' + v.id_mye + '">' +
'<div class="vc-brand">' + esc(v.brand) + '</div>' +
'<div class="vc-model">' + esc(v.model) + '</div>' +
'<div class="vc-details">' + v.year + ' &middot; ' + esc(v.engine) +
(v.trim_level ? ' &middot; ' + esc(v.trim_level) : '') + '</div>' +
(v.parts_count ? '<div class="vc-parts-count">' + v.parts_count + ' partes registradas</div>' : '') +
'</div>';
}).join('');
// Click handler for vehicle cards
list.querySelectorAll('.vehicle-card').forEach(function (card) {
card.addEventListener('click', function () {
selectVehicle(parseInt(card.getAttribute('data-mye')));
});
});
// Pagination
renderPagination('oem-vehicle-pagination', res.pagination, function (p) {
vehiclePage = p;
loadVehicles();
});
});
}
function renderPagination(containerId, pag, onPage) {
var c = document.getElementById(containerId);
if (!pag || pag.total_pages <= 1) { c.innerHTML = ''; return; }
c.innerHTML = '<button ' + (pag.page <= 1 ? 'disabled' : '') + ' data-p="' + (pag.page - 1) + '">&laquo; Anterior</button>' +
'<span class="page-info">Pag ' + pag.page + ' de ' + pag.total_pages + ' (' + pag.total + ' total)</span>' +
'<button ' + (pag.page >= pag.total_pages ? 'disabled' : '') + ' data-p="' + (pag.page + 1) + '">Siguiente &raquo;</button>';
c.querySelectorAll('button').forEach(function (btn) {
btn.addEventListener('click', function () {
onPage(parseInt(btn.getAttribute('data-p')));
});
});
}
// --- Select vehicle and show part entry ---
function selectVehicle(myeId) {
currentMye = myeId;
document.getElementById('oem-vehicle-select').style.display = 'none';
document.getElementById('oem-part-entry').style.display = 'block';
// Mark as in_progress
api('/api/captura/vehicles/' + myeId + '/status', {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ status: 'in_progress' })
});
loadVehicleParts(myeId);
}
function loadVehicleParts(myeId) {
api('/api/captura/vehicles/' + myeId + '/parts').then(function (res) {
currentVehicle = res.vehicle;
vehicleParts = res.parts || [];
// Render vehicle header
var hdr = document.getElementById('oem-vehicle-header');
hdr.innerHTML = '<div class="vh-info">' +
'<div><div class="vh-label">Marca</div><div class="vh-value vh-brand">' + esc(currentVehicle.brand) + '</div></div>' +
'<div><div class="vh-label">Modelo</div><div class="vh-value">' + esc(currentVehicle.model) + '</div></div>' +
'<div><div class="vh-label">Ano</div><div class="vh-value">' + currentVehicle.year + '</div></div>' +
'<div><div class="vh-label">Motor</div><div class="vh-value">' + esc(currentVehicle.engine) + '</div></div>' +
(currentVehicle.trim_level ? '<div><div class="vh-label">Trim</div><div class="vh-value">' + esc(currentVehicle.trim_level) + '</div></div>' : '') +
'</div>' +
'<div class="vh-actions">' +
'<button class="btn btn-secondary" id="btn-back-vehicles">&#9664; Volver</button>' +
'<button class="btn btn-primary" id="btn-complete-vehicle">Terminado &#10003;</button>' +
'</div>';
document.getElementById('btn-back-vehicles').addEventListener('click', backToVehicles);
document.getElementById('btn-complete-vehicle').addEventListener('click', completeVehicle);
// Build groups by category
renderGroups(res.groups, vehicleParts);
updateProgress();
});
}
function backToVehicles() {
document.getElementById('oem-vehicle-select').style.display = 'block';
document.getElementById('oem-part-entry').style.display = 'none';
currentMye = null;
loadVehicles();
}
function completeVehicle() {
if (vehicleParts.length === 0) {
toast('Registra al menos una parte antes de marcar como terminado', 'error');
return;
}
api('/api/captura/vehicles/' + currentMye + '/status', {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ status: 'completed' })
}).then(function () {
toast('Vehiculo completado');
backToVehicles();
});
}
// --- Render groups/categories ---
function renderGroups(groups, parts) {
var container = document.getElementById('oem-groups-container');
// Group by category
var categories = {};
groups.forEach(function (g) {
if (!categories[g.category]) {
categories[g.category] = { id: g.id_part_category, groups: [] };
}
categories[g.category].groups.push(g);
});
var html = '';
Object.keys(categories).forEach(function (catName) {
var cat = categories[catName];
var catParts = parts.filter(function (p) {
return cat.groups.some(function (g) { return g.id_part_group === p.group_id; });
});
html += '<div class="category-section">' +
'<div class="category-header" data-cat="' + cat.id + '">' +
'<h3>' + esc(catName) + ' (' + catParts.length + ')</h3>' +
'<span class="cat-toggle">&#9660;</span></div>' +
'<div class="category-body" data-cat-body="' + cat.id + '">';
cat.groups.forEach(function (g) {
var groupParts = parts.filter(function (p) { return p.group_id === g.id_part_group; });
html += '<div class="group-section" data-group="' + g.id_part_group + '">' +
'<div class="group-name">' + esc(g.group_name) + '</div>' +
'<div class="part-rows" data-group-parts="' + g.id_part_group + '">';
groupParts.forEach(function (p) {
html += savedPartRow(p);
});
html += '</div>' +
'<button class="btn-add-part" data-group-id="' + g.id_part_group + '">+ Agregar pieza</button>' +
'</div>';
});
html += '</div></div>';
});
container.innerHTML = html;
// Category toggle
container.querySelectorAll('.category-header').forEach(function (ch) {
ch.addEventListener('click', function () {
var catId = ch.getAttribute('data-cat');
var body = container.querySelector('[data-cat-body="' + catId + '"]');
ch.classList.toggle('collapsed');
body.classList.toggle('collapsed');
});
});
// Add part buttons
container.querySelectorAll('.btn-add-part').forEach(function (btn) {
btn.addEventListener('click', function () {
addPartRow(parseInt(btn.getAttribute('data-group-id')), btn);
});
});
}
function savedPartRow(p) {
return '<div class="part-row saved" data-fitment-id="' + p.id_vehicle_part + '">' +
'<input class="pr-oem" value="' + esc(p.oem_part_number) + '" readonly>' +
'<input class="pr-name" value="' + esc(p.name_part || '') + '" readonly>' +
'<input class="pr-qty" value="' + (p.quantity_required || 1) + '" readonly>' +
'<button class="pr-btn pr-delete" title="Eliminar">&#10005;</button>' +
'</div>';
}
function addPartRow(groupId, addBtn) {
var rowsContainer = document.querySelector('[data-group-parts="' + groupId + '"]');
var row = document.createElement('div');
row.className = 'part-row';
row.innerHTML = '<input class="pr-oem" placeholder="# OEM" data-group="' + groupId + '">' +
'<input class="pr-name" placeholder="Nombre pieza">' +
'<input class="pr-qty" value="1" type="number" min="1">' +
'<button class="pr-btn pr-save" title="Guardar">&#10003;</button>' +
'<button class="pr-btn pr-delete" title="Quitar">&#10005;</button>';
rowsContainer.appendChild(row);
// Focus OEM field
row.querySelector('.pr-oem').focus();
// OEM blur: check if exists
row.querySelector('.pr-oem').addEventListener('blur', function () {
var oem = this.value.trim();
if (!oem) return;
api('/api/captura/parts/check-oem?oem=' + encodeURIComponent(oem)).then(function (res) {
if (res.exists) {
row.querySelector('.pr-name').value = res.part.name_part || '';
row.querySelector('.pr-name').style.borderColor = 'var(--success)';
row.dataset.existingPartId = res.part.id_part;
}
});
});
// Save
row.querySelector('.pr-save').addEventListener('click', function () {
savePart(row, groupId);
});
// Delete (unsaved)
row.querySelector('.pr-delete').addEventListener('click', function () {
row.remove();
});
}
function savePart(row, groupId) {
var oem = row.querySelector('.pr-oem').value.trim();
var name = row.querySelector('.pr-name').value.trim();
var qty = parseInt(row.querySelector('.pr-qty').value) || 1;
if (!oem) {
toast('Ingresa el numero OEM', 'error');
row.querySelector('.pr-oem').focus();
return;
}
var saveBtn = row.querySelector('.pr-save');
saveBtn.disabled = true;
saveBtn.textContent = '...';
// Check if part already exists
var existingId = row.dataset.existingPartId;
function createFitment(partId) {
api('/api/admin/fitment', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
model_year_engine_id: currentMye,
part_id: partId,
quantity_required: qty
})
}).then(function (res) {
// Replace row with saved version
var newPart = {
id_vehicle_part: res.id,
part_id: partId,
oem_part_number: oem,
name_part: name,
quantity_required: qty,
group_id: groupId
};
vehicleParts.push(newPart);
row.outerHTML = savedPartRow(newPart);
updateProgress();
toast('Parte guardada: ' + oem);
// Re-attach delete handlers
attachDeleteHandlers();
}).catch(function (err) {
toast(err.message, 'error');
saveBtn.disabled = false;
saveBtn.textContent = '\u2713';
});
}
if (existingId) {
createFitment(parseInt(existingId));
} else {
// Create part first
api('/api/admin/parts', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
oem_part_number: oem,
name: name || oem,
group_id: groupId
})
}).then(function (res) {
createFitment(res.id);
}).catch(function (err) {
toast(err.message, 'error');
saveBtn.disabled = false;
saveBtn.textContent = '\u2713';
});
}
}
function attachDeleteHandlers() {
document.querySelectorAll('.part-row.saved .pr-delete').forEach(function (btn) {
btn.onclick = function () {
var row = btn.closest('.part-row');
var fitmentId = row.getAttribute('data-fitment-id');
if (!fitmentId) { row.remove(); return; }
api('/api/admin/fitment/' + fitmentId, { method: 'DELETE' }).then(function () {
vehicleParts = vehicleParts.filter(function (p) {
return p.id_vehicle_part !== parseInt(fitmentId);
});
row.remove();
updateProgress();
toast('Parte eliminada');
}).catch(function (err) {
toast(err.message, 'error');
});
};
});
}
function updateProgress() {
var count = vehicleParts.length;
var totalGroups = 63;
var pct = Math.min(100, Math.round((count / totalGroups) * 100));
document.getElementById('oem-progress-fill').style.width = pct + '%';
document.getElementById('oem-progress-text').textContent = count + ' partes registradas';
// Update category counts
document.querySelectorAll('.category-header h3').forEach(function (h3) {
var catSection = h3.closest('.category-section');
var rows = catSection.querySelectorAll('.part-row.saved');
var catName = h3.textContent.replace(/\s*\(\d+\)$/, '');
h3.textContent = catName + ' (' + rows.length + ')';
});
}
// ================================================================
// SECTION 2: Aftermarket / Interchange
// ================================================================
var aftermarketPage = 1;
function loadPartsWithoutAftermarket(page) {
page = page || 1;
aftermarketPage = page;
var search = document.getElementById('aftermarket-search').value;
var list = document.getElementById('aftermarket-list');
list.innerHTML = '<div class="loading"><div class="spinner"></div></div>';
var params = '?page=' + page + '&per_page=20';
if (search) params += '&search=' + encodeURIComponent(search);
api('/api/captura/parts/without-aftermarket' + params).then(function (res) {
var data = res.data || [];
if (data.length === 0) {
list.innerHTML = '<div class="empty-state"><div class="es-icon">&#9989;</div><div class="es-text">No hay piezas sin intercambios</div></div>';
document.getElementById('aftermarket-pagination').innerHTML = '';
return;
}
list.innerHTML = data.map(function (p) {
return '<div class="part-detail-card" data-part-id="' + p.id_part + '">' +
'<div class="pdc-header">' +
'<div><span class="pdc-oem">' + esc(p.oem_part_number) + '</span>' +
' <span class="pdc-name">' + esc(p.name_part) + '</span></div>' +
'<span class="pdc-group">' + esc(p.category) + ' &rsaquo; ' + esc(p.group_name) + '</span></div>' +
'<div class="aftermarket-existing" data-af-list="' + p.id_part + '"></div>' +
'<div class="aftermarket-form" data-af-form="' + p.id_part + '">' +
'<div class="af-field"><label>Fabricante</label>' +
'<select class="af-manufacturer">' + manufacturerOptions() + '</select></div>' +
'<div class="af-field"><label># Aftermarket</label>' +
'<input class="af-partnum" placeholder="Ej: MK1234"></div>' +
'<div class="af-field"><label>Nombre</label>' +
'<input class="af-name" placeholder="Nombre pieza"></div>' +
'<div class="af-field"><label>Calidad</label>' +
'<select class="af-quality">' +
'<option value="standard">Standard</option>' +
'<option value="economy">Economy</option>' +
'<option value="oem">OEM</option>' +
'<option value="premium">Premium</option></select></div>' +
'<div class="af-field"><label>Precio USD</label>' +
'<input class="af-price" type="number" step="0.01" placeholder="0.00" style="width:80px"></div>' +
'<div class="af-field"><label>Garantia (meses)</label>' +
'<input class="af-warranty" type="number" placeholder="12" style="width:70px"></div>' +
'<button class="btn btn-primary af-save-btn" style="padding:0.4rem 1rem">+ Agregar</button>' +
'</div></div>';
}).join('');
// Load existing aftermarket for each part
data.forEach(function (p) {
loadPartAftermarket(p.id_part);
});
// Save handlers
list.querySelectorAll('.af-save-btn').forEach(function (btn) {
btn.addEventListener('click', function () {
var card = btn.closest('.part-detail-card');
saveAftermarket(card);
});
});
renderPagination('aftermarket-pagination', res.pagination, function (p) {
loadPartsWithoutAftermarket(p);
});
});
}
function manufacturerOptions() {
return manufacturers.map(function (m) {
return '<option value="' + m.id + '">' + esc(m.name) + '</option>';
}).join('');
}
function loadPartAftermarket(partId) {
api('/api/captura/parts/' + partId + '/aftermarket').then(function (items) {
var container = document.querySelector('[data-af-list="' + partId + '"]');
if (items.length === 0) {
container.innerHTML = '<p style="font-size:0.8rem;color:var(--text-secondary);margin-bottom:0.5rem">Sin intercambios registrados</p>';
return;
}
var html = '<table class="aftermarket-table"><thead><tr>' +
'<th>Fabricante</th><th># Parte</th><th>Nombre</th><th>Calidad</th><th>Precio</th><th>Garantia</th></tr></thead><tbody>';
items.forEach(function (a) {
html += '<tr><td>' + esc(a.manufacturer) + '</td><td>' + esc(a.part_number) +
'</td><td>' + esc(a.name || '') + '</td><td>' + esc(a.quality || '') +
'</td><td>' + (a.price_usd ? '$' + a.price_usd : '') +
'</td><td>' + (a.warranty_months || '') + '</td></tr>';
});
html += '</tbody></table>';
container.innerHTML = html;
});
}
function saveAftermarket(card) {
var partId = card.getAttribute('data-part-id');
var manufacturer = card.querySelector('.af-manufacturer').value;
var partNumber = card.querySelector('.af-partnum').value.trim();
var name = card.querySelector('.af-name').value.trim();
var quality = card.querySelector('.af-quality').value;
var price = card.querySelector('.af-price').value;
var warranty = card.querySelector('.af-warranty').value;
if (!partNumber) {
toast('Ingresa el numero de parte aftermarket', 'error');
return;
}
api('/api/admin/aftermarket', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
oem_part_id: parseInt(partId),
manufacturer_id: parseInt(manufacturer),
part_number: partNumber,
name: name,
quality_tier: quality,
price_usd: price ? parseFloat(price) : null,
warranty_months: warranty ? parseInt(warranty) : null
})
}).then(function () {
toast('Intercambio guardado: ' + partNumber);
// Clear form
card.querySelector('.af-partnum').value = '';
card.querySelector('.af-name').value = '';
card.querySelector('.af-price').value = '';
card.querySelector('.af-warranty').value = '';
// Reload aftermarket list
loadPartAftermarket(parseInt(partId));
}).catch(function (err) {
toast(err.message, 'error');
});
}
// ================================================================
// SECTION 3: Images
// ================================================================
var imagePage = 1;
function loadPartsWithoutImage(page) {
page = page || 1;
imagePage = page;
var search = document.getElementById('image-search').value;
var list = document.getElementById('image-list');
list.innerHTML = '<div class="loading"><div class="spinner"></div></div>';
var params = '?page=' + page + '&per_page=20';
if (search) params += '&search=' + encodeURIComponent(search);
api('/api/captura/parts/without-image' + params).then(function (res) {
var data = res.data || [];
if (data.length === 0) {
list.innerHTML = '<div class="empty-state"><div class="es-icon">&#128247;</div><div class="es-text">No hay piezas sin imagen</div></div>';
document.getElementById('image-pagination').innerHTML = '';
return;
}
list.innerHTML = data.map(function (p) {
return '<div class="image-card" data-part-id="' + p.id_part + '">' +
'<div class="ic-preview"><span>Sin imagen</span></div>' +
'<div class="ic-info">' +
'<div class="ic-oem">' + esc(p.oem_part_number) + '</div>' +
'<div class="ic-name">' + esc(p.name_part) + ' &middot; ' + esc(p.group_name) + '</div>' +
'<div class="ic-upload">' +
'<input type="file" accept="image/jpeg,image/png,image/webp" class="ic-file-input">' +
'<button class="btn btn-primary ic-upload-btn" style="padding:0.3rem 0.8rem;font-size:0.8rem" disabled>Subir</button>' +
'</div></div></div>';
}).join('');
// File input change → enable upload button and show preview
list.querySelectorAll('.ic-file-input').forEach(function (input) {
input.addEventListener('change', function () {
var card = input.closest('.image-card');
var btn = card.querySelector('.ic-upload-btn');
var preview = card.querySelector('.ic-preview');
if (input.files && input.files[0]) {
btn.disabled = false;
// Show preview
var reader = new FileReader();
reader.onload = function (e) {
preview.innerHTML = '<img src="' + e.target.result + '">';
};
reader.readAsDataURL(input.files[0]);
}
});
});
// Upload button
list.querySelectorAll('.ic-upload-btn').forEach(function (btn) {
btn.addEventListener('click', function () {
var card = btn.closest('.image-card');
uploadImage(card);
});
});
renderPagination('image-pagination', res.pagination, function (p) {
loadPartsWithoutImage(p);
});
});
}
function uploadImage(card) {
var partId = card.getAttribute('data-part-id');
var fileInput = card.querySelector('.ic-file-input');
var btn = card.querySelector('.ic-upload-btn');
if (!fileInput.files || !fileInput.files[0]) return;
btn.disabled = true;
btn.textContent = 'Subiendo...';
var formData = new FormData();
formData.append('image', fileInput.files[0]);
fetch(API + '/api/captura/parts/' + partId + '/image', {
method: 'POST',
body: formData
}).then(function (r) { return r.json(); })
.then(function (res) {
if (res.error) throw new Error(res.error);
toast('Imagen subida correctamente');
// Remove card from list
card.style.opacity = '0.3';
setTimeout(function () { card.remove(); }, 500);
}).catch(function (err) {
toast(err.message, 'error');
btn.disabled = false;
btn.textContent = 'Subir';
});
}
// ================================================================
// Init
// ================================================================
function init() {
loadBrands();
loadVehicles();
// Pre-load manufacturers for Section 2
api('/api/captura/manufacturers').then(function (data) {
manufacturers = data;
});
}
// Make functions globally accessible for inline onclick handlers
window.loadPartsWithoutAftermarket = loadPartsWithoutAftermarket;
window.loadPartsWithoutImage = loadPartsWithoutImage;
init();
})();

282
dashboard/cuentas.css Normal file
View File

@@ -0,0 +1,282 @@
/* ============================================================
cuentas.css -- Accounts receivable styles
============================================================ */
.cuentas-container {
max-width: 1200px;
margin: 0 auto;
padding: 5rem 2rem 2rem;
}
/* --- Customer list --- */
.cuentas-search {
display: flex;
gap: 0.8rem;
margin-bottom: 1rem;
}
.cuentas-search input {
flex: 1;
max-width: 400px;
padding: 0.5rem 0.8rem;
background: var(--bg-secondary);
border: 1px solid var(--border);
border-radius: 8px;
color: var(--text-primary);
font-size: 0.9rem;
}
.cuentas-search input:focus {
outline: none;
border-color: var(--accent);
}
.customer-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(300px, 1fr));
gap: 0.8rem;
margin-bottom: 1.5rem;
}
.customer-card-item {
background: var(--bg-card);
border: 1px solid var(--border);
border-radius: 10px;
padding: 1rem;
cursor: pointer;
transition: all 0.2s;
}
.customer-card-item:hover {
border-color: var(--accent);
background: var(--bg-hover);
}
.cci-name {
font-weight: 700;
font-size: 1rem;
margin-bottom: 0.2rem;
}
.cci-rfc {
font-family: monospace;
font-size: 0.8rem;
color: var(--text-secondary);
}
.cci-balance-row {
display: flex;
justify-content: space-between;
align-items: center;
margin-top: 0.5rem;
}
.cci-balance {
font-size: 1.1rem;
font-weight: 700;
}
.cci-balance.positive { color: var(--danger); }
.cci-balance.zero { color: var(--success); }
.cci-limit {
font-size: 0.75rem;
color: var(--text-secondary);
}
/* --- Customer detail view --- */
.detail-view {
display: none;
}
.detail-header {
background: linear-gradient(135deg, var(--bg-card) 0%, var(--bg-hover) 100%);
border: 1px solid var(--accent);
border-radius: 12px;
padding: 1.2rem 1.5rem;
margin-bottom: 1.5rem;
display: flex;
justify-content: space-between;
align-items: center;
flex-wrap: wrap;
gap: 1rem;
}
.dh-info {
display: flex;
gap: 2rem;
flex-wrap: wrap;
}
.dh-field .dh-label {
font-size: 0.7rem;
color: var(--text-secondary);
text-transform: uppercase;
}
.dh-field .dh-value {
font-size: 1rem;
font-weight: 600;
}
.dh-field .dh-value.accent { color: var(--accent); }
.dh-field .dh-value.danger { color: var(--danger); }
.dh-field .dh-value.success { color: var(--success); }
/* --- Two-column layout for invoices/payments --- */
.detail-columns {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 1.5rem;
}
@media (max-width: 768px) {
.detail-columns { grid-template-columns: 1fr; }
}
.detail-card {
background: var(--bg-card);
border: 1px solid var(--border);
border-radius: 10px;
overflow: hidden;
}
.detail-card h3 {
padding: 0.8rem 1rem;
border-bottom: 1px solid var(--border);
font-size: 0.9rem;
color: var(--text-secondary);
text-transform: uppercase;
letter-spacing: 0.05em;
}
.detail-table {
width: 100%;
border-collapse: collapse;
font-size: 0.85rem;
}
.detail-table th {
text-align: left;
padding: 0.5rem 0.6rem;
border-bottom: 1px solid var(--border);
color: var(--text-secondary);
font-size: 0.75rem;
text-transform: uppercase;
}
.detail-table td {
padding: 0.4rem 0.6rem;
border-bottom: 1px solid rgba(42, 42, 58, 0.5);
}
.status-badge {
display: inline-block;
padding: 0.15rem 0.5rem;
border-radius: 4px;
font-size: 0.75rem;
font-weight: 600;
}
.status-badge.pending { background: rgba(245, 158, 11, 0.15); color: var(--warning); }
.status-badge.partial { background: rgba(59, 130, 246, 0.15); color: var(--info); }
.status-badge.paid { background: rgba(34, 197, 94, 0.15); color: var(--success); }
.status-badge.cancelled { background: rgba(255, 68, 68, 0.15); color: var(--danger); }
/* --- Payment form --- */
.payment-form {
padding: 1rem;
border-top: 1px solid var(--border);
}
.payment-form h4 {
font-size: 0.85rem;
color: var(--accent);
margin-bottom: 0.8rem;
}
.pf-row {
display: flex;
gap: 0.5rem;
margin-bottom: 0.5rem;
flex-wrap: wrap;
}
.pf-field {
display: flex;
flex-direction: column;
gap: 0.2rem;
}
.pf-field label {
font-size: 0.7rem;
color: var(--text-secondary);
text-transform: uppercase;
}
.pf-field input,
.pf-field select {
padding: 0.4rem 0.6rem;
background: var(--bg-primary);
border: 1px solid var(--border);
border-radius: 6px;
color: var(--text-primary);
font-size: 0.85rem;
}
.pf-field input:focus,
.pf-field select:focus {
outline: none;
border-color: var(--accent);
}
/* --- Toast --- */
.toast {
position: fixed;
bottom: 2rem;
right: 2rem;
padding: 0.8rem 1.5rem;
border-radius: 10px;
color: #fff;
font-weight: 600;
font-size: 0.9rem;
z-index: 9999;
animation: toastIn 0.3s ease;
box-shadow: 0 4px 20px rgba(0,0,0,0.3);
}
.toast.success { background: var(--success); }
.toast.error { background: var(--danger); }
@keyframes toastIn {
from { transform: translateY(20px); opacity: 0; }
to { transform: translateY(0); opacity: 1; }
}
/* --- Pagination --- */
.cuentas-pagination {
display: flex;
justify-content: center;
align-items: center;
gap: 0.5rem;
margin-top: 1rem;
}
.cuentas-pagination button {
padding: 0.4rem 0.8rem;
background: var(--bg-card);
border: 1px solid var(--border);
border-radius: 6px;
color: var(--text-primary);
cursor: pointer;
font-size: 0.85rem;
}
.cuentas-pagination button:hover { border-color: var(--accent); }
.cuentas-pagination button:disabled { opacity: 0.4; cursor: not-allowed; }
.cuentas-pagination .page-info { font-size: 0.8rem; color: var(--text-secondary); }
.empty-state {
text-align: center;
padding: 2rem;
color: var(--text-secondary);
font-size: 0.9rem;
}

102
dashboard/cuentas.html Normal file
View File

@@ -0,0 +1,102 @@
<!DOCTYPE html>
<html lang="es">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Cuentas por Cobrar — NEXUS AUTOPARTS</title>
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700&family=Orbitron:wght@700&display=swap" rel="stylesheet">
<link rel="stylesheet" href="/shared.css">
<link rel="stylesheet" href="/cuentas.css">
</head>
<body>
<div id="shared-nav"></div>
<div class="cuentas-container">
<!-- Customer List View -->
<div id="list-view">
<div class="cuentas-search">
<input id="customer-search" type="text" placeholder="Buscar cliente por nombre o RFC...">
</div>
<div id="customer-grid" class="customer-grid"></div>
<div id="customer-pagination" class="cuentas-pagination"></div>
</div>
<!-- Customer Detail View -->
<div id="detail-view" class="detail-view">
<div class="detail-header">
<div class="dh-info">
<div class="dh-field"><div class="dh-label">Cliente</div><div class="dh-value accent" id="dh-name"></div></div>
<div class="dh-field"><div class="dh-label">RFC</div><div class="dh-value" id="dh-rfc"></div></div>
<div class="dh-field"><div class="dh-label">Saldo</div><div class="dh-value" id="dh-balance"></div></div>
<div class="dh-field"><div class="dh-label">Limite</div><div class="dh-value" id="dh-limit"></div></div>
<div class="dh-field"><div class="dh-label">Plazo</div><div class="dh-value" id="dh-terms"></div></div>
</div>
<button class="btn btn-secondary" id="btn-back-list">&laquo; Volver</button>
</div>
<div class="detail-columns">
<!-- Invoices -->
<div class="detail-card">
<h3>Facturas</h3>
<table class="detail-table">
<thead>
<tr><th>Folio</th><th>Fecha</th><th>Total</th><th>Pagado</th><th>Estado</th></tr>
</thead>
<tbody id="invoice-list"></tbody>
</table>
</div>
<!-- Payments + Form -->
<div class="detail-card">
<h3>Pagos</h3>
<table class="detail-table">
<thead>
<tr><th>Fecha</th><th>Monto</th><th>Metodo</th><th>Ref</th><th>Factura</th></tr>
</thead>
<tbody id="payment-list"></tbody>
</table>
<div class="payment-form">
<h4>Registrar Pago</h4>
<div class="pf-row">
<div class="pf-field">
<label>Monto *</label>
<input id="pay-amount" type="number" step="0.01" min="0.01" placeholder="0.00" style="width:120px">
</div>
<div class="pf-field">
<label>Metodo</label>
<select id="pay-method">
<option value="efectivo">Efectivo</option>
<option value="transferencia">Transferencia</option>
<option value="cheque">Cheque</option>
<option value="tarjeta">Tarjeta</option>
</select>
</div>
<div class="pf-field">
<label>Referencia</label>
<input id="pay-reference" placeholder="# ref" style="width:120px">
</div>
<div class="pf-field">
<label>Aplicar a factura</label>
<select id="pay-invoice">
<option value="">Abono general</option>
</select>
</div>
</div>
<div class="pf-row">
<div class="pf-field" style="flex:1">
<label>Notas</label>
<input id="pay-notes" placeholder="Notas del pago" style="width:100%">
</div>
<button class="btn btn-primary" id="btn-pay" style="align-self:flex-end;padding:0.4rem 1.2rem">Registrar Pago</button>
</div>
</div>
</div>
</div>
</div>
</div>
<script src="/nav.js"></script>
<script src="/cuentas.js"></script>
</body>
</html>

222
dashboard/cuentas.js Normal file
View File

@@ -0,0 +1,222 @@
/**
* cuentas.js — Accounts receivable logic for Nexus Autoparts
*/
(function () {
'use strict';
var API = '';
var currentCustomerId = null;
var customerPage = 1;
// ================================================================
// Utility
// ================================================================
function toast(msg, type) {
var el = document.createElement('div');
el.className = 'toast ' + (type || 'success');
el.textContent = msg;
document.body.appendChild(el);
setTimeout(function () { el.remove(); }, 3000);
}
function api(path, opts) {
opts = opts || {};
return fetch(API + path, opts).then(function (r) {
if (!r.ok) return r.json().then(function (d) { throw new Error(d.error || 'Error'); });
return r.json();
});
}
function esc(s) {
if (!s) return '';
var d = document.createElement('div');
d.textContent = s;
return d.innerHTML;
}
function fmt(n) {
return '$' + (parseFloat(n) || 0).toFixed(2).replace(/\B(?=(\d{3})+(?!\d))/g, ',');
}
function fmtDate(d) {
if (!d) return '';
var dt = new Date(d);
return dt.toLocaleDateString('es-MX', { day: '2-digit', month: 'short', year: 'numeric' });
}
// ================================================================
// Customer List
// ================================================================
var searchTimer = null;
document.getElementById('customer-search').addEventListener('input', function () {
clearTimeout(searchTimer);
searchTimer = setTimeout(function () {
customerPage = 1;
loadCustomers();
}, 400);
});
function loadCustomers() {
var search = document.getElementById('customer-search').value;
var grid = document.getElementById('customer-grid');
grid.innerHTML = '<div class="empty-state">Cargando...</div>';
var params = '?page=' + customerPage + '&per_page=30';
if (search) params += '&search=' + encodeURIComponent(search);
api('/api/pos/customers' + params).then(function (res) {
var data = res.data || [];
if (data.length === 0) {
grid.innerHTML = '<div class="empty-state">No se encontraron clientes</div>';
document.getElementById('customer-pagination').innerHTML = '';
return;
}
grid.innerHTML = data.map(function (c) {
return '<div class="customer-card-item" data-id="' + c.id_customer + '">' +
'<div class="cci-name">' + esc(c.name) + '</div>' +
'<div class="cci-rfc">' + esc(c.rfc || 'Sin RFC') + '</div>' +
'<div class="cci-balance-row">' +
'<span class="cci-balance ' + (c.balance > 0 ? 'positive' : 'zero') + '">' + fmt(c.balance) + '</span>' +
'<span class="cci-limit">Limite: ' + fmt(c.credit_limit) + '</span></div></div>';
}).join('');
grid.querySelectorAll('.customer-card-item').forEach(function (card) {
card.addEventListener('click', function () {
showCustomerDetail(parseInt(card.getAttribute('data-id')));
});
});
// Pagination
var pag = res.pagination;
var pagEl = document.getElementById('customer-pagination');
if (pag.total_pages <= 1) { pagEl.innerHTML = ''; return; }
pagEl.innerHTML = '<button ' + (pag.page <= 1 ? 'disabled' : '') + ' data-p="' + (pag.page - 1) + '">&laquo;</button>' +
'<span class="page-info">Pag ' + pag.page + '/' + pag.total_pages + '</span>' +
'<button ' + (pag.page >= pag.total_pages ? 'disabled' : '') + ' data-p="' + (pag.page + 1) + '">&raquo;</button>';
pagEl.querySelectorAll('button').forEach(function (btn) {
btn.addEventListener('click', function () {
customerPage = parseInt(btn.getAttribute('data-p'));
loadCustomers();
});
});
}).catch(function (err) {
console.error('Error loading customers:', err);
grid.innerHTML = '<div class="empty-state">Error al cargar clientes</div>';
});
}
// ================================================================
// Customer Detail
// ================================================================
function showCustomerDetail(customerId) {
currentCustomerId = customerId;
document.getElementById('list-view').style.display = 'none';
document.getElementById('detail-view').style.display = 'block';
api('/api/pos/customers/' + customerId + '/statement').then(function (res) {
var c = res.customer;
document.getElementById('dh-name').textContent = c.name;
document.getElementById('dh-rfc').textContent = c.rfc || 'Sin RFC';
var balEl = document.getElementById('dh-balance');
balEl.textContent = fmt(c.balance);
balEl.className = 'dh-value ' + (c.balance > 0 ? 'danger' : 'success');
document.getElementById('dh-limit').textContent = fmt(c.credit_limit);
document.getElementById('dh-terms').textContent = c.payment_terms + ' dias';
// Invoices
var invBody = document.getElementById('invoice-list');
if (res.invoices.length === 0) {
invBody.innerHTML = '<tr><td colspan="5" class="empty-state">Sin facturas</td></tr>';
} else {
invBody.innerHTML = res.invoices.map(function (i) {
return '<tr>' +
'<td style="font-family:monospace;font-weight:600">' + esc(i.folio) + '</td>' +
'<td>' + fmtDate(i.date_issued) + '</td>' +
'<td>' + fmt(i.total) + '</td>' +
'<td>' + fmt(i.amount_paid) + '</td>' +
'<td><span class="status-badge ' + i.status + '">' + i.status + '</span></td></tr>';
}).join('');
}
// Payments
var payBody = document.getElementById('payment-list');
if (res.payments.length === 0) {
payBody.innerHTML = '<tr><td colspan="5" class="empty-state">Sin pagos</td></tr>';
} else {
payBody.innerHTML = res.payments.map(function (p) {
return '<tr>' +
'<td>' + fmtDate(p.date_payment) + '</td>' +
'<td style="font-weight:600;color:var(--success)">' + fmt(p.amount) + '</td>' +
'<td>' + esc(p.payment_method) + '</td>' +
'<td>' + esc(p.reference || '') + '</td>' +
'<td>' + esc(p.invoice_folio || 'General') + '</td></tr>';
}).join('');
}
// Populate invoice dropdown for payment form
var invSelect = document.getElementById('pay-invoice');
invSelect.innerHTML = '<option value="">Abono general</option>';
res.invoices.filter(function (i) { return i.status !== 'paid' && i.status !== 'cancelled'; })
.forEach(function (i) {
invSelect.innerHTML += '<option value="' + i.id_invoice + '">' +
i.folio + ' — ' + fmt(i.total - i.amount_paid) + ' pendiente</option>';
});
});
}
document.getElementById('btn-back-list').addEventListener('click', function () {
document.getElementById('detail-view').style.display = 'none';
document.getElementById('list-view').style.display = 'block';
currentCustomerId = null;
loadCustomers();
});
// ================================================================
// Register Payment
// ================================================================
document.getElementById('btn-pay').addEventListener('click', function () {
var amount = parseFloat(document.getElementById('pay-amount').value);
if (!amount || amount <= 0) {
toast('Ingresa un monto valido', 'error');
return;
}
var invoiceId = document.getElementById('pay-invoice').value;
api('/api/pos/payments', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
customer_id: currentCustomerId,
amount: amount,
payment_method: document.getElementById('pay-method').value,
reference: document.getElementById('pay-reference').value.trim() || null,
invoice_id: invoiceId ? parseInt(invoiceId) : null,
notes: document.getElementById('pay-notes').value.trim() || null
})
}).then(function () {
toast('Pago de ' + fmt(amount) + ' registrado');
// Clear form
document.getElementById('pay-amount').value = '';
document.getElementById('pay-reference').value = '';
document.getElementById('pay-notes').value = '';
// Refresh detail
showCustomerDetail(currentCustomerId);
}).catch(function (err) {
toast(err.message, 'error');
});
});
// ================================================================
// Init
// ================================================================
loadCustomers();
})();

418
dashboard/pos.css Normal file
View File

@@ -0,0 +1,418 @@
/* ============================================================
pos.css -- Point of Sale styles
============================================================ */
.pos-container {
max-width: 1400px;
margin: 0 auto;
padding: 5rem 2rem 2rem;
}
/* --- Layout: 2 columns --- */
.pos-layout {
display: grid;
grid-template-columns: 1fr 360px;
gap: 1.5rem;
align-items: start;
}
/* --- Left: Search + Cart --- */
.pos-main {
display: flex;
flex-direction: column;
gap: 1rem;
}
/* --- Customer bar --- */
.customer-bar {
background: var(--bg-card);
border: 1px solid var(--border);
border-radius: 10px;
padding: 1rem;
display: flex;
gap: 1rem;
align-items: center;
}
.customer-bar .cb-search {
flex: 1;
padding: 0.5rem 0.8rem;
background: var(--bg-primary);
border: 1px solid var(--border);
border-radius: 8px;
color: var(--text-primary);
font-size: 0.9rem;
}
.customer-bar .cb-search:focus {
outline: none;
border-color: var(--accent);
}
.customer-bar .cb-selected {
display: flex;
align-items: center;
gap: 0.8rem;
flex: 1;
}
.customer-bar .cb-name {
font-weight: 700;
font-size: 1rem;
}
.customer-bar .cb-rfc {
font-size: 0.8rem;
color: var(--text-secondary);
font-family: monospace;
}
.customer-bar .cb-balance {
font-size: 0.85rem;
padding: 0.2rem 0.6rem;
border-radius: 6px;
}
.cb-balance.positive { background: rgba(255, 68, 68, 0.15); color: var(--danger); }
.cb-balance.zero { background: rgba(34, 197, 94, 0.15); color: var(--success); }
/* --- Customer dropdown --- */
.customer-dropdown {
position: absolute;
top: 100%;
left: 0;
right: 0;
background: var(--bg-card);
border: 1px solid var(--border);
border-radius: 0 0 8px 8px;
max-height: 250px;
overflow-y: auto;
z-index: 100;
box-shadow: 0 8px 30px rgba(0,0,0,0.4);
}
.customer-dropdown-item {
padding: 0.6rem 1rem;
cursor: pointer;
display: flex;
justify-content: space-between;
align-items: center;
border-bottom: 1px solid var(--border);
}
.customer-dropdown-item:hover {
background: var(--bg-hover);
}
.customer-dropdown-item .cdi-name { font-weight: 600; }
.customer-dropdown-item .cdi-rfc { font-size: 0.8rem; color: var(--text-secondary); }
/* --- Part search --- */
.part-search-wrap {
position: relative;
}
.part-search {
width: 100%;
padding: 0.7rem 1rem;
background: var(--bg-card);
border: 1px solid var(--border);
border-radius: 10px;
color: var(--text-primary);
font-size: 1rem;
}
.part-search:focus {
outline: none;
border-color: var(--accent);
}
.part-results {
position: absolute;
top: 100%;
left: 0;
right: 0;
background: var(--bg-card);
border: 1px solid var(--border);
border-radius: 0 0 10px 10px;
max-height: 300px;
overflow-y: auto;
z-index: 100;
box-shadow: 0 8px 30px rgba(0,0,0,0.4);
display: none;
}
.part-result-item {
padding: 0.6rem 1rem;
cursor: pointer;
display: flex;
justify-content: space-between;
align-items: center;
border-bottom: 1px solid var(--border);
}
.part-result-item:hover,
.part-result-item.part-result-active {
background: var(--bg-hover);
border-left: 3px solid var(--accent);
}
.part-result-item .pri-number {
font-family: monospace;
font-weight: 600;
color: var(--accent);
}
.part-result-item .pri-name {
font-size: 0.85rem;
color: var(--text-secondary);
margin-left: 0.5rem;
}
.part-result-item .pri-type {
font-size: 0.7rem;
padding: 0.15rem 0.4rem;
border-radius: 4px;
text-transform: uppercase;
}
.pri-type.oem { background: rgba(59, 130, 246, 0.15); color: var(--info); }
.pri-type.aftermarket { background: rgba(245, 158, 11, 0.15); color: var(--warning); }
/* --- Cart table --- */
.cart-card {
background: var(--bg-card);
border: 1px solid var(--border);
border-radius: 10px;
overflow: hidden;
}
.cart-card h3 {
padding: 0.8rem 1rem;
border-bottom: 1px solid var(--border);
font-size: 0.9rem;
color: var(--text-secondary);
}
.cart-table {
width: 100%;
border-collapse: collapse;
font-size: 0.85rem;
}
.cart-table th {
text-align: left;
padding: 0.5rem 0.6rem;
border-bottom: 1px solid var(--border);
color: var(--text-secondary);
font-size: 0.75rem;
text-transform: uppercase;
}
.cart-table td {
padding: 0.5rem 0.6rem;
border-bottom: 1px solid rgba(42, 42, 58, 0.5);
vertical-align: middle;
}
.cart-table input {
background: var(--bg-primary);
border: 1px solid var(--border);
border-radius: 4px;
color: var(--text-primary);
padding: 0.3rem 0.4rem;
font-size: 0.85rem;
width: 70px;
text-align: right;
}
.cart-table input:focus {
outline: none;
border-color: var(--accent);
}
.cart-table .cart-desc { max-width: 250px; }
.cart-table .cart-qty { width: 45px; text-align: center; }
.cart-table .cart-cost { width: 80px; }
.cart-table .cart-margin { width: 55px; }
.cart-table .cart-price { width: 80px; }
.cart-table .cart-remove {
background: none;
border: none;
color: var(--danger);
cursor: pointer;
font-size: 1rem;
padding: 0.2rem;
}
.cart-empty {
text-align: center;
padding: 2rem;
color: var(--text-secondary);
font-size: 0.9rem;
}
/* --- Right sidebar: Invoice summary --- */
.pos-sidebar {
position: sticky;
top: 5rem;
}
.invoice-summary {
background: var(--bg-card);
border: 1px solid var(--border);
border-radius: 10px;
padding: 1.2rem;
}
.invoice-summary h3 {
font-size: 0.9rem;
color: var(--text-secondary);
margin-bottom: 1rem;
text-transform: uppercase;
letter-spacing: 0.05em;
}
.summary-row {
display: flex;
justify-content: space-between;
padding: 0.4rem 0;
font-size: 0.9rem;
}
.summary-row.total {
border-top: 2px solid var(--accent);
margin-top: 0.5rem;
padding-top: 0.8rem;
font-size: 1.2rem;
font-weight: 700;
}
.summary-row .sr-label { color: var(--text-secondary); }
.summary-row .sr-value { font-weight: 600; }
.summary-row.total .sr-value { color: var(--accent); }
.btn-facturar {
width: 100%;
margin-top: 1.2rem;
padding: 0.9rem;
font-size: 1rem;
background: linear-gradient(135deg, var(--accent), #ff4500);
border: none;
border-radius: 10px;
color: #fff;
font-weight: 700;
cursor: pointer;
transition: all 0.3s;
}
.btn-facturar:hover {
transform: translateY(-2px);
box-shadow: 0 6px 25px var(--accent-glow);
}
.btn-facturar:disabled {
opacity: 0.4;
cursor: not-allowed;
transform: none;
box-shadow: none;
}
.invoice-notes {
width: 100%;
margin-top: 0.8rem;
padding: 0.5rem;
background: var(--bg-primary);
border: 1px solid var(--border);
border-radius: 6px;
color: var(--text-primary);
font-size: 0.85rem;
resize: vertical;
min-height: 60px;
}
.invoice-notes:focus {
outline: none;
border-color: var(--accent);
}
/* --- New customer modal --- */
.modal-overlay {
position: fixed;
top: 0; left: 0; right: 0; bottom: 0;
background: rgba(0,0,0,0.6);
z-index: 2000;
display: flex;
align-items: center;
justify-content: center;
}
.modal-content {
background: var(--bg-card);
border: 1px solid var(--border);
border-radius: 12px;
padding: 1.5rem;
width: 450px;
max-width: 95vw;
}
.modal-content h3 {
margin-bottom: 1rem;
font-size: 1.1rem;
}
.modal-field {
margin-bottom: 0.8rem;
}
.modal-field label {
display: block;
font-size: 0.75rem;
color: var(--text-secondary);
text-transform: uppercase;
margin-bottom: 0.2rem;
}
.modal-field input {
width: 100%;
padding: 0.5rem;
background: var(--bg-primary);
border: 1px solid var(--border);
border-radius: 6px;
color: var(--text-primary);
font-size: 0.9rem;
}
.modal-field input:focus {
outline: none;
border-color: var(--accent);
}
.modal-actions {
display: flex;
gap: 0.5rem;
justify-content: flex-end;
margin-top: 1rem;
}
/* --- Toast (reuse from captura) --- */
.toast {
position: fixed;
bottom: 2rem;
right: 2rem;
padding: 0.8rem 1.5rem;
border-radius: 10px;
color: #fff;
font-weight: 600;
font-size: 0.9rem;
z-index: 9999;
animation: toastIn 0.3s ease;
box-shadow: 0 4px 20px rgba(0,0,0,0.3);
}
.toast.success { background: var(--success); }
.toast.error { background: var(--danger); }
@keyframes toastIn {
from { transform: translateY(20px); opacity: 0; }
to { transform: translateY(0); opacity: 1; }
}

113
dashboard/pos.html Normal file
View File

@@ -0,0 +1,113 @@
<!DOCTYPE html>
<html lang="es">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Punto de Venta — NEXUS AUTOPARTS</title>
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700&family=Orbitron:wght@700&display=swap" rel="stylesheet">
<link rel="stylesheet" href="/shared.css">
<link rel="stylesheet" href="/pos.css">
</head>
<body>
<div id="shared-nav"></div>
<div class="pos-container">
<div class="pos-layout">
<!-- LEFT: Main area -->
<div class="pos-main">
<!-- Customer selection -->
<div class="customer-bar" style="position:relative">
<div id="customer-select" style="flex:1;position:relative">
<input class="cb-search" id="customer-search" type="text" placeholder="Buscar cliente por nombre o RFC...">
<div id="customer-dropdown" class="customer-dropdown" style="display:none"></div>
</div>
<div id="customer-info" class="cb-selected" style="display:none">
<span class="cb-name" id="sel-customer-name"></span>
<span class="cb-rfc" id="sel-customer-rfc"></span>
<span class="cb-balance" id="sel-customer-balance"></span>
<button class="btn btn-secondary" style="padding:0.3rem 0.6rem;font-size:0.8rem" id="btn-change-customer">Cambiar</button>
</div>
<button class="btn btn-secondary" id="btn-new-customer" style="padding:0.5rem 0.8rem;font-size:0.85rem">+ Nuevo</button>
</div>
<!-- Part search -->
<div class="part-search-wrap">
<input class="part-search" id="part-search" type="text" placeholder="Buscar parte por # OEM, # aftermarket o nombre...">
<div id="part-results" class="part-results"></div>
</div>
<!-- Cart -->
<div class="cart-card">
<h3>Carrito</h3>
<table class="cart-table">
<thead>
<tr>
<th>Descripcion</th>
<th>Tipo</th>
<th>Cant</th>
<th>Costo</th>
<th>Margen%</th>
<th>Precio</th>
<th>Total</th>
<th></th>
</tr>
</thead>
<tbody id="cart-body">
<tr><td colspan="8" class="cart-empty">Busca y agrega partes al carrito</td></tr>
</tbody>
</table>
</div>
</div>
<!-- RIGHT: Summary -->
<div class="pos-sidebar">
<div class="invoice-summary">
<h3>Resumen de Factura</h3>
<div class="summary-row">
<span class="sr-label">Articulos</span>
<span class="sr-value" id="sum-items">0</span>
</div>
<div class="summary-row">
<span class="sr-label">Subtotal</span>
<span class="sr-value" id="sum-subtotal">$0.00</span>
</div>
<div class="summary-row">
<span class="sr-label">IVA (16%)</span>
<span class="sr-value" id="sum-tax">$0.00</span>
</div>
<div class="summary-row total">
<span class="sr-label">Total</span>
<span class="sr-value" id="sum-total">$0.00</span>
</div>
<textarea class="invoice-notes" id="invoice-notes" placeholder="Notas de la factura (opcional)"></textarea>
<button class="btn-facturar" id="btn-facturar" disabled>Facturar</button>
</div>
</div>
</div>
</div>
<!-- New Customer Modal -->
<div id="modal-new-customer" class="modal-overlay" style="display:none">
<div class="modal-content">
<h3>Nuevo Cliente</h3>
<div class="modal-field"><label>Nombre *</label><input id="nc-name" required></div>
<div class="modal-field"><label>RFC</label><input id="nc-rfc" maxlength="13" placeholder="XAXX010101000"></div>
<div class="modal-field"><label>Razon Social</label><input id="nc-business"></div>
<div class="modal-field"><label>Telefono</label><input id="nc-phone"></div>
<div class="modal-field"><label>Email</label><input id="nc-email" type="email"></div>
<div class="modal-field"><label>Direccion</label><input id="nc-address"></div>
<div style="display:flex;gap:1rem">
<div class="modal-field" style="flex:1"><label>Limite de Credito</label><input id="nc-credit" type="number" value="0"></div>
<div class="modal-field" style="flex:1"><label>Dias de Credito</label><input id="nc-terms" type="number" value="30"></div>
</div>
<div class="modal-actions">
<button class="btn btn-secondary" id="nc-cancel">Cancelar</button>
<button class="btn btn-primary" id="nc-save">Guardar</button>
</div>
</div>
</div>
<script src="/nav.js"></script>
<script src="/pos.js"></script>
</body>
</html>

413
dashboard/pos.js Normal file
View File

@@ -0,0 +1,413 @@
/**
* pos.js — Point of Sale logic for Nexus Autoparts
*/
(function () {
'use strict';
var API = '';
var selectedCustomer = null;
var cart = [];
var defaultMargin = 30;
// ================================================================
// Utility
// ================================================================
function toast(msg, type) {
var el = document.createElement('div');
el.className = 'toast ' + (type || 'success');
el.textContent = msg;
document.body.appendChild(el);
setTimeout(function () { el.remove(); }, 3000);
}
function api(path, opts) {
opts = opts || {};
return fetch(API + path, opts).then(function (r) {
if (!r.ok) return r.json().then(function (d) { throw new Error(d.error || 'Error'); });
return r.json();
});
}
function esc(s) {
if (!s) return '';
var d = document.createElement('div');
d.textContent = s;
return d.innerHTML;
}
function fmt(n) {
return '$' + (parseFloat(n) || 0).toFixed(2).replace(/\B(?=(\d{3})+(?!\d))/g, ',');
}
// ================================================================
// Customer Selection
// ================================================================
var customerSearchTimer = null;
var customerSearchEl = document.getElementById('customer-search');
var customerDropdown = document.getElementById('customer-dropdown');
customerSearchEl.addEventListener('input', function () {
clearTimeout(customerSearchTimer);
var q = this.value.trim();
if (q.length < 2) { customerDropdown.style.display = 'none'; return; }
customerSearchTimer = setTimeout(function () {
api('/api/pos/customers?search=' + encodeURIComponent(q) + '&per_page=10')
.then(function (res) {
var data = res.data || [];
if (data.length === 0) {
customerDropdown.innerHTML = '<div style="padding:0.8rem;color:var(--text-secondary)">No se encontraron clientes</div>';
} else {
customerDropdown.innerHTML = data.map(function (c) {
return '<div class="customer-dropdown-item" data-id="' + c.id_customer + '">' +
'<div><span class="cdi-name">' + esc(c.name) + '</span>' +
(c.rfc ? ' <span class="cdi-rfc">' + esc(c.rfc) + '</span>' : '') + '</div>' +
'<span style="font-size:0.8rem;color:' + (c.balance > 0 ? 'var(--danger)' : 'var(--success)') + '">' +
fmt(c.balance) + '</span></div>';
}).join('');
customerDropdown.querySelectorAll('.customer-dropdown-item').forEach(function (item) {
item.addEventListener('click', function () {
selectCustomer(parseInt(item.getAttribute('data-id')));
});
});
}
customerDropdown.style.display = 'block';
});
}, 300);
});
customerSearchEl.addEventListener('blur', function () {
setTimeout(function () { customerDropdown.style.display = 'none'; }, 200);
});
function selectCustomer(id) {
api('/api/pos/customers/' + id).then(function (c) {
selectedCustomer = c;
document.getElementById('customer-select').style.display = 'none';
var info = document.getElementById('customer-info');
info.style.display = 'flex';
document.getElementById('sel-customer-name').textContent = c.name;
document.getElementById('sel-customer-rfc').textContent = c.rfc || 'Sin RFC';
var balEl = document.getElementById('sel-customer-balance');
balEl.textContent = 'Saldo: ' + fmt(c.balance);
balEl.className = 'cb-balance ' + (c.balance > 0 ? 'positive' : 'zero');
customerDropdown.style.display = 'none';
updateFacturarBtn();
});
}
document.getElementById('btn-change-customer').addEventListener('click', function () {
selectedCustomer = null;
document.getElementById('customer-info').style.display = 'none';
document.getElementById('customer-select').style.display = 'block';
customerSearchEl.value = '';
customerSearchEl.focus();
updateFacturarBtn();
});
// ================================================================
// New Customer Modal
// ================================================================
document.getElementById('btn-new-customer').addEventListener('click', function () {
document.getElementById('modal-new-customer').style.display = 'flex';
document.getElementById('nc-name').focus();
});
document.getElementById('nc-cancel').addEventListener('click', function () {
document.getElementById('modal-new-customer').style.display = 'none';
});
document.getElementById('nc-save').addEventListener('click', function () {
var name = document.getElementById('nc-name').value.trim();
if (!name) { toast('Ingresa el nombre del cliente', 'error'); return; }
api('/api/pos/customers', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
name: name,
rfc: document.getElementById('nc-rfc').value.trim() || null,
business_name: document.getElementById('nc-business').value.trim() || null,
phone: document.getElementById('nc-phone').value.trim() || null,
email: document.getElementById('nc-email').value.trim() || null,
address: document.getElementById('nc-address').value.trim() || null,
credit_limit: parseFloat(document.getElementById('nc-credit').value) || 0,
payment_terms: parseInt(document.getElementById('nc-terms').value) || 30
})
}).then(function (res) {
toast('Cliente creado: ' + name);
document.getElementById('modal-new-customer').style.display = 'none';
selectCustomer(res.id);
// Clear form
['nc-name','nc-rfc','nc-business','nc-phone','nc-email','nc-address'].forEach(function(id) {
document.getElementById(id).value = '';
});
document.getElementById('nc-credit').value = '0';
document.getElementById('nc-terms').value = '30';
}).catch(function (err) {
toast(err.message, 'error');
});
});
// ================================================================
// Part Search — Autocomplete
// ================================================================
var partSearchTimer = null;
var partSearchEl = document.getElementById('part-search');
var partResults = document.getElementById('part-results');
var searchResults = [];
var highlightIdx = -1;
function doPartSearch() {
var q = partSearchEl.value.trim();
if (q.length < 1) { partResults.style.display = 'none'; searchResults = []; return; }
clearTimeout(partSearchTimer);
partSearchTimer = setTimeout(function () {
api('/api/pos/search-parts?q=' + encodeURIComponent(q)).then(function (results) {
searchResults = results;
highlightIdx = -1;
renderSearchResults();
});
}, 150);
}
function renderSearchResults() {
if (searchResults.length === 0 && partSearchEl.value.trim().length > 0) {
partResults.innerHTML = '<div style="padding:0.8rem;color:var(--text-secondary)">No se encontraron partes para "' + esc(partSearchEl.value) + '"</div>';
partResults.style.display = 'block';
return;
}
if (searchResults.length === 0) { partResults.style.display = 'none'; return; }
partResults.innerHTML = searchResults.map(function (p, i) {
var active = i === highlightIdx ? ' part-result-active' : '';
return '<div class="part-result-item' + active + '" data-idx="' + i + '">' +
'<div><span class="pri-number">' + esc(p.oem_part_number) + '</span>' +
'<span class="pri-name">' + esc(p.name_part) + '</span></div>' +
'<div style="display:flex;align-items:center;gap:0.4rem">' +
'<span class="pri-type ' + p.part_type + '">' + p.part_type + '</span>' +
(p.cost_usd ? '<span style="font-size:0.8rem;color:var(--text-secondary)">' + fmt(p.cost_usd) + '</span>' : '') +
'<span style="font-size:0.75rem;color:var(--text-secondary)">' + esc(p.group_name || '') + '</span>' +
'</div></div>';
}).join('');
partResults.querySelectorAll('.part-result-item').forEach(function (item) {
item.addEventListener('mousedown', function (e) {
e.preventDefault();
selectSearchResult(parseInt(item.getAttribute('data-idx')));
});
item.addEventListener('mouseenter', function () {
highlightIdx = parseInt(item.getAttribute('data-idx'));
updateHighlight();
});
});
partResults.style.display = 'block';
}
function updateHighlight() {
partResults.querySelectorAll('.part-result-item').forEach(function (el, i) {
if (i === highlightIdx) {
el.classList.add('part-result-active');
el.scrollIntoView({ block: 'nearest' });
} else {
el.classList.remove('part-result-active');
}
});
}
function selectSearchResult(idx) {
if (idx >= 0 && idx < searchResults.length) {
addToCart(searchResults[idx]);
partSearchEl.value = '';
partResults.style.display = 'none';
searchResults = [];
highlightIdx = -1;
partSearchEl.focus();
}
}
partSearchEl.addEventListener('input', doPartSearch);
partSearchEl.addEventListener('keydown', function (e) {
if (partResults.style.display === 'none' || searchResults.length === 0) return;
if (e.key === 'ArrowDown') {
e.preventDefault();
highlightIdx = Math.min(highlightIdx + 1, searchResults.length - 1);
updateHighlight();
} else if (e.key === 'ArrowUp') {
e.preventDefault();
highlightIdx = Math.max(highlightIdx - 1, 0);
updateHighlight();
} else if (e.key === 'Enter') {
e.preventDefault();
if (highlightIdx >= 0) {
selectSearchResult(highlightIdx);
} else if (searchResults.length === 1) {
selectSearchResult(0);
}
} else if (e.key === 'Escape') {
partResults.style.display = 'none';
highlightIdx = -1;
}
});
partSearchEl.addEventListener('focus', function () {
if (searchResults.length > 0) {
partResults.style.display = 'block';
}
});
partSearchEl.addEventListener('blur', function () {
setTimeout(function () { partResults.style.display = 'none'; }, 200);
});
// ================================================================
// Cart
// ================================================================
function addToCart(part) {
cart.push({
part_id: part.part_type === 'oem' ? part.id_part : null,
aftermarket_id: part.part_type === 'aftermarket' ? part.id_part : null,
description: (part.oem_part_number || '') + ' - ' + (part.name_part || ''),
part_type: part.part_type,
quantity: 1,
unit_cost: part.cost_usd || 0,
margin_pct: defaultMargin,
unit_price: (part.cost_usd || 0) * (1 + defaultMargin / 100)
});
renderCart();
partSearchEl.focus();
}
function renderCart() {
var tbody = document.getElementById('cart-body');
if (cart.length === 0) {
tbody.innerHTML = '<tr><td colspan="8" class="cart-empty">Busca y agrega partes al carrito</td></tr>';
updateTotals();
return;
}
tbody.innerHTML = cart.map(function (item, i) {
var lineTotal = item.quantity * item.unit_price;
return '<tr>' +
'<td class="cart-desc">' + esc(item.description) + '</td>' +
'<td><span class="pri-type ' + item.part_type + '">' + item.part_type + '</span></td>' +
'<td><input class="cart-qty" type="number" min="1" value="' + item.quantity + '" data-idx="' + i + '" data-field="quantity"></td>' +
'<td><input class="cart-cost" type="number" step="0.01" value="' + item.unit_cost.toFixed(2) + '" data-idx="' + i + '" data-field="unit_cost"></td>' +
'<td><input class="cart-margin" type="number" step="1" value="' + item.margin_pct.toFixed(0) + '" data-idx="' + i + '" data-field="margin_pct">%</td>' +
'<td>' + fmt(item.unit_price) + '</td>' +
'<td>' + fmt(lineTotal) + '</td>' +
'<td><button class="cart-remove" data-idx="' + i + '">&times;</button></td>' +
'</tr>';
}).join('');
// Input change handlers
tbody.querySelectorAll('input').forEach(function (input) {
input.addEventListener('change', function () {
var idx = parseInt(input.getAttribute('data-idx'));
var field = input.getAttribute('data-field');
var val = parseFloat(input.value) || 0;
cart[idx][field] = val;
// Recalculate price from cost + margin
if (field === 'unit_cost' || field === 'margin_pct') {
cart[idx].unit_price = cart[idx].unit_cost * (1 + cart[idx].margin_pct / 100);
}
renderCart();
});
});
// Remove handlers
tbody.querySelectorAll('.cart-remove').forEach(function (btn) {
btn.addEventListener('click', function () {
cart.splice(parseInt(btn.getAttribute('data-idx')), 1);
renderCart();
});
});
updateTotals();
}
function updateTotals() {
var itemCount = cart.reduce(function (sum, it) { return sum + it.quantity; }, 0);
var subtotal = cart.reduce(function (sum, it) { return sum + (it.quantity * it.unit_price); }, 0);
var tax = subtotal * 0.16;
var total = subtotal + tax;
document.getElementById('sum-items').textContent = itemCount;
document.getElementById('sum-subtotal').textContent = fmt(subtotal);
document.getElementById('sum-tax').textContent = fmt(tax);
document.getElementById('sum-total').textContent = fmt(total);
updateFacturarBtn();
}
function updateFacturarBtn() {
document.getElementById('btn-facturar').disabled = !(selectedCustomer && cart.length > 0);
}
// ================================================================
// Facturar
// ================================================================
document.getElementById('btn-facturar').addEventListener('click', function () {
if (!selectedCustomer || cart.length === 0) return;
var btn = this;
btn.disabled = true;
btn.textContent = 'Generando...';
var items = cart.map(function (it) {
return {
part_id: it.part_id,
aftermarket_id: it.aftermarket_id,
description: it.description,
quantity: it.quantity,
unit_cost: it.unit_cost,
margin_pct: it.margin_pct,
unit_price: Math.round(it.unit_price * 100) / 100
};
});
api('/api/pos/invoices', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
customer_id: selectedCustomer.id_customer,
items: items,
notes: document.getElementById('invoice-notes').value.trim()
})
}).then(function (res) {
toast('Factura ' + res.folio + ' creada por ' + fmt(res.total));
// Reset cart
cart = [];
renderCart();
document.getElementById('invoice-notes').value = '';
// Refresh customer balance
selectCustomer(selectedCustomer.id_customer);
btn.textContent = 'Facturar';
}).catch(function (err) {
toast(err.message, 'error');
btn.disabled = false;
btn.textContent = 'Facturar';
});
});
// ================================================================
// Init
// ================================================================
renderCart();
})();

678
dashboard/tienda.css Normal file
View File

@@ -0,0 +1,678 @@
/* ============================================================
tienda.css -- Store / Tablet dashboard styles
Nexus Autoparts — tablet-first, touch-friendly
============================================================ */
/* --- Base overrides for tienda page --- */
body {
margin: 0;
padding: 0;
font-family: 'DM Sans', sans-serif;
background: var(--bg-primary);
color: var(--text-primary);
min-height: 100vh;
-webkit-tap-highlight-color: transparent;
-webkit-touch-callout: none;
overscroll-behavior: none;
}
/* --- Header --- */
.t-header {
position: fixed;
top: 0; left: 0; right: 0;
z-index: 100;
display: flex;
align-items: center;
justify-content: space-between;
padding: 0.6rem 1.2rem;
background: rgba(18, 18, 26, 0.92);
backdrop-filter: blur(24px);
-webkit-backdrop-filter: blur(24px);
border-bottom: 1px solid var(--border);
}
.t-header-left {
display: flex;
align-items: center;
gap: 0.6rem;
flex-shrink: 0;
}
.t-logo-mark {
width: 36px;
height: 36px;
background: linear-gradient(135deg, var(--accent) 0%, #ff4500 100%);
border-radius: 9px;
box-shadow: 0 3px 14px var(--accent-glow);
display: flex;
align-items: center;
justify-content: center;
}
.t-logo-mark::after {
content: '\2699\FE0F';
font-size: 1.2rem;
}
.t-brand {
display: flex;
flex-direction: column;
line-height: 1.1;
}
.t-brand-name {
font-family: 'Outfit', sans-serif;
font-weight: 800;
font-size: 1.1rem;
background: linear-gradient(135deg, #fff 0%, var(--accent) 100%);
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
background-clip: text;
}
.t-brand-sub {
font-size: 0.55rem;
font-weight: 600;
color: var(--text-secondary);
letter-spacing: 0.15em;
text-transform: uppercase;
}
/* --- Header center: search --- */
.t-header-center {
flex: 1;
max-width: 420px;
margin: 0 1rem;
}
.t-search-box {
position: relative;
display: flex;
align-items: center;
}
.t-search-icon {
position: absolute;
left: 0.7rem;
width: 18px;
height: 18px;
color: var(--text-secondary);
pointer-events: none;
}
.t-search-box input {
width: 100%;
padding: 0.55rem 0.8rem 0.55rem 2.2rem;
background: var(--bg-card);
border: 1px solid var(--border);
border-radius: 10px;
color: var(--text-primary);
font-family: 'DM Sans', sans-serif;
font-size: 0.85rem;
outline: none;
transition: border-color 0.2s;
}
.t-search-box input:focus {
border-color: var(--accent);
}
.t-search-box input::placeholder {
color: var(--text-secondary);
}
.t-search-results {
position: absolute;
top: calc(100% + 4px);
left: 0; right: 0;
background: var(--bg-card);
border: 1px solid var(--border);
border-radius: 10px;
max-height: 300px;
overflow-y: auto;
box-shadow: 0 12px 40px rgba(0,0,0,0.5);
display: none;
z-index: 200;
}
.t-search-results.active {
display: block;
}
.t-search-result-item {
display: flex;
justify-content: space-between;
align-items: center;
padding: 0.6rem 0.8rem;
border-bottom: 1px solid var(--border);
cursor: pointer;
transition: background 0.15s;
}
.t-search-result-item:last-child {
border-bottom: none;
}
.t-search-result-item:hover,
.t-search-result-item:active {
background: var(--bg-hover);
}
.t-search-result-item .sri-number {
font-family: 'JetBrains Mono', monospace;
font-weight: 600;
font-size: 0.85rem;
color: var(--accent);
}
.t-search-result-item .sri-name {
font-size: 0.8rem;
color: var(--text-secondary);
margin-left: 0.4rem;
}
/* --- Header right: clock --- */
.t-header-right {
flex-shrink: 0;
}
.t-clock {
font-family: 'JetBrains Mono', monospace;
font-size: 0.85rem;
font-weight: 500;
color: var(--text-secondary);
letter-spacing: 0.03em;
}
/* --- Main --- */
.t-main {
padding: 4.2rem 1rem 1.5rem;
max-width: 1200px;
margin: 0 auto;
}
/* --- KPI Row --- */
.t-kpi-row {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
gap: 0.8rem;
margin-bottom: 1rem;
}
.t-kpi {
background: var(--bg-card);
border: 1px solid var(--border);
border-radius: 12px;
padding: 0.9rem 1rem;
display: flex;
align-items: center;
gap: 0.8rem;
position: relative;
overflow: hidden;
transition: transform 0.2s, box-shadow 0.2s;
}
.t-kpi:active {
transform: scale(0.98);
}
/* Colored left accent bar */
.t-kpi::before {
content: '';
position: absolute;
left: 0; top: 0; bottom: 0;
width: 3px;
border-radius: 3px 0 0 3px;
}
.t-kpi[data-color="accent"]::before { background: var(--accent); }
.t-kpi[data-color="success"]::before { background: var(--success); }
.t-kpi[data-color="info"]::before { background: var(--info); }
.t-kpi[data-color="warning"]::before { background: var(--warning); }
.t-kpi-icon {
width: 40px;
height: 40px;
border-radius: 10px;
display: flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
}
.t-kpi-icon svg {
width: 22px;
height: 22px;
}
.t-kpi[data-color="accent"] .t-kpi-icon { background: rgba(255, 107, 53, 0.12); color: var(--accent); }
.t-kpi[data-color="success"] .t-kpi-icon { background: rgba(34, 197, 94, 0.12); color: var(--success); }
.t-kpi[data-color="info"] .t-kpi-icon { background: rgba(59, 130, 246, 0.12); color: var(--info); }
.t-kpi[data-color="warning"] .t-kpi-icon { background: rgba(245, 158, 11, 0.12); color: var(--warning); }
.t-kpi-data {
display: flex;
flex-direction: column;
flex: 1;
min-width: 0;
}
.t-kpi-value {
font-family: 'Outfit', sans-serif;
font-weight: 700;
font-size: 1.3rem;
line-height: 1.2;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.t-kpi-label {
font-size: 0.72rem;
color: var(--text-secondary);
text-transform: uppercase;
letter-spacing: 0.04em;
font-weight: 500;
}
.t-kpi-count {
font-size: 0.65rem;
color: var(--text-secondary);
font-family: 'JetBrains Mono', monospace;
white-space: nowrap;
align-self: flex-start;
margin-top: 0.2rem;
}
/* --- Content Grid --- */
.t-content {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 0.8rem;
}
/* --- Cards --- */
.t-card {
background: var(--bg-card);
border: 1px solid var(--border);
border-radius: 12px;
padding: 1rem;
}
.t-card-full {
min-height: 0;
}
.t-card-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 0.8rem;
}
.t-card-title {
font-family: 'DM Sans', sans-serif;
font-size: 0.85rem;
font-weight: 600;
color: var(--text-secondary);
text-transform: uppercase;
letter-spacing: 0.04em;
margin-bottom: 0.8rem;
}
.t-card-header .t-card-title {
margin-bottom: 0;
}
.t-see-all {
font-size: 0.75rem;
color: var(--accent);
text-decoration: none;
font-weight: 600;
padding: 0.3rem 0.6rem;
border-radius: 6px;
transition: background 0.2s;
}
.t-see-all:hover,
.t-see-all:active {
background: rgba(255, 107, 53, 0.1);
}
/* --- Quick Actions Grid --- */
.t-actions-card {
padding-bottom: 0.8rem;
}
.t-actions-grid {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 0.6rem;
}
.t-action {
display: flex;
align-items: center;
gap: 0.7rem;
padding: 0.8rem;
background: var(--bg-primary);
border: 1px solid var(--border);
border-radius: 10px;
text-decoration: none;
color: var(--text-primary);
font-size: 0.85rem;
font-weight: 600;
transition: transform 0.15s, background 0.2s, border-color 0.2s;
-webkit-tap-highlight-color: transparent;
}
.t-action:active {
transform: scale(0.96);
}
.t-action:hover {
background: var(--bg-hover);
}
.t-action-icon {
width: 36px;
height: 36px;
border-radius: 9px;
display: flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
}
.t-action-icon svg {
width: 20px;
height: 20px;
}
.t-action[data-color="accent"] .t-action-icon { background: rgba(255, 107, 53, 0.12); color: var(--accent); }
.t-action[data-color="accent"]:hover { border-color: var(--accent); }
.t-action[data-color="info"] .t-action-icon { background: rgba(59, 130, 246, 0.12); color: var(--info); }
.t-action[data-color="info"]:hover { border-color: var(--info); }
.t-action[data-color="success"] .t-action-icon { background: rgba(34, 197, 94, 0.12); color: var(--success); }
.t-action[data-color="success"]:hover { border-color: var(--success); }
.t-action[data-color="warning"] .t-action-icon { background: rgba(245, 158, 11, 0.12); color: var(--warning); }
.t-action[data-color="warning"]:hover { border-color: var(--warning); }
/* --- Debtors List --- */
.t-debtors-list {
display: flex;
flex-direction: column;
gap: 0.4rem;
max-height: 280px;
overflow-y: auto;
}
.t-debtor {
display: flex;
justify-content: space-between;
align-items: center;
padding: 0.6rem 0.7rem;
background: var(--bg-primary);
border: 1px solid var(--border);
border-radius: 8px;
cursor: pointer;
transition: background 0.15s, border-color 0.15s;
}
.t-debtor:hover,
.t-debtor:active {
background: var(--bg-hover);
border-color: var(--danger);
}
.t-debtor-name {
font-weight: 600;
font-size: 0.85rem;
}
.t-debtor-invoices {
font-size: 0.7rem;
color: var(--text-secondary);
}
.t-debtor-amount {
font-family: 'JetBrains Mono', monospace;
font-weight: 700;
font-size: 0.9rem;
color: var(--danger);
}
/* --- Invoice List --- */
.t-invoice-list {
display: flex;
flex-direction: column;
gap: 0.4rem;
max-height: 320px;
overflow-y: auto;
}
.t-invoice {
display: flex;
justify-content: space-between;
align-items: center;
padding: 0.6rem 0.7rem;
background: var(--bg-primary);
border: 1px solid var(--border);
border-radius: 8px;
transition: background 0.15s;
}
.t-invoice:hover,
.t-invoice:active {
background: var(--bg-hover);
}
.t-invoice-left {
display: flex;
flex-direction: column;
gap: 0.15rem;
}
.t-invoice-folio {
font-family: 'JetBrains Mono', monospace;
font-weight: 700;
font-size: 0.85rem;
color: var(--accent);
}
.t-invoice-customer {
font-size: 0.75rem;
color: var(--text-secondary);
}
.t-invoice-right {
display: flex;
flex-direction: column;
align-items: flex-end;
gap: 0.15rem;
}
.t-invoice-total {
font-family: 'JetBrains Mono', monospace;
font-weight: 600;
font-size: 0.85rem;
}
.t-invoice-status {
font-size: 0.65rem;
font-weight: 600;
padding: 0.15rem 0.45rem;
border-radius: 4px;
text-transform: uppercase;
letter-spacing: 0.03em;
}
.t-invoice-status.paid {
background: rgba(34, 197, 94, 0.15);
color: var(--success);
}
.t-invoice-status.pending {
background: rgba(245, 158, 11, 0.15);
color: var(--warning);
}
.t-invoice-status.partial {
background: rgba(59, 130, 246, 0.15);
color: var(--info);
}
.t-invoice-status.cancelled {
background: rgba(255, 68, 68, 0.15);
color: var(--danger);
}
/* --- Today's Payments card --- */
.t-today-payments {
text-align: center;
padding: 0.5rem 0;
}
.t-today-amount {
font-family: 'Outfit', sans-serif;
font-weight: 800;
font-size: 2rem;
color: var(--success);
line-height: 1.2;
}
.t-today-count {
font-size: 0.8rem;
color: var(--text-secondary);
margin-top: 0.3rem;
}
/* --- Empty state --- */
.t-empty {
text-align: center;
padding: 1.5rem;
color: var(--text-secondary);
font-size: 0.85rem;
}
/* --- Scrollbar (minimal for touch) --- */
.t-debtors-list::-webkit-scrollbar,
.t-invoice-list::-webkit-scrollbar,
.t-search-results::-webkit-scrollbar {
width: 4px;
}
.t-debtors-list::-webkit-scrollbar-track,
.t-invoice-list::-webkit-scrollbar-track,
.t-search-results::-webkit-scrollbar-track {
background: transparent;
}
.t-debtors-list::-webkit-scrollbar-thumb,
.t-invoice-list::-webkit-scrollbar-thumb,
.t-search-results::-webkit-scrollbar-thumb {
background: var(--border);
border-radius: 2px;
}
/* --- Responsive --- */
/* Tablet landscape (default target) */
@media (max-width: 1024px) {
.t-main {
padding: 4rem 0.8rem 1.2rem;
}
.t-kpi-row {
grid-template-columns: repeat(2, 1fr);
}
}
/* Tablet portrait / large phone */
@media (max-width: 768px) {
.t-header-center {
display: none;
}
.t-main {
padding: 3.8rem 0.6rem 1rem;
}
.t-content {
grid-template-columns: 1fr;
}
.t-kpi-row {
grid-template-columns: repeat(2, 1fr);
gap: 0.6rem;
}
.t-kpi {
padding: 0.7rem 0.8rem;
}
.t-kpi-value {
font-size: 1.1rem;
}
.t-kpi-count {
display: none;
}
.t-actions-grid {
grid-template-columns: 1fr 1fr;
}
}
/* Small phone */
@media (max-width: 480px) {
.t-kpi-row {
grid-template-columns: 1fr 1fr;
}
.t-kpi-icon {
width: 32px;
height: 32px;
}
.t-kpi-icon svg {
width: 18px;
height: 18px;
}
.t-kpi-value {
font-size: 1rem;
}
.t-actions-grid {
grid-template-columns: 1fr;
}
}
/* --- Fade-in animation for cards --- */
@keyframes t-fadeIn {
from { opacity: 0; transform: translateY(8px); }
to { opacity: 1; transform: translateY(0); }
}
.t-kpi {
animation: t-fadeIn 0.4s ease both;
}
.t-kpi:nth-child(1) { animation-delay: 0.05s; }
.t-kpi:nth-child(2) { animation-delay: 0.1s; }
.t-kpi:nth-child(3) { animation-delay: 0.15s; }
.t-kpi:nth-child(4) { animation-delay: 0.2s; }
.t-card {
animation: t-fadeIn 0.4s ease both;
animation-delay: 0.25s;
}
.t-content .t-col:nth-child(2) .t-card {
animation-delay: 0.3s;
}
.t-content .t-col:nth-child(2) .t-card:nth-child(2) {
animation-delay: 0.35s;
}

156
dashboard/tienda.html Normal file
View File

@@ -0,0 +1,156 @@
<!DOCTYPE html>
<html lang="es">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0, user-scalable=no">
<title>Tienda — NEXUS AUTOPARTS</title>
<link href="https://fonts.googleapis.com/css2?family=DM+Sans:wght@400;500;600;700;800&family=Outfit:wght@700;800;900&family=JetBrains+Mono:wght@500;700&display=swap" rel="stylesheet">
<link rel="stylesheet" href="/shared.css">
<link rel="stylesheet" href="/tienda.css">
<link rel="manifest" crossorigin="use-credentials">
<meta name="theme-color" content="#0a0a0f">
<meta name="apple-mobile-web-app-capable" content="yes">
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent">
</head>
<body>
<!-- Header -->
<header class="t-header">
<div class="t-header-left">
<div class="t-logo-mark"></div>
<div class="t-brand">
<span class="t-brand-name">NEXUS</span>
<span class="t-brand-sub">AUTOPARTS</span>
</div>
</div>
<div class="t-header-center">
<div class="t-search-box">
<svg class="t-search-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="11" cy="11" r="8"/><path d="M21 21l-4.35-4.35"/></svg>
<input id="global-search" type="text" placeholder="Buscar parte, OEM, cliente..." autocomplete="off">
<div id="global-results" class="t-search-results"></div>
</div>
</div>
<div class="t-header-right">
<span class="t-clock" id="clock"></span>
</div>
</header>
<!-- Main Grid -->
<main class="t-main">
<!-- KPI Row -->
<section class="t-kpi-row">
<div class="t-kpi" data-color="accent">
<div class="t-kpi-icon">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M12 2v20M17 5H9.5a3.5 3.5 0 000 7h5a3.5 3.5 0 010 7H6"/></svg>
</div>
<div class="t-kpi-data">
<span class="t-kpi-value" id="kpi-sales-today">$0</span>
<span class="t-kpi-label">Ventas hoy</span>
</div>
<span class="t-kpi-count" id="kpi-sales-count">0 facturas</span>
</div>
<div class="t-kpi" data-color="success">
<div class="t-kpi-icon">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M22 12h-4l-3 9L9 3l-3 9H2"/></svg>
</div>
<div class="t-kpi-data">
<span class="t-kpi-value" id="kpi-month">$0</span>
<span class="t-kpi-label">Ventas del mes</span>
</div>
<span class="t-kpi-count" id="kpi-month-count">0 facturas</span>
</div>
<div class="t-kpi" data-color="info">
<div class="t-kpi-icon">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M17 21v-2a4 4 0 00-4-4H5a4 4 0 00-4-4v2"/><circle cx="9" cy="7" r="4"/><path d="M23 21v-2a4 4 0 00-3-3.87"/><path d="M16 3.13a4 4 0 010 7.75"/></svg>
</div>
<div class="t-kpi-data">
<span class="t-kpi-value" id="kpi-customers">0</span>
<span class="t-kpi-label">Clientes activos</span>
</div>
<span class="t-kpi-count" id="kpi-parts-count">0 partes</span>
</div>
<div class="t-kpi" data-color="warning">
<div class="t-kpi-icon">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect x="1" y="4" width="22" height="16" rx="2"/><path d="M1 10h22"/></svg>
</div>
<div class="t-kpi-data">
<span class="t-kpi-value" id="kpi-pending">$0</span>
<span class="t-kpi-label">Por cobrar</span>
</div>
<span class="t-kpi-count" id="kpi-pending-count">0 facturas</span>
</div>
</section>
<!-- Content Grid: 2 columns -->
<section class="t-content">
<!-- Left column -->
<div class="t-col">
<!-- Quick Actions -->
<div class="t-card t-actions-card">
<h2 class="t-card-title">Acciones</h2>
<div class="t-actions-grid">
<a href="/pos" class="t-action" data-color="accent">
<div class="t-action-icon">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect x="2" y="3" width="20" height="14" rx="2"/><path d="M8 21h8M12 17v4"/></svg>
</div>
<span>Nueva Venta</span>
</a>
<a href="/cuentas" class="t-action" data-color="info">
<div class="t-action-icon">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M16 21v-2a4 4 0 00-4-4H5a4 4 0 00-4-4v2"/><circle cx="8.5" cy="7" r="4"/><path d="M20 8v6M23 11h-6"/></svg>
</div>
<span>Cuentas</span>
</a>
<a href="/captura" class="t-action" data-color="success">
<div class="t-action-icon">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M11 4H4a2 2 0 00-2 2v14a2 2 0 002 2h14a2 2 0 002-2v-7"/><path d="M18.5 2.5a2.121 2.121 0 013 3L12 15l-4 1 1-4 9.5-9.5z"/></svg>
</div>
<span>Captura</span>
</a>
<a href="/" class="t-action" data-color="warning">
<div class="t-action-icon">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="11" cy="11" r="8"/><path d="M21 21l-4.35-4.35"/></svg>
</div>
<span>Catalogo</span>
</a>
</div>
</div>
<!-- Top Debtors -->
<div class="t-card">
<h2 class="t-card-title">Cuentas pendientes</h2>
<div id="debtors-list" class="t-debtors-list">
<div class="t-empty">Sin cuentas pendientes</div>
</div>
</div>
</div>
<!-- Right column -->
<div class="t-col">
<!-- Recent Invoices -->
<div class="t-card t-card-full">
<div class="t-card-header">
<h2 class="t-card-title">Ultimas facturas</h2>
<a href="/cuentas" class="t-see-all">Ver todas</a>
</div>
<div id="recent-invoices" class="t-invoice-list">
<div class="t-empty">Sin facturas recientes</div>
</div>
</div>
<!-- Cobros de hoy -->
<div class="t-card">
<div class="t-card-header">
<h2 class="t-card-title">Cobros de hoy</h2>
</div>
<div class="t-today-payments">
<div class="t-today-amount" id="payments-today-amount">$0.00</div>
<div class="t-today-count" id="payments-today-count">0 pagos registrados</div>
</div>
</div>
</div>
</section>
</main>
<script src="/tienda.js"></script>
</body>
</html>

187
dashboard/tienda.js Normal file
View File

@@ -0,0 +1,187 @@
/**
* tienda.js — Store / Tablet dashboard logic for Nexus Autoparts
*/
(function () {
'use strict';
var API = '';
// ================================================================
// Utility
// ================================================================
function fmt(n) {
return '$' + (parseFloat(n) || 0).toFixed(2).replace(/\B(?=(\d{3})+(?!\d))/g, ',');
}
function esc(s) {
if (!s) return '';
var d = document.createElement('div');
d.textContent = s;
return d.innerHTML;
}
// ================================================================
// Clock
// ================================================================
function updateClock() {
var now = new Date();
var h = now.getHours();
var m = String(now.getMinutes()).padStart(2, '0');
var ampm = h >= 12 ? 'PM' : 'AM';
h = h % 12 || 12;
document.getElementById('clock').textContent = h + ':' + m + ' ' + ampm;
}
updateClock();
setInterval(updateClock, 30000);
// ================================================================
// Load Dashboard Stats
// ================================================================
function loadStats() {
fetch(API + '/api/tienda/stats')
.then(function (r) { return r.json(); })
.then(function (d) {
var st = d.sales_today || {};
var sm = d.sales_month || {};
var pt = d.payments_today || {};
// KPIs
document.getElementById('kpi-sales-today').textContent = fmt(st.total);
document.getElementById('kpi-sales-count').textContent = (st.count || 0) + ' facturas';
document.getElementById('kpi-month').textContent = fmt(sm.total);
document.getElementById('kpi-month-count').textContent = (sm.count || 0) + ' facturas';
document.getElementById('kpi-customers').textContent = d.total_customers || 0;
document.getElementById('kpi-parts-count').textContent = (d.total_parts || 0) + ' partes';
document.getElementById('kpi-pending').textContent = fmt(d.pending_balance || 0);
document.getElementById('kpi-pending-count').textContent = (d.pending_invoices || 0) + ' facturas';
// Today's payments
document.getElementById('payments-today-amount').textContent = fmt(pt.total);
document.getElementById('payments-today-count').textContent = (pt.count || 0) + ' pagos registrados';
// Top debtors
renderDebtors(d.top_debtors || []);
// Recent invoices
renderInvoices(d.recent_invoices || []);
})
.catch(function (err) {
console.error('Error loading stats:', err);
});
}
// ================================================================
// Render Debtors
// ================================================================
function renderDebtors(debtors) {
var el = document.getElementById('debtors-list');
if (debtors.length === 0) {
el.innerHTML = '<div class="t-empty">Sin cuentas pendientes</div>';
return;
}
el.innerHTML = debtors.map(function (d) {
var limitPct = d.credit_limit > 0 ? Math.round(d.balance / d.credit_limit * 100) : 0;
return '<a href="/cuentas" class="t-debtor">'
+ '<div>'
+ '<div class="t-debtor-name">' + esc(d.name) + '</div>'
+ (d.credit_limit > 0 ? '<div class="t-debtor-invoices">' + limitPct + '% de l\u00edmite</div>' : '')
+ '</div>'
+ '<span class="t-debtor-amount">' + fmt(d.balance) + '</span>'
+ '</a>';
}).join('');
}
// ================================================================
// Render Recent Invoices
// ================================================================
function renderInvoices(invoices) {
var el = document.getElementById('recent-invoices');
if (invoices.length === 0) {
el.innerHTML = '<div class="t-empty">Sin facturas recientes</div>';
return;
}
el.innerHTML = invoices.map(function (inv) {
var statusClass = inv.status || 'pending';
var statusLabel = { pending: 'Pendiente', paid: 'Pagada', partial: 'Parcial', cancelled: 'Cancelada' };
return '<div class="t-invoice">'
+ '<div class="t-invoice-left">'
+ '<span class="t-invoice-folio">' + esc(inv.folio) + '</span>'
+ '<span class="t-invoice-customer">' + esc(inv.customer_name) + '</span>'
+ '</div>'
+ '<div class="t-invoice-right">'
+ '<span class="t-invoice-total">' + fmt(inv.total) + '</span>'
+ '<span class="t-invoice-status ' + statusClass + '">' + (statusLabel[statusClass] || statusClass) + '</span>'
+ '</div>'
+ '</div>';
}).join('');
}
// ================================================================
// Global Search
// ================================================================
var searchTimer = null;
var searchInput = document.getElementById('global-search');
var searchResults = document.getElementById('global-results');
if (searchInput) {
searchInput.addEventListener('input', function () {
clearTimeout(searchTimer);
var q = this.value.trim();
if (q.length < 2) {
searchResults.classList.remove('active');
searchResults.innerHTML = '';
return;
}
searchTimer = setTimeout(function () {
fetch(API + '/api/pos/search-parts?q=' + encodeURIComponent(q))
.then(function (r) { return r.json(); })
.then(function (results) {
if (results.length === 0) {
searchResults.innerHTML = '<div style="padding:0.8rem;color:var(--text-secondary);font-size:0.85rem">Sin resultados para "' + esc(q) + '"</div>';
} else {
searchResults.innerHTML = results.slice(0, 8).map(function (p) {
return '<div class="t-search-result-item">'
+ '<div>'
+ '<span class="sri-number">' + esc(p.oem_part_number) + '</span>'
+ '<span class="sri-name">' + esc(p.name_part) + '</span>'
+ '</div>'
+ '</div>';
}).join('');
}
searchResults.classList.add('active');
});
}, 250);
});
searchInput.addEventListener('blur', function () {
setTimeout(function () { searchResults.classList.remove('active'); }, 200);
});
searchInput.addEventListener('focus', function () {
if (searchResults.innerHTML.trim()) {
searchResults.classList.add('active');
}
});
}
// ================================================================
// Init
// ================================================================
loadStats();
// Auto-refresh every 2 minutes
setInterval(loadStats, 120000);
})();