Compare commits

..

1 Commits

Author SHA1 Message Date
Flatlogic Bot
3cfcfefee3 v 0.1 2026-05-11 21:40:02 +00:00
7 changed files with 2401 additions and 527 deletions

File diff suppressed because it is too large Load Diff

View File

@ -1,39 +1,110 @@
document.addEventListener('DOMContentLoaded', () => { document.addEventListener('DOMContentLoaded', () => {
const chatForm = document.getElementById('chat-form'); const toastElements = document.querySelectorAll('.js-app-toast');
const chatInput = document.getElementById('chat-input'); toastElements.forEach((element) => {
const chatMessages = document.getElementById('chat-messages'); if (window.bootstrap && window.bootstrap.Toast) {
const toast = new window.bootstrap.Toast(element);
const appendMessage = (text, sender) => { toast.show();
const msgDiv = document.createElement('div'); }
msgDiv.classList.add('message', sender);
msgDiv.textContent = text;
chatMessages.appendChild(msgDiv);
chatMessages.scrollTop = chatMessages.scrollHeight;
};
chatForm.addEventListener('submit', async (e) => {
e.preventDefault();
const message = chatInput.value.trim();
if (!message) return;
appendMessage(message, 'visitor');
chatInput.value = '';
try {
const response = await fetch('api/chat.php', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ message })
}); });
const data = await response.json();
// Artificial delay for realism const accessCodeInput = document.getElementById('access_code');
setTimeout(() => { const pinInput = document.getElementById('pin');
appendMessage(data.reply, 'bot'); document.querySelectorAll('[data-fill-login]').forEach((button) => {
}, 500); button.addEventListener('click', () => {
} catch (error) { if (accessCodeInput) {
console.error('Error:', error); accessCodeInput.value = button.dataset.code || '';
appendMessage("Sorry, something went wrong. Please try again.", 'bot'); }
if (pinInput) {
pinInput.value = button.dataset.pin || '';
pinInput.focus();
pinInput.select();
} }
}); });
}); });
document.querySelectorAll('form[data-confirm]').forEach((form) => {
form.addEventListener('submit', (event) => {
const message = form.getAttribute('data-confirm') || '¿Deseas continuar?';
if (!window.confirm(message)) {
event.preventDefault();
}
});
});
const searchInput = document.getElementById('product-search');
const categoryButtons = document.querySelectorAll('[data-filter-category]');
const categorySections = document.querySelectorAll('[data-category-section]');
const productCards = document.querySelectorAll('[data-product-card]');
const emptySearchState = document.getElementById('empty-search-state');
let activeCategory = 'all';
const applyCatalogFilters = () => {
if (!categorySections.length) {
return;
}
const query = (searchInput?.value || '').trim().toLowerCase();
let visibleCount = 0;
categorySections.forEach((section) => {
const sectionCategory = section.getAttribute('data-category-section') || '';
const cards = section.querySelectorAll('[data-product-card]');
let sectionVisibleCards = 0;
cards.forEach((card) => {
const matchesCategory = activeCategory === 'all' || card.getAttribute('data-category') === activeCategory;
const haystack = (card.getAttribute('data-search') || '').toLowerCase();
const matchesSearch = query === '' || haystack.includes(query);
const shouldShow = matchesCategory && matchesSearch;
const wrapper = card.closest('.product-col');
if (wrapper) {
wrapper.classList.toggle('d-none', !shouldShow);
}
if (shouldShow) {
sectionVisibleCards += 1;
visibleCount += 1;
}
});
const sectionShouldShow = (activeCategory === 'all' || sectionCategory === activeCategory) && sectionVisibleCards > 0;
section.classList.toggle('d-none', !sectionShouldShow);
});
if (emptySearchState) {
emptySearchState.classList.toggle('d-none', visibleCount > 0);
}
};
categoryButtons.forEach((button) => {
button.addEventListener('click', () => {
activeCategory = button.getAttribute('data-filter-category') || 'all';
categoryButtons.forEach((candidate) => {
const isActive = candidate === button;
candidate.classList.toggle('is-active', isActive);
if (isActive) {
candidate.classList.remove('btn-outline-secondary');
candidate.classList.add('btn-dark');
} else {
candidate.classList.remove('btn-dark');
candidate.classList.add('btn-outline-secondary');
}
});
applyCatalogFilters();
});
});
if (searchInput) {
searchInput.addEventListener('input', applyCatalogFilters);
}
if (productCards.length) {
applyCatalogFilters();
}
const printButton = document.getElementById('print-receipt');
if (printButton) {
printButton.addEventListener('click', () => {
window.print();
});
}
});

244
db/pos_bootstrap.php Normal file
View File

