Compare commits
1 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3cfcfefee3 |
File diff suppressed because it is too large
Load Diff
@ -1,39 +1,110 @@
|
||||
document.addEventListener('DOMContentLoaded', () => {
|
||||
const chatForm = document.getElementById('chat-form');
|
||||
const chatInput = document.getElementById('chat-input');
|
||||
const chatMessages = document.getElementById('chat-messages');
|
||||
|
||||
const appendMessage = (text, sender) => {
|
||||
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 toastElements = document.querySelectorAll('.js-app-toast');
|
||||
toastElements.forEach((element) => {
|
||||
if (window.bootstrap && window.bootstrap.Toast) {
|
||||
const toast = new window.bootstrap.Toast(element);
|
||||
toast.show();
|
||||
}
|
||||
});
|
||||
const data = await response.json();
|
||||
|
||||
// Artificial delay for realism
|
||||
setTimeout(() => {
|
||||
appendMessage(data.reply, 'bot');
|
||||
}, 500);
|
||||
} catch (error) {
|
||||
console.error('Error:', error);
|
||||
appendMessage("Sorry, something went wrong. Please try again.", 'bot');
|
||||
const accessCodeInput = document.getElementById('access_code');
|
||||
const pinInput = document.getElementById('pin');
|
||||
document.querySelectorAll('[data-fill-login]').forEach((button) => {
|
||||
button.addEventListener('click', () => {
|
||||
if (accessCodeInput) {
|
||||
accessCodeInput.value = button.dataset.code || '';
|
||||
}
|
||||
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
244
db/pos_bootstrap.php
Normal 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
24
healthz.php
Normal 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
713
index.php
@ -1,150 +1,607 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
@ini_set('display_errors', '1');
|
||||
@error_reporting(E_ALL);
|
||||
@date_default_timezone_set('UTC');
|
||||
|
||||
$phpVersion = PHP_VERSION;
|
||||
$now = date('Y-m-d H:i:s');
|
||||
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="en">
|
||||
<html lang="es">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<title>New Style</title>
|
||||
<?php
|
||||
// Read project preview data from environment
|
||||
$projectDescription = $_SERVER['PROJECT_DESCRIPTION'] ?? '';
|
||||
$projectImageUrl = $_SERVER['PROJECT_IMAGE_URL'] ?? '';
|
||||
?>
|
||||
<?php if ($projectDescription): ?>
|
||||
<!-- Meta description -->
|
||||
<meta name="description" content='<?= htmlspecialchars($projectDescription) ?>' />
|
||||
<!-- Open Graph meta tags -->
|
||||
<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) ?>" />
|
||||
<!-- Twitter meta tags -->
|
||||
<meta property="twitter:title" content="<?= htmlspecialchars($pageTitle) ?>" />
|
||||
<meta property="twitter:description" content="<?= htmlspecialchars($projectDescription) ?>" />
|
||||
<?php endif; ?>
|
||||
<?php if ($projectImageUrl): ?>
|
||||
<!-- Open Graph image -->
|
||||
<?php if ($projectImageUrl !== ''): ?>
|
||||
<meta property="og:image" content="<?= htmlspecialchars($projectImageUrl) ?>" />
|
||||
<!-- Twitter image -->
|
||||
<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;700&display=swap" rel="stylesheet">
|
||||
<style>
|
||||
:root {
|
||||
--bg-color-start: #6a11cb;
|
||||
--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>
|
||||
<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>
|
||||
<main>
|
||||
<div class="card">
|
||||
<h1>Analyzing your requirements and generating your website…</h1>
|
||||
<div class="loader" role="status" aria-live="polite" aria-label="Applying initial changes">
|
||||
<span class="sr-only">Loading…</span>
|
||||
<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>
|
||||
<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>
|
||||
</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>
|
||||
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>
|
||||
|
||||
<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>
|
||||
|
||||
513
pos_app.php
Normal file
513
pos_app.php
Normal 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
205
receipt.php
Normal 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>
|
||||
Loading…
x
Reference in New Issue
Block a user