608 lines
32 KiB
PHP
608 lines
32 KiB
PHP
<?php
|
||
declare(strict_types=1);
|
||
|
||
require_once __DIR__ . '/pos_app.php';
|
||
|
||
pos_handle_post_request();
|
||
|
||
$projectName = pos_project_name();
|
||
$projectDescription = pos_project_description();
|
||
$projectImageUrl = pos_project_image_url();
|
||
$pageTitle = $projectName . ' | Punto de venta accesible';
|
||
$currentUser = pos_current_user();
|
||
$flashes = pos_pull_flashes();
|
||
$categoryMeta = pos_category_meta();
|
||
$products = pos_all_products();
|
||
$groupedProducts = [];
|
||
foreach ($categoryMeta as $category => $_meta) {
|
||
$groupedProducts[$category] = [];
|
||
}
|
||
foreach ($products as $product) {
|
||
$groupedProducts[$product['category']][] = $product;
|
||
}
|
||
$groupedProducts = array_filter($groupedProducts, static fn (array $items): bool => $items !== []);
|
||
$cartSummary = pos_cart_summary();
|
||
$recentSales = pos_recent_sales(8);
|
||
$metrics = pos_today_metrics();
|
||
$lowStockCount = pos_low_stock_count();
|
||
$lowStockProducts = pos_low_stock_products(6);
|
||
$cssVersion = file_exists(__DIR__ . '/assets/css/custom.css') ? (string)filemtime(__DIR__ . '/assets/css/custom.css') : (string)time();
|
||
$jsVersion = file_exists(__DIR__ . '/assets/js/main.js') ? (string)filemtime(__DIR__ . '/assets/js/main.js') : (string)time();
|
||
$flashClassMap = [
|
||
'success' => 'text-bg-success',
|
||
'danger' => 'text-bg-danger',
|
||
'warning' => 'text-bg-warning',
|
||
'secondary' => 'text-bg-secondary',
|
||
'info' => 'text-bg-primary',
|
||
];
|
||
$toLower = static function (string $value): string {
|
||
if (function_exists('mb_strtolower')) {
|
||
return mb_strtolower($value, 'UTF-8');
|
||
}
|
||
return strtolower($value);
|
||
};
|
||
?>
|
||
<!doctype html>
|
||
<html lang="es">
|
||
<head>
|
||
<meta charset="utf-8" />
|
||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||
<title><?= htmlspecialchars($pageTitle) ?></title>
|
||
<meta name="description" content="<?= htmlspecialchars($projectDescription) ?>" />
|
||
<meta name="robots" content="noindex, nofollow" />
|
||
<meta property="og:title" content="<?= htmlspecialchars($pageTitle) ?>" />
|
||
<meta property="og:description" content="<?= htmlspecialchars($projectDescription) ?>" />
|
||
<meta property="twitter:title" content="<?= htmlspecialchars($pageTitle) ?>" />
|
||
<meta property="twitter:description" content="<?= htmlspecialchars($projectDescription) ?>" />
|
||
<?php if ($projectImageUrl !== ''): ?>
|
||
<meta property="og:image" content="<?= htmlspecialchars($projectImageUrl) ?>" />
|
||
<meta property="twitter:image" content="<?= htmlspecialchars($projectImageUrl) ?>" />
|
||
<?php endif; ?>
|
||
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
||
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&display=swap" rel="stylesheet">
|
||
<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">
|
||
<link href="assets/css/custom.css?v=<?= htmlspecialchars($cssVersion) ?>" rel="stylesheet">
|
||
</head>
|
||
<body class="app-body">
|
||
<header class="topbar border-bottom">
|
||
<nav class="navbar navbar-expand-lg py-3">
|
||
<div class="container-xxl align-items-center">
|
||
<a class="navbar-brand brand-mark" href="index.php">
|
||
<span class="brand-mark__icon"><i class="bi bi-grid-1x2-fill"></i></span>
|
||
<span>
|
||
<small class="text-uppercase text-muted d-block">MVP de ventas</small>
|
||
<strong><?= htmlspecialchars($projectName) ?></strong>
|
||
</span>
|
||
</a>
|
||
<div class="d-flex align-items-center gap-2 ms-auto flex-wrap justify-content-end">
|
||
<a class="btn btn-outline-secondary btn-sm" href="healthz.php">Estado</a>
|
||
<?php if ($currentUser): ?>
|
||
<a class="btn btn-outline-secondary btn-sm" href="#catalogo">Catálogo</a>
|
||
<a class="btn btn-outline-secondary btn-sm" href="#carrito">Canasta</a>
|
||
<a class="btn btn-outline-secondary btn-sm" href="#ventas">Ventas</a>
|
||
<a class="btn btn-outline-secondary btn-sm" href="#stock">Stock</a>
|
||
<span class="badge rounded-pill text-bg-dark px-3 py-2"><?= htmlspecialchars($currentUser['role_label']) ?></span>
|
||
<span class="user-chip">
|
||
<strong><?= htmlspecialchars($currentUser['name']) ?></strong>
|
||
<small><?= htmlspecialchars($currentUser['branch']) ?></small>
|
||
</span>
|
||
<form method="post" class="d-inline-flex">
|
||
<input type="hidden" name="action" value="logout">
|
||
<button class="btn btn-outline-danger btn-sm" type="submit">Salir</button>
|
||
</form>
|
||
<?php endif; ?>
|
||
</div>
|
||
</div>
|
||
</nav>
|
||
</header>
|
||
|
||
<div class="toast-container position-fixed top-0 end-0 p-3">
|
||
<?php foreach ($flashes as $flash): ?>
|
||
<?php $flashClass = $flashClassMap[$flash['type']] ?? 'text-bg-dark'; ?>
|
||
<div class="toast align-items-center border-0 js-app-toast <?= htmlspecialchars($flashClass) ?>" role="status" aria-live="polite" aria-atomic="true" data-bs-delay="3600">
|
||
<div class="d-flex">
|
||
<div class="toast-body"><?= htmlspecialchars($flash['message']) ?></div>
|
||
<button type="button" class="btn-close btn-close-white me-2 m-auto" data-bs-dismiss="toast" aria-label="Cerrar"></button>
|
||
</div>
|
||
</div>
|
||
<?php endforeach; ?>
|
||
</div>
|
||
|
||
<main class="container-xxl py-4 py-lg-5">
|
||
<?php if (!$currentUser): ?>
|
||
<section class="row g-4 align-items-stretch mb-4">
|
||
<div class="col-lg-7">
|
||
<div class="panel hero-panel h-100">
|
||
<span class="eyebrow">Diseño limpio, alto contraste, flujo corto</span>
|
||
<h1 class="display-heading">Vende en tres pasos y con menos errores.</h1>
|
||
<p class="lead-copy">
|
||
Catálogo por categorías, canasta siempre visible, recibos claros y alertas de stock bajo.
|
||
Este primer MVP está listo para conectar con NestJS más adelante sin cambiar la experiencia de caja.
|
||
</p>
|
||
<div class="instruction-grid mt-4">
|
||
<div class="instruction-card">
|
||
<span>1</span>
|
||
<strong>Selecciona</strong>
|
||
<p>Elige una categoría con botones grandes y visibles.</p>
|
||
</div>
|
||
<div class="instruction-card">
|
||
<span>2</span>
|
||
<strong>Agrega</strong>
|
||
<p>Presiona “Agregar” una sola vez para llevarlo a la canasta.</p>
|
||
</div>
|
||
<div class="instruction-card">
|
||
<span>3</span>
|
||
<strong>Confirma</strong>
|
||
<p>Registra la venta y genera un recibo simple al instante.</p>
|
||
</div>
|
||
</div>
|
||
<div class="d-flex flex-wrap gap-3 mt-4">
|
||
<a class="btn btn-dark btn-lg px-4" href="#login">Iniciar sesión</a>
|
||
<a class="btn btn-outline-secondary btn-lg px-4" href="healthz.php">Ver estado del sistema</a>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
<div class="col-lg-5">
|
||
<div class="panel h-100 status-panel">
|
||
<div class="d-flex justify-content-between align-items-center mb-3">
|
||
<div>
|
||
<span class="eyebrow">Qué incluye esta entrega</span>
|
||
<h2 class="section-title mb-0">Primer flujo real</h2>
|
||
</div>
|
||
<span class="status-dot"></span>
|
||
</div>
|
||
<ul class="feature-list mb-0">
|
||
<li><i class="bi bi-check2-circle"></i> Login con roles Admin y Cajero</li>
|
||
<li><i class="bi bi-check2-circle"></i> Productos agrupados por categoría</li>
|
||
<li><i class="bi bi-check2-circle"></i> Canasta con total y anulación rápida</li>
|
||
<li><i class="bi bi-check2-circle"></i> Registro de venta con recibo</li>
|
||
<li><i class="bi bi-check2-circle"></i> Lista de ventas recientes</li>
|
||
<li><i class="bi bi-check2-circle"></i> Control de stock con alertas</li>
|
||
</ul>
|
||
<div class="integration-note mt-4">
|
||
<strong>Escalable:</strong> la UI queda lista para mover autenticación, productos y ventas a endpoints de NestJS en una siguiente iteración.
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</section>
|
||
|
||
<section id="login" class="row g-4 align-items-stretch">
|
||
<div class="col-lg-5">
|
||
<div class="panel h-100">
|
||
<span class="eyebrow">Acceso rápido con PIN</span>
|
||
<h2 class="section-title">Entrar al punto de venta</h2>
|
||
<p class="section-copy">Usa un código corto y un PIN de cuatro dígitos. Esto es ideal para una caja con operadores frecuentes.</p>
|
||
<form method="post" class="stack-form mt-4">
|
||
<input type="hidden" name="action" value="login">
|
||
<div>
|
||
<label class="form-label" for="access_code">Código de acceso</label>
|
||
<input class="form-control form-control-lg" id="access_code" name="access_code" placeholder="Ej. CAJA01" autocomplete="username" required>
|
||
</div>
|
||
<div>
|
||
<label class="form-label" for="pin">PIN</label>
|
||
<input class="form-control form-control-lg" id="pin" name="pin" type="password" placeholder="4 dígitos" inputmode="numeric" maxlength="4" autocomplete="current-password" required>
|
||
</div>
|
||
<button class="btn btn-dark btn-lg w-100" type="submit">
|
||
<i class="bi bi-box-arrow-in-right me-2"></i>Ingresar ahora
|
||
</button>
|
||
</form>
|
||
</div>
|
||
</div>
|
||
<div class="col-lg-7">
|
||
<div class="panel h-100">
|
||
<div class="d-flex justify-content-between align-items-center flex-wrap gap-2 mb-3">
|
||
<div>
|
||
<span class="eyebrow">Accesos de prueba</span>
|
||
<h2 class="section-title mb-0">Roles listos para probar</h2>
|
||
</div>
|
||
<span class="text-muted small">Autocompleta el formulario con un toque</span>
|
||
</div>
|
||
<div class="row g-3">
|
||
<?php foreach (pos_demo_users() as $code => $demoUser): ?>
|
||
<div class="col-md-4">
|
||
<button
|
||
class="demo-user-card text-start w-100"
|
||
type="button"
|
||
data-fill-login
|
||
data-code="<?= htmlspecialchars($code) ?>"
|
||
data-pin="<?= htmlspecialchars((string)$demoUser['pin']) ?>"
|
||
>
|
||
<span class="demo-role"><?= htmlspecialchars($demoUser['role_label']) ?></span>
|
||
<strong><?= htmlspecialchars($demoUser['name']) ?></strong>
|
||
<span class="demo-code">Código: <?= htmlspecialchars($code) ?></span>
|
||
<span class="demo-code">PIN: <?= htmlspecialchars((string)$demoUser['pin']) ?></span>
|
||
</button>
|
||
</div>
|
||
<?php endforeach; ?>
|
||
</div>
|
||
<div class="mini-grid mt-4">
|
||
<div class="mini-card">
|
||
<i class="bi bi-person-check"></i>
|
||
<strong>Admin</strong>
|
||
<p>Ajusta stock y supervisa alertas críticas.</p>
|
||
</div>
|
||
<div class="mini-card">
|
||
<i class="bi bi-cart-check"></i>
|
||
<strong>Cajero</strong>
|
||
<p>Vende rápido sin entrar a pantallas complejas.</p>
|
||
</div>
|
||
<div class="mini-card">
|
||
<i class="bi bi-receipt"></i>
|
||
<strong>Recibos</strong>
|
||
<p>Consulta la venta al detalle con un clic.</p>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</section>
|
||
<?php else: ?>
|
||
<section class="overview-grid mb-4">
|
||
<div class="panel overview-panel">
|
||
<span class="eyebrow">Caja activa</span>
|
||
<h1 class="display-heading mb-3">Hola, <?= htmlspecialchars($currentUser['name']) ?>.</h1>
|
||
<p class="lead-copy mb-0">Usa el flujo corto: categoría, agregar a canasta y confirmar venta. Todo lo esencial está visible en la misma pantalla.</p>
|
||
<div class="quick-step-row mt-4">
|
||
<span class="quick-step"><strong>1.</strong> Elegir categoría</span>
|
||
<span class="quick-step"><strong>2.</strong> Agregar productos</span>
|
||
<span class="quick-step"><strong>3.</strong> Registrar venta</span>
|
||
</div>
|
||
</div>
|
||
<div class="panel metric-card">
|
||
<span class="metric-label">Ventas hoy</span>
|
||
<strong><?= htmlspecialchars((string)$metrics['sales_count']) ?></strong>
|
||
<small>Operaciones registradas hoy</small>
|
||
</div>
|
||
<div class="panel metric-card">
|
||
<span class="metric-label">Total vendido</span>
|
||
<strong><?= htmlspecialchars(pos_format_money((float)$metrics['sales_total'])) ?></strong>
|
||
<small>Acumulado del día</small>
|
||
</div>
|
||
<div class="panel metric-card">
|
||
<span class="metric-label">Alertas</span>
|
||
<strong><?= htmlspecialchars((string)$lowStockCount) ?></strong>
|
||
<small>Productos con stock bajo</small>
|
||
</div>
|
||
</section>
|
||
|
||
<?php if ($lowStockCount > 0): ?>
|
||
<section class="panel alert-panel mb-4">
|
||
<div>
|
||
<span class="eyebrow text-warning-emphasis">Atención visible</span>
|
||
<h2 class="section-title mb-1">Hay productos con stock por debajo del umbral.</h2>
|
||
<p class="section-copy mb-0">Esto ayuda a evitar ventas fallidas y a reponer inventario a tiempo.</p>
|
||
</div>
|
||
<a class="btn btn-outline-secondary" href="#stock">Revisar stock</a>
|
||
</section>
|
||
<?php endif; ?>
|
||
|
||
<section class="row g-4 align-items-start">
|
||
<div class="col-xl-8">
|
||
<div class="panel mb-4" id="catalogo">
|
||
<div class="d-flex flex-column flex-lg-row justify-content-between gap-3 align-items-lg-center mb-4">
|
||
<div>
|
||
<span class="eyebrow">Catálogo por categorías</span>
|
||
<h2 class="section-title mb-1">Selecciona productos</h2>
|
||
<p class="section-copy mb-0">Filtros grandes, categorías claras y botones de acción directos.</p>
|
||
</div>
|
||
<div class="search-shell">
|
||
<label class="form-label visually-hidden" for="product-search">Buscar producto</label>
|
||
<div class="input-group input-group-lg">
|
||
<span class="input-group-text"><i class="bi bi-search"></i></span>
|
||
<input id="product-search" class="form-control" type="search" placeholder="Buscar producto" aria-label="Buscar producto">
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="filter-toolbar mb-4" role="tablist" aria-label="Filtro por categoría">
|
||
<button class="btn btn-dark is-active" type="button" data-filter-category="all">Todas</button>
|
||
<?php foreach ($groupedProducts as $category => $items): ?>
|
||
<?php $meta = $categoryMeta[$category] ?? ['icon' => 'bi-box-seam', 'description' => 'Productos']; ?>
|
||
<button class="btn btn-outline-secondary" type="button" data-filter-category="<?= htmlspecialchars($category) ?>">
|
||
<i class="bi <?= htmlspecialchars($meta['icon']) ?> me-2"></i><?= htmlspecialchars($category) ?>
|
||
</button>
|
||
<?php endforeach; ?>
|
||
</div>
|
||
|
||
<div id="catalog-results">
|
||
<?php foreach ($groupedProducts as $category => $items): ?>
|
||
<?php $meta = $categoryMeta[$category] ?? ['icon' => 'bi-box-seam', 'description' => 'Productos']; ?>
|
||
<section class="catalog-section mb-4" data-category-section="<?= htmlspecialchars($category) ?>">
|
||
<div class="catalog-section__header">
|
||
<div>
|
||
<span class="category-pill"><i class="bi <?= htmlspecialchars($meta['icon']) ?>"></i><?= htmlspecialchars($category) ?></span>
|
||
<p class="section-copy mb-0"><?= htmlspecialchars($meta['description']) ?></p>
|
||
</div>
|
||
<span class="text-muted small"><?= htmlspecialchars((string)count($items)) ?> productos</span>
|
||
</div>
|
||
<div class="row g-3 mt-1">
|
||
<?php foreach ($items as $product): ?>
|
||
<?php
|
||
$stock = (int)$product['stock'];
|
||
$isLow = $stock <= (int)$product['low_stock_threshold'];
|
||
$searchData = $toLower((string)$product['name'] . ' ' . (string)$product['category']);
|
||
?>
|
||
<div class="col-sm-6 col-xxl-4 product-col" data-product-card data-search="<?= htmlspecialchars($searchData) ?>" data-category="<?= htmlspecialchars((string)$product['category']) ?>">
|
||
<article class="product-card h-100">
|
||
<div class="product-card__head">
|
||
<span class="icon-surface"><i class="bi <?= htmlspecialchars($meta['icon']) ?>"></i></span>
|
||
<span class="stock-badge <?= $isLow ? 'is-low' : '' ?>">
|
||
<?= $isLow ? 'Stock bajo' : 'Disponible' ?>
|
||
</span>
|
||
</div>
|
||
<div class="product-card__body">
|
||
<h3><?= htmlspecialchars((string)$product['name']) ?></h3>
|
||
<p><?= htmlspecialchars((string)$product['unit_label']) ?> · <?= htmlspecialchars((string)$stock) ?> en stock</p>
|
||
</div>
|
||
<div class="product-card__footer">
|
||
<div>
|
||
<div class="price-tag"><?= htmlspecialchars(pos_format_money((float)$product['price'])) ?></div>
|
||
<small class="text-muted">Categoría: <?= htmlspecialchars((string)$product['category']) ?></small>
|
||
</div>
|
||
<form method="post">
|
||
<input type="hidden" name="action" value="add_to_cart">
|
||
<input type="hidden" name="product_id" value="<?= htmlspecialchars((string)$product['id']) ?>">
|
||
<button class="btn btn-dark btn-action w-100 mt-3" type="submit" <?= $stock < 1 ? 'disabled' : '' ?>>
|
||
<i class="bi bi-plus-lg me-2"></i><?= $stock < 1 ? 'Sin stock' : 'Agregar a canasta' ?>
|
||
</button>
|
||
</form>
|
||
</div>
|
||
</article>
|
||
</div>
|
||
<?php endforeach; ?>
|
||
</div>
|
||
</section>
|
||
<?php endforeach; ?>
|
||
</div>
|
||
|
||
<div class="empty-search-state d-none" id="empty-search-state">
|
||
<i class="bi bi-search-heart"></i>
|
||
<strong>No encontramos productos con ese filtro.</strong>
|
||
<p>Prueba otra categoría o borra la búsqueda.</p>
|
||
</div>
|
||
</div>
|
||
|
||
<section class="panel mb-4" id="ventas">
|
||
<div class="d-flex justify-content-between align-items-center flex-wrap gap-3 mb-3">
|
||
<div>
|
||
<span class="eyebrow">Historial</span>
|
||
<h2 class="section-title mb-1">Ventas recientes</h2>
|
||
<p class="section-copy mb-0">Cada venta queda guardada con total, fecha y enlace al recibo.</p>
|
||
</div>
|
||
<?php if (!empty($_SESSION['pos_last_receipt_id'])): ?>
|
||
<a class="btn btn-outline-secondary" href="receipt.php?id=<?= htmlspecialchars((string)$_SESSION['pos_last_receipt_id']) ?>">Ver último recibo</a>
|
||
<?php endif; ?>
|
||
</div>
|
||
<?php if ($recentSales === []): ?>
|
||
<div class="empty-state">
|
||
<i class="bi bi-receipt-cutoff"></i>
|
||
<strong>Todavía no hay ventas registradas.</strong>
|
||
<p>Cuando confirmes una venta aparecerá aquí automáticamente.</p>
|
||
</div>
|
||
<?php else: ?>
|
||
<div class="table-responsive">
|
||
<table class="table table-borderless align-middle sales-table mb-0">
|
||
<thead>
|
||
<tr>
|
||
<th>Recibo</th>
|
||
<th>Fecha</th>
|
||
<th>Cajero</th>
|
||
<th>Items</th>
|
||
<th>Total</th>
|
||
<th></th>
|
||
</tr>
|
||
</thead>
|
||
<tbody>
|
||
<?php foreach ($recentSales as $sale): ?>
|
||
<tr>
|
||
<td>
|
||
<strong><?= htmlspecialchars((string)$sale['receipt_number']) ?></strong>
|
||
<div class="text-muted small"><?= htmlspecialchars((string)$sale['cashier_role']) ?></div>
|
||
</td>
|
||
<td><?= htmlspecialchars(pos_format_datetime((string)$sale['created_at'])) ?></td>
|
||
<td><?= htmlspecialchars((string)$sale['cashier_name']) ?></td>
|
||
<td><?= htmlspecialchars((string)$sale['item_count']) ?></td>
|
||
<td><strong><?= htmlspecialchars(pos_format_money((float)$sale['total'])) ?></strong></td>
|
||
<td class="text-end">
|
||
<a class="btn btn-outline-secondary btn-sm" href="receipt.php?id=<?= htmlspecialchars((string)$sale['id']) ?>">Ver detalle</a>
|
||
</td>
|
||
</tr>
|
||
<?php endforeach; ?>
|
||
</tbody>
|
||
</table>
|
||
</div>
|
||
<?php endif; ?>
|
||
</section>
|
||
|
||
<section class="panel" id="stock">
|
||
<?php if (pos_is_admin()): ?>
|
||
<div class="d-flex justify-content-between align-items-center flex-wrap gap-3 mb-3">
|
||
<div>
|
||
<span class="eyebrow">Administración</span>
|
||
<h2 class="section-title mb-1">Control de stock</h2>
|
||
<p class="section-copy mb-0">Ajusta inventario con botones directos y alertas visibles cuando la cantidad sea crítica.</p>
|
||
</div>
|
||
<span class="badge text-bg-light border">Solo admin</span>
|
||
</div>
|
||
<div class="table-responsive">
|
||
<table class="table table-borderless align-middle stock-table mb-0">
|
||
<thead>
|
||
<tr>
|
||
<th>Producto</th>
|
||
<th>Categoría</th>
|
||
<th>Stock actual</th>
|
||
<th>Umbral</th>
|
||
<th class="text-end">Acciones</th>
|
||
</tr>
|
||
</thead>
|
||
<tbody>
|
||
<?php foreach ($products as $product): ?>
|
||
<?php $isLow = (int)$product['stock'] <= (int)$product['low_stock_threshold']; ?>
|
||
<tr class="<?= $isLow ? 'row-alert' : '' ?>">
|
||
<td>
|
||
<strong><?= htmlspecialchars((string)$product['name']) ?></strong>
|
||
</td>
|
||
<td><?= htmlspecialchars((string)$product['category']) ?></td>
|
||
<td>
|
||
<span class="stock-figure"><?= htmlspecialchars((string)$product['stock']) ?></span>
|
||
<?php if ($isLow): ?>
|
||
<span class="badge text-bg-warning ms-2">Bajo</span>
|
||
<?php endif; ?>
|
||
</td>
|
||
<td><?= htmlspecialchars((string)$product['low_stock_threshold']) ?></td>
|
||
<td class="text-end">
|
||
<div class="stock-actions justify-content-end">
|
||
<?php foreach ([-5, -1, 1, 5] as $delta): ?>
|
||
<form method="post">
|
||
<input type="hidden" name="action" value="adjust_stock">
|
||
<input type="hidden" name="product_id" value="<?= htmlspecialchars((string)$product['id']) ?>">
|
||
<input type="hidden" name="delta" value="<?= htmlspecialchars((string)$delta) ?>">
|
||
<button class="btn <?= $delta > 0 ? 'btn-dark' : 'btn-outline-secondary' ?> btn-sm" type="submit">
|
||
<?= $delta > 0 ? '+' . $delta : (string)$delta ?>
|
||
</button>
|
||
</form>
|
||
<?php endforeach; ?>
|
||
</div>
|
||
</td>
|
||
</tr>
|
||
<?php endforeach; ?>
|
||
</tbody>
|
||
</table>
|
||
</div>
|
||
<?php else: ?>
|
||
<div class="d-flex justify-content-between align-items-center flex-wrap gap-3 mb-3">
|
||
<div>
|
||
<span class="eyebrow">Avisos visibles</span>
|
||
<h2 class="section-title mb-1">Stock que requiere apoyo del administrador</h2>
|
||
<p class="section-copy mb-0">Como cajero puedes ver qué productos están por acabarse para pedir reposición.</p>
|
||
</div>
|
||
<span class="badge text-bg-light border">Lectura</span>
|
||
</div>
|
||
<?php if ($lowStockProducts === []): ?>
|
||
<div class="empty-state compact">
|
||
<i class="bi bi-check2-square"></i>
|
||
<strong>Todo el stock está dentro del nivel esperado.</strong>
|
||
<p>No hay alertas activas por ahora.</p>
|
||
</div>
|
||
<?php else: ?>
|
||
<div class="row g-3">
|
||
<?php foreach ($lowStockProducts as $product): ?>
|
||
<div class="col-md-6">
|
||
<div class="compact-alert-card">
|
||
<strong><?= htmlspecialchars((string)$product['name']) ?></strong>
|
||
<span><?= htmlspecialchars((string)$product['category']) ?></span>
|
||
<small>Actual: <?= htmlspecialchars((string)$product['stock']) ?> · Umbral: <?= htmlspecialchars((string)$product['low_stock_threshold']) ?></small>
|
||
</div>
|
||
</div>
|
||
<?php endforeach; ?>
|
||
</div>
|
||
<?php endif; ?>
|
||
<?php endif; ?>
|
||
</section>
|
||
</div>
|
||
|
||
<div class="col-xl-4">
|
||
<aside class="panel cart-panel" id="carrito">
|
||
<div class="d-flex justify-content-between align-items-start gap-3 mb-3">
|
||
<div>
|
||
<span class="eyebrow">Canasta visible</span>
|
||
<h2 class="section-title mb-1">Venta en curso</h2>
|
||
<p class="section-copy mb-0">Revisa productos y total antes de cobrar.</p>
|
||
</div>
|
||
<span class="count-chip"><?= htmlspecialchars((string)$cartSummary['item_count']) ?> ítems</span>
|
||
</div>
|
||
|
||
<?php if ($cartSummary['items'] === []): ?>
|
||
<div class="empty-state compact">
|
||
<i class="bi bi-cart-x"></i>
|
||
<strong>La canasta está vacía.</strong>
|
||
<p>Agrega productos para comenzar una venta.</p>
|
||
</div>
|
||
<?php else: ?>
|
||
<div class="cart-items">
|
||
<?php foreach ($cartSummary['items'] as $item): ?>
|
||
<article class="cart-item">
|
||
<div>
|
||
<h3><?= htmlspecialchars((string)$item['name']) ?></h3>
|
||
<p><?= htmlspecialchars((string)$item['category']) ?> · <?= htmlspecialchars(pos_format_money((float)$item['price'])) ?></p>
|
||
<?php if ($item['low_stock']): ?>
|
||
<span class="badge text-bg-warning">Stock bajo</span>
|
||
<?php endif; ?>
|
||
</div>
|
||
<div class="text-end">
|
||
<strong class="d-block mb-2"><?= htmlspecialchars(pos_format_money((float)$item['line_total'])) ?></strong>
|
||
<div class="cart-item__controls">
|
||
<form method="post">
|
||
<input type="hidden" name="action" value="change_qty">
|
||
<input type="hidden" name="product_id" value="<?= htmlspecialchars((string)$item['id']) ?>">
|
||
<input type="hidden" name="delta" value="-1">
|
||
<button class="btn btn-outline-secondary btn-sm" type="submit" aria-label="Disminuir cantidad">−</button>
|
||
</form>
|
||
<span class="qty-pill"><?= htmlspecialchars((string)$item['quantity']) ?></span>
|
||
<form method="post">
|
||
<input type="hidden" name="action" value="change_qty">
|
||
<input type="hidden" name="product_id" value="<?= htmlspecialchars((string)$item['id']) ?>">
|
||
<input type="hidden" name="delta" value="1">
|
||
<button class="btn btn-outline-secondary btn-sm" type="submit" aria-label="Aumentar cantidad" <?= $item['max_reached'] ? 'disabled' : '' ?>>+</button>
|
||
</form>
|
||
<form method="post">
|
||
<input type="hidden" name="action" value="remove_item">
|
||
<input type="hidden" name="product_id" value="<?= htmlspecialchars((string)$item['id']) ?>">
|
||
<button class="btn btn-outline-danger btn-sm" type="submit">Quitar</button>
|
||
</form>
|
||
</div>
|
||
</div>
|
||
</article>
|
||
<?php endforeach; ?>
|
||
</div>
|
||
|
||
<div class="totals-box mt-4">
|
||
<div class="d-flex justify-content-between">
|
||
<span>Subtotal</span>
|
||
<strong><?= htmlspecialchars(pos_format_money((float)$cartSummary['subtotal'])) ?></strong>
|
||
</div>
|
||
<div class="d-flex justify-content-between total-line mt-2">
|
||
<span>Total a pagar</span>
|
||
<strong><?= htmlspecialchars(pos_format_money((float)$cartSummary['total'])) ?></strong>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="d-grid gap-3 mt-4">
|
||
<form method="post">
|
||
<input type="hidden" name="action" value="checkout">
|
||
<button class="btn btn-dark btn-lg w-100" type="submit">
|
||
<i class="bi bi-check2-circle me-2"></i>Registrar venta
|
||
</button>
|
||
</form>
|
||
<form method="post" data-confirm="¿Deseas anular la venta en curso? Esta acción vaciará la canasta.">
|
||
<input type="hidden" name="action" value="cancel_cart">
|
||
<button class="btn btn-outline-danger btn-lg w-100" type="submit">
|
||
<i class="bi bi-x-circle me-2"></i>Anular venta
|
||
</button>
|
||
</form>
|
||
</div>
|
||
<?php endif; ?>
|
||
|
||
<div class="info-callout mt-4">
|
||
<i class="bi bi-info-circle"></i>
|
||
<p>Al confirmar, el sistema descuenta stock y guarda un recibo consultable desde “Ventas recientes”.</p>
|
||
</div>
|
||
</aside>
|
||
</div>
|
||
</section>
|
||
<?php endif; ?>
|
||
</main>
|
||
|
||
<footer class="site-footer border-top py-4">
|
||
<div class="container-xxl d-flex flex-column flex-lg-row justify-content-between gap-2 text-muted small">
|
||
<span><?= htmlspecialchars($projectName) ?> · Punto de venta accesible · <?= htmlspecialchars(gmdate('d/m/Y H:i')) ?> UTC</span>
|
||
<span>Listo para evolucionar a API + NestJS en una siguiente iteración.</span>
|
||
</div>
|
||
</footer>
|
||
|
||
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/js/bootstrap.bundle.min.js" defer></script>
|
||
<script src="assets/js/main.js?v=<?= htmlspecialchars($jsVersion) ?>" defer></script>
|
||
</body>
|
||
</html>
|