@ -0,0 +1,244 @@
<?php
declare(strict_types=1);
require_once __DIR__ . '/config.php';
function pos_bootstrap(): void
{
static $booted = false;
if ($booted) {
return;
}
$pdo = db();
$pdo->exec(
"CREATE TABLE IF NOT EXISTS pos_products (
id INT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
name VARCHAR(120) NOT NULL,
category VARCHAR(80) NOT NULL,
price DECIMAL(10,2) NOT NULL DEFAULT 0,
stock INT NOT NULL DEFAULT 0,
low_stock_threshold INT NOT NULL DEFAULT 5,
unit_label VARCHAR(30) NOT NULL DEFAULT 'unidad',
sort_order INT NOT NULL DEFAULT 0,
is_active TINYINT(1) NOT NULL DEFAULT 1,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
KEY idx_pos_products_category (category),
KEY idx_pos_products_active (is_active)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci"
);
$pdo->exec(
"CREATE TABLE IF NOT EXISTS pos_sales (
id INT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
receipt_number VARCHAR(40) NOT NULL UNIQUE,
cashier_code VARCHAR(40) NOT NULL,
cashier_name VARCHAR(120) NOT NULL,
cashier_role VARCHAR(30) NOT NULL,
item_count INT NOT NULL DEFAULT 0,
subtotal DECIMAL(10,2) NOT NULL DEFAULT 0,
total DECIMAL(10,2) NOT NULL DEFAULT 0,
items_json LONGTEXT NOT NULL,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
KEY idx_pos_sales_created (created_at)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci"
);
$count = (int)$pdo->query("SELECT COUNT(*) FROM pos_products")->fetchColumn();
if ($count === 0) {
$seedProducts = [
['Manzana roja', 'Frutas', 1.20, 18, 5, 'unidad', 10],
['Plátano', 'Frutas', 0.60, 22, 6, 'unidad', 20],
['Naranja dulce', 'Frutas', 0.90, 9, 4, 'unidad', 30],
['Zanahoria', 'Verduras', 0.70, 14, 5, 'unidad', 40],
['Lechuga romana', 'Verduras', 1.50, 6, 3, 'unidad', 50],
['Tomate saladet', 'Verduras', 1.10, 8, 4, 'unidad', 60],
['Leche entera 1L', 'Lácteos', 1.80, 12, 4, 'botella', 70],
['Yogur natural', 'Lácteos', 0.95, 4, 3, 'unidad', 80],
['Queso fresco', 'Lácteos', 2.60, 5, 3, 'pieza', 90],
['Pan integral', 'Despensa', 1.30, 10, 4, 'pieza', 100],
['Arroz 1kg', 'Despensa', 2.10, 7, 3, 'paquete', 110],
['Café molido', 'Despensa', 4.90, 3, 2, 'paquete', 120],
];
$stmt = $pdo->prepare(
"INSERT INTO pos_products (
name,
category,
price,
stock,
low_stock_threshold,
unit_label,
sort_order
) VALUES (
:name,
:category,
:price,
:stock,
:low_stock_threshold,
:unit_label,
:sort_order
)"
);
foreach ($seedProducts as [$name, $category, $price, $stock, $threshold, $unitLabel, $sortOrder]) {
$stmt->execute([
'name' => $name,
'category' => $category,
'price' => $price,
'stock' => $stock,
'low_stock_threshold' => $threshold,
'unit_label' => $unitLabel,
'sort_order' => $sortOrder,
]);
}
}
$booted = true;
}
function pos_all_products(): array
{
$stmt = db()->query(
"SELECT id, name, category, price, stock, low_stock_threshold, unit_label, sort_order
FROM pos_products
WHERE is_active = 1
ORDER BY FIELD(category, 'Frutas', 'Verduras', 'Lácteos', 'Despensa'), sort_order, name"
);
return $stmt->fetchAll() ?: [];
}
function pos_product_by_id(int $productId): ?array
{
$stmt = db()->prepare(
"SELECT id, name, category, price, stock, low_stock_threshold, unit_label, sort_order
FROM pos_products
WHERE id = :id AND is_active = 1
LIMIT 1"
);
$stmt->execute(['id' => $productId]);
$product = $stmt->fetch();
return $product ?: null;
}
function pos_products_by_ids(array $productIds): array
{
$productIds = array_values(array_filter(array_map('intval', $productIds), static fn (int $id): bool => $id > 0));
if ($productIds === []) {
return [];
}
$placeholders = implode(',', array_fill(0, count($productIds), '?'));
$stmt = db()->prepare(
"SELECT id, name, category, price, stock, low_stock_threshold, unit_label, sort_order
FROM pos_products
WHERE is_active = 1 AND id IN ($placeholders)"
);
$stmt->execute($productIds);
$rows = $stmt->fetchAll() ?: [];
$map = [];
foreach ($rows as $row) {
$map[(int)$row['id']] = $row;
}
return $map;
}
function pos_recent_sales(int $limit = 8): array
{
$limit = max(1, min(25, $limit));
$stmt = db()->query(
"SELECT id, receipt_number, cashier_name, cashier_role, item_count, subtotal, total, created_at
FROM pos_sales
ORDER BY created_at DESC
LIMIT {$limit}"
);
return $stmt->fetchAll() ?: [];
}
function pos_sale_by_id(int $saleId): ?array
{
$stmt = db()->prepare(
"SELECT id, receipt_number, cashier_code, cashier_name, cashier_role, item_count, subtotal, total, items_json, created_at
FROM pos_sales
WHERE id = :id
LIMIT 1"
);
$stmt->execute(['id' => $saleId]);
$sale = $stmt->fetch();
if (!$sale) {
return null;
}
$items = json_decode((string)$sale['items_json'], true);
$sale['items'] = is_array($items) ? $items : [];
return $sale;
}
function pos_low_stock_count(): int
{
$stmt = db()->query(
"SELECT COUNT(*)
FROM pos_products
WHERE is_active = 1 AND stock <= low_stock_threshold"
);
return (int)$stmt->fetchColumn();
}
function pos_low_stock_products(int $limit = 6): array
{
$limit = max(1, min(20, $limit));
$stmt = db()->query(
"SELECT id, name, category, price, stock, low_stock_threshold, unit_label, sort_order
FROM pos_products
WHERE is_active = 1 AND stock <= low_stock_threshold
ORDER BY stock ASC, sort_order ASC, name ASC
LIMIT {$limit}"
);
return $stmt->fetchAll() ?: [];
}
function pos_today_metrics(): array
{
$stmt = db()->query(
"SELECT COUNT(*) AS sales_count, COALESCE(SUM(total), 0) AS sales_total
FROM pos_sales
WHERE DATE(created_at) = CURDATE()"
);
$row = $stmt->fetch() ?: ['sales_count' => 0, 'sales_total' => 0];
return [
'sales_count' => (int)($row['sales_count'] ?? 0),
'sales_total' => (float)($row['sales_total'] ?? 0),
];
}
function pos_adjust_stock(int $productId, int $delta): ?array
{
$product = pos_product_by_id($productId);
if (!$product) {
return null;
}
$newStock = max(0, (int)$product['stock'] + $delta);
$stmt = db()->prepare(
"UPDATE pos_products
SET stock = :stock
WHERE id = :id
LIMIT 1"
);
$stmt->execute([
'stock' => $newStock,
'id' => $productId,
]);
return pos_product_by_id($productId);
}

