From 3cfcfefee3634b2d400166511b5f260ab25ddc3d Mon Sep 17 00:00:00 2001 From: Flatlogic Bot Date: Mon, 11 May 2026 21:40:02 +0000 Subject: [PATCH] v 0.1 --- assets/css/custom.css | 1086 +++++++++++++++++++++++++++-------------- assets/js/main.js | 141 ++++-- db/pos_bootstrap.php | 244 +++++++++ healthz.php | 24 + index.php | 715 ++++++++++++++++++++++----- pos_app.php | 513 +++++++++++++++++++ receipt.php | 205 ++++++++ 7 files changed, 2401 insertions(+), 527 deletions(-) create mode 100644 db/pos_bootstrap.php create mode 100644 healthz.php create mode 100644 pos_app.php create mode 100644 receipt.php diff --git a/assets/css/custom.css b/assets/css/custom.css index 789132e..290072a 100644 --- a/assets/css/custom.css +++ b/assets/css/custom.css @@ -1,403 +1,763 @@ -body { - background: linear-gradient(-45deg, #ee7752, #e73c7e, #23a6d5, #23d5ab); - background-size: 400% 400%; - animation: gradient 15s ease infinite; - color: #212529; - font-family: 'Inter', -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif; - font-size: 14px; +:root { + --bg: #f3f4f6; + --surface: #ffffff; + --surface-muted: #f8fafc; + --surface-soft: #eef2f7; + --line: #d8dee8; + --line-strong: #c4ccd7; + --text: #111827; + --muted: #475569; + --muted-soft: #64748b; + --accent: #1f2937; + --accent-soft: #334155; + --warning: #b45309; + --warning-bg: #fff7ed; + --danger: #b91c1c; + --shadow: 0 18px 50px rgba(15, 23, 42, 0.06); + --radius-sm: 10px; + --radius-md: 14px; + --radius-lg: 18px; + --spacing-xs: 0.5rem; + --spacing-sm: 0.75rem; + --spacing-md: 1rem; + --spacing-lg: 1.5rem; + --spacing-xl: 2rem; +} + +* { + box-sizing: border-box; +} + +html { + scroll-behavior: smooth; +} + +body.app-body { margin: 0; min-height: 100vh; + background: var(--bg); + color: var(--text); + font-family: 'Inter', system-ui, -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif; + line-height: 1.5; } -.main-wrapper { - display: flex; +a { + color: inherit; +} + +.topbar { + background: rgba(243, 244, 246, 0.92); + backdrop-filter: blur(10px); + position: sticky; + top: 0; + z-index: 1030; +} + +.brand-mark { + display: inline-flex; + align-items: center; + gap: 0.9rem; + text-decoration: none; + color: var(--text); +} + +.brand-mark:hover { + color: var(--text); +} + +.brand-mark__icon { + width: 2.9rem; + height: 2.9rem; + display: inline-flex; align-items: center; justify-content: center; - min-height: 100vh; - width: 100%; - padding: 20px; - box-sizing: border-box; - position: relative; - z-index: 1; -} - -@keyframes gradient { - 0% { - background-position: 0% 50%; - } - 50% { - background-position: 100% 50%; - } - 100% { - background-position: 0% 50%; - } -} - -.chat-container { - width: 100%; - max-width: 600px; - background: rgba(255, 255, 255, 0.85); - border: 1px solid rgba(255, 255, 255, 0.3); - border-radius: 20px; - display: flex; - flex-direction: column; - height: 85vh; - box-shadow: 0 20px 40px rgba(0,0,0,0.2); - backdrop-filter: blur(15px); - -webkit-backdrop-filter: blur(15px); - overflow: hidden; -} - -.chat-header { - padding: 1.5rem; - border-bottom: 1px solid rgba(0, 0, 0, 0.05); - background: rgba(255, 255, 255, 0.5); - font-weight: 700; + border-radius: var(--radius-md); + border: 1px solid var(--line); + background: var(--surface); + box-shadow: var(--shadow); font-size: 1.1rem; - display: flex; - justify-content: space-between; - align-items: center; } -.chat-messages { - flex: 1; - overflow-y: auto; - padding: 1.5rem; - display: flex; +.user-chip { + display: inline-flex; flex-direction: column; - gap: 1.25rem; -} - -/* Custom Scrollbar */ -::-webkit-scrollbar { - width: 6px; -} - -::-webkit-scrollbar-track { - background: transparent; -} - -::-webkit-scrollbar-thumb { - background: rgba(255, 255, 255, 0.3); - border-radius: 10px; -} - -::-webkit-scrollbar-thumb:hover { - background: rgba(255, 255, 255, 0.5); -} - -.message { - max-width: 85%; - padding: 0.85rem 1.1rem; - border-radius: 16px; - line-height: 1.5; - font-size: 0.95rem; - box-shadow: 0 4px 15px rgba(0,0,0,0.05); - animation: fadeIn 0.4s cubic-bezier(0.175, 0.885, 0.32, 1.275); -} - -@keyframes fadeIn { - from { opacity: 0; transform: translateY(20px) scale(0.95); } - to { opacity: 1; transform: translateY(0) scale(1); } -} - -.message.visitor { - align-self: flex-end; - background: linear-gradient(135deg, #212529 0%, #343a40 100%); - color: #fff; - border-bottom-right-radius: 4px; -} - -.message.bot { - align-self: flex-start; - background: #ffffff; - color: #212529; - border-bottom-left-radius: 4px; -} - -.chat-input-area { - padding: 1.25rem; - background: rgba(255, 255, 255, 0.5); - border-top: 1px solid rgba(0, 0, 0, 0.05); -} - -.chat-input-area form { - display: flex; - gap: 0.75rem; -} - -.chat-input-area input { - flex: 1; - border: 1px solid rgba(0, 0, 0, 0.1); - border-radius: 12px; - padding: 0.75rem 1rem; - outline: none; - background: rgba(255, 255, 255, 0.9); - transition: all 0.3s ease; -} - -.chat-input-area input:focus { - border-color: #23a6d5; - box-shadow: 0 0 0 3px rgba(35, 166, 213, 0.2); -} - -.chat-input-area button { - background: #212529; - color: #fff; - border: none; - padding: 0.75rem 1.5rem; - border-radius: 12px; - cursor: pointer; - font-weight: 600; - transition: all 0.3s ease; -} - -.chat-input-area button:hover { - background: #000; - transform: translateY(-2px); - box-shadow: 0 5px 15px rgba(0,0,0,0.2); -} - -/* Background Animations */ -.bg-animations { - position: fixed; - top: 0; - left: 0; - width: 100%; - height: 100%; - z-index: 0; - overflow: hidden; - pointer-events: none; -} - -.blob { - position: absolute; - width: 500px; - height: 500px; - background: rgba(255, 255, 255, 0.2); - border-radius: 50%; - filter: blur(80px); - animation: move 20s infinite alternate cubic-bezier(0.45, 0, 0.55, 1); -} - -.blob-1 { - top: -10%; - left: -10%; - background: rgba(238, 119, 82, 0.4); -} - -.blob-2 { - bottom: -10%; - right: -10%; - background: rgba(35, 166, 213, 0.4); - animation-delay: -7s; - width: 600px; - height: 600px; -} - -.blob-3 { - top: 40%; - left: 30%; - background: rgba(231, 60, 126, 0.3); - animation-delay: -14s; - width: 450px; - height: 450px; -} - -@keyframes move { - 0% { transform: translate(0, 0) rotate(0deg) scale(1); } - 33% { transform: translate(150px, 100px) rotate(120deg) scale(1.1); } - 66% { transform: translate(-50px, 200px) rotate(240deg) scale(0.9); } - 100% { transform: translate(0, 0) rotate(360deg) scale(1); } -} - -.header-link { - font-size: 14px; - color: #fff; - text-decoration: none; - background: rgba(0, 0, 0, 0.2); - padding: 0.5rem 1rem; - border-radius: 8px; - transition: all 0.3s ease; -} - -.header-link:hover { - background: rgba(0, 0, 0, 0.4); - text-decoration: none; -} - -/* Admin Styles */ -.admin-container { - max-width: 900px; - margin: 3rem auto; - padding: 2.5rem; - background: rgba(255, 255, 255, 0.85); - backdrop-filter: blur(20px); - -webkit-backdrop-filter: blur(20px); - border-radius: 24px; - box-shadow: 0 20px 50px rgba(0,0,0,0.15); - border: 1px solid rgba(255, 255, 255, 0.4); - position: relative; - z-index: 1; -} - -.admin-container h1 { - margin-top: 0; - color: #212529; - font-weight: 800; -} - -.table { - width: 100%; - border-collapse: separate; - border-spacing: 0 8px; - margin-top: 1.5rem; -} - -.table th { - background: transparent; - border: none; - padding: 1rem; - color: #6c757d; - font-weight: 600; - text-transform: uppercase; - font-size: 0.75rem; - letter-spacing: 1px; -} - -.table td { - background: #fff; - padding: 1rem; - border: none; -} - -.table tr td:first-child { border-radius: 12px 0 0 12px; } -.table tr td:last-child { border-radius: 0 12px 12px 0; } - -.form-group { - margin-bottom: 1.25rem; -} - -.form-group label { - display: block; - margin-bottom: 0.5rem; - font-weight: 600; + align-items: flex-start; + justify-content: center; + gap: 0.1rem; + min-width: 8.5rem; + padding: 0.65rem 0.9rem; + border-radius: var(--radius-sm); + border: 1px solid var(--line); + background: var(--surface); font-size: 0.9rem; } -.form-control { - width: 100%; - padding: 0.75rem 1rem; - border: 1px solid rgba(0, 0, 0, 0.1); - border-radius: 12px; - background: #fff; - transition: all 0.3s ease; - box-sizing: border-box; +.panel { + background: var(--surface); + border: 1px solid var(--line); + border-radius: var(--radius-lg); + box-shadow: var(--shadow); + padding: clamp(1.15rem, 2vw, 1.75rem); } -.form-control:focus { - outline: none; - border-color: #23a6d5; - box-shadow: 0 0 0 3px rgba(35, 166, 213, 0.1); +.hero-panel, +.overview-panel, +.receipt-panel { + padding: clamp(1.5rem, 3vw, 2.25rem); } -.header-container { - display: flex; - justify-content: space-between; - align-items: center; -} - -.header-links { - display: flex; - gap: 1rem; -} - -.admin-card { - background: rgba(255, 255, 255, 0.6); - padding: 2rem; - border-radius: 20px; - border: 1px solid rgba(255, 255, 255, 0.5); - margin-bottom: 2.5rem; - box-shadow: 0 10px 30px rgba(0,0,0,0.05); -} - -.admin-card h3 { - margin-top: 0; - margin-bottom: 1.5rem; +.display-heading { + font-size: clamp(2rem, 3vw, 2.75rem); + line-height: 1.08; + letter-spacing: -0.04em; + margin: 0; font-weight: 700; } -.btn-delete { - background: #dc3545; - color: white; - border: none; - padding: 0.25rem 0.5rem; - border-radius: 4px; - cursor: pointer; +.section-title { + margin: 0; + font-size: 1.4rem; + line-height: 1.2; + font-weight: 700; } -.btn-add { - background: #212529; - color: white; - border: none; - padding: 0.5rem 1rem; - border-radius: 4px; - cursor: pointer; - margin-top: 1rem; +.lead-copy, +.section-copy { + color: var(--muted); + font-size: 1rem; + max-width: 64ch; } -.btn-save { - background: #0088cc; - color: white; - border: none; - padding: 0.8rem 1.5rem; - border-radius: 12px; - cursor: pointer; - font-weight: 600; - width: 100%; - transition: all 0.3s ease; +.eyebrow { + display: inline-block; + margin-bottom: 0.85rem; + color: var(--muted-soft); + text-transform: uppercase; + letter-spacing: 0.14em; + font-size: 0.78rem; + font-weight: 700; } -.webhook-url { - font-size: 0.85em; - color: #555; - margin-top: 0.5rem; +.status-dot { + width: 0.8rem; + height: 0.8rem; + border-radius: 999px; + background: #16a34a; + box-shadow: 0 0 0 5px rgba(22, 163, 74, 0.15); } -.history-table-container { - overflow-x: auto; - background: rgba(255, 255, 255, 0.4); +.instruction-grid { + display: grid; + grid-template-columns: repeat(3, minmax(0, 1fr)); + gap: 1rem; +} + +.instruction-card, +.mini-card, +.demo-user-card, +.compact-alert-card, +.product-card, +.cart-item, +.info-callout, +.integration-note, +.totals-box, +.empty-state, +.count-chip, +.quick-step, +.receipt-total-card, +.receipt-metadata > div { + border: 1px solid var(--line); + background: var(--surface-muted); + border-radius: var(--radius-md); +} + +.instruction-card { padding: 1rem; - border-radius: 12px; - border: 1px solid rgba(255, 255, 255, 0.3); } -.history-table { - width: 100%; +.instruction-card span { + display: inline-flex; + align-items: center; + justify-content: center; + width: 2rem; + height: 2rem; + margin-bottom: 0.75rem; + border-radius: 999px; + background: var(--accent); + color: #fff; + font-weight: 700; } -.history-table-time { - width: 15%; - white-space: nowrap; - font-size: 0.85em; - color: #555; +.instruction-card strong, +.mini-card strong, +.compact-alert-card strong { + display: block; + font-size: 1rem; + margin-bottom: 0.35rem; } -.history-table-user { - width: 35%; - background: rgba(255, 255, 255, 0.3); - border-radius: 8px; - padding: 8px; +.instruction-card p, +.mini-card p, +.compact-alert-card small, +.integration-note, +.feature-list, +.empty-state p, +.info-callout p { + margin: 0; + color: var(--muted); } -.history-table-ai { - width: 50%; - background: rgba(255, 255, 255, 0.5); - border-radius: 8px; - padding: 8px; +.feature-list { + list-style: none; + padding: 0; + display: grid; + gap: 0.9rem; } -.no-messages { +.feature-list li { + display: flex; + align-items: center; + gap: 0.75rem; + font-size: 1rem; +} + +.feature-list i, +.mini-card i, +.compact-alert-card i { + color: var(--accent-soft); +} + +.integration-note, +.info-callout { + padding: 1rem 1.1rem; +} + +.mini-grid { + display: grid; + grid-template-columns: repeat(3, minmax(0, 1fr)); + gap: 1rem; +} + +.mini-grid.single-column { + grid-template-columns: 1fr; +} + +.mini-card { + padding: 1rem; +} + +.mini-card i { + font-size: 1.15rem; + display: inline-flex; + margin-bottom: 0.65rem; +} + +.stack-form { + display: grid; + gap: 1rem; +} + +.demo-user-card { + display: flex; + flex-direction: column; + gap: 0.35rem; + padding: 1rem; + color: var(--text); + text-decoration: none; + transition: transform 0.18s ease, border-color 0.18s ease, box-shadow 0.18s ease; + box-shadow: 0 0 0 rgba(0, 0, 0, 0); +} + +.demo-user-card:hover, +.demo-user-card:focus-visible, +.product-card:hover, +.product-card:focus-within { + transform: translateY(-2px); + border-color: var(--line-strong); + box-shadow: 0 16px 36px rgba(15, 23, 42, 0.08); +} + +.demo-role, +.demo-code { + color: var(--muted-soft); + font-size: 0.86rem; +} + +.overview-grid { + display: grid; + grid-template-columns: minmax(0, 2fr) repeat(3, minmax(0, 1fr)); + gap: 1rem; +} + +.metric-card { + display: flex; + flex-direction: column; + justify-content: space-between; + min-height: 10rem; +} + +.metric-card strong { + font-size: clamp(1.8rem, 2vw, 2.25rem); + line-height: 1; + letter-spacing: -0.04em; +} + +.metric-label { + color: var(--muted-soft); + text-transform: uppercase; + font-size: 0.78rem; + letter-spacing: 0.14em; + font-weight: 700; +} + +.metric-card small { + color: var(--muted); +} + +.quick-step-row { + display: flex; + flex-wrap: wrap; + gap: 0.75rem; +} + +.quick-step { + padding: 0.8rem 1rem; + font-size: 0.95rem; + color: var(--muted); +} + +.alert-panel { + display: flex; + align-items: center; + justify-content: space-between; + gap: 1rem; + border-color: #fed7aa; + background: var(--warning-bg); +} + +.search-shell { + width: min(100%, 22rem); +} + +.filter-toolbar { + display: flex; + gap: 0.75rem; + flex-wrap: wrap; +} + +.filter-toolbar .btn { + min-height: 3rem; + padding-inline: 1rem; + border-radius: 999px; + font-weight: 600; +} + +.filter-toolbar .btn.is-active { + background: var(--accent); + border-color: var(--accent); + color: #fff; +} + +.catalog-section__header { + display: flex; + justify-content: space-between; + align-items: center; + gap: 1rem; + margin-bottom: 0.9rem; +} + +.category-pill { + display: inline-flex; + align-items: center; + gap: 0.55rem; + padding: 0.55rem 0.8rem; + margin-bottom: 0.55rem; + border-radius: 999px; + border: 1px solid var(--line); + background: var(--surface-soft); + font-size: 0.92rem; + font-weight: 600; +} + +.product-card { + display: flex; + flex-direction: column; + justify-content: space-between; + height: 100%; + padding: 1rem; + transition: transform 0.18s ease, box-shadow 0.18s ease, border-color 0.18s ease; +} + +.product-card__head { + display: flex; + justify-content: space-between; + align-items: center; + gap: 1rem; + margin-bottom: 1rem; +} + +.icon-surface { + width: 2.65rem; + height: 2.65rem; + display: inline-flex; + align-items: center; + justify-content: center; + border-radius: var(--radius-sm); + background: var(--surface); + border: 1px solid var(--line); + font-size: 1.05rem; +} + +.stock-badge { + padding: 0.3rem 0.65rem; + border-radius: 999px; + font-size: 0.78rem; + font-weight: 700; + color: #14532d; + background: #ecfdf5; + border: 1px solid #bbf7d0; +} + +.stock-badge.is-low { + color: var(--warning); + background: var(--warning-bg); + border-color: #fed7aa; +} + +.product-card__body h3, +.cart-item h3 { + margin: 0 0 0.35rem; + font-size: 1.1rem; + font-weight: 700; +} + +.product-card__body p, +.cart-item p { + margin: 0; + color: var(--muted); +} + +.product-card__footer { + margin-top: 1.2rem; +} + +.price-tag { + font-size: 1.4rem; + line-height: 1; + letter-spacing: -0.04em; + font-weight: 700; + margin-bottom: 0.2rem; +} + +.btn-action, +.btn-lg { + min-height: 3.35rem; + font-weight: 600; + border-radius: var(--radius-sm); +} + +.cart-panel { + position: sticky; + top: 6.25rem; +} + +.count-chip { + display: inline-flex; + align-items: center; + justify-content: center; + padding: 0.75rem 0.95rem; + font-weight: 700; + min-width: 5.7rem; +} + +.cart-items { + display: grid; + gap: 0.9rem; +} + +.cart-item { + display: flex; + justify-content: space-between; + gap: 1rem; + padding: 1rem; +} + +.cart-item__controls, +.stock-actions { + display: flex; + align-items: center; + gap: 0.45rem; + flex-wrap: wrap; +} + +.qty-pill { + display: inline-flex; + align-items: center; + justify-content: center; + min-width: 2.25rem; + min-height: 2.25rem; + padding: 0.3rem 0.6rem; + border-radius: 999px; + background: var(--surface); + border: 1px solid var(--line); + font-weight: 700; +} + +.totals-box { + padding: 1rem 1.15rem; +} + +.total-line { + padding-top: 0.75rem; + border-top: 1px solid var(--line); +} + +.sales-table thead th, +.stock-table thead th, +.receipt-table thead th { + font-size: 0.82rem; + color: var(--muted-soft); + font-weight: 700; + text-transform: uppercase; + letter-spacing: 0.08em; + border-bottom: 1px solid var(--line); + padding-bottom: 0.9rem; +} + +.sales-table tbody tr, +.stock-table tbody tr, +.receipt-table tbody tr { + border-bottom: 1px solid rgba(216, 222, 232, 0.7); +} + +.sales-table td, +.stock-table td, +.receipt-table td { + padding-block: 1rem; + vertical-align: middle; +} + +.stock-figure { + font-size: 1.05rem; + font-weight: 700; +} + +.row-alert { + background: linear-gradient(0deg, rgba(255, 247, 237, 0.45), rgba(255, 247, 237, 0.45)); +} + +.compact-alert-card { + display: flex; + flex-direction: column; + gap: 0.25rem; + padding: 1rem; +} + +.empty-state { + display: grid; + justify-items: center; + gap: 0.5rem; text-align: center; - color: #777; -} \ No newline at end of file + padding: 2rem 1.25rem; +} + +.empty-state.compact { + padding-block: 1.5rem; +} + +.empty-state i, +.empty-search-state i { + font-size: 1.5rem; + color: var(--accent-soft); +} + +.empty-state strong, +.empty-search-state strong { + font-size: 1rem; +} + +.empty-search-state { + border: 1px dashed var(--line-strong); + border-radius: var(--radius-md); + background: var(--surface-soft); + text-align: center; + padding: 2rem 1rem; +} + +.site-footer { + background: rgba(248, 250, 252, 0.9); +} + +.receipt-body .panel { + background: var(--surface); +} + +.receipt-metadata { + display: grid; + grid-template-columns: repeat(4, minmax(0, 1fr)); + gap: 0.9rem; +} + +.receipt-metadata > div, +.receipt-total-card { + padding: 1rem; +} + +.receipt-metadata span, +.receipt-total-card span { + display: block; + color: var(--muted-soft); + font-size: 0.84rem; + text-transform: uppercase; + letter-spacing: 0.08em; + margin-bottom: 0.35rem; +} + +.receipt-metadata strong, +.receipt-total-card strong { + font-size: 1.08rem; +} + +.receipt-total-card { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 1rem; + background: var(--surface-soft); +} + +.btn, +.form-control, +.input-group-text { + border-radius: var(--radius-sm); +} + +.form-control, +.input-group-text, +.btn-outline-secondary, +.btn-outline-danger, +.btn-outline-primary { + border-color: var(--line-strong); +} + +.form-control, +.input-group-text { + min-height: 3.35rem; + padding-inline: 1rem; + font-size: 1rem; + background: var(--surface); +} + +.form-control:focus, +.btn:focus-visible, +.demo-user-card:focus-visible, +.product-card:focus-within { + box-shadow: 0 0 0 0.2rem rgba(17, 24, 39, 0.15); + border-color: var(--accent-soft); + outline: none; +} + +.btn-dark { + background: var(--accent); + border-color: var(--accent); +} + +.btn-dark:hover, +.btn-dark:focus-visible { + background: #0f172a; + border-color: #0f172a; +} + +.btn-outline-secondary:hover, +.btn-outline-secondary:focus-visible { + background: #e2e8f0; + color: var(--text); + border-color: #cbd5e1; +} + +.btn-outline-danger:hover, +.btn-outline-danger:focus-visible { + background: #fef2f2; + color: var(--danger); + border-color: #fecaca; +} + +.toast-container { + z-index: 1085; +} + +@media (max-width: 1199.98px) { + .overview-grid { + grid-template-columns: repeat(2, minmax(0, 1fr)); + } + + .overview-panel { + grid-column: span 2; + } + + .cart-panel { + position: static; + } +} + +@media (max-width: 991.98px) { + .instruction-grid, + .mini-grid, + .receipt-metadata, + .receipt-total-card { + grid-template-columns: 1fr; + } + + .catalog-section__header, + .alert-panel { + flex-direction: column; + align-items: flex-start; + } +} + +@media (max-width: 767.98px) { + .overview-grid { + grid-template-columns: 1fr; + } + + .overview-panel { + grid-column: auto; + } + + .cart-item { + flex-direction: column; + align-items: flex-start; + } + + .cart-item .text-end { + width: 100%; + text-align: left !important; + } + + .cart-item__controls { + justify-content: flex-start; + } + + .brand-mark__icon { + width: 2.5rem; + height: 2.5rem; + } +} + +@media print { + .topbar, + .site-footer, + .d-print-none, + .toast-container { + display: none !important; + } + + body.app-body { + background: #ffffff; + } + + .panel, + .receipt-panel { + box-shadow: none; + border-color: #d1d5db; + } +} diff --git a/assets/js/main.js b/assets/js/main.js index d349598..26d39a6 100644 --- a/assets/js/main.js +++ b/assets/js/main.js @@ -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 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 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 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(); + }); + } }); diff --git a/db/pos_bootstrap.php b/db/pos_bootstrap.php new file mode 100644 index 0000000..ca346b9 --- /dev/null +++ b/db/pos_bootstrap.php @@ -0,0 +1,244 @@ +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); +} diff --git a/healthz.php b/healthz.php new file mode 100644 index 0000000..7ee749c --- /dev/null +++ b/healthz.php @@ -0,0 +1,24 @@ +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); +} diff --git a/index.php b/index.php index 7205f3d..c39393b 100644 --- a/index.php +++ b/index.php @@ -1,150 +1,607 @@ $_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); +}; ?> - + - New Style - - - - - + <?= htmlspecialchars($pageTitle) ?> + + + - + - - - + - - - + + + + - -
-
-

Analyzing your requirements and generating your website…

-
- Loading… + +
+
+ + + +
+ + +
+
+
+ +
+
+ +
+ +
+ +
+
+
+ Diseño limpio, alto contraste, flujo corto +

Vende en tres pasos y con menos errores.

+

+ 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. +

+
+
+ 1 + Selecciona +

Elige una categoría con botones grandes y visibles.

+
+
+ 2 + Agrega +

Presiona “Agregar” una sola vez para llevarlo a la canasta.

+
+
+ 3 + Confirma +

Registra la venta y genera un recibo simple al instante.

+
+
+ +
+
+
+
+
+
+ Qué incluye esta entrega +

Primer flujo real

+
+ +
+
    +
  • Login con roles Admin y Cajero
  • +
  • Productos agrupados por categoría
  • +
  • Canasta con total y anulación rápida
  • +
  • Registro de venta con recibo
  • +
  • Lista de ventas recientes
  • +
  • Control de stock con alertas
  • +
+
+ Escalable: la UI queda lista para mover autenticación, productos y ventas a endpoints de NestJS en una siguiente iteración. +
+
+
+
+ +
+
+
+ Acceso rápido con PIN +

Entrar al punto de venta

+

Usa un código corto y un PIN de cuatro dígitos. Esto es ideal para una caja con operadores frecuentes.

+
+ +
+ + +
+
+ + +
+ +
+
+
+
+
+
+
+ Accesos de prueba +

Roles listos para probar

+
+ Autocompleta el formulario con un toque +
+
+ $demoUser): ?> +
+ +
+ +
+
+
+ + Admin +

Ajusta stock y supervisa alertas críticas.

+
+
+ + Cajero +

Vende rápido sin entrar a pantallas complejas.

+
+
+ + Recibos +

Consulta la venta al detalle con un clic.

+
+
+
+
+
+ +
+
+ Caja activa +

Hola, .

+

Usa el flujo corto: categoría, agregar a canasta y confirmar venta. Todo lo esencial está visible en la misma pantalla.

+
+ 1. Elegir categoría + 2. Agregar productos + 3. Registrar venta +
+
+
+ Ventas hoy + + Operaciones registradas hoy +
+
+ Total vendido + + Acumulado del día +
+
+ Alertas + + Productos con stock bajo +
+
+ + 0): ?> +
+
+ Atención visible +

