Files
SekiPOS/templates/index.html
2026-02-26 22:50:20 -03:00

689 lines
28 KiB
HTML

<!DOCTYPE html>
<html lang="es" data-theme="light">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>SekiPOS - Inventory</title>
<link rel="shortcut icon" href="./static/favicon.png" type="image/x-icon">
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/css/bootstrap.min.css" rel="stylesheet">
<link href="https://cdn.jsdelivr.net/npm/bootstrap-icons@1.11.3/font/bootstrap-icons.min.css" rel="stylesheet">
<script src="https://cdnjs.cloudflare.com/ajax/libs/socket.io/4.0.1/socket.io.js"></script>
<style>
:root {
--bg: #ebedef;
--card-bg: #ffffff;
--text-main: #2e3338;
--text-muted: #4f5660;
--border: #e3e5e8;
--navbar-bg: #ffffff;
--input-bg: #e3e5e8;
--table-head: #f2f3f5;
--accent: #5865f2;
--accent-hover: #4752c4;
--danger: #ed4245;
}
[data-theme="dark"] {
--bg: #36393f;
--card-bg: #2f3136;
--text-main: #dcddde;
--text-muted: #b9bbbe;
--border: #202225;
--navbar-bg: #202225;
--input-bg: #202225;
--table-head: #292b2f;
}
body {
background: var(--bg);
color: var(--text-main);
font-family: "gg sans", "Segoe UI", sans-serif;
transition: background 0.2s, color 0.2s;
}
/* ── Navbar ── */
.navbar {
background: var(--navbar-bg) !important;
border-bottom: 1px solid var(--border);
}
.navbar-brand {
color: var(--text-main) !important;
font-weight: 700;
}
.nav-link,
.dropdown-item {
color: var(--text-main) !important;
}
.dropdown-menu {
background: var(--card-bg);
border: 1px solid var(--border);
}
.dropdown-item:hover {
background: var(--input-bg);
}
.dropdown-item.text-danger {
color: var(--danger) !important;
}
/* ── Cards ── */
.discord-card {
background: var(--card-bg);
border: 1px solid var(--border);
border-radius: 8px;
box-shadow: 0 2px 10px rgba(0, 0, 0, 0.1);
}
/* ── Inputs ── */
.form-control,
.form-control:focus {
background: var(--input-bg);
color: var(--text-main);
border: none;
box-shadow: none;
}
.form-control:focus {
outline: 2px solid var(--accent);
}
.form-control::placeholder {
color: var(--text-muted);
}
/* ── Buttons ── */
.btn-accent {
background: var(--accent);
color: #fff;
border: none;
}
.btn-accent:hover {
background: var(--accent-hover);
color: #fff;
}
.btn-danger-discord {
background: var(--danger);
color: #fff;
border: none;
}
.btn-danger-discord:hover {
background: #c23235;
color: #fff;
}
/* ── Price tag ── */
.price-tag {
font-size: 2.2rem;
font-weight: 800;
color: var(--text-main);
}
/* ── Table ── */
.table {
color: var(--text-main);
--bs-table-color: var(--text-main);
--bs-table-bg: transparent;
--bs-table-border-color: var(--border);
}
/* -- Checkbox Size Fix -- */
#select-all {
transform: scale(1.3);
margin-top: 2px;
}
[data-theme="dark"] .table {
--bs-table-color: var(--text-main);
color: var(--text-main);
}
.table thead th {
background: var(--table-head);
color: var(--text-muted);
font-size: 0.72rem;
text-transform: uppercase;
border-bottom: 1px solid var(--border);
}
.table tbody td {
border-bottom: 1px solid var(--border);
vertical-align: middle;
}
/* ── Bulk bar ── */
.bulk-bar {
background: var(--accent);
color: #fff;
border-radius: 8px;
}
.bulk-bar .form-control {
width: 110px;
background: rgba(0, 0, 0, 0.2) !important;
color: #fff !important;
border: 1px solid rgba(255, 255, 255, 0.25) !important;
}
.bulk-bar .form-control::placeholder {
color: rgba(255, 255, 255, 0.6);
}
/* ── New-product prompt ── */
.new-product-prompt {
background: var(--accent);
color: #fff;
border-radius: 8px;
}
/* ── Product image ── */
#display-img {
max-width: 160px;
max-height: 160px;
object-fit: contain;
}
/* ── Checkbox ── */
input[type="checkbox"] {
cursor: pointer;
}
/* ── Mobile: hide barcode column ── */
@media (max-width: 576px) {
.col-barcode {
display: none;
}
.btn-edit-sm,
.btn-del-sm {
padding: 4px 7px;
font-size: 0.75rem;
}
}
.modal-content {
background: var(--card-bg);
color: var(--text-main);
border: 1px solid var(--border);
}
.modal-header,
.modal-footer {
border-color: var(--border);
}
.btn-close {
/* Makes the X button visible in dark mode */
filter: var(--bs-theme-placeholder, invert(0.7) grayscale(100%) brightness(200%));
}
[data-theme="dark"] .btn-close {
filter: invert(1) grayscale(100%) brightness(200%);
}
/* Add this inside your <style> tag */
[data-theme="dark"] .text-muted {
color: var(--text-muted) !important;
}
[data-theme="dark"] .modal-body .text-muted {
color: #f6f6f7 !important;
}
</style>
</head>
<body>
<!-- ════════════════════════ NAVBAR ════════════════════════ -->
<nav class="navbar navbar-expand-md sticky-top px-3 mb-3">
<span class="navbar-brand">SekiPOS <small class="text-muted fw-normal"
style="font-size:0.65rem;">v1.5</small></span>
<!-- Always-visible dropdown on the right -->
<div class="ms-auto">
<div class="dropdown">
<button class="btn btn-accent dropdown-toggle" type="button" data-bs-toggle="dropdown"
aria-expanded="false">
<i class="bi bi-person-circle me-1"></i>
<span class="d-none d-sm-inline">{{ user.username }}</span>
</button>
<ul class="dropdown-menu dropdown-menu-end">
<li>
<button class="dropdown-item" onclick="toggleTheme()">
<i class="bi bi-moon-stars me-2" id="theme-icon"></i>
<span id="theme-label">Modo Oscuro</span>
</button>
</li>
<li>
<hr class="dropdown-divider" style="border-color: var(--border);">
</li>
<li>
<a class="dropdown-item text-danger" href="/logout">
<i class="bi bi-box-arrow-right me-2"></i>Salir
</a>
</li>
</ul>
</div>
</div>
</nav>
<!-- ════════════════════════ MAIN ════════════════════════ -->
<div class="container-fluid px-3">
<div class="row g-3">
<!-- ── LEFT COLUMN ── -->
<div class="col-12 col-lg-4">
<!-- New product prompt -->
<div id="new-product-prompt"
class="new-product-prompt p-3 mb-3 d-none d-flex justify-content-between align-items-center">
<span>Nuevo: <b id="new-barcode-display"></b></span>
<button onclick="dismissPrompt()" class="btn btn-sm"
style="background:rgba(0,0,0,0.25);color:#fff;">
Omitir
</button>
</div>
<!-- Last scanned card -->
<div class="discord-card p-3 mb-3 text-center">
<p class="mb-1 fw-semibold"
style="color:var(--text-muted); font-size:0.8rem; text-transform:uppercase; letter-spacing:.05em;">
Último Escaneado</p>
<img id="display-img" src="./static/placeholder.png" class="mb-2" alt="product">
<h5 id="display-name" class="mb-1">Esperando scan...</h5>
<div class="price-tag" id="display-price">$0</div>
<p id="display-barcode" class="mb-0 mt-1"
style="font-family:monospace; opacity:.5; font-size:.8rem;"></p>
</div>
<!-- Edit / Create card -->
<div class="discord-card p-3">
<h6 id="form-title" class="mb-3 fw-bold">Editar / Crear</h6>
<form action="/upsert" method="POST" id="product-form">
<input class="form-control mb-2" type="text" name="barcode" id="form-barcode"
placeholder="Barcode" required>
<input class="form-control mb-2" type="text" name="name" id="form-name" placeholder="Nombre"
required>
<input class="form-control mb-2" type="number" name="price" id="form-price"
placeholder="Precio (CLP)" required>
<input class="form-control mb-3" type="text" name="image_url" id="form-image"
placeholder="URL Imagen">
<button type="submit" class="btn btn-accent w-100">
<i class="bi bi-floppy me-1"></i>Guardar Cambios
</button>
</form>
</div>
</div>
<!-- ── RIGHT COLUMN ── -->
<div class="col-12 col-lg-8">
<div class="discord-card p-3">
<!-- Bulk actions bar -->
<div id="bulk-bar" class="bulk-bar p-2 mb-3 d-flex justify-content-between align-items-center">
<span><b id="selected-count">0</b> seleccionados</span>
<div class="d-flex align-items-center gap-2">
<input type="number" id="bulk-price-input" class="form-control form-control-sm"
placeholder="Precio">
<button onclick="applyBulkPrice()" class="btn btn-sm"
style="background:#fff; color:var(--accent); font-weight:600;">OK</button>
<button onclick="applyBulkDelete()" class="btn btn-sm btn-danger-discord">
<i class="bi bi-trash"></i>
</button>
<button onclick="clearSelection()" class="btn btn-sm"
style="background: rgba(255,255,255,0.2); color:#fff;">
<i class="bi bi-x-lg"></i>
</button>
</div>
</div>
<!-- Search -->
<input type="text" id="searchInput" class="form-control mb-3" onkeyup="searchTable()"
placeholder="Filtrar productos...">
<!-- Table -->
<div class="table-responsive">
<table class="table table-borderless mb-0" id="inventoryTable">
<thead>
<tr>
<th style="width:36px;">
<input class="form-check-input" type="checkbox" id="select-all"
onclick="toggleAll(this)">
</th>
<th class="col-barcode">Código</th>
<th>Nombre</th>
<th>Precio</th>
<th>Acciones</th>
</tr>
</thead>
<tbody>
{% for p in products %}
<tr data-barcode="{{ p[0] }}">
<td>
<input class="form-check-input product-checkbox" type="checkbox"
onclick="updateBulkBar()">
</td>
<td class="col-barcode" style="font-family:monospace; font-size:.8rem;">{{ p[0] }}
</td>
<td class="name-cell">{{ p[1] }}</td>
<td class="price-cell" data-value="{{ p[2] }}"></td>
<td style="white-space:nowrap;">
<button class="btn btn-accent btn-sm btn-edit-sm me-1"
onclick="editProduct('{{ p[0] }}', '{{ p[1] }}', '{{ p[2] }}', '{{ p[3] }}')"
data-bs-toggle="modal" data-bs-target="#editModal">
<i class="bi bi-pencil"></i>
</button>
<button class="btn btn-danger-discord btn-sm btn-del-sm" data-bs-toggle="modal"
data-bs-target="#deleteModal" data-barcode="{{ p[0] }}"
data-name="{{ p[1] }}">
<i class="bi bi-trash"></i>
</button>
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
</div>
</div>
</div><!-- /row -->
</div><!-- /container -->
<!-- Edit Modal -->
<div class="modal fade" id="editModal" tabindex="-1">
<div class="modal-dialog modal-dialog-centered">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title">Editar Producto</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
</div>
<div class="modal-body">
<p>¿Quieres editar <strong id="editProductName"></strong>?</p>
<div class="d-grid gap-2">
<button class="btn btn-accent" onclick="confirmEdit()">
<i class="bi bi-pencil me-1"></i>Editar
</button>
<button class="btn btn-secondary" data-bs-dismiss="modal">Cancelar</button>
</div>
</div>
</div>
</div>
</div>
<!-- Delete Modal -->
<div class="modal fade" id="deleteModal" tabindex="-1">
<div class="modal-dialog modal-dialog-centered">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title">Eliminar Producto</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
</div>
<div class="modal-body">
<p>¿Seguro que quieres eliminar <strong id="deleteProductName"></strong>?</p>
<p class="text-muted small">Esta acción no se puede deshacer.</p>
<div class="d-grid gap-2 mt-3">
<button class="btn btn-danger-discord" onclick="confirmDelete()">
<i class="bi bi-trash me-1"></i>Eliminar
</button>
<button class="btn btn-secondary" data-bs-dismiss="modal">Cancelar</button>
</div>
</div>
</div>
</div>
</div>
<div class="modal fade" id="bulkConfirmModal" tabindex="-1">
<div class="modal-dialog modal-dialog-centered">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title">Confirmar Cambio Masivo</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
</div>
<div class="modal-body text-center">
<i class="bi bi-exclamation-triangle text-warning" style="font-size: 3rem;"></i>
<p class="mt-3">Vas a actualizar <strong id="bulk-count-text">0</strong> productos al precio de
<strong id="bulk-price-text"></strong>.
</p>
<div class="d-grid gap-2 mt-3">
<button class="btn btn-accent" onclick="executeBulkPrice()">
Confirmar Cambios
</button>
<button class="btn btn-secondary" data-bs-dismiss="modal">Cancelar</button>
</div>
</div>
</div>
</div>
</div>
<div class="modal fade" id="bulkDeleteModal" tabindex="-1">
<div class="modal-dialog modal-dialog-centered">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title">Confirmar Eliminación Masiva</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
</div>
<div class="modal-body text-center">
<i class="bi bi-exclamation-octagon text-danger" style="font-size: 3rem;"></i>
<p class="mt-3">Vas a eliminar <strong id="bulk-delete-count">0</strong> productos permanentemente.
</p>
<p class="text-muted small">Esta acción borrará los datos y las imágenes de la caché.</p>
<div class="d-grid gap-2 mt-3">
<button class="btn btn-danger-discord" onclick="executeBulkDelete()">
Eliminar permanentemente
</button>
<button class="btn btn-secondary" data-bs-dismiss="modal">Cancelar</button>
</div>
</div>
</div>
</div>
</div>
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/js/bootstrap.bundle.min.js"></script>
<script>
/* ── Theme ── */
function applyTheme(t) {
document.documentElement.setAttribute('data-theme', t);
const isDark = t === 'dark';
document.getElementById('theme-icon').className = isDark ? 'bi bi-sun me-2' : 'bi bi-moon-stars me-2';
document.getElementById('theme-label').innerText = isDark ? 'Modo Claro' : 'Modo Oscuro';
}
function toggleTheme() {
const next = document.documentElement.getAttribute('data-theme') === 'dark' ? 'light' : 'dark';
localStorage.setItem('theme', next);
applyTheme(next);
}
applyTheme(localStorage.getItem('theme') || 'light');
/* ── Socket.IO ── */
const socket = io();
const clp = new Intl.NumberFormat('es-CL', { style: 'currency', currency: 'CLP', minimumFractionDigits: 0 });
function formatAll() {
document.querySelectorAll('.price-cell').forEach(td => {
td.innerText = clp.format(td.getAttribute('data-value'));
});
}
formatAll();
socket.on('new_scan', d => {
document.getElementById('display-name').innerText = d.name;
document.getElementById('display-price').innerText = clp.format(d.price);
document.getElementById('display-barcode').innerText = d.barcode;
document.getElementById('display-img').src = d.image || './static/placeholder.png';
updateForm(d.barcode, d.name, d.price, d.image, 'Editando: ' + d.name);
});
socket.on('scan_error', d => {
const prompt = document.getElementById('new-product-prompt');
document.getElementById('new-barcode-display').innerText = d.barcode;
prompt.classList.remove('d-none');
updateForm(d.barcode, d.name || '', '', d.image || '', 'Crear: ' + d.barcode);
});
function updateForm(b, n, p, i, t) {
dismissPrompt();
document.getElementById('form-barcode').value = b;
document.getElementById('form-name').value = n;
document.getElementById('form-price').value = p;
document.getElementById('form-image').value = i;
document.getElementById('form-title').innerText = t;
}
function dismissPrompt() {
document.getElementById('new-product-prompt').classList.add('d-none');
}
function editProduct(b, n, p, i) {
document.getElementById('editProductName').innerText = n;
document.getElementById('editModal').dataset.barcode = b;
document.getElementById('editModal').dataset.name = n;
document.getElementById('editModal').dataset.price = p;
document.getElementById('editModal').dataset.image = i;
}
function confirmEdit() {
const modal = document.getElementById('editModal');
updateForm(
modal.dataset.barcode,
modal.dataset.name,
modal.dataset.price,
modal.dataset.image,
'Editando: ' + modal.dataset.name
);
window.scrollTo({ top: 0, behavior: 'smooth' });
bootstrap.Modal.getInstance(modal).hide();
}
function confirmDelete() {
const modal = document.getElementById('deleteModal');
const form = document.createElement('form');
form.action = `/delete/${modal.dataset.barcode}`;
form.method = 'POST';
form.style.display = 'none';
document.body.appendChild(form);
form.submit();
}
// Delete modal setup
document.getElementById('deleteModal').addEventListener('show.bs.modal', e => {
const button = e.relatedTarget;
document.getElementById('deleteProductName').innerText = button.dataset.name;
document.getElementById('deleteModal').dataset.barcode = button.dataset.barcode;
});
/* ── Bulk selection ── */
function toggleAll(src) {
const visibleRows = document.querySelectorAll('#inventoryTable tbody tr:not([style*="display: none"])');
visibleRows.forEach(row => {
const cb = row.querySelector('.product-checkbox');
if (cb) cb.checked = src.checked;
});
updateBulkBar();
}
function updateBulkBar() {
document.getElementById('selected-count').innerText =
document.querySelectorAll('.product-checkbox:checked').length;
}
function clearSelection() {
document.querySelectorAll('.product-checkbox').forEach(cb => cb.checked = false);
document.getElementById('select-all').checked = false;
updateBulkBar();
}
function applyBulkPrice() {
const price = document.getElementById('bulk-price-input').value;
const checked = document.querySelectorAll('.product-checkbox:checked');
if (!price || checked.length === 0) return;
// Set text in the pretty modal
document.getElementById('bulk-count-text').innerText = checked.length;
document.getElementById('bulk-price-text').innerText = clp.format(price);
// Show the modal
const bulkModal = new bootstrap.Modal(document.getElementById('bulkConfirmModal'));
bulkModal.show();
}
async function executeBulkPrice() {
const price = document.getElementById('bulk-price-input').value;
const checked = document.querySelectorAll('.product-checkbox:checked');
const barcodes = Array.from(checked).map(cb => cb.closest('tr').getAttribute('data-barcode'));
const res = await fetch('/bulk_price_update', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ barcodes, new_price: price })
});
if (res.ok) {
checked.forEach(cb => {
const cell = cb.closest('tr').querySelector('.price-cell');
cell.setAttribute('data-value', price);
cell.innerText = clp.format(price);
cb.checked = false;
});
document.getElementById('bulk-price-input').value = '';
updateBulkBar();
// Hide modal
const modalEl = document.getElementById('bulkConfirmModal');
bootstrap.Modal.getInstance(modalEl).hide();
}
}
function applyBulkDelete() {
const checked = document.querySelectorAll('.product-checkbox:checked');
if (checked.length === 0) return;
document.getElementById('bulk-delete-count').innerText = checked.length;
const delModal = new bootstrap.Modal(document.getElementById('bulkDeleteModal'));
delModal.show();
}
async function executeBulkDelete() {
const checked = document.querySelectorAll('.product-checkbox:checked');
const barcodes = Array.from(checked).map(cb => cb.closest('tr').getAttribute('data-barcode'));
const res = await fetch('/bulk_delete', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ barcodes })
});
if (res.ok) {
checked.forEach(cb => {
cb.closest('tr').remove(); // Remove the row from the table immediately
});
updateBulkBar();
const modalEl = document.getElementById('bulkDeleteModal');
bootstrap.Modal.getInstance(modalEl).hide();
} else {
alert("Error al eliminar productos.");
}
}
/* ── Search ── */
function searchTable() {
const q = document.getElementById('searchInput').value.toUpperCase();
document.querySelectorAll('#inventoryTable tbody tr').forEach(tr => {
tr.style.display = tr.innerText.toUpperCase().includes(q) ? '' : 'none';
});
// Reset select-all when search changes
document.getElementById('select-all').checked = false;
}
</script>
</body>
</html>