24
healthz.php Normal file
View File

@ -0,0 +1,24 @@
<?php
declare(strict_types=1);
header('Content-Type: application/json; charset=utf-8');
try {
require_once __DIR__ . '/db/pos_bootstrap.php';
pos_bootstrap();
$pdo = db();
$pdo->query('SELECT 1');
echo json_encode([
'status' => 'ok',
'time' => gmdate('c'),
'php' => PHP_VERSION,
'database' => 'connected',
], JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES);
} catch (Throwable $exception) {
http_response_code(500);
echo json_encode([
'status' => 'error',
'time' => gmdate('c'),
'message' => 'health-check failed',
], JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES);
}

713
index.php
View File

@ -1,150 +1,607 @@
<?php <?php
declare(strict_types=1); declare(strict_types=1);
@ini_set('display_errors', '1');
@error_reporting(E_ALL);
@date_default_timezone_set('UTC');
$phpVersion = PHP_VERSION; require_once __DIR__ . '/pos_app.php';
$now = date('Y-m-d H:i:s');
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> <!doctype html>
<html lang="en"> <html lang="es">
<head> <head>
<meta charset="utf-8" /> <meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" /> <meta name="viewport" content="width=device-width, initial-scale=1" />
<title>New Style</title> <title><?= htmlspecialchars($pageTitle) ?></title>
<?php <meta name="description" content="<?= htmlspecialchars($projectDescription) ?>" />
// Read project preview data from environment <meta name="robots" content="noindex, nofollow" />
$projectDescription = $_SERVER['PROJECT_DESCRIPTION'] ?? ''; <meta property="og:title" content="<?= htmlspecialchars($pageTitle) ?>" />
$projectImageUrl = $_SERVER['PROJECT_IMAGE_URL'] ?? '';
?>
<?php if ($projectDescription): ?>
<!-- Meta description -->
<meta name="description" content='<?= htmlspecialchars($projectDescription) ?>' />
<!-- Open Graph meta tags -->
<meta property="og:description" content="<?= htmlspecialchars($projectDescription) ?>" /> <meta property="og:description" content="<?= htmlspecialchars($projectDescription) ?>" />
<!-- Twitter meta tags --> <meta property="twitter:title" content="<?= htmlspecialchars($pageTitle) ?>" />
<meta property="twitter:description" content="<?= htmlspecialchars($projectDescription) ?>" /> <meta property="twitter:description" content="<?= htmlspecialchars($projectDescription) ?>" />
<?php endif; ?> <?php if ($projectImageUrl !== ''): ?>
<?php if ($projectImageUrl): ?>
<!-- Open Graph image -->
<meta property="og:image" content="<?= htmlspecialchars($projectImageUrl) ?>" /> <meta property="og:image" content="<?= htmlspecialchars($projectImageUrl) ?>" />
<!-- Twitter image -->
<meta property="twitter:image" content="<?= htmlspecialchars($projectImageUrl) ?>" /> <meta property="twitter:image" content="<?= htmlspecialchars($projectImageUrl) ?>" />
<?php endif; ?> <?php endif; ?>
<link rel="preconnect" href="https://fonts.googleapis.com"> <link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin> <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;700&display=swap" rel="stylesheet"> <link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&display=swap" rel="stylesheet">
<style> <link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/css/bootstrap.min.css" rel="stylesheet">
:root { <link href="https://cdn.jsdelivr.net/npm/bootstrap-icons@1.11.3/font/bootstrap-icons.min.css" rel="stylesheet">
--bg-color-start: #6a11cb; <link href="assets/css/custom.css?v=<?= htmlspecialchars($cssVersion) ?>" rel="stylesheet">
--bg-color-end: #2575fc;
--text-color: #ffffff;
--card-bg-color: rgba(255, 255, 255, 0.01);
--card-border-color: rgba(255, 255, 255, 0.1);
}
body {
margin: 0;
font-family: 'Inter', sans-serif;
background: linear-gradient(45deg, var(--bg-color-start), var(--bg-color-end));
color: var(--text-color);
display: flex;
justify-content: center;
align-items: center;
min-height: 100vh;
text-align: center;
overflow: hidden;
position: relative;
}
body::before {
content: '';
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
background-image: url('data:image/svg+xml,<svg xmlns="http://www.w3.org/2000/svg" width="100" height="100" viewBox="0 0 100 100"><path d="M-10 10L110 10M10 -10L10 110" stroke-width="1" stroke="rgba(255,255,255,0.05)"/></svg>');
animation: bg-pan 20s linear infinite;
z-index: -1;
}
@keyframes bg-pan {
0% { background-position: 0% 0%; }
100% { background-position: 100% 100%; }
}
main {
padding: 2rem;
}
.card {
background: var(--card-bg-color);
border: 1px solid var(--card-border-color);
border-radius: 16px;
padding: 2rem;
backdrop-filter: blur(20px);
-webkit-backdrop-filter: blur(20px);
box-shadow: 0 8px 32px 0 rgba(0, 0, 0, 0.1);
}
.loader {
margin: 1.25rem auto 1.25rem;
width: 48px;
height: 48px;
border: 3px solid rgba(255, 255, 255, 0.25);
border-top-color: #fff;
border-radius: 50%;
animation: spin 1s linear infinite;
}
@keyframes spin {
from { transform: rotate(0deg); }
to { transform: rotate(360deg); }
}
.hint {
opacity: 0.9;
}
.sr-only {
position: absolute;
width: 1px; height: 1px;
padding: 0; margin: -1px;
overflow: hidden;
clip: rect(0, 0, 0, 0);
white-space: nowrap; border: 0;
}
h1 {
font-size: 3rem;
font-weight: 700;
margin: 0 0 1rem;
letter-spacing: -1px;
}
p {
margin: 0.5rem 0;
font-size: 1.1rem;
}
code {
background: rgba(0,0,0,0.2);
padding: 2px 6px;
border-radius: 4px;
font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
}
footer {
position: absolute;
bottom: 1rem;
font-size: 0.8rem;
opacity: 0.7;
}
</style>
</head> </head>
<body> <body class="app-body">
<main> <header class="topbar border-bottom">
<div class="card"> <nav class="navbar navbar-expand-lg py-3">
<h1>Analyzing your requirements and generating your website…</h1> <div class="container-xxl align-items-center">
<div class="loader" role="status" aria-live="polite" aria-label="Applying initial changes"> <a class="navbar-brand brand-mark" href="index.php">
<span class="sr-only">Loading…</span> <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>
<p class="hint"><?= ($_SERVER['HTTP_HOST'] ?? '') === 'appwizzy.com' ? 'AppWizzy' : 'Flatlogic' ?> AI is collecting your requirements and applying the first changes.</p>
<p class="hint">This page will update automatically as the plan is implemented.</p>
<p>Runtime: PHP <code><?= htmlspecialchars($phpVersion) ?></code> — UTC <code><?= htmlspecialchars($now) ?></code></p>
</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> </main>
<footer>
Page updated: <?= htmlspecialchars($now) ?> (UTC) <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> </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> </body>
</html> </html>