Hay productos con stock por debajo del umbral.

+

Esto ayuda a evitar ventas fallidas y a reponer inventario a tiempo.

+
+ Revisar stock +
+ + +
+
+ + +
+
+
+ Historial +

Ventas recientes

+

Cada venta queda guardada con total, fecha y enlace al recibo.

+
+ + Ver último recibo + +
+ +
+ + Todavía no hay ventas registradas. +

Cuando confirmes una venta aparecerá aquí automáticamente.

+
+ +
+ + + + + + + + + + + + + + + + + + + + + + + +
ReciboFechaCajeroItemsTotal
+ +
+
+ Ver detalle +
+
+ +
+ +
+ +
+
+ Administración +

Control de stock

+

Ajusta inventario con botones directos y alertas visibles cuando la cantidad sea crítica.

+
+ Solo admin +
+
+ + + + + + + + + + + + + + + + + + + + + + +
ProductoCategoríaStock actualUmbralAcciones
+ + + + + Bajo + + +
+ +
+ + + + +
+ +
+
+
+ +
+
+ Avisos visibles +

Stock que requiere apoyo del administrador

+

Como cajero puedes ver qué productos están por acabarse para pedir reposición.

+
+ Lectura +
+ +
+ + Todo el stock está dentro del nivel esperado. +

No hay alertas activas por ahora.

+
+ +
+ +
+
+ + + Actual: · Umbral: +
+
+ +
+ + +
+
+ +
+ +
+
+
-