513
pos_app.php Normal file
View File

@ -0,0 +1,513 @@
<?php
declare(strict_types=1);
require_once __DIR__ . '/db/pos_bootstrap.php';
if (session_status() === PHP_SESSION_NONE) {
session_start();
}
date_default_timezone_set('UTC');
pos_bootstrap();
function pos_project_name(): string
{
$name = trim((string)($_SERVER['PROJECT_NAME'] ?? ''));
return $name !== '' ? $name : 'Senior POS';
}
function pos_project_description(): string
{
$description = trim((string)($_SERVER['PROJECT_DESCRIPTION'] ?? ''));
return $description !== ''
? $description
: 'Punto de venta accesible con categorías, canasta visible, recibos simples y alertas de stock bajo.';
}
function pos_project_image_url(): string
{
return trim((string)($_SERVER['PROJECT_IMAGE_URL'] ?? ''));
}
function pos_demo_users(): array
{
return [
'ADMIN01' => [
'pin' => '4321',
'name' => 'María Ortega',
'role' => 'admin',
'role_label' => 'Administrador',
'branch' => 'Sucursal Centro',
],
'CAJA01' => [
'pin' => '1234',
'name' => 'Luis Rivera',
'role' => 'cashier',
'role_label' => 'Cajero',
'branch' => 'Sucursal Centro',
],
'CAJA02' => [
'pin' => '2468',
'name' => 'Ana Torres',
'role' => 'cashier',
'role_label' => 'Cajero',
'branch' => 'Sucursal Norte',
],
];
}
function pos_category_meta(): array
{
return [
'Frutas' => [
'icon' => 'bi-apple',
'description' => 'Selecciona frutas frescas con un toque.',
],
'Verduras' => [
'icon' => 'bi-flower1',
'description' => 'Verduras y hortalizas de salida rápida.',
],
'Lácteos' => [
'icon' => 'bi-cup-hot',
'description' => 'Lácteos básicos para venta diaria.',
],
'Despensa' => [
'icon' => 'bi-basket2',
'description' => 'Productos básicos y de reposición.',
],
];
}
function pos_current_user(): ?array
{
return isset($_SESSION['pos_user']) && is_array($_SESSION['pos_user'])
? $_SESSION['pos_user']
: null;
}
function pos_is_admin(): bool
{
$user = pos_current_user();
return $user !== null && (($user['role'] ?? '') === 'admin');
}
function pos_redirect(string $path): void
{
header('Location: ' . $path);
exit;
}
function pos_flash_add(string $type, string $message): void
{
$_SESSION['pos_flashes'][] = [
'type' => $type,
'message' => $message,
];
}
function pos_pull_flashes(): array
{
$flashes = isset($_SESSION['pos_flashes']) && is_array($_SESSION['pos_flashes'])
? $_SESSION['pos_flashes']
: [];
unset($_SESSION['pos_flashes']);
return $flashes;
}
function pos_login_user(string $accessCode, string $pin): bool
{
$users = pos_demo_users();
$accessCode = strtoupper(trim($accessCode));
$pin = preg_replace('/\D+/', '', $pin) ?? '';
if (!isset($users[$accessCode])) {
return false;
}
$candidate = $users[$accessCode];
if (!hash_equals((string)$candidate['pin'], (string)$pin)) {
return false;
}
session_regenerate_id(true);
$_SESSION['pos_user'] = [
'code' => $accessCode,
'name' => (string)$candidate['name'],
'role' => (string)$candidate['role'],
'role_label' => (string)$candidate['role_label'],
'branch' => (string)$candidate['branch'],
];
return true;
}
function pos_logout_user(bool $clearCart = true): void
{
unset($_SESSION['pos_user']);
if ($clearCart) {
unset($_SESSION['pos_cart']);
}
session_regenerate_id(true);
}
function pos_cart(): array
{
return isset($_SESSION['pos_cart']) && is_array($_SESSION['pos_cart'])
? $_SESSION['pos_cart']
: [];
}
function pos_save_cart(array $cart): void
{
$normalized = [];
foreach ($cart as $productId => $quantity) {
$productId = (int)$productId;
$quantity = (int)$quantity;
if ($productId > 0 && $quantity > 0) {
$normalized[$productId] = $quantity;
}
}
ksort($normalized);
$_SESSION['pos_cart'] = $normalized;
}
function pos_format_money(float $value): string
{
return '$' . number_format($value, 2, '.', ',');
}
function pos_format_datetime(?string $value): string
{
if (!$value) {
return '—';
}
$timestamp = strtotime($value);
return $timestamp ? date('d/m/Y H:i', $timestamp) : '—';
}
function pos_cart_summary(): array
{
$cart = pos_cart();
$products = pos_products_by_ids(array_keys($cart));
$items = [];
$subtotal = 0.0;
$itemCount = 0;
foreach ($cart as $productId => $quantity) {
$productId = (int)$productId;
$quantity = (int)$quantity;
if (!isset($products[$productId])) {
continue;
}
$product = $products[$productId];
$price = (float)$product['price'];
$stock = (int)$product['stock'];
$lineTotal = $price * $quantity;
$subtotal += $lineTotal;
$itemCount += $quantity;
$items[] = [
'id' => $productId,
'name' => (string)$product['name'],
'category' => (string)$product['category'],
'price' => $price,
'stock' => $stock,
'unit_label' => (string)$product['unit_label'],
'quantity' => $quantity,
'line_total' => $lineTotal,
'max_reached' => $quantity >= $stock,
'low_stock' => $stock <= (int)$product['low_stock_threshold'],
];
}
return [
'items' => $items,
'subtotal' => $subtotal,
'total' => $subtotal,
'item_count' => $itemCount,
];
}
function pos_complete_sale(): ?int
{
$user = pos_current_user();
$cart = pos_cart();
if ($user === null || $cart === []) {
pos_flash_add('warning', 'La canasta está vacía. Agrega productos antes de registrar la venta.');
return null;
}
$pdo = db();
try {
$pdo->beginTransaction();
$lockStmt = $pdo->prepare(
"SELECT id, name, category, price, stock, low_stock_threshold, unit_label
FROM pos_products
WHERE id = :id AND is_active = 1
LIMIT 1 FOR UPDATE"
);
$updateStmt = $pdo->prepare(
"UPDATE pos_products
SET stock = :stock
WHERE id = :id
LIMIT 1"
);
$insertStmt = $pdo->prepare(
"INSERT INTO pos_sales (
receipt_number,
cashier_code,
cashier_name,
cashier_role,
item_count,
subtotal,
total,
items_json
) VALUES (
:receipt_number,
:cashier_code,
:cashier_name,
:cashier_role,
:item_count,
:subtotal,
:total,
:items_json
)"
);
$items = [];
$subtotal = 0.0;
$itemCount = 0;
foreach ($cart as $productId => $quantity) {
$productId = (int)$productId;
$quantity = (int)$quantity;
if ($quantity < 1) {
continue;
}
$lockStmt->execute(['id' => $productId]);
$product = $lockStmt->fetch();
if (!$product) {
throw new RuntimeException('Uno de los productos ya no está disponible.');
}
$stock = (int)$product['stock'];
if ($stock < $quantity) {
throw new RuntimeException('Stock insuficiente para ' . (string)$product['name'] . '.');
}
$newStock = $stock - $quantity;
$updateStmt->execute([
'stock' => $newStock,
'id' => $productId,
]);
$price = (float)$product['price'];
$lineTotal = $price * $quantity;
$subtotal += $lineTotal;
$itemCount += $quantity;
$items[] = [
'product_id' => $productId,
'name' => (string)$product['name'],
'category' => (string)$product['category'],
'unit_label' => (string)$product['unit_label'],
'price' => $price,
'quantity' => $quantity,
'line_total' => round($lineTotal, 2),
];
}
if ($items === []) {
throw new RuntimeException('La canasta está vacía.');
}
$receiptNumber = 'VT-' . gmdate('ymd-His') . '-' . strtoupper(bin2hex(random_bytes(2)));
$encodedItems = json_encode($items, JSON_UNESCAPED_UNICODE);
if ($encodedItems === false) {
$encodedItems = '[]';
}
$insertStmt->execute([
'receipt_number' => $receiptNumber,
'cashier_code' => (string)$user['code'],
'cashier_name' => (string)$user['name'],
'cashier_role' => (string)$user['role_label'],
'item_count' => $itemCount,
'subtotal' => $subtotal,
'total' => $subtotal,
'items_json' => $encodedItems,
]);
$saleId = (int)$pdo->lastInsertId();
$pdo->commit();
unset($_SESSION['pos_cart']);
$_SESSION['pos_last_receipt_id'] = $saleId;
pos_flash_add('success', 'Venta registrada correctamente. Se generó un recibo simple.');
return $saleId;
} catch (Throwable $exception) {
if ($pdo->inTransaction()) {
$pdo->rollBack();
}
error_log('POS checkout error: ' . $exception->getMessage());
$message = $exception instanceof RuntimeException
? $exception->getMessage()
: 'Intenta nuevamente en unos segundos.';
pos_flash_add('danger', 'No se pudo registrar la venta. ' . $message);
return null;
}
}
function pos_require_login(): array
{
$user = pos_current_user();
if ($user === null) {
pos_flash_add('warning', 'Inicia sesión para acceder al punto de venta.');
pos_redirect('index.php');
}
return $user;
}
function pos_handle_post_request(): void
{
if (($_SERVER['REQUEST_METHOD'] ?? 'GET') !== 'POST') {
return;
}
$action = trim((string)($_POST['action'] ?? ''));
if ($action === '') {
return;
}
if ($action === 'login') {
$accessCode = (string)($_POST['access_code'] ?? '');
$pin = (string)($_POST['pin'] ?? '');
if (pos_login_user($accessCode, $pin)) {
pos_flash_add('success', 'Sesión iniciada correctamente.');
} else {
pos_flash_add('danger', 'Código o PIN incorrecto. Usa uno de los accesos de prueba visibles.');
}
pos_redirect('index.php');
}
if ($action === 'logout') {
pos_logout_user();
pos_flash_add('secondary', 'Sesión cerrada.');
pos_redirect('index.php');
}
$user = pos_current_user();
if ($user === null) {
pos_flash_add('warning', 'Debes iniciar sesión para continuar.');
pos_redirect('index.php');
}
if ($action === 'add_to_cart') {
$productId = (int)($_POST['product_id'] ?? 0);
$product = pos_product_by_id($productId);
if (!$product) {
pos_flash_add('danger', 'No encontramos ese producto.');
pos_redirect('index.php#catalogo');
}
$cart = pos_cart();
$currentQuantity = (int)($cart[$productId] ?? 0);
$availableStock = (int)$product['stock'];
if ($availableStock <= $currentQuantity) {
pos_flash_add('warning', 'No hay más stock disponible para ' . (string)$product['name'] . '.');
pos_redirect('index.php#catalogo');
}
$cart[$productId] = $currentQuantity + 1;
pos_save_cart($cart);
pos_flash_add('success', 'Producto agregado a la canasta.');
pos_redirect('index.php#carrito');
}
if ($action === 'change_qty') {
$productId = (int)($_POST['product_id'] ?? 0);
$delta = (int)($_POST['delta'] ?? 0);
$cart = pos_cart();
$product = pos_product_by_id($productId);
if (!$product || !isset($cart[$productId])) {
pos_flash_add('warning', 'Ese producto ya no está en la canasta.');
pos_redirect('index.php#carrito');
}
$currentQuantity = (int)$cart[$productId];
$nextQuantity = $currentQuantity + $delta;
if ($delta > 0 && $nextQuantity > (int)$product['stock']) {
pos_flash_add('warning', 'Stock insuficiente para aumentar la cantidad.');
pos_redirect('index.php#carrito');
}
if ($nextQuantity <= 0) {
unset($cart[$productId]);
pos_save_cart($cart);
pos_flash_add('info', 'Producto quitado de la canasta.');
pos_redirect('index.php#carrito');
}
$cart[$productId] = $nextQuantity;
pos_save_cart($cart);
pos_flash_add('success', 'Cantidad actualizada.');
pos_redirect('index.php#carrito');
}
if ($action === 'remove_item') {
$productId = (int)($_POST['product_id'] ?? 0);
$cart = pos_cart();
if (isset($cart[$productId])) {
unset($cart[$productId]);
pos_save_cart($cart);
pos_flash_add('info', 'Producto quitado de la canasta.');
}
pos_redirect('index.php#carrito');
}
if ($action === 'cancel_cart') {
unset($_SESSION['pos_cart']);
pos_flash_add('warning', 'Venta anulada. La canasta quedó vacía.');
pos_redirect('index.php#carrito');
}
if ($action === 'adjust_stock') {
if (!pos_is_admin()) {
pos_flash_add('danger', 'Solo el administrador puede ajustar stock.');
pos_redirect('index.php#stock');
}
$productId = (int)($_POST['product_id'] ?? 0);
$delta = (int)($_POST['delta'] ?? 0);
$allowed = [-5, -1, 1, 5];
if (!in_array($delta, $allowed, true)) {
pos_flash_add('danger', 'Ajuste de stock inválido.');
pos_redirect('index.php#stock');
}
$updated = pos_adjust_stock($productId, $delta);
if ($updated === null) {
pos_flash_add('danger', 'No se pudo actualizar el stock.');
pos_redirect('index.php#stock');
}
$message = $delta > 0
? 'Stock aumentado correctamente.'
: 'Stock reducido correctamente.';
pos_flash_add('success', $message);
pos_redirect('index.php#stock');
}
if ($action === 'checkout') {
$saleId = pos_complete_sale();
if ($saleId !== null) {
pos_redirect('receipt.php?id=' . $saleId);
}
pos_redirect('index.php#carrito');
}
}

205
receipt.php Normal file
View File

@ -0,0 +1,205 @@
<?php
declare(strict_types=1);
require_once __DIR__ . '/pos_app.php';
$projectName = pos_project_name();
$projectDescription = pos_project_description();
$projectImageUrl = pos_project_image_url();
$pageTitle = $projectName . ' | Recibo';
$currentUser = pos_require_login();
$flashes = pos_pull_flashes();
$saleId = (int)($_GET['id'] ?? 0);
$sale = $saleId > 0 ? pos_sale_by_id($saleId) : null;
$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',
];
if (!$sale) {
http_response_code(404);
}
?>
<!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 receipt-body">
<header class="topbar border-bottom">
<nav class="navbar py-3">
<div class="container-xxl d-flex justify-content-between align-items-center gap-3">
<a class="navbar-brand brand-mark" href="index.php">
<span class="brand-mark__icon"><i class="bi bi-receipt-cutoff"></i></span>
<span>
<small class="text-uppercase text-muted d-block">Detalle</small>
<strong><?= htmlspecialchars($projectName) ?></strong>
</span>
</a>
<div class="d-flex gap-2 flex-wrap justify-content-end">
<span class="user-chip">
<strong><?= htmlspecialchars($currentUser['name']) ?></strong>
<small><?= htmlspecialchars($currentUser['role_label']) ?></small>
</span>
<a class="btn btn-outline-secondary" href="index.php#ventas">Volver al POS</a>
</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 (!$sale): ?>
<section class="panel receipt-panel mx-auto" style="max-width: 760px;">
<span class="eyebrow">Detalle no disponible</span>
<h1 class="section-title">No encontramos ese recibo.</h1>
<p class="section-copy">Puede que el identificador sea incorrecto o que el recibo ya no exista en esta base de datos.</p>
<a class="btn btn-dark" href="index.php#ventas">Volver a ventas recientes</a>
</section>
<?php else: ?>
<section class="row g-4 align-items-start">
<div class="col-lg-8">
<article class="panel receipt-panel printable-area" id="receipt-card">
<div class="d-flex justify-content-between align-items-start flex-wrap gap-3 mb-4">
<div>
<span class="eyebrow">Recibo simple</span>
<h1 class="display-heading mb-1"><?= htmlspecialchars((string)$sale['receipt_number']) ?></h1>
<p class="section-copy mb-0">Venta registrada el <?= htmlspecialchars(pos_format_datetime((string)$sale['created_at'])) ?>.</p>
</div>
<span class="badge text-bg-light border px-3 py-2"><?= htmlspecialchars((string)$sale['cashier_role']) ?></span>
</div>
<div class="receipt-metadata mb-4">
<div>
<span>Cajero</span>
<strong><?= htmlspecialchars((string)$sale['cashier_name']) ?></strong>
</div>
<div>
<span>Código</span>
<strong><?= htmlspecialchars((string)$sale['cashier_code']) ?></strong>
</div>
<div>
<span>Items</span>
<strong><?= htmlspecialchars((string)$sale['item_count']) ?></strong>
</div>
<div>
<span>Total</span>
<strong><?= htmlspecialchars(pos_format_money((float)$sale['total'])) ?></strong>
</div>
</div>
<div class="table-responsive">
<table class="table table-borderless receipt-table align-middle mb-0">
<thead>
<tr>
<th>Producto</th>
<th>Categoría</th>
<th class="text-center">Cantidad</th>
<th class="text-end">Precio</th>
<th class="text-end">Importe</th>
</tr>
</thead>
<tbody>
<?php foreach ($sale['items'] as $item): ?>
<tr>
<td>
<strong><?= htmlspecialchars((string)($item['name'] ?? 'Producto')) ?></strong>
<div class="text-muted small"><?= htmlspecialchars((string)($item['unit_label'] ?? 'unidad')) ?></div>
</td>
<td><?= htmlspecialchars((string)($item['category'] ?? '—')) ?></td>
<td class="text-center"><?= htmlspecialchars((string)($item['quantity'] ?? 0)) ?></td>
<td class="text-end"><?= htmlspecialchars(pos_format_money((float)($item['price'] ?? 0))) ?></td>
<td class="text-end"><strong><?= htmlspecialchars(pos_format_money((float)($item['line_total'] ?? 0))) ?></strong></td>
</tr>
<?php endforeach; ?>
</tbody>
</table>
</div>
<div class="receipt-total-card mt-4">
<div>
<span>Subtotal</span>
<strong><?= htmlspecialchars(pos_format_money((float)$sale['subtotal'])) ?></strong>
</div>
<div>
<span>Total a pagar</span>
<strong><?= htmlspecialchars(pos_format_money((float)$sale['total'])) ?></strong>
</div>
</div>
<div class="d-print-none d-flex flex-wrap gap-3 mt-4">
<a class="btn btn-dark btn-lg" href="index.php#catalogo">
<i class="bi bi-plus-circle me-2"></i>Nueva venta
</a>
<button class="btn btn-outline-secondary btn-lg" type="button" id="print-receipt">
<i class="bi bi-printer me-2"></i>Imprimir recibo
</button>
</div>
</article>
</div>
<div class="col-lg-4 d-print-none">
<aside class="panel h-100">
<span class="eyebrow">Confirmación</span>
<h2 class="section-title">La venta quedó registrada.</h2>
<p class="section-copy">Ya puedes iniciar una nueva venta o volver al listado para revisar otros recibos.</p>
<div class="mini-grid single-column mt-4">
<div class="mini-card">
<i class="bi bi-check2-circle"></i>
<strong>Stock actualizado</strong>
<p>El inventario ya se descontó automáticamente.</p>
</div>
<div class="mini-card">
<i class="bi bi-journal-text"></i>
<strong>Historial guardado</strong>
<p>El recibo queda visible en “Ventas recientes”.</p>
</div>
<div class="mini-card">
<i class="bi bi-arrow-repeat"></i>
<strong>Siguiente paso</strong>
<p>Regresa al catálogo para iniciar otra operación.</p>
</div>
</div>
</aside>
</div>
</section>
<?php endif; ?>
</main>
<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>