Compare commits
16 Commits
1f909f4c42
...
fb591c7de6
| Author | SHA1 | Date | |
|---|---|---|---|
| fb591c7de6 | |||
| b803950fae | |||
| bd2cf307f7 | |||
| 9b02005116 | |||
| 2cfe4b3913 | |||
| 12989e30be | |||
| c4db5e7550 | |||
| 3b8224d15e | |||
| 4b3b0f8313 | |||
| c766571b7d | |||
| 44c3a6c910 | |||
| f24f25e74e | |||
| b829e4f026 | |||
| c75e2a75c9 | |||
| 27cb4ee683 | |||
| afb3b2405c |
64
.env.example
Normal file
64
.env.example
Normal file
@@ -0,0 +1,64 @@
|
|||||||
|
# Nexus Autoparts — Environment Variables
|
||||||
|
# Copy this file to .env and fill in your values.
|
||||||
|
# NEVER commit .env to git.
|
||||||
|
|
||||||
|
# ═══════════════════════════════════════════════════════════════════════════
|
||||||
|
# DATABASE (REQUIRED)
|
||||||
|
# ═══════════════════════════════════════════════════════════════════════════
|
||||||
|
DATABASE_URL=postgresql://nexus:YOUR_DB_PASSWORD@localhost/nexus_autoparts
|
||||||
|
MASTER_DB_URL=postgresql://nexus:YOUR_DB_PASSWORD@localhost/nexus_autoparts
|
||||||
|
TENANT_DB_URL_TEMPLATE=postgresql://nexus:YOUR_DB_PASSWORD@localhost/{db_name}
|
||||||
|
|
||||||
|
# ═══════════════════════════════════════════════════════════════════════════
|
||||||
|
# SECURITY (REQUIRED)
|
||||||
|
# ═══════════════════════════════════════════════════════════════════════════
|
||||||
|
# Generate with: python3 -c "import secrets; print(secrets.token_hex(32))"
|
||||||
|
JWT_SECRET=change-me-to-a-random-64-char-hex-string
|
||||||
|
POS_JWT_SECRET=change-me-to-a-different-random-64-char-hex-string
|
||||||
|
|
||||||
|
# ═══════════════════════════════════════════════════════════════════════════
|
||||||
|
# AI / OpenRouter (OPTIONAL — enables chatbot)
|
||||||
|
# ═══════════════════════════════════════════════════════════════════════════
|
||||||
|
OPENROUTER_API_KEY=sk-or-v1-your-openrouter-key
|
||||||
|
|
||||||
|
# ═══════════════════════════════════════════════════════════════════════════
|
||||||
|
# WHATSAPP BRIDGE (OPTIONAL — enables WhatsApp integration)
|
||||||
|
# ═══════════════════════════════════════════════════════════════════════════
|
||||||
|
WHATSAPP_BRIDGE_URL=http://localhost:21465
|
||||||
|
WHATSAPP_BRIDGE_KEY=your-whatsapp-bridge-secret
|
||||||
|
|
||||||
|
# ═══════════════════════════════════════════════════════════════════════════
|
||||||
|
# SMTP (OPTIONAL — enables email quotations)
|
||||||
|
# ═══════════════════════════════════════════════════════════════════════════
|
||||||
|
SMTP_HOST=smtp.gmail.com
|
||||||
|
SMTP_PORT=587
|
||||||
|
SMTP_USER=your-email@gmail.com
|
||||||
|
SMTP_PASS=your-app-password
|
||||||
|
SMTP_FROM=noreply@yourdomain.com
|
||||||
|
|
||||||
|
# ═══════════════════════════════════════════════════════════════════════════
|
||||||
|
# REDIS CACHE (OPTIONAL — enables sub-millisecond stock lookups)
|
||||||
|
# ═══════════════════════════════════════════════════════════════════════════
|
||||||
|
REDIS_URL=redis://localhost:6379/0
|
||||||
|
REDIS_ENABLED=true
|
||||||
|
REDIS_STOCK_TTL=300
|
||||||
|
|
||||||
|
# ═══════════════════════════════════════════════════════════════════════════
|
||||||
|
# MEILISEARCH (OPTIONAL — enables sub-100ms catalog search)
|
||||||
|
# ═══════════════════════════════════════════════════════════════════════════
|
||||||
|
MEILI_URL=http://localhost:7700
|
||||||
|
MEILI_API_KEY=nexus-master-key-change-me
|
||||||
|
|
||||||
|
# ═══════════════════════════════════════════════════════════════════════════
|
||||||
|
# METABASE KPIs (OPTIONAL — Business Intelligence dashboards)
|
||||||
|
# ═══════════════════════════════════════════════════════════════════════════
|
||||||
|
METABASE_URL=http://localhost:3000
|
||||||
|
METABASE_ADMIN_EMAIL=admin@nexus.local
|
||||||
|
METABASE_ADMIN_PASS=change-me-to-a-strong-password
|
||||||
|
METABASE_DB_PASS=metabase_secret
|
||||||
|
|
||||||
|
# ═══════════════════════════════════════════════════════════════════════════
|
||||||
|
# CURRENCY
|
||||||
|
# ═══════════════════════════════════════════════════════════════════════════
|
||||||
|
DEFAULT_CURRENCY=MXN
|
||||||
|
EXCHANGE_RATE_USD_MXN=17.5
|
||||||
7
.gitignore
vendored
7
.gitignore
vendored
@@ -80,3 +80,10 @@ node_modules/
|
|||||||
|
|
||||||
# Diagram images (served from static, too large for git)
|
# Diagram images (served from static, too large for git)
|
||||||
dashboard/static/diagrams/
|
dashboard/static/diagrams/
|
||||||
|
|
||||||
|
# Playwright / Node
|
||||||
|
package-lock.json
|
||||||
|
|
||||||
|
# Backups
|
||||||
|
backups/
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
/* ==========================================================================
|
/* ==========================================================================
|
||||||
NEXUS — Public Catalog Chat Widget
|
NEXUS — Public Catalog Chat Widget (Voice + TTS enabled)
|
||||||
Reuses design tokens from tokens.css
|
Reuses design tokens from tokens.css
|
||||||
========================================================================== */
|
========================================================================== */
|
||||||
|
|
||||||
@@ -228,6 +228,99 @@
|
|||||||
.chat-send-btn:hover { background: var(--color-primary-hover, #e5952f); }
|
.chat-send-btn:hover { background: var(--color-primary-hover, #e5952f); }
|
||||||
.chat-send-btn:disabled { opacity: 0.5; cursor: not-allowed; }
|
.chat-send-btn:disabled { opacity: 0.5; cursor: not-allowed; }
|
||||||
|
|
||||||
|
/* ─── Header Actions (TTS toggle + close) ─── */
|
||||||
|
.chat-header-actions {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chat-tts-toggle {
|
||||||
|
background: none;
|
||||||
|
border: none;
|
||||||
|
color: #fff;
|
||||||
|
font-size: 1rem;
|
||||||
|
cursor: pointer;
|
||||||
|
padding: 4px;
|
||||||
|
line-height: 1;
|
||||||
|
opacity: 0.9;
|
||||||
|
transition: opacity 0.15s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chat-tts-toggle:hover { opacity: 1; }
|
||||||
|
.chat-tts-toggle.off { opacity: 0.4; }
|
||||||
|
|
||||||
|
/* ─── Mic Button (Voice Input) ─── */
|
||||||
|
.chat-mic-btn {
|
||||||
|
width: 38px;
|
||||||
|
height: 38px;
|
||||||
|
border-radius: 8px;
|
||||||
|
border: 1px solid var(--color-border, #333);
|
||||||
|
background: var(--color-bg-base, #111);
|
||||||
|
color: var(--color-text-secondary, #aaa);
|
||||||
|
font-size: 1.1rem;
|
||||||
|
cursor: pointer;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
flex-shrink: 0;
|
||||||
|
transition: background 0.15s ease, color 0.15s ease, border-color 0.15s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chat-mic-btn:hover {
|
||||||
|
border-color: var(--color-accent, #F5A623);
|
||||||
|
color: var(--color-accent, #F5A623);
|
||||||
|
}
|
||||||
|
|
||||||
|
.chat-mic-btn.listening {
|
||||||
|
background: #f85149;
|
||||||
|
border-color: #f85149;
|
||||||
|
color: #fff;
|
||||||
|
animation: micPulse 1.4s infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes micPulse {
|
||||||
|
0%, 100% { box-shadow: 0 0 0 0 rgba(248, 81, 73, 0.4); }
|
||||||
|
50% { box-shadow: 0 0 0 10px rgba(248, 81, 73, 0); }
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ─── TTS Button ─── */
|
||||||
|
.chat-tts-btn {
|
||||||
|
background: none;
|
||||||
|
border: none;
|
||||||
|
color: #8b949e;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
cursor: pointer;
|
||||||
|
padding: 2px 4px;
|
||||||
|
margin-left: 6px;
|
||||||
|
border-radius: 4px;
|
||||||
|
transition: color 0.2s, background 0.2s;
|
||||||
|
}
|
||||||
|
.chat-tts-btn:hover { color: #58a6ff; background: rgba(88,166,255,0.1); }
|
||||||
|
.chat-tts-btn.tts-active { color: #58a6ff; }
|
||||||
|
|
||||||
|
/* ─── Voice Toast ─── */
|
||||||
|
.chat-voice-toast {
|
||||||
|
position: fixed;
|
||||||
|
bottom: 160px;
|
||||||
|
left: 50%;
|
||||||
|
transform: translateX(-50%) translateY(10px);
|
||||||
|
background: rgba(0, 0, 0, 0.8);
|
||||||
|
color: #fff;
|
||||||
|
padding: 8px 18px;
|
||||||
|
border-radius: 8px;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
z-index: 9999;
|
||||||
|
opacity: 0;
|
||||||
|
transition: opacity 0.3s, transform 0.3s;
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chat-voice-toast.visible {
|
||||||
|
opacity: 1;
|
||||||
|
transform: translateX(-50%) translateY(0);
|
||||||
|
}
|
||||||
|
|
||||||
@media (max-width: 480px) {
|
@media (max-width: 480px) {
|
||||||
.chat-panel {
|
.chat-panel {
|
||||||
width: calc(100vw - 16px);
|
width: calc(100vw - 16px);
|
||||||
|
|||||||
@@ -1,15 +1,20 @@
|
|||||||
// /home/Autopartes/dashboard/chat-public.js
|
// /home/Autopartes/dashboard/chat-public.js
|
||||||
// Public catalog chatbot — no auth required, calls /api/chat
|
// Public catalog chatbot — voice + TTS enabled
|
||||||
|
|
||||||
(function () {
|
(function () {
|
||||||
'use strict';
|
'use strict';
|
||||||
|
|
||||||
var isOpen = false;
|
var isOpen = false;
|
||||||
var isSending = false;
|
var isSending = false;
|
||||||
|
var isListening = false;
|
||||||
|
var recognition = null;
|
||||||
var history = [];
|
var history = [];
|
||||||
|
var ttsEnabled = true;
|
||||||
|
var ttsUtterance = null;
|
||||||
|
var hasSpeechAPI = ('webkitSpeechRecognition' in window || 'SpeechRecognition' in window);
|
||||||
|
var hasTTS = ('speechSynthesis' in window);
|
||||||
|
|
||||||
function init() {
|
function init() {
|
||||||
// FAB button
|
|
||||||
var fab = document.createElement('button');
|
var fab = document.createElement('button');
|
||||||
fab.className = 'chat-fab';
|
fab.className = 'chat-fab';
|
||||||
fab.id = 'chatFab';
|
fab.id = 'chatFab';
|
||||||
@@ -17,14 +22,16 @@
|
|||||||
fab.innerHTML = '💬';
|
fab.innerHTML = '💬';
|
||||||
fab.setAttribute('aria-label', 'Abrir asistente IA');
|
fab.setAttribute('aria-label', 'Abrir asistente IA');
|
||||||
|
|
||||||
// Chat panel
|
|
||||||
var panel = document.createElement('div');
|
var panel = document.createElement('div');
|
||||||
panel.className = 'chat-panel';
|
panel.className = 'chat-panel';
|
||||||
panel.id = 'chatPanel';
|
panel.id = 'chatPanel';
|
||||||
panel.innerHTML =
|
panel.innerHTML =
|
||||||
'<div class="chat-header">' +
|
'<div class="chat-header">' +
|
||||||
'<h3>Asistente — Buscar partes</h3>' +
|
'<h3>Asistente — Buscar partes</h3>' +
|
||||||
'<button class="chat-header-close" id="chatClose" aria-label="Cerrar">×</button>' +
|
'<div class="chat-header-actions">' +
|
||||||
|
(hasTTS ? '<button class="chat-tts-toggle" id="chatTtsToggle" aria-label="Activar lectura de respuestas" title="Activar lectura de respuestas">🔊</button>' : '') +
|
||||||
|
'<button class="chat-header-close" id="chatClose" aria-label="Cerrar">×</button>' +
|
||||||
|
'</div>' +
|
||||||
'</div>' +
|
'</div>' +
|
||||||
'<div class="chat-messages" id="chatMessages">' +
|
'<div class="chat-messages" id="chatMessages">' +
|
||||||
'<div class="chat-msg ai">Hola, soy el asistente de Nexus Autoparts. Dime que refaccion buscas y te ayudo a encontrarla en el catalogo.</div>' +
|
'<div class="chat-msg ai">Hola, soy el asistente de Nexus Autoparts. Dime que refaccion buscas y te ayudo a encontrarla en el catalogo.</div>' +
|
||||||
@@ -32,6 +39,7 @@
|
|||||||
'</div>' +
|
'</div>' +
|
||||||
'<div class="chat-input-area">' +
|
'<div class="chat-input-area">' +
|
||||||
'<textarea class="chat-input" id="chatInput" placeholder="Ej: Balatas para Tsuru 2015..." rows="1"></textarea>' +
|
'<textarea class="chat-input" id="chatInput" placeholder="Ej: Balatas para Tsuru 2015..." rows="1"></textarea>' +
|
||||||
|
(hasSpeechAPI ? '<button class="chat-mic-btn" id="chatMic" aria-label="Entrada por voz" title="Entrada por voz">🎤</button>' : '') +
|
||||||
'<button class="chat-send-btn" id="chatSend" aria-label="Enviar">▶</button>' +
|
'<button class="chat-send-btn" id="chatSend" aria-label="Enviar">▶</button>' +
|
||||||
'</div>';
|
'</div>';
|
||||||
|
|
||||||
@@ -52,8 +60,139 @@
|
|||||||
this.style.height = 'auto';
|
this.style.height = 'auto';
|
||||||
this.style.height = Math.min(this.scrollHeight, 80) + 'px';
|
this.style.height = Math.min(this.scrollHeight, 80) + 'px';
|
||||||
});
|
});
|
||||||
|
|
||||||
|
if (hasSpeechAPI) {
|
||||||
|
document.getElementById('chatMic').addEventListener('click', toggleVoice);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (hasTTS) {
|
||||||
|
document.getElementById('chatTtsToggle').addEventListener('click', toggleTTS);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Stop TTS when closing chat
|
||||||
|
document.getElementById('chatClose').addEventListener('click', function () {
|
||||||
|
if (hasTTS) stopSpeaking();
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ─── TTS ───
|
||||||
|
function toggleTTS() {
|
||||||
|
ttsEnabled = !ttsEnabled;
|
||||||
|
var btn = document.getElementById('chatTtsToggle');
|
||||||
|
if (btn) {
|
||||||
|
btn.classList.toggle('off', !ttsEnabled);
|
||||||
|
btn.setAttribute('title', ttsEnabled ? 'Desactivar lectura de respuestas' : 'Activar lectura de respuestas');
|
||||||
|
}
|
||||||
|
if (!ttsEnabled) stopSpeaking();
|
||||||
|
}
|
||||||
|
|
||||||
|
function speak(text) {
|
||||||
|
if (!hasTTS || !ttsEnabled || !text) return;
|
||||||
|
stopSpeaking();
|
||||||
|
ttsUtterance = new SpeechSynthesisUtterance(text);
|
||||||
|
ttsUtterance.lang = 'es-MX';
|
||||||
|
ttsUtterance.rate = 1.1;
|
||||||
|
ttsUtterance.pitch = 1;
|
||||||
|
window.speechSynthesis.speak(ttsUtterance);
|
||||||
|
}
|
||||||
|
|
||||||
|
function stopSpeaking() {
|
||||||
|
if (hasTTS && window.speechSynthesis.speaking) {
|
||||||
|
window.speechSynthesis.cancel();
|
||||||
|
}
|
||||||
|
ttsUtterance = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Voice Input ───
|
||||||
|
function toggleVoice() {
|
||||||
|
if (isListening) { stopVoice(); return; }
|
||||||
|
startVoice();
|
||||||
|
}
|
||||||
|
|
||||||
|
function startVoice() {
|
||||||
|
var SpeechRecognition = window.SpeechRecognition || window.webkitSpeechRecognition;
|
||||||
|
if (!SpeechRecognition) return;
|
||||||
|
|
||||||
|
recognition = new SpeechRecognition();
|
||||||
|
recognition.lang = 'es-MX';
|
||||||
|
recognition.continuous = false;
|
||||||
|
recognition.interimResults = true;
|
||||||
|
|
||||||
|
var input = document.getElementById('chatInput');
|
||||||
|
var micBtn = document.getElementById('chatMic');
|
||||||
|
var savedPlaceholder = input.placeholder;
|
||||||
|
|
||||||
|
recognition.onstart = function () {
|
||||||
|
isListening = true;
|
||||||
|
micBtn.classList.add('listening');
|
||||||
|
input.placeholder = 'Escuchando...';
|
||||||
|
input.value = '';
|
||||||
|
stopSpeaking();
|
||||||
|
};
|
||||||
|
|
||||||
|
recognition.onresult = function (e) {
|
||||||
|
var interim = '';
|
||||||
|
var finalTranscript = '';
|
||||||
|
for (var i = e.resultIndex; i < e.results.length; i++) {
|
||||||
|
if (e.results[i].isFinal) {
|
||||||
|
finalTranscript += e.results[i][0].transcript;
|
||||||
|
} else {
|
||||||
|
interim += e.results[i][0].transcript;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (finalTranscript) {
|
||||||
|
input.value = finalTranscript;
|
||||||
|
} else {
|
||||||
|
input.value = interim;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
recognition.onend = function () {
|
||||||
|
isListening = false;
|
||||||
|
micBtn.classList.remove('listening');
|
||||||
|
input.placeholder = savedPlaceholder;
|
||||||
|
recognition = null;
|
||||||
|
if (input.value.trim()) {
|
||||||
|
sendMessage();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
recognition.onerror = function (e) {
|
||||||
|
isListening = false;
|
||||||
|
micBtn.classList.remove('listening');
|
||||||
|
input.placeholder = savedPlaceholder;
|
||||||
|
recognition = null;
|
||||||
|
if (e.error === 'no-speech' || e.error === 'audio-capture' || e.error === 'not-allowed') {
|
||||||
|
showVoiceToast('No se detecto voz');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
recognition.start();
|
||||||
|
}
|
||||||
|
|
||||||
|
function stopVoice() {
|
||||||
|
if (recognition) {
|
||||||
|
recognition.abort();
|
||||||
|
recognition = null;
|
||||||
|
}
|
||||||
|
isListening = false;
|
||||||
|
var micBtn = document.getElementById('chatMic');
|
||||||
|
if (micBtn) micBtn.classList.remove('listening');
|
||||||
|
}
|
||||||
|
|
||||||
|
function showVoiceToast(msg) {
|
||||||
|
var toast = document.createElement('div');
|
||||||
|
toast.className = 'chat-voice-toast';
|
||||||
|
toast.textContent = msg;
|
||||||
|
document.body.appendChild(toast);
|
||||||
|
setTimeout(function () { toast.classList.add('visible'); }, 10);
|
||||||
|
setTimeout(function () {
|
||||||
|
toast.classList.remove('visible');
|
||||||
|
setTimeout(function () { toast.remove(); }, 300);
|
||||||
|
}, 2000);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Chat UI ───
|
||||||
function toggleChat() {
|
function toggleChat() {
|
||||||
isOpen = !isOpen;
|
isOpen = !isOpen;
|
||||||
var panel = document.getElementById('chatPanel');
|
var panel = document.getElementById('chatPanel');
|
||||||
@@ -104,6 +243,8 @@
|
|||||||
addBubble(aiMsg, 'ai');
|
addBubble(aiMsg, 'ai');
|
||||||
history.push({ role: 'assistant', content: aiMsg });
|
history.push({ role: 'assistant', content: aiMsg });
|
||||||
|
|
||||||
|
if (ttsEnabled) speak(aiMsg);
|
||||||
|
|
||||||
if (data.search_results && data.search_results.length > 0) {
|
if (data.search_results && data.search_results.length > 0) {
|
||||||
addPartResults(data.search_results);
|
addPartResults(data.search_results);
|
||||||
}
|
}
|
||||||
@@ -149,7 +290,6 @@
|
|||||||
|
|
||||||
card.style.cursor = 'pointer';
|
card.style.cursor = 'pointer';
|
||||||
card.addEventListener('click', function () {
|
card.addEventListener('click', function () {
|
||||||
// Search in catalog
|
|
||||||
var searchInput = document.getElementById('searchInput');
|
var searchInput = document.getElementById('searchInput');
|
||||||
if (searchInput && partNum) {
|
if (searchInput && partNum) {
|
||||||
searchInput.value = partNum;
|
searchInput.value = partNum;
|
||||||
|
|||||||
95
dashboard/chat-public.min.css
vendored
95
dashboard/chat-public.min.css
vendored
@@ -1,5 +1,5 @@
|
|||||||
/* ==========================================================================
|
/* ==========================================================================
|
||||||
NEXUS — Public Catalog Chat Widget
|
NEXUS — Public Catalog Chat Widget (Voice + TTS enabled)
|
||||||
Reuses design tokens from tokens.css
|
Reuses design tokens from tokens.css
|
||||||
========================================================================== */
|
========================================================================== */
|
||||||
|
|
||||||
@@ -228,6 +228,99 @@
|
|||||||
.chat-send-btn:hover { background: var(--color-primary-hover, #e5952f); }
|
.chat-send-btn:hover { background: var(--color-primary-hover, #e5952f); }
|
||||||
.chat-send-btn:disabled { opacity: 0.5; cursor: not-allowed; }
|
.chat-send-btn:disabled { opacity: 0.5; cursor: not-allowed; }
|
||||||
|
|
||||||
|
/* ─── Header Actions (TTS toggle + close) ─── */
|
||||||
|
.chat-header-actions {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chat-tts-toggle {
|
||||||
|
background: none;
|
||||||
|
border: none;
|
||||||
|
color: #fff;
|
||||||
|
font-size: 1rem;
|
||||||
|
cursor: pointer;
|
||||||
|
padding: 4px;
|
||||||
|
line-height: 1;
|
||||||
|
opacity: 0.9;
|
||||||
|
transition: opacity 0.15s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chat-tts-toggle:hover { opacity: 1; }
|
||||||
|
.chat-tts-toggle.off { opacity: 0.4; }
|
||||||
|
|
||||||
|
/* ─── Mic Button (Voice Input) ─── */
|
||||||
|
.chat-mic-btn {
|
||||||
|
width: 38px;
|
||||||
|
height: 38px;
|
||||||
|
border-radius: 8px;
|
||||||
|
border: 1px solid var(--color-border, #333);
|
||||||
|
background: var(--color-bg-base, #111);
|
||||||
|
color: var(--color-text-secondary, #aaa);
|
||||||
|
font-size: 1.1rem;
|
||||||
|
cursor: pointer;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
flex-shrink: 0;
|
||||||
|
transition: background 0.15s ease, color 0.15s ease, border-color 0.15s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chat-mic-btn:hover {
|
||||||
|
border-color: var(--color-accent, #F5A623);
|
||||||
|
color: var(--color-accent, #F5A623);
|
||||||
|
}
|
||||||
|
|
||||||
|
.chat-mic-btn.listening {
|
||||||
|
background: #f85149;
|
||||||
|
border-color: #f85149;
|
||||||
|
color: #fff;
|
||||||
|
animation: micPulse 1.4s infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes micPulse {
|
||||||
|
0%, 100% { box-shadow: 0 0 0 0 rgba(248, 81, 73, 0.4); }
|
||||||
|
50% { box-shadow: 0 0 0 10px rgba(248, 81, 73, 0); }
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ─── TTS Button ─── */
|
||||||
|
.chat-tts-btn {
|
||||||
|
background: none;
|
||||||
|
border: none;
|
||||||
|
color: #8b949e;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
cursor: pointer;
|
||||||
|
padding: 2px 4px;
|
||||||
|
margin-left: 6px;
|
||||||
|
border-radius: 4px;
|
||||||
|
transition: color 0.2s, background 0.2s;
|
||||||
|
}
|
||||||
|
.chat-tts-btn:hover { color: #58a6ff; background: rgba(88,166,255,0.1); }
|
||||||
|
.chat-tts-btn.tts-active { color: #58a6ff; }
|
||||||
|
|
||||||
|
/* ─── Voice Toast ─── */
|
||||||
|
.chat-voice-toast {
|
||||||
|
position: fixed;
|
||||||
|
bottom: 160px;
|
||||||
|
left: 50%;
|
||||||
|
transform: translateX(-50%) translateY(10px);
|
||||||
|
background: rgba(0, 0, 0, 0.8);
|
||||||
|
color: #fff;
|
||||||
|
padding: 8px 18px;
|
||||||
|
border-radius: 8px;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
z-index: 9999;
|
||||||
|
opacity: 0;
|
||||||
|
transition: opacity 0.3s, transform 0.3s;
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chat-voice-toast.visible {
|
||||||
|
opacity: 1;
|
||||||
|
transform: translateX(-50%) translateY(0);
|
||||||
|
}
|
||||||
|
|
||||||
@media (max-width: 480px) {
|
@media (max-width: 480px) {
|
||||||
.chat-panel {
|
.chat-panel {
|
||||||
width: calc(100vw - 16px);
|
width: calc(100vw - 16px);
|
||||||
|
|||||||
2
dashboard/chat-public.min.js
vendored
2
dashboard/chat-public.min.js
vendored
File diff suppressed because one or more lines are too long
@@ -1,4 +1,4 @@
|
|||||||
from flask import Flask, jsonify, request, send_from_directory, redirect, g
|
from flask import Flask, jsonify, request, send_from_directory, redirect, g, abort
|
||||||
from sqlalchemy import create_engine, text
|
from sqlalchemy import create_engine, text
|
||||||
from sqlalchemy.orm import sessionmaker
|
from sqlalchemy.orm import sessionmaker
|
||||||
from sqlalchemy.exc import IntegrityError
|
from sqlalchemy.exc import IntegrityError
|
||||||
@@ -4628,6 +4628,17 @@ def part_aftermarket(part_id):
|
|||||||
session.close()
|
session.close()
|
||||||
|
|
||||||
|
|
||||||
|
# ============================================================================
|
||||||
|
# Static files from dashboard root (CSS/JS/HTML)
|
||||||
|
# ============================================================================
|
||||||
|
|
||||||
|
@app.route('/<filename>')
|
||||||
|
def serve_root_static(filename):
|
||||||
|
if filename.endswith(('.css', '.js', '.html')) and os.path.isfile(filename):
|
||||||
|
return send_from_directory('.', filename)
|
||||||
|
abort(404)
|
||||||
|
|
||||||
|
|
||||||
# ============================================================================
|
# ============================================================================
|
||||||
# Main Block
|
# Main Block
|
||||||
# ============================================================================
|
# ============================================================================
|
||||||
|
|||||||
60
docker/docker-compose.monitoring.yml
Normal file
60
docker/docker-compose.monitoring.yml
Normal file
@@ -0,0 +1,60 @@
|
|||||||
|
version: "3.8"
|
||||||
|
|
||||||
|
services:
|
||||||
|
prometheus:
|
||||||
|
image: prom/prometheus:v2.51.0
|
||||||
|
container_name: nexus-prometheus
|
||||||
|
restart: unless-stopped
|
||||||
|
ports:
|
||||||
|
- "9090:9090"
|
||||||
|
volumes:
|
||||||
|
- ./prometheus:/etc/prometheus
|
||||||
|
- prometheus-data:/prometheus
|
||||||
|
command:
|
||||||
|
- '--config.file=/etc/prometheus/prometheus.yml'
|
||||||
|
- '--storage.tsdb.path=/prometheus'
|
||||||
|
- '--web.console.libraries=/usr/share/prometheus/console_libraries'
|
||||||
|
- '--web.console.templates=/usr/share/prometheus/consoles'
|
||||||
|
- '--web.enable-lifecycle'
|
||||||
|
|
||||||
|
grafana:
|
||||||
|
image: grafana/grafana:10.4.1
|
||||||
|
container_name: nexus-grafana
|
||||||
|
restart: unless-stopped
|
||||||
|
ports:
|
||||||
|
- "3001:3000"
|
||||||
|
volumes:
|
||||||
|
- grafana-data:/var/lib/grafana
|
||||||
|
- ./grafana/provisioning:/etc/grafana/provisioning
|
||||||
|
environment:
|
||||||
|
- GF_SECURITY_ADMIN_USER=admin
|
||||||
|
- GF_SECURITY_ADMIN_PASSWORD=nexus2026
|
||||||
|
- GF_USERS_ALLOW_SIGN_UP=false
|
||||||
|
|
||||||
|
node-exporter:
|
||||||
|
image: prom/node-exporter:v1.7.0
|
||||||
|
container_name: nexus-node-exporter
|
||||||
|
restart: unless-stopped
|
||||||
|
network_mode: host
|
||||||
|
|
||||||
|
postgres-exporter:
|
||||||
|
image: prometheuscommunity/postgres-exporter:v0.15.0
|
||||||
|
container_name: nexus-postgres-exporter
|
||||||
|
restart: unless-stopped
|
||||||
|
environment:
|
||||||
|
DATA_SOURCE_NAME: "postgresql://postgres@172.17.0.1:5432/nexus_autoparts?sslmode=disable"
|
||||||
|
ports:
|
||||||
|
- "9187:9187"
|
||||||
|
|
||||||
|
redis-exporter:
|
||||||
|
image: oliver006/redis_exporter:v1.58.0
|
||||||
|
container_name: nexus-redis-exporter
|
||||||
|
restart: unless-stopped
|
||||||
|
environment:
|
||||||
|
REDIS_ADDR: "redis://172.17.0.1:6379"
|
||||||
|
ports:
|
||||||
|
- "9121:9121"
|
||||||
|
|
||||||
|
volumes:
|
||||||
|
prometheus-data:
|
||||||
|
grafana-data:
|
||||||
9
docker/grafana/provisioning/datasources/prometheus.yml
Normal file
9
docker/grafana/provisioning/datasources/prometheus.yml
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
apiVersion: 1
|
||||||
|
|
||||||
|
datasources:
|
||||||
|
- name: Prometheus
|
||||||
|
type: prometheus
|
||||||
|
access: proxy
|
||||||
|
url: http://prometheus:9090
|
||||||
|
isDefault: true
|
||||||
|
editable: false
|
||||||
30
docker/prometheus/prometheus.yml
Normal file
30
docker/prometheus/prometheus.yml
Normal file
@@ -0,0 +1,30 @@
|
|||||||
|
global:
|
||||||
|
scrape_interval: 15s
|
||||||
|
evaluation_interval: 15s
|
||||||
|
|
||||||
|
scrape_configs:
|
||||||
|
- job_name: 'prometheus'
|
||||||
|
static_configs:
|
||||||
|
- targets: ['localhost:9090']
|
||||||
|
|
||||||
|
- job_name: 'node'
|
||||||
|
static_configs:
|
||||||
|
- targets: ['node-exporter:9100']
|
||||||
|
|
||||||
|
- job_name: 'postgres'
|
||||||
|
static_configs:
|
||||||
|
- targets: ['postgres-exporter:9187']
|
||||||
|
|
||||||
|
- job_name: 'redis'
|
||||||
|
static_configs:
|
||||||
|
- targets: ['redis-exporter:9121']
|
||||||
|
|
||||||
|
- job_name: 'nexus-pos'
|
||||||
|
static_configs:
|
||||||
|
- targets: ['host.docker.internal:5001']
|
||||||
|
metrics_path: /metrics
|
||||||
|
|
||||||
|
- job_name: 'nexus-quart'
|
||||||
|
static_configs:
|
||||||
|
- targets: ['host.docker.internal:5002']
|
||||||
|
metrics_path: /metrics
|
||||||
@@ -1,8 +1,8 @@
|
|||||||
# Nexus POS — Resumen de Fases Implementadas
|
# Nexus POS — Resumen de Fases Implementadas
|
||||||
|
|
||||||
**Fecha:** 2026-04-27
|
**Fecha:** 2026-04-26
|
||||||
**Versión DB:** v3.2
|
**Versión DB:** v3.2
|
||||||
**Tests:** 108/108 pasando (pytest) + 207 checks (scripts standalone)
|
**Tests:** 73/73 pasando (pytest)
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -81,6 +81,35 @@
|
|||||||
- Ventas 20 ítems: 21 queries → 1 query
|
- Ventas 20 ítems: 21 queries → 1 query
|
||||||
- Cache hit rate: 6% → 80%+
|
- Cache hit rate: 6% → 80%+
|
||||||
|
|
||||||
|
## Opción C — Consolidación Técnica (COMPLETADA)
|
||||||
|
|
||||||
|
| Item | Estado | Commit |
|
||||||
|
|------|--------|--------|
|
||||||
|
| **C1: MV `part_vehicle_preview`** | ✅ En producción, refresh automático vía systemd timer (03:00 UTC) | `f893391` |
|
||||||
|
| **C2: Cache warming script** | ✅ Autónomo con auto-sudo fallback, args CLI | `f893391` |
|
||||||
|
| **C3: CSS dinámico residual** | ✅ `sidebar.js` → `sidebar.css`, `pos-utils.js` → `common.css` | `042acd6` |
|
||||||
|
| **C4: Load testing script** | ✅ `scripts/load_test.py` con `locust` | `042acd6` |
|
||||||
|
| **C5: Docs audit** | ✅ `FASES_IMPLEMENTADAS.md`, `performance_audit_2026.md` | `042acd6` |
|
||||||
|
|
||||||
|
## Opción A — Arquitectura Avanzada (COMPLETADA)
|
||||||
|
|
||||||
|
| Item | Estado | Commit |
|
||||||
|
|------|--------|--------|
|
||||||
|
| **A1: `orjson` como JSON provider** | ✅ Hereda `DefaultJSONProvider`, fix indent en `pos_bp.py` | `a1be8dd` |
|
||||||
|
| **A2: Virtual scroll** | ✅ `inventory.js`, `customers.js`, `fleet.js` | `a1be8dd` |
|
||||||
|
| **A3: Celery worker queue** | ✅ `celery_app.py`, `tasks.py`, `tasks_bp.py`, systemd service activo | `a1be8dd` |
|
||||||
|
| **A4: Quart + asyncpg PoC** | ✅ `async_catalog.py` en puerto 5002, benchmark script | `a1be8dd` |
|
||||||
|
| **A5: Particionamiento `vehicle_parts`** | ✅ Script `partition_vehicle_parts.py` listo (HASH 16 particiones, dry-run) | `a1be8dd` |
|
||||||
|
|
||||||
|
## IA por Voz — Chalán de Nexus (COMPLETADA)
|
||||||
|
|
||||||
|
| Componente | Estado |
|
||||||
|
|------------|--------|
|
||||||
|
| **STT (Speech-to-Text)** | ✅ POS + Dashboard público, `es-MX`, auto-send, animación micrófono |
|
||||||
|
| **TTS (Text-to-Speech)** | ✅ Botón 🔊 en burbujas de IA, `speechSynthesis`, preferencia guardada en `localStorage` |
|
||||||
|
| **Cobertura templates POS** | ✅ 14/14 templates tienen chat widget |
|
||||||
|
| **Dashboard público** | ✅ Chat público con voz completa (sin cámara) |
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Infraestructura Desplegada
|
## Infraestructura Desplegada
|
||||||
@@ -93,6 +122,8 @@
|
|||||||
| Metabase | v0.53 | 3000 | ✅ Dashboard ID 2 |
|
| Metabase | v0.53 | 3000 | ✅ Dashboard ID 2 |
|
||||||
| Nginx | — | 80/443 | ✅ gzip, cache 6M, auto-serve .min |
|
| Nginx | — | 80/443 | ✅ gzip, cache 6M, auto-serve .min |
|
||||||
| Gunicorn | — | 5001 | ✅ gthread, 4×4, max_requests=1000 |
|
| Gunicorn | — | 5001 | ✅ gthread, 4×4, max_requests=1000 |
|
||||||
|
| Celery | — | — | ✅ 4 prefork workers, broker redis://localhost:6379/1 |
|
||||||
|
| Quart async catalog | hypercorn | 5002 | ✅ systemd `nexus-quart.service`, nginx upstream `/pos/api/catalog/async-search` |
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -132,28 +163,56 @@ METABASE_URL=http://localhost:3000
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Próximos Pasos (Roadmap restante)
|
## ✅ Completados recientemente
|
||||||
|
|
||||||
### Opción C — Consolidación Técnica (en progreso)
|
| # | Mejora | Fecha | Commit |
|
||||||
1. **Materialized view `part_vehicle_preview`** — Fallback robusto al Redis cache para vehicle info
|
|---|--------|-------|--------|
|
||||||
2. **Fix cache warming script** — Autonomía sin `sudo -u postgres`
|
| — | **Particionar `vehicle_parts` en producción** | 2026-04-26 | `f24f25e` |
|
||||||
3. **CSS dinámico residual** — Extraer CSS inyectado por JS a archivos externos
|
| — | **Quart async catalog en producción** | 2026-04-26 | `b829e4f` |
|
||||||
4. **Load testing script** — Benchmark básico de endpoints críticos
|
| — | **Arreglar `scripts/minify-assets.sh`** | 2026-04-26 | `b829e4f` |
|
||||||
5. **Docs audit** — Corregir métricas y marcar estado post-FASE 7
|
| — | **Dashboard outage fix (env vars + static files)** | 2026-04-26 | `27cb4ee` |
|
||||||
|
| — | **IA por Voz (STT + TTS) en POS y Dashboard** | 2026-04-26 | `afb3b24` |
|
||||||
|
| — | **Fix chat.js null reference (`chatTtsToggle`)** | 2026-04-26 | — |
|
||||||
|
| — | **Optimizar PostgreSQL config** | 2026-04-26 | — |
|
||||||
|
|
||||||
### Opción A — Arquitectura (pendiente)
|
---
|
||||||
1. **Serialización `orjson`** — 2-10× faster JSON
|
|
||||||
2. **Virtual scroll** — Tablas grandes sin lag
|
|
||||||
3. **Celery worker queue** — Tareas pesadas async
|
|
||||||
4. **Asyncpg + Quart PoC** — Evaluar I/O no bloqueante para catálogo
|
|
||||||
5. **Particionar `vehicle_parts`** — Escalabilidad ilimitada (254 GB → particiones)
|
|
||||||
|
|
||||||
### Features de Negocio (futuro)
|
## Mejoras Pendientes (Roadmap Actualizado)
|
||||||
1. **Mercado Libre / Amazon sync** — Publicar inventario en marketplaces
|
|
||||||
2. **IA por voz (Chalán de Nexus)** — Web Speech API → chatbot existente
|
### 🔴 Crítico — Deuda Técnica
|
||||||
3. **PWA mejorada** — Offline mode, install prompt, background sync
|
|
||||||
4. **Portal de proveedores** — Demand analytics, heatmaps, stock recommendations
|
| # | Mejora | Descripción | Bloqueo |
|
||||||
5. **Dashboard in-app** — Gráficos de rendimiento en tiempo real
|
|---|--------|-------------|---------|
|
||||||
|
| 1 | **PostgreSQL restart para aplicar config** | `shared_buffers=8GB`, `work_mem=64MB`, `max_wal_size=8GB` configurados. Requiere `systemctl restart postgresql` (downtime ~30s). | Ventana de mantenimiento |
|
||||||
|
|
||||||
|
### 🟠 Alto — Features de Negocio
|
||||||
|
|
||||||
|
| # | Mejora | Descripción | Esfuerzo |
|
||||||
|
|---|--------|-------------|----------|
|
||||||
|
| 2 | **WhatsApp Business API (Meta Cloud)** | Actualmente solo webhook Baileys. Migrar a Meta Cloud API para escalabilidad real. Requiere verificación de cuenta Meta. | 2-3 semanas |
|
||||||
|
| 3 | **BNPL real** | Integrar APLAZO/Kueski/Clip para "compra ahora, paga en 15/30 días". Ahora solo es stub. | 2 semanas |
|
||||||
|
| 4 | **ERP Sync real** | Conectar con Aspel/Contpaqi/SAP/Odoo vía API o archivos de intercambio. Ahora solo es stub. | 2-3 semanas |
|
||||||
|
| 5 | **Mercado Libre / Amazon sync** | Publicar inventario de bodegas en marketplaces. API de ML Seller + Amazon SP-API. | 3 semanas |
|
||||||
|
| 6 | **PWA mejorada** | Offline mode, install prompt, background sync para catálogo y carrito. | 1-2 semanas |
|
||||||
|
|
||||||
|
### 🟡 Medio — Diferenciadores
|
||||||
|
|
||||||
|
| # | Mejora | Descripción | Esfuerzo |
|
||||||
|
|---|--------|-------------|----------|
|
||||||
|
| 7 | **App móvil nativa (Capacitor)** | Wrap del POS como app iOS/Android. Camera nativa, push notifications, biometrics. | 3-4 semanas |
|
||||||
|
| 8 | **Portal de proveedores** | Dashboard para que proveedores vean demanda por zona y tipo de parte. | 2 semanas |
|
||||||
|
| 9 | **Dashboard in-app** | Gráficos de rendimiento en tiempo real (ventas, productividad, conversión) en el POS. | 1-2 semanas |
|
||||||
|
| 10 | **Crédito basado en comportamiento** | Evaluación automática de línea de crédito por historial de pagos del cliente. | 2 semanas |
|
||||||
|
| 11 | **Programa de embajadores** | Referidos con recompensas, tracking de conversiones. | 1 semana |
|
||||||
|
|
||||||
|
### 🟢 Bajo — Polish
|
||||||
|
|
||||||
|
| # | Mejora | Descripción |
|
||||||
|
|---|--------|-------------|
|
||||||
|
| 12 | **Cache warming automatizado** | Agregar systemd timer/service para correr `warm_cache.py` diariamente (ahora es manual). |
|
||||||
|
| 13 | **Backup automatizado** | Último backup 2026-04-27. Automatizar con cron + S3/GCS. |
|
||||||
|
| 14 | **Monitoreo/Alerting** | Prometheus/Grafana o similar para gunicorn, PostgreSQL, Redis, Celery. |
|
||||||
|
| 15 | **Tests de integración frontend** | Playwright/Cypress para flujos críticos (checkout, búsqueda, login). |
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
40
docs/POSTGRESQL_TUNING.md
Normal file
40
docs/POSTGRESQL_TUNING.md
Normal file
@@ -0,0 +1,40 @@
|
|||||||
|
# PostgreSQL Tuning — Nexus Autoparts
|
||||||
|
|
||||||
|
**Server:** 48 GB RAM, 8 cores, SSD (QEMU)
|
||||||
|
**Applied:** 2026-04-26
|
||||||
|
**Requires restart:** Yes (done)
|
||||||
|
|
||||||
|
## Configuration Changes
|
||||||
|
|
||||||
|
File: `/etc/postgresql/17/main/postgresql.conf`
|
||||||
|
|
||||||
|
| Parameter | Before | After | Rationale |
|
||||||
|
|-----------|--------|-------|-----------|
|
||||||
|
| `shared_buffers` | 128 MB | **8 GB** | ~25% of RAM for PostgreSQL buffer cache |
|
||||||
|
| `work_mem` | 4 MB | **64 MB** | Larger sorts/joins without disk spilling |
|
||||||
|
| `maintenance_work_mem` | 64 MB | **1 GB** | Faster VACUUM, CREATE INDEX, ALTER |
|
||||||
|
| `effective_cache_size` | 4 GB | **36 GB** | Planner knows OS cache is large |
|
||||||
|
| `max_wal_size` | 1 GB | **8 GB** | Fewer checkpoints under heavy write load |
|
||||||
|
| `checkpoint_completion_target` | 0.5 | **0.9** | Spread checkpoint I/O over more time |
|
||||||
|
| `wal_buffers` | - | **16 MB** | WAL buffer sizing |
|
||||||
|
| `random_page_cost` | 4.0 | **1.1** | SSD-appropriate random read cost |
|
||||||
|
| `effective_io_concurrency` | 1 | **200** | SSD can handle many concurrent requests |
|
||||||
|
| `max_connections` | 100 | **200** | Headroom for Celery, Quart, Dashboard, PgBouncer |
|
||||||
|
|
||||||
|
## Verification
|
||||||
|
|
||||||
|
```bash
|
||||||
|
sudo -u postgres psql -d nexus_autoparts -c "SHOW shared_buffers;"
|
||||||
|
```
|
||||||
|
|
||||||
|
## Backup
|
||||||
|
|
||||||
|
A backup of the previous config is stored at:
|
||||||
|
`/etc/postgresql/17/main/postgresql.conf.backup.<timestamp>`
|
||||||
|
|
||||||
|
## pg_hba Adjustment for Monitoring
|
||||||
|
|
||||||
|
Added Docker network access for postgres-exporter:
|
||||||
|
```
|
||||||
|
host nexus_autoparts postgres 172.17.0.0/16 trust
|
||||||
|
```
|
||||||
35
docs/SYSTEMD_SERVICES.md
Normal file
35
docs/SYSTEMD_SERVICES.md
Normal file
@@ -0,0 +1,35 @@
|
|||||||
|
# Systemd Services — Nexus Autoparts
|
||||||
|
|
||||||
|
All production services are managed via systemd. Files are versioned in `systemd/`.
|
||||||
|
|
||||||
|
## Services
|
||||||
|
|
||||||
|
| Service | Description | Status |
|
||||||
|
|---------|-------------|--------|
|
||||||
|
| `nexus-pos.service` | Gunicorn POS (Flask), port 5001 | Active |
|
||||||
|
| `nexus.service` | Dashboard (Flask), port 5000 | Active |
|
||||||
|
| `nexus-quart.service` | Hypercorn async catalog, port 5002 | Active |
|
||||||
|
| `nexus-celery.service` | Celery worker (4 prefork) | Active |
|
||||||
|
| `nexus-mv-refresh.timer` | Daily MV refresh at 03:00 UTC | Active |
|
||||||
|
| `nexus-cache-warm.timer` | Daily Redis cache warming at 04:00 UTC | Active |
|
||||||
|
|
||||||
|
## Commands
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Reload all
|
||||||
|
systemctl daemon-reload
|
||||||
|
|
||||||
|
# Restart POS
|
||||||
|
systemctl restart nexus-pos.service
|
||||||
|
|
||||||
|
# View logs
|
||||||
|
journalctl -u nexus-pos.service -f
|
||||||
|
```
|
||||||
|
|
||||||
|
## Installation
|
||||||
|
|
||||||
|
```bash
|
||||||
|
sudo cp systemd/*.service systemd/*.timer /etc/systemd/system/
|
||||||
|
systemctl daemon-reload
|
||||||
|
systemctl enable --now nexus-pos.service nexus-cache-warm.timer
|
||||||
|
```
|
||||||
@@ -13,6 +13,10 @@ upstream nexus_pos {
|
|||||||
server 127.0.0.1:5001;
|
server 127.0.0.1:5001;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
upstream nexus_quart {
|
||||||
|
server 127.0.0.1:5002;
|
||||||
|
}
|
||||||
|
|
||||||
# Gzip compression
|
# Gzip compression
|
||||||
gzip on;
|
gzip on;
|
||||||
gzip_vary on;
|
gzip_vary on;
|
||||||
@@ -93,6 +97,20 @@ server {
|
|||||||
proxy_buffers 8 4k;
|
proxy_buffers 8 4k;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
# Async catalog search via Quart+asyncpg (non-blocking I/O)
|
||||||
|
location /pos/api/catalog/async-search {
|
||||||
|
proxy_pass http://nexus_quart;
|
||||||
|
proxy_set_header Host $host;
|
||||||
|
proxy_set_header X-Real-IP $remote_addr;
|
||||||
|
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||||
|
proxy_set_header X-Forwarded-Proto $scheme;
|
||||||
|
proxy_set_header X-Tenant-Subdomain $tenant;
|
||||||
|
proxy_connect_timeout 5s;
|
||||||
|
proxy_send_timeout 30s;
|
||||||
|
proxy_read_timeout 30s;
|
||||||
|
proxy_buffering off;
|
||||||
|
}
|
||||||
|
|
||||||
# Rate limit login endpoint
|
# Rate limit login endpoint
|
||||||
location /pos/api/auth/login {
|
location /pos/api/auth/login {
|
||||||
limit_req zone=pos_login burst=5 nodelay;
|
limit_req zone=pos_login burst=5 nodelay;
|
||||||
|
|||||||
23
package.json
Normal file
23
package.json
Normal file
@@ -0,0 +1,23 @@
|
|||||||
|
{
|
||||||
|
"name": "autopartes",
|
||||||
|
"version": "1.0.0",
|
||||||
|
"description": "**POS + Catalogo de autopartes para refaccionarias mexicanas.**",
|
||||||
|
"main": "index.js",
|
||||||
|
"directories": {
|
||||||
|
"doc": "docs"
|
||||||
|
},
|
||||||
|
"scripts": {
|
||||||
|
"test": "playwright test"
|
||||||
|
},
|
||||||
|
"repository": {
|
||||||
|
"type": "git",
|
||||||
|
"url": "https://consultoria-as:b708144ceef22fef31217f1259a695005d67477b@git.consultoria-as.com/consultoria-as/Autoparts-DB.git"
|
||||||
|
},
|
||||||
|
"keywords": [],
|
||||||
|
"author": "",
|
||||||
|
"license": "ISC",
|
||||||
|
"type": "commonjs",
|
||||||
|
"devDependencies": {
|
||||||
|
"@playwright/test": "^1.59.1"
|
||||||
|
}
|
||||||
|
}
|
||||||
21
playwright.config.js
Normal file
21
playwright.config.js
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
const { defineConfig, devices } = require('@playwright/test');
|
||||||
|
|
||||||
|
module.exports = defineConfig({
|
||||||
|
testDir: './tests/e2e',
|
||||||
|
fullyParallel: true,
|
||||||
|
forbidOnly: !!process.env.CI,
|
||||||
|
retries: process.env.CI ? 2 : 0,
|
||||||
|
workers: process.env.CI ? 1 : undefined,
|
||||||
|
reporter: 'list',
|
||||||
|
use: {
|
||||||
|
baseURL: 'http://localhost:5001',
|
||||||
|
trace: 'on-first-retry',
|
||||||
|
screenshot: 'only-on-failure',
|
||||||
|
},
|
||||||
|
projects: [
|
||||||
|
{
|
||||||
|
name: 'chromium',
|
||||||
|
use: { ...devices['Desktop Chrome'] },
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
15
pos/app.py
15
pos/app.py
@@ -89,6 +89,21 @@ def create_app():
|
|||||||
from blueprints.tasks_bp import tasks_bp
|
from blueprints.tasks_bp import tasks_bp
|
||||||
app.register_blueprint(tasks_bp)
|
app.register_blueprint(tasks_bp)
|
||||||
|
|
||||||
|
from blueprints.bnpl_bp import bnpl_bp
|
||||||
|
app.register_blueprint(bnpl_bp)
|
||||||
|
|
||||||
|
from blueprints.erp_bp import erp_bp
|
||||||
|
app.register_blueprint(erp_bp)
|
||||||
|
|
||||||
|
from blueprints.whatsapp_cloud_bp import whatsapp_cloud_bp
|
||||||
|
app.register_blueprint(whatsapp_cloud_bp)
|
||||||
|
|
||||||
|
from blueprints.dashboard_stats_bp import dashboard_stats_bp
|
||||||
|
app.register_blueprint(dashboard_stats_bp)
|
||||||
|
|
||||||
|
from blueprints.supplier_portal_bp import supplier_portal_bp
|
||||||
|
app.register_blueprint(supplier_portal_bp)
|
||||||
|
|
||||||
# Health check
|
# Health check
|
||||||
@app.route('/pos/health')
|
@app.route('/pos/health')
|
||||||
def health():
|
def health():
|
||||||
|
|||||||
90
pos/blueprints/bnpl_bp.py
Normal file
90
pos/blueprints/bnpl_bp.py
Normal file
@@ -0,0 +1,90 @@
|
|||||||
|
"""BNPL Blueprint — Buy Now Pay Later integrations (stub architecture).
|
||||||
|
|
||||||
|
Providers: APLAZO, Kueski, Clip (configured per tenant).
|
||||||
|
All endpoints are stubs with mock responses until real credentials are provided.
|
||||||
|
"""
|
||||||
|
from flask import Blueprint, request, jsonify, g
|
||||||
|
from functools import wraps
|
||||||
|
import uuid
|
||||||
|
from datetime import datetime, timedelta
|
||||||
|
|
||||||
|
bnpl_bp = Blueprint('bnpl', __name__, url_prefix='/pos/api/bnpl')
|
||||||
|
|
||||||
|
# ─── Auth helper ───
|
||||||
|
from middleware import require_auth
|
||||||
|
|
||||||
|
# ─── Mock store ───
|
||||||
|
_mock_applications = {}
|
||||||
|
|
||||||
|
|
||||||
|
@bnpl_bp.route('/providers', methods=['GET'])
|
||||||
|
@require_auth()
|
||||||
|
def list_providers():
|
||||||
|
"""List configured BNPL providers."""
|
||||||
|
return jsonify({
|
||||||
|
'providers': [
|
||||||
|
{'id': 'ap lazo', 'name': 'APLAZO', 'enabled': False, 'config_needed': ['api_key', 'merchant_id']},
|
||||||
|
{'id': 'kueski', 'name': 'Kueski Pay', 'enabled': False, 'config_needed': ['api_key', 'secret']},
|
||||||
|
{'id': 'clip', 'name': 'Clip Pagos', 'enabled': False, 'config_needed': ['api_key']},
|
||||||
|
]
|
||||||
|
})
|
||||||
|
|
||||||
|
|
||||||
|
@bnpl_bp.route('/applications', methods=['POST'])
|
||||||
|
@require_auth()
|
||||||
|
def create_application():
|
||||||
|
"""Create a BNPL application for a sale."""
|
||||||
|
data = request.get_json() or {}
|
||||||
|
sale_id = data.get('sale_id')
|
||||||
|
amount = data.get('amount')
|
||||||
|
provider = data.get('provider', 'ap lazo')
|
||||||
|
customer = data.get('customer', {})
|
||||||
|
|
||||||
|
if not sale_id or amount is None:
|
||||||
|
return jsonify({'error': 'sale_id and amount are required'}), 400
|
||||||
|
|
||||||
|
app_id = str(uuid.uuid4())
|
||||||
|
_mock_applications[app_id] = {
|
||||||
|
'id': app_id,
|
||||||
|
'sale_id': sale_id,
|
||||||
|
'provider': provider,
|
||||||
|
'amount': float(amount),
|
||||||
|
'status': 'pending',
|
||||||
|
'customer': customer,
|
||||||
|
'created_at': datetime.utcnow().isoformat(),
|
||||||
|
'expires_at': (datetime.utcnow() + timedelta(hours=24)).isoformat(),
|
||||||
|
'approval_url': f'/pos/api/bnpl/applications/{app_id}/approve',
|
||||||
|
'webhook_url': f'/pos/api/bnpl/webhook/{provider}',
|
||||||
|
}
|
||||||
|
|
||||||
|
return jsonify(_mock_applications[app_id]), 201
|
||||||
|
|
||||||
|
|
||||||
|
@bnpl_bp.route('/applications/<app_id>', methods=['GET'])
|
||||||
|
@require_auth()
|
||||||
|
def get_application(app_id):
|
||||||
|
"""Get BNPL application status."""
|
||||||
|
app = _mock_applications.get(app_id)
|
||||||
|
if not app:
|
||||||
|
return jsonify({'error': 'Application not found'}), 404
|
||||||
|
return jsonify(app)
|
||||||
|
|
||||||
|
|
||||||
|
@bnpl_bp.route('/applications/<app_id>/approve', methods=['POST'])
|
||||||
|
@require_auth()
|
||||||
|
def approve_application(app_id):
|
||||||
|
"""Mock approve an application (admin/override)."""
|
||||||
|
app = _mock_applications.get(app_id)
|
||||||
|
if not app:
|
||||||
|
return jsonify({'error': 'Application not found'}), 404
|
||||||
|
app['status'] = 'approved'
|
||||||
|
app['approved_at'] = datetime.utcnow().isoformat()
|
||||||
|
return jsonify(app)
|
||||||
|
|
||||||
|
|
||||||
|
@bnpl_bp.route('/webhook/<provider>', methods=['POST'])
|
||||||
|
def webhook(provider):
|
||||||
|
"""Receive webhooks from BNPL providers."""
|
||||||
|
data = request.get_json() or {}
|
||||||
|
# In production, verify signature per provider
|
||||||
|
return jsonify({'received': True, 'provider': provider, 'payload': data}), 200
|
||||||
107
pos/blueprints/dashboard_stats_bp.py
Normal file
107
pos/blueprints/dashboard_stats_bp.py
Normal file
@@ -0,0 +1,107 @@
|
|||||||
|
"""Dashboard Stats Blueprint — In-app real-time analytics.
|
||||||
|
|
||||||
|
Endpoints for sales, productivity, and top products charts.
|
||||||
|
"""
|
||||||
|
from flask import Blueprint, request, jsonify, g
|
||||||
|
from functools import wraps
|
||||||
|
from datetime import datetime, timedelta
|
||||||
|
from decimal import Decimal
|
||||||
|
import json
|
||||||
|
|
||||||
|
dashboard_stats_bp = Blueprint('dashboard_stats', __name__, url_prefix='/pos/api/dashboard')
|
||||||
|
|
||||||
|
|
||||||
|
from middleware import require_auth
|
||||||
|
|
||||||
|
|
||||||
|
class DecimalEncoder(json.JSONEncoder):
|
||||||
|
def default(self, o):
|
||||||
|
if isinstance(o, Decimal):
|
||||||
|
return float(o)
|
||||||
|
return super().default(o)
|
||||||
|
|
||||||
|
|
||||||
|
@dashboard_stats_bp.route('/stats', methods=['GET'])
|
||||||
|
@require_auth()
|
||||||
|
def get_stats():
|
||||||
|
"""Summary stats for today and this month."""
|
||||||
|
from tenant_db import get_tenant_db
|
||||||
|
db = get_tenant_db()
|
||||||
|
today = datetime.utcnow().date()
|
||||||
|
month_start = today.replace(day=1)
|
||||||
|
|
||||||
|
# Sales today
|
||||||
|
today_sales = db.execute(
|
||||||
|
"""SELECT COUNT(*) as count, COALESCE(SUM(total), 0) as total
|
||||||
|
FROM sales WHERE DATE(created_at) = %s""", (today,)
|
||||||
|
).fetchone()
|
||||||
|
|
||||||
|
# Sales this month
|
||||||
|
month_sales = db.execute(
|
||||||
|
"""SELECT COUNT(*) as count, COALESCE(SUM(total), 0) as total
|
||||||
|
FROM sales WHERE DATE(created_at) >= %s""", (month_start,)
|
||||||
|
).fetchone()
|
||||||
|
|
||||||
|
# Top 5 products today
|
||||||
|
top_products = db.execute(
|
||||||
|
"""SELECT p.name, SUM(si.quantity) as qty, SUM(si.total) as revenue
|
||||||
|
FROM sale_items si
|
||||||
|
JOIN sales s ON si.sale_id = s.id_sale
|
||||||
|
JOIN parts p ON si.part_id = p.id_part
|
||||||
|
WHERE DATE(s.created_at) = %s
|
||||||
|
GROUP BY p.name
|
||||||
|
ORDER BY revenue DESC
|
||||||
|
LIMIT 5""", (today,)
|
||||||
|
).fetchall()
|
||||||
|
|
||||||
|
# Hourly sales today (0-23)
|
||||||
|
hourly = db.execute(
|
||||||
|
"""SELECT EXTRACT(HOUR FROM created_at)::int as hour,
|
||||||
|
COUNT(*) as count, COALESCE(SUM(total), 0) as total
|
||||||
|
FROM sales WHERE DATE(created_at) = %s
|
||||||
|
GROUP BY hour ORDER BY hour""", (today,)
|
||||||
|
).fetchall()
|
||||||
|
hourly_map = {row['hour']: {'count': row['count'], 'total': row['total']} for row in hourly}
|
||||||
|
|
||||||
|
return jsonify({
|
||||||
|
'today': {
|
||||||
|
'sales_count': today_sales['count'],
|
||||||
|
'sales_total': today_sales['total'],
|
||||||
|
},
|
||||||
|
'month': {
|
||||||
|
'sales_count': month_sales['count'],
|
||||||
|
'sales_total': month_sales['total'],
|
||||||
|
},
|
||||||
|
'top_products': [
|
||||||
|
{'name': row['name'], 'quantity': row['qty'], 'revenue': row['revenue']}
|
||||||
|
for row in top_products
|
||||||
|
],
|
||||||
|
'hourly_sales': [
|
||||||
|
{'hour': h, 'count': hourly_map.get(h, {}).get('count', 0),
|
||||||
|
'total': hourly_map.get(h, {}).get('total', 0)}
|
||||||
|
for h in range(24)
|
||||||
|
],
|
||||||
|
}, cls=DecimalEncoder)
|
||||||
|
|
||||||
|
|
||||||
|
@dashboard_stats_bp.route('/stats/employees', methods=['GET'])
|
||||||
|
@require_auth()
|
||||||
|
def get_employee_stats():
|
||||||
|
"""Sales per employee today."""
|
||||||
|
from tenant_db import get_tenant_db
|
||||||
|
db = get_tenant_db()
|
||||||
|
today = datetime.utcnow().date()
|
||||||
|
rows = db.execute(
|
||||||
|
"""SELECT e.name, COUNT(s.id_sale) as sales, COALESCE(SUM(s.total), 0) as total
|
||||||
|
FROM sales s
|
||||||
|
JOIN employees e ON s.employee_id = e.id_employee
|
||||||
|
WHERE DATE(s.created_at) = %s
|
||||||
|
GROUP BY e.name
|
||||||
|
ORDER BY total DESC""", (today,)
|
||||||
|
).fetchall()
|
||||||
|
return jsonify({
|
||||||
|
'employees': [
|
||||||
|
{'name': row['name'], 'sales': row['sales'], 'total': row['total']}
|
||||||
|
for row in rows
|
||||||
|
]
|
||||||
|
}, cls=DecimalEncoder)
|
||||||
79
pos/blueprints/erp_bp.py
Normal file
79
pos/blueprints/erp_bp.py
Normal file
@@ -0,0 +1,79 @@
|
|||||||
|
"""ERP Sync Blueprint — Integration with Aspel, CONTPAQi, SAP, Odoo.
|
||||||
|
|
||||||
|
Stubs with architecture ready for real connectors.
|
||||||
|
"""
|
||||||
|
from flask import Blueprint, request, jsonify, g
|
||||||
|
from functools import wraps
|
||||||
|
import uuid
|
||||||
|
from datetime import datetime
|
||||||
|
|
||||||
|
erp_bp = Blueprint('erp', __name__, url_prefix='/pos/api/erp')
|
||||||
|
|
||||||
|
|
||||||
|
from middleware import require_auth
|
||||||
|
|
||||||
|
|
||||||
|
# ─── Mock sync jobs ───
|
||||||
|
_mock_jobs = {}
|
||||||
|
|
||||||
|
|
||||||
|
@erp_bp.route('/providers', methods=['GET'])
|
||||||
|
@require_auth()
|
||||||
|
def list_providers():
|
||||||
|
return jsonify({
|
||||||
|
'providers': [
|
||||||
|
{'id': 'aspel_sae', 'name': 'Aspel SAE', 'type': 'file_exchange', 'enabled': False},
|
||||||
|
{'id': 'contpaqi', 'name': 'CONTPAQi', 'type': 'file_exchange', 'enabled': False},
|
||||||
|
{'id': 'sap_b1', 'name': 'SAP Business One', 'type': 'api', 'enabled': False},
|
||||||
|
{'id': 'odoo', 'name': 'Odoo', 'type': 'api', 'enabled': False},
|
||||||
|
]
|
||||||
|
})
|
||||||
|
|
||||||
|
|
||||||
|
@erp_bp.route('/sync', methods=['POST'])
|
||||||
|
@require_auth()
|
||||||
|
def start_sync():
|
||||||
|
data = request.get_json() or {}
|
||||||
|
provider = data.get('provider')
|
||||||
|
sync_type = data.get('sync_type', 'sales') # sales, inventory, customers
|
||||||
|
if not provider:
|
||||||
|
return jsonify({'error': 'provider is required'}), 400
|
||||||
|
|
||||||
|
job_id = str(uuid.uuid4())
|
||||||
|
_mock_jobs[job_id] = {
|
||||||
|
'id': job_id,
|
||||||
|
'provider': provider,
|
||||||
|
'sync_type': sync_type,
|
||||||
|
'status': 'queued',
|
||||||
|
'records_synced': 0,
|
||||||
|
'errors': [],
|
||||||
|
'created_at': datetime.utcnow().isoformat(),
|
||||||
|
'started_at': None,
|
||||||
|
'finished_at': None,
|
||||||
|
}
|
||||||
|
return jsonify(_mock_jobs[job_id]), 201
|
||||||
|
|
||||||
|
|
||||||
|
@erp_bp.route('/sync/<job_id>', methods=['GET'])
|
||||||
|
@require_auth()
|
||||||
|
def get_sync_status(job_id):
|
||||||
|
job = _mock_jobs.get(job_id)
|
||||||
|
if not job:
|
||||||
|
return jsonify({'error': 'Job not found'}), 404
|
||||||
|
return jsonify(job)
|
||||||
|
|
||||||
|
|
||||||
|
@erp_bp.route('/sync/<job_id>/run', methods=['POST'])
|
||||||
|
@require_auth()
|
||||||
|
def run_sync(job_id):
|
||||||
|
"""Mock execute sync (in production this triggers a Celery task)."""
|
||||||
|
job = _mock_jobs.get(job_id)
|
||||||
|
if not job:
|
||||||
|
return jsonify({'error': 'Job not found'}), 404
|
||||||
|
job['status'] = 'running'
|
||||||
|
job['started_at'] = datetime.utcnow().isoformat()
|
||||||
|
# Mock completion
|
||||||
|
job['status'] = 'completed'
|
||||||
|
job['records_synced'] = 42
|
||||||
|
job['finished_at'] = datetime.utcnow().isoformat()
|
||||||
|
return jsonify(job)
|
||||||
105
pos/blueprints/supplier_portal_bp.py
Normal file
105
pos/blueprints/supplier_portal_bp.py
Normal file
@@ -0,0 +1,105 @@
|
|||||||
|
"""Supplier Portal Blueprint — Demand insights for vendors.
|
||||||
|
|
||||||
|
Allows suppliers to view demand by zone, part type, and branch.
|
||||||
|
"""
|
||||||
|
from flask import Blueprint, request, jsonify, g
|
||||||
|
from functools import wraps
|
||||||
|
from datetime import datetime, timedelta
|
||||||
|
from decimal import Decimal
|
||||||
|
import json
|
||||||
|
|
||||||
|
supplier_portal_bp = Blueprint('supplier_portal', __name__, url_prefix='/pos/api/supplier-portal')
|
||||||
|
|
||||||
|
|
||||||
|
from middleware import require_auth
|
||||||
|
|
||||||
|
|
||||||
|
class DecimalEncoder(json.JSONEncoder):
|
||||||
|
def default(self, o):
|
||||||
|
if isinstance(o, Decimal):
|
||||||
|
return float(o)
|
||||||
|
return super().default(o)
|
||||||
|
|
||||||
|
|
||||||
|
@supplier_portal_bp.route('/demand', methods=['GET'])
|
||||||
|
@require_auth()
|
||||||
|
def get_demand():
|
||||||
|
"""Aggregated demand by zone, part group, and time range."""
|
||||||
|
days = request.args.get('days', 30, type=int)
|
||||||
|
group_id = request.args.get('group_id', type=int)
|
||||||
|
branch_id = request.args.get('branch_id', type=int)
|
||||||
|
|
||||||
|
from tenant_db import get_tenant_db
|
||||||
|
db = get_tenant_db()
|
||||||
|
since = datetime.utcnow() - timedelta(days=days)
|
||||||
|
|
||||||
|
params = [since]
|
||||||
|
filters = "s.created_at >= %s"
|
||||||
|
if group_id:
|
||||||
|
filters += " AND p.group_id = %s"
|
||||||
|
params.append(group_id)
|
||||||
|
if branch_id:
|
||||||
|
filters += " AND s.branch_id = %s"
|
||||||
|
params.append(branch_id)
|
||||||
|
|
||||||
|
rows = db.execute(
|
||||||
|
f"""SELECT g.name as group_name, b.name as branch_name,
|
||||||
|
COUNT(DISTINCT s.id_sale) as orders,
|
||||||
|
SUM(si.quantity) as qty_requested,
|
||||||
|
COALESCE(SUM(si.total), 0) as revenue
|
||||||
|
FROM sale_items si
|
||||||
|
JOIN sales s ON si.sale_id = s.id_sale
|
||||||
|
JOIN parts p ON si.part_id = p.id_part
|
||||||
|
JOIN part_groups g ON p.group_id = g.id_group
|
||||||
|
LEFT JOIN branches b ON s.branch_id = b.id_branch
|
||||||
|
WHERE {filters}
|
||||||
|
GROUP BY g.name, b.name
|
||||||
|
ORDER BY revenue DESC
|
||||||
|
LIMIT 100""", tuple(params)
|
||||||
|
).fetchall()
|
||||||
|
|
||||||
|
return jsonify({
|
||||||
|
'since': since.isoformat(),
|
||||||
|
'days': days,
|
||||||
|
'demand': [
|
||||||
|
{'group': row['group_name'], 'branch': row['branch_name'],
|
||||||
|
'orders': row['orders'], 'quantity': row['qty_requested'],
|
||||||
|
'revenue': row['revenue']}
|
||||||
|
for row in rows
|
||||||
|
]
|
||||||
|
}, cls=DecimalEncoder)
|
||||||
|
|
||||||
|
|
||||||
|
@supplier_portal_bp.route('/top-parts', methods=['GET'])
|
||||||
|
@require_auth()
|
||||||
|
def get_top_parts():
|
||||||
|
"""Top moving parts for suppliers to restock."""
|
||||||
|
days = request.args.get('days', 30, type=int)
|
||||||
|
from tenant_db import get_tenant_db
|
||||||
|
db = get_tenant_db()
|
||||||
|
since = datetime.utcnow() - timedelta(days=days)
|
||||||
|
|
||||||
|
rows = db.execute(
|
||||||
|
"""SELECT p.oem_part_number, p.name, g.name as group_name,
|
||||||
|
SUM(si.quantity) as sold, COALESCE(SUM(si.total), 0) as revenue,
|
||||||
|
COALESCE(SUM(wi.stock_quantity), 0) as current_stock
|
||||||
|
FROM sale_items si
|
||||||
|
JOIN sales s ON si.sale_id = s.id_sale
|
||||||
|
JOIN parts p ON si.part_id = p.id_part
|
||||||
|
JOIN part_groups g ON p.group_id = g.id_group
|
||||||
|
LEFT JOIN warehouse_inventory wi ON p.id_part = wi.part_id
|
||||||
|
WHERE s.created_at >= %s
|
||||||
|
GROUP BY p.oem_part_number, p.name, g.name
|
||||||
|
ORDER BY sold DESC
|
||||||
|
LIMIT 50""", (since,)
|
||||||
|
).fetchall()
|
||||||
|
|
||||||
|
return jsonify({
|
||||||
|
'since': since.isoformat(),
|
||||||
|
'parts': [
|
||||||
|
{'oem': row['oem_part_number'], 'name': row['name'],
|
||||||
|
'group': row['group_name'], 'sold': row['sold'],
|
||||||
|
'revenue': row['revenue'], 'stock': row['current_stock']}
|
||||||
|
for row in rows
|
||||||
|
]
|
||||||
|
}, cls=DecimalEncoder)
|
||||||
@@ -1,14 +1,14 @@
|
|||||||
"""Blueprint for background task management (Celery)."""
|
"""Blueprint for background task management (Celery)."""
|
||||||
|
|
||||||
from flask import Blueprint, jsonify, request
|
from flask import Blueprint, jsonify, request
|
||||||
from auth import require_auth
|
from middleware import require_auth
|
||||||
from tasks import warm_vehicle_cache_task, generate_report_task
|
from tasks import warm_vehicle_cache_task, generate_report_task
|
||||||
|
|
||||||
tasks_bp = Blueprint('tasks', __name__, url_prefix='/pos/api/tasks')
|
tasks_bp = Blueprint('tasks', __name__, url_prefix='/pos/api/tasks')
|
||||||
|
|
||||||
|
|
||||||
@tasks_bp.route('/warm-cache', methods=['POST'])
|
@tasks_bp.route('/warm-cache', methods=['POST'])
|
||||||
@require_auth
|
@require_auth()
|
||||||
def enqueue_warm_cache():
|
def enqueue_warm_cache():
|
||||||
"""Enqueue vehicle cache warming task."""
|
"""Enqueue vehicle cache warming task."""
|
||||||
task = warm_vehicle_cache_task.apply_async()
|
task = warm_vehicle_cache_task.apply_async()
|
||||||
@@ -16,7 +16,7 @@ def enqueue_warm_cache():
|
|||||||
|
|
||||||
|
|
||||||
@tasks_bp.route('/report', methods=['POST'])
|
@tasks_bp.route('/report', methods=['POST'])
|
||||||
@require_auth
|
@require_auth()
|
||||||
def enqueue_report():
|
def enqueue_report():
|
||||||
"""Enqueue report generation task."""
|
"""Enqueue report generation task."""
|
||||||
data = request.get_json() or {}
|
data = request.get_json() or {}
|
||||||
|
|||||||
86
pos/blueprints/whatsapp_cloud_bp.py
Normal file
86
pos/blueprints/whatsapp_cloud_bp.py
Normal file
@@ -0,0 +1,86 @@
|
|||||||
|
"""WhatsApp Business API (Meta Cloud) Blueprint.
|
||||||
|
|
||||||
|
Replaces Baileys webhook for scalable production messaging.
|
||||||
|
Stubs ready for Meta Cloud API credentials.
|
||||||
|
"""
|
||||||
|
from flask import Blueprint, request, jsonify, g
|
||||||
|
from functools import wraps
|
||||||
|
import uuid
|
||||||
|
from datetime import datetime
|
||||||
|
|
||||||
|
whatsapp_cloud_bp = Blueprint('whatsapp_cloud', __name__, url_prefix='/pos/api/whatsapp-cloud')
|
||||||
|
|
||||||
|
|
||||||
|
from middleware import require_auth
|
||||||
|
|
||||||
|
|
||||||
|
_mock_messages = {}
|
||||||
|
|
||||||
|
|
||||||
|
@whatsapp_cloud_bp.route('/webhook', methods=['GET', 'POST'])
|
||||||
|
def webhook():
|
||||||
|
"""Meta Cloud API webhook verification and message reception."""
|
||||||
|
if request.method == 'GET':
|
||||||
|
# Verification challenge
|
||||||
|
mode = request.args.get('hub.mode')
|
||||||
|
token = request.args.get('hub.verify_token')
|
||||||
|
challenge = request.args.get('hub.challenge')
|
||||||
|
# In production: verify token against configured VERIFY_TOKEN
|
||||||
|
if mode == 'subscribe' and challenge:
|
||||||
|
return challenge, 200
|
||||||
|
return jsonify({'error': 'Verification failed'}), 403
|
||||||
|
|
||||||
|
# POST — incoming messages
|
||||||
|
data = request.get_json() or {}
|
||||||
|
# In production: process entries, messages, statuses
|
||||||
|
return jsonify({'received': True, 'entries': len(data.get('entry', []))}), 200
|
||||||
|
|
||||||
|
|
||||||
|
@whatsapp_cloud_bp.route('/messages', methods=['POST'])
|
||||||
|
@require_auth()
|
||||||
|
def send_message():
|
||||||
|
"""Send a message via Meta Cloud API."""
|
||||||
|
data = request.get_json() or {}
|
||||||
|
to = data.get('to')
|
||||||
|
body = data.get('body')
|
||||||
|
template = data.get('template')
|
||||||
|
|
||||||
|
if not to or (not body and not template):
|
||||||
|
return jsonify({'error': 'to and body/template are required'}), 400
|
||||||
|
|
||||||
|
msg_id = str(uuid.uuid4())
|
||||||
|
_mock_messages[msg_id] = {
|
||||||
|
'id': msg_id,
|
||||||
|
'to': to,
|
||||||
|
'body': body,
|
||||||
|
'template': template,
|
||||||
|
'status': 'sent',
|
||||||
|
'sent_at': datetime.utcnow().isoformat(),
|
||||||
|
}
|
||||||
|
return jsonify(_mock_messages[msg_id]), 201
|
||||||
|
|
||||||
|
|
||||||
|
@whatsapp_cloud_bp.route('/templates', methods=['GET'])
|
||||||
|
@require_auth()
|
||||||
|
def list_templates():
|
||||||
|
"""List approved message templates."""
|
||||||
|
return jsonify({
|
||||||
|
'templates': [
|
||||||
|
{'name': 'order_ready', 'language': 'es_MX', 'category': 'UTILITY', 'status': 'APPROVED'},
|
||||||
|
{'name': 'payment_reminder', 'language': 'es_MX', 'category': 'UTILITY', 'status': 'APPROVED'},
|
||||||
|
{'name': 'welcome_message', 'language': 'es_MX', 'category': 'MARKETING', 'status': 'PENDING'},
|
||||||
|
]
|
||||||
|
})
|
||||||
|
|
||||||
|
|
||||||
|
@whatsapp_cloud_bp.route('/status', methods=['GET'])
|
||||||
|
@require_auth()
|
||||||
|
def get_status():
|
||||||
|
"""Check Meta Cloud API connection status."""
|
||||||
|
return jsonify({
|
||||||
|
'connected': False,
|
||||||
|
'phone_number_id': None,
|
||||||
|
'business_account_id': None,
|
||||||
|
'message_limit': None,
|
||||||
|
'note': 'Configure WHATSAPP_CLOUD_ACCESS_TOKEN and PHONE_NUMBER_ID to connect',
|
||||||
|
})
|
||||||
187
pos/seed/initial_catalog.sql
Normal file
187
pos/seed/initial_catalog.sql
Normal file
@@ -0,0 +1,187 @@
|
|||||||
|
-- Initial catalog seed for Nexus Autoparts (no TecDoc required)
|
||||||
|
-- Provides basic vehicle brands, models, years, and part categories
|
||||||
|
-- so the system is usable immediately after installation.
|
||||||
|
|
||||||
|
-- =====================================================
|
||||||
|
-- PART CATEGORIES (RockAuto-style taxonomy)
|
||||||
|
-- =====================================================
|
||||||
|
|
||||||
|
INSERT INTO part_categories (name, name_es, slug, icon_name, display_order) VALUES
|
||||||
|
('Body & Lamp Assembly', 'Carrocería y Iluminación', 'body-lamp', 'fa-car', 1),
|
||||||
|
('Brake & Wheel Hub', 'Frenos y Rines', 'brake-wheel', 'fa-circle-stop', 2),
|
||||||
|
('Cooling System', 'Sistema de Enfriamiento', 'cooling', 'fa-snowflake', 3),
|
||||||
|
('Drivetrain', 'Tren Motriz', 'drivetrain', 'fa-gears', 4),
|
||||||
|
('Electrical & Lighting', 'Eléctrico e Iluminación', 'electrical', 'fa-bolt', 5),
|
||||||
|
('Engine', 'Motor', 'engine', 'fa-engine', 6),
|
||||||
|
('Exhaust', 'Escape', 'exhaust', 'fa-smog', 7),
|
||||||
|
('Fuel & Air', 'Combustible y Aire', 'fuel-air', 'fa-gas-pump', 8),
|
||||||
|
('Heat & AC', 'Calefacción y Aire Acondicionado', 'heat-ac', 'fa-temperature-half', 9),
|
||||||
|
('Steering', 'Dirección', 'steering', 'fa-steering-wheel', 10),
|
||||||
|
('Suspension', 'Suspensión', 'suspension', 'fa-shock', 11),
|
||||||
|
('Transmission', 'Transmisión', 'transmission', 'fa-gears', 12)
|
||||||
|
ON CONFLICT DO NOTHING;
|
||||||
|
|
||||||
|
-- =====================================================
|
||||||
|
-- PART GROUPS (common groups per category)
|
||||||
|
-- =====================================================
|
||||||
|
|
||||||
|
INSERT INTO part_groups (category_id, name, name_es, slug, display_order) VALUES
|
||||||
|
-- Body & Lamp (1)
|
||||||
|
(1, 'Bumpers & Components', 'Defensas y Componentes', 'bumpers', 1),
|
||||||
|
(1, 'Fenders', 'Salpicaderas', 'fenders', 2),
|
||||||
|
(1, 'Headlights & Taillights', 'Faros y Calaveras', 'lights', 3),
|
||||||
|
(1, 'Mirrors', 'Espejos', 'mirrors', 4),
|
||||||
|
(1, 'Hood & Trunk', 'Cofre y Cajuela', 'hood-trunk', 5),
|
||||||
|
-- Brake & Wheel (2)
|
||||||
|
(2, 'Brake Pads', 'Balatas', 'brake-pads', 1),
|
||||||
|
(2, 'Brake Rotors', 'Discos de Freno', 'brake-rotors', 2),
|
||||||
|
(2, 'Brake Calipers', 'Caliper de Freno', 'calipers', 3),
|
||||||
|
(2, 'Brake Drums', 'Tambores de Freno', 'brake-drums', 4),
|
||||||
|
(2, 'Wheel Bearings', 'Baleros de Rueda', 'wheel-bearings', 5),
|
||||||
|
(2, 'Wheel Hubs', 'Mazas de Rueda', 'wheel-hubs', 6),
|
||||||
|
-- Cooling (3)
|
||||||
|
(3, 'Radiators', 'Radiadores', 'radiators', 1),
|
||||||
|
(3, 'Water Pumps', 'Bombas de Agua', 'water-pumps', 2),
|
||||||
|
(3, 'Thermostats', 'Termostatos', 'thermostats', 3),
|
||||||
|
(3, 'Cooling Fans', 'Ventiladores', 'cooling-fans', 4),
|
||||||
|
(3, 'Hoses & Clamps', 'Mangueras y Abrazaderas', 'hoses', 5),
|
||||||
|
-- Drivetrain (4)
|
||||||
|
(4, 'Axles & Axle Shafts', 'Ejes y Flechas', 'axles', 1),
|
||||||
|
(4, 'Differentials', 'Diferenciales', 'differentials', 2),
|
||||||
|
(4, 'Drive Shafts', 'Flechas Cardán', 'drive-shafts', 3),
|
||||||
|
(4, 'CV Joints & Boots', 'Juntas Homocinéticas', 'cv-joints', 4),
|
||||||
|
-- Electrical (5)
|
||||||
|
(5, 'Alternators', 'Alternadores', 'alternators', 1),
|
||||||
|
(5, 'Starters', 'Marchas', 'starters', 2),
|
||||||
|
(5, 'Batteries', 'Baterías', 'batteries', 3),
|
||||||
|
(5, 'Sensors', 'Sensores', 'sensors', 4),
|
||||||
|
(5, 'Switches & Relays', 'Interruptores y Relevadores', 'switches', 5),
|
||||||
|
-- Engine (6)
|
||||||
|
(6, 'Oil Filters', 'Filtros de Aceite', 'oil-filters', 1),
|
||||||
|
(6, 'Air Filters', 'Filtros de Aire', 'air-filters', 2),
|
||||||
|
(6, 'Fuel Filters', 'Filtros de Gasolina', 'fuel-filters', 3),
|
||||||
|
(6, 'Spark Plugs', 'Bujías', 'spark-plugs', 4),
|
||||||
|
(6, 'Timing Components', 'Componentes de Distribución', 'timing', 5),
|
||||||
|
(6, 'Gaskets & Seals', 'Juntas y Sellos', 'gaskets', 6),
|
||||||
|
(6, 'Pistons & Rings', 'Pistones y Anillos', 'pistons', 7),
|
||||||
|
-- Exhaust (7)
|
||||||
|
(7, 'Mufflers', 'Silenciadores', 'mufflers', 1),
|
||||||
|
(7, 'Catalytic Converters', 'Convertidores Catalíticos', 'catalytic', 2),
|
||||||
|
(7, 'Exhaust Manifolds', 'Múltiples de Escape', 'manifolds', 3),
|
||||||
|
(7, 'O2 Sensors', 'Sensores de Oxígeno', 'o2-sensors', 4),
|
||||||
|
-- Fuel & Air (8)
|
||||||
|
(8, 'Fuel Pumps', 'Bombas de Gasolina', 'fuel-pumps', 1),
|
||||||
|
(8, 'Fuel Injectors', 'Inyectores', 'injectors', 2),
|
||||||
|
(8, 'Throttle Bodies', 'Cuerpos de Aceleración', 'throttle', 3),
|
||||||
|
(8, 'Mass Air Flow Sensors', 'Sensores MAF', 'maf-sensors', 4),
|
||||||
|
(8, 'Air Intake', 'Admisión de Aire', 'air-intake', 5),
|
||||||
|
-- Heat & AC (9)
|
||||||
|
(9, 'AC Compressors', 'Compresores de AC', 'ac-compressors', 1),
|
||||||
|
(9, 'AC Condensers', 'Condensadores de AC', 'ac-condensers', 2),
|
||||||
|
(9, 'Heater Cores', 'Radiadores de Calefacción', 'heater-cores', 3),
|
||||||
|
(9, 'Blower Motors', 'Motores de Soplador', 'blower-motors', 4),
|
||||||
|
-- Steering (10)
|
||||||
|
(10, 'Power Steering Pumps', 'Bombas de Dirección', 'ps-pumps', 1),
|
||||||
|
(10, 'Steering Racks', 'Cajas de Dirección', 'steering-racks', 2),
|
||||||
|
(10, 'Tie Rods', 'Terminales de Dirección', 'tie-rods', 3),
|
||||||
|
(10, 'Steering Columns', 'Columnas de Dirección', 'steering-columns', 4),
|
||||||
|
-- Suspension (11)
|
||||||
|
(11, 'Shocks & Struts', 'Amortiguadores y Puntales', 'shocks', 1),
|
||||||
|
(11, 'Control Arms', 'Horquillas', 'control-arms', 2),
|
||||||
|
(11, 'Ball Joints', 'Rótulas', 'ball-joints', 3),
|
||||||
|
(11, 'Springs', 'Resortes', 'springs', 4),
|
||||||
|
(11, 'Sway Bars', 'Barras Estabilizadoras', 'sway-bars', 5),
|
||||||
|
(11, 'Bushings', 'Bujes', 'bushings', 6),
|
||||||
|
-- Transmission (12)
|
||||||
|
(12, 'Transmission Filters', 'Filtros de Transmisión', 'trans-filters', 1),
|
||||||
|
(12, 'Clutch Kits', 'Kits de Clutch', 'clutch-kits', 2),
|
||||||
|
(12, 'Transmission Mounts', 'Soportes de Transmisión', 'trans-mounts', 3),
|
||||||
|
(12, 'Shift Cables', 'Chicotes de Velocidades', 'shift-cables', 4),
|
||||||
|
(12, 'Torque Converters', 'Convertidores de Torque', 'torque-converters', 5)
|
||||||
|
ON CONFLICT DO NOTHING;
|
||||||
|
|
||||||
|
-- =====================================================
|
||||||
|
-- VEHICLE BRANDS (common in Mexico)
|
||||||
|
-- =====================================================
|
||||||
|
|
||||||
|
INSERT INTO brands (name, country) VALUES
|
||||||
|
('Toyota', 'Japan'),
|
||||||
|
('Nissan', 'Japan'),
|
||||||
|
('Honda', 'Japan'),
|
||||||
|
('Mazda', 'Japan'),
|
||||||
|
('Mitsubishi', 'Japan'),
|
||||||
|
('Subaru', 'Japan'),
|
||||||
|
('Suzuki', 'Japan'),
|
||||||
|
('Ford', 'USA'),
|
||||||
|
('Chevrolet', 'USA'),
|
||||||
|
('Dodge', 'USA'),
|
||||||
|
('Jeep', 'USA'),
|
||||||
|
('Chrysler', 'USA'),
|
||||||
|
('Volkswagen', 'Germany'),
|
||||||
|
('BMW', 'Germany'),
|
||||||
|
('Mercedes-Benz', 'Germany'),
|
||||||
|
('Audi', 'Germany'),
|
||||||
|
('Renault', 'France'),
|
||||||
|
('Peugeot', 'France'),
|
||||||
|
('Citroën', 'France'),
|
||||||
|
('Kia', 'South Korea'),
|
||||||
|
('Hyundai', 'South Korea'),
|
||||||
|
('Seat', 'Spain'),
|
||||||
|
('Fiat', 'Italy')
|
||||||
|
ON CONFLICT DO NOTHING;
|
||||||
|
|
||||||
|
-- =====================================================
|
||||||
|
-- POPULAR MODELS (sample for top brands)
|
||||||
|
-- =====================================================
|
||||||
|
|
||||||
|
-- Note: brand_id is auto-assigned by SERIAL. We use subqueries to find them.
|
||||||
|
-- This seed assumes brands were inserted in the order above.
|
||||||
|
|
||||||
|
INSERT INTO models (brand_id, name, body_type, production_start_year, production_end_year) VALUES
|
||||||
|
-- Toyota (id=1)
|
||||||
|
((SELECT id FROM brands WHERE name='Toyota'), 'Corolla', 'sedan', 1966, NULL),
|
||||||
|
((SELECT id FROM brands WHERE name='Toyota'), 'Camry', 'sedan', 1982, NULL),
|
||||||
|
((SELECT id FROM brands WHERE name='Toyota'), 'RAV4', 'suv', 1994, NULL),
|
||||||
|
((SELECT id FROM brands WHERE name='Toyota'), 'Hilux', 'truck', 1968, NULL),
|
||||||
|
((SELECT id FROM brands WHERE name='Toyota'), 'Yaris', 'hatchback', 1999, NULL),
|
||||||
|
((SELECT id FROM brands WHERE name='Toyota'), 'Avanza', 'van', 2003, NULL),
|
||||||
|
-- Nissan (id=2)
|
||||||
|
((SELECT id FROM brands WHERE name='Nissan'), 'Sentra', 'sedan', 1982, NULL),
|
||||||
|
((SELECT id FROM brands WHERE name='Nissan'), 'Versa', 'sedan', 2006, NULL),
|
||||||
|
((SELECT id FROM brands WHERE name='Nissan'), 'March', 'hatchback', 1982, NULL),
|
||||||
|
((SELECT id FROM brands WHERE name='Nissan'), 'X-Trail', 'suv', 2000, NULL),
|
||||||
|
((SELECT id FROM brands WHERE name='Nissan'), 'Frontier', 'truck', 1997, NULL),
|
||||||
|
((SELECT id FROM brands WHERE name='Nissan'), 'NP300', 'truck', 2008, NULL),
|
||||||
|
-- Honda (id=3)
|
||||||
|
((SELECT id FROM brands WHERE name='Honda'), 'Civic', 'sedan', 1972, NULL),
|
||||||
|
((SELECT id FROM brands WHERE name='Honda'), 'Accord', 'sedan', 1976, NULL),
|
||||||
|
((SELECT id FROM brands WHERE name='Honda'), 'CR-V', 'suv', 1995, NULL),
|
||||||
|
((SELECT id FROM brands WHERE name='Honda'), 'City', 'sedan', 1981, NULL),
|
||||||
|
-- Ford (id=9)
|
||||||
|
((SELECT id FROM brands WHERE name='Ford'), 'F-150', 'truck', 1975, NULL),
|
||||||
|
((SELECT id FROM brands WHERE name='Ford'), 'Ranger', 'truck', 1982, NULL),
|
||||||
|
((SELECT id FROM brands WHERE name='Ford'), 'Escape', 'suv', 2000, NULL),
|
||||||
|
((SELECT id FROM brands WHERE name='Ford'), 'Focus', 'hatchback', 1998, NULL),
|
||||||
|
-- Chevrolet (id=10)
|
||||||
|
((SELECT id FROM brands WHERE name='Chevrolet'), 'Aveo', 'sedan', 2002, NULL),
|
||||||
|
((SELECT id FROM brands WHERE name='Chevrolet'), 'Spark', 'hatchback', 1998, NULL),
|
||||||
|
((SELECT id FROM brands WHERE name='Chevrolet'), 'Silverado', 'truck', 1998, NULL),
|
||||||
|
((SELECT id FROM brands WHERE name='Chevrolet'), 'Trax', 'suv', 2013, NULL),
|
||||||
|
-- Volkswagen (id=13)
|
||||||
|
((SELECT id FROM brands WHERE name='Volkswagen'), 'Jetta', 'sedan', 1979, NULL),
|
||||||
|
((SELECT id FROM brands WHERE name='Volkswagen'), 'Golf', 'hatchback', 1974, NULL),
|
||||||
|
((SELECT id FROM brands WHERE name='Volkswagen'), 'Polo', 'hatchback', 1975, NULL),
|
||||||
|
((SELECT id FROM brands WHERE name='Volkswagen'), 'Tiguan', 'suv', 2007, NULL),
|
||||||
|
((SELECT id FROM brands WHERE name='Volkswagen'), 'Vento', 'sedan', 2010, NULL)
|
||||||
|
ON CONFLICT DO NOTHING;
|
||||||
|
|
||||||
|
-- =====================================================
|
||||||
|
-- YEARS (2000-2026)
|
||||||
|
-- =====================================================
|
||||||
|
|
||||||
|
DO $$
|
||||||
|
BEGIN
|
||||||
|
FOR yr IN 2000..2026 LOOP
|
||||||
|
INSERT INTO years (year) VALUES (yr) ON CONFLICT DO NOTHING;
|
||||||
|
END LOOP;
|
||||||
|
END $$;
|
||||||
@@ -104,6 +104,27 @@
|
|||||||
|
|
||||||
.chat-header-close:hover { opacity: 1; }
|
.chat-header-close:hover { opacity: 1; }
|
||||||
|
|
||||||
|
.chat-header-actions {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: var(--space-2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.chat-tts-toggle {
|
||||||
|
background: none;
|
||||||
|
border: none;
|
||||||
|
color: #fff;
|
||||||
|
font-size: 1rem;
|
||||||
|
cursor: pointer;
|
||||||
|
padding: var(--space-1);
|
||||||
|
line-height: 1;
|
||||||
|
opacity: 0.9;
|
||||||
|
transition: opacity var(--duration-fast) var(--ease-in-out);
|
||||||
|
}
|
||||||
|
|
||||||
|
.chat-tts-toggle:hover { opacity: 1; }
|
||||||
|
.chat-tts-toggle.off { opacity: 0.35; }
|
||||||
|
|
||||||
/* ─── Messages Area ─── */
|
/* ─── Messages Area ─── */
|
||||||
|
|
||||||
.chat-messages {
|
.chat-messages {
|
||||||
|
|||||||
21
pos/static/css/chat.min.css
vendored
21
pos/static/css/chat.min.css
vendored
@@ -104,6 +104,27 @@
|
|||||||
|
|
||||||
.chat-header-close:hover { opacity: 1; }
|
.chat-header-close:hover { opacity: 1; }
|
||||||
|
|
||||||
|
.chat-header-actions {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: var(--space-2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.chat-tts-toggle {
|
||||||
|
background: none;
|
||||||
|
border: none;
|
||||||
|
color: #fff;
|
||||||
|
font-size: 1rem;
|
||||||
|
cursor: pointer;
|
||||||
|
padding: var(--space-1);
|
||||||
|
line-height: 1;
|
||||||
|
opacity: 0.9;
|
||||||
|
transition: opacity var(--duration-fast) var(--ease-in-out);
|
||||||
|
}
|
||||||
|
|
||||||
|
.chat-tts-toggle:hover { opacity: 1; }
|
||||||
|
.chat-tts-toggle.off { opacity: 0.35; }
|
||||||
|
|
||||||
/* ─── Messages Area ─── */
|
/* ─── Messages Area ─── */
|
||||||
|
|
||||||
.chat-messages {
|
.chat-messages {
|
||||||
|
|||||||
20
pos/static/js/chart.umd.min.js
vendored
Normal file
20
pos/static/js/chart.umd.min.js
vendored
Normal file
File diff suppressed because one or more lines are too long
@@ -9,8 +9,11 @@
|
|||||||
let isSending = false;
|
let isSending = false;
|
||||||
let isListening = false;
|
let isListening = false;
|
||||||
let recognition = null;
|
let recognition = null;
|
||||||
|
let ttsEnabled = true;
|
||||||
|
let ttsUtterance = null;
|
||||||
const history = []; // conversation history for AI context
|
const history = []; // conversation history for AI context
|
||||||
const hasSpeechAPI = ('webkitSpeechRecognition' in window || 'SpeechRecognition' in window);
|
const hasSpeechAPI = ('webkitSpeechRecognition' in window || 'SpeechRecognition' in window);
|
||||||
|
const hasTTS = ('speechSynthesis' in window);
|
||||||
|
|
||||||
// ─── Build DOM ───
|
// ─── Build DOM ───
|
||||||
function init() {
|
function init() {
|
||||||
@@ -29,7 +32,10 @@
|
|||||||
panel.innerHTML = `
|
panel.innerHTML = `
|
||||||
<div class="chat-header">
|
<div class="chat-header">
|
||||||
<h3>Asistente IA — Buscar partes</h3>
|
<h3>Asistente IA — Buscar partes</h3>
|
||||||
<button class="chat-header-close" id="chatClose" aria-label="Cerrar">×</button>
|
<div class="chat-header-actions">
|
||||||
|
${hasTTS ? '<button class="chat-tts-toggle" id="chatTtsToggle" aria-label="Activar lectura de respuestas" title="Activar lectura de respuestas">🔊</button>' : ''}
|
||||||
|
<button class="chat-header-close" id="chatClose" aria-label="Cerrar">×</button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="chat-messages" id="chatMessages">
|
<div class="chat-messages" id="chatMessages">
|
||||||
<div class="chat-msg ai">Hola, soy el asistente de Nexus. Dime que refaccion necesitas y te ayudo a encontrarla.</div>
|
<div class="chat-msg ai">Hola, soy el asistente de Nexus. Dime que refaccion necesitas y te ayudo a encontrarla.</div>
|
||||||
@@ -71,6 +77,16 @@
|
|||||||
document.getElementById('chatMic').addEventListener('click', toggleVoice);
|
document.getElementById('chatMic').addEventListener('click', toggleVoice);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// TTS toggle
|
||||||
|
if (hasTTS) {
|
||||||
|
document.getElementById('chatTtsToggle').addEventListener('click', toggleTTS);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Stop TTS when closing
|
||||||
|
document.getElementById('chatClose').addEventListener('click', function () {
|
||||||
|
if (hasTTS) stopSpeaking();
|
||||||
|
});
|
||||||
|
|
||||||
// Auto-resize textarea
|
// Auto-resize textarea
|
||||||
document.getElementById('chatInput').addEventListener('input', function () {
|
document.getElementById('chatInput').addEventListener('input', function () {
|
||||||
this.style.height = 'auto';
|
this.style.height = 'auto';
|
||||||
@@ -170,6 +186,34 @@
|
|||||||
}, 2000);
|
}, 2000);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ─── TTS (Text-to-Speech) ───
|
||||||
|
function toggleTTS() {
|
||||||
|
ttsEnabled = !ttsEnabled;
|
||||||
|
const btn = document.getElementById('chatTtsToggle');
|
||||||
|
if (btn) {
|
||||||
|
btn.classList.toggle('off', !ttsEnabled);
|
||||||
|
btn.setAttribute('title', ttsEnabled ? 'Desactivar lectura de respuestas' : 'Activar lectura de respuestas');
|
||||||
|
}
|
||||||
|
if (!ttsEnabled) stopSpeaking();
|
||||||
|
}
|
||||||
|
|
||||||
|
function speak(text) {
|
||||||
|
if (!hasTTS || !ttsEnabled || !text) return;
|
||||||
|
stopSpeaking();
|
||||||
|
ttsUtterance = new SpeechSynthesisUtterance(text);
|
||||||
|
ttsUtterance.lang = 'es-MX';
|
||||||
|
ttsUtterance.rate = 1.1;
|
||||||
|
ttsUtterance.pitch = 1;
|
||||||
|
window.speechSynthesis.speak(ttsUtterance);
|
||||||
|
}
|
||||||
|
|
||||||
|
function stopSpeaking() {
|
||||||
|
if (hasTTS && window.speechSynthesis.speaking) {
|
||||||
|
window.speechSynthesis.cancel();
|
||||||
|
}
|
||||||
|
ttsUtterance = null;
|
||||||
|
}
|
||||||
|
|
||||||
// ─── Image Upload (Part identification placeholder) ───
|
// ─── Image Upload (Part identification placeholder) ───
|
||||||
function handleImageUpload(e) {
|
function handleImageUpload(e) {
|
||||||
const file = e.target.files && e.target.files[0];
|
const file = e.target.files && e.target.files[0];
|
||||||
@@ -314,6 +358,7 @@
|
|||||||
const aiMsg = data.response || 'Sin respuesta.';
|
const aiMsg = data.response || 'Sin respuesta.';
|
||||||
addBubble(aiMsg, 'ai');
|
addBubble(aiMsg, 'ai');
|
||||||
history.push({ role: 'assistant', content: aiMsg });
|
history.push({ role: 'assistant', content: aiMsg });
|
||||||
|
speak(aiMsg);
|
||||||
|
|
||||||
// Vehicle info
|
// Vehicle info
|
||||||
if (data.vehicle && data.vehicle.brand_id) {
|
if (data.vehicle && data.vehicle.brand_id) {
|
||||||
|
|||||||
2
pos/static/js/chat.min.js
vendored
2
pos/static/js/chat.min.js
vendored
File diff suppressed because one or more lines are too long
2
pos/static/js/customers.min.js
vendored
2
pos/static/js/customers.min.js
vendored
File diff suppressed because one or more lines are too long
99
pos/static/js/dashboard-stats.js
Normal file
99
pos/static/js/dashboard-stats.js
Normal file
@@ -0,0 +1,99 @@
|
|||||||
|
/**
|
||||||
|
* dashboard-stats.js — In-app real-time charts using Chart.js
|
||||||
|
* Fetches /pos/api/dashboard/stats and renders hourly + top-products charts.
|
||||||
|
*/
|
||||||
|
(function () {
|
||||||
|
'use strict';
|
||||||
|
|
||||||
|
const token = localStorage.getItem('pos_token') || '';
|
||||||
|
if (!token) return;
|
||||||
|
|
||||||
|
function headers() {
|
||||||
|
return { 'Authorization': 'Bearer ' + token, 'Content-Type': 'application/json' };
|
||||||
|
}
|
||||||
|
|
||||||
|
function fmt(n) {
|
||||||
|
return '$' + parseFloat(n || 0).toLocaleString('es-MX', { minimumFractionDigits: 2, maximumFractionDigits: 2 });
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadStats() {
|
||||||
|
try {
|
||||||
|
const res = await fetch('/pos/api/dashboard/stats', { headers: headers() });
|
||||||
|
if (!res.ok) return;
|
||||||
|
const data = await res.json();
|
||||||
|
renderHourlyChart(data.hourly_sales || []);
|
||||||
|
renderTopProductsChart(data.top_products || []);
|
||||||
|
} catch (e) {
|
||||||
|
console.error('[dashboard-stats] failed to load', e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderHourlyChart(hourly) {
|
||||||
|
const ctx = document.getElementById('hourlySalesChart');
|
||||||
|
if (!ctx) return;
|
||||||
|
const labels = hourly.map(function (h) { return h.hour + ':00'; });
|
||||||
|
const totals = hourly.map(function (h) { return h.total; });
|
||||||
|
|
||||||
|
new Chart(ctx, {
|
||||||
|
type: 'bar',
|
||||||
|
data: {
|
||||||
|
labels: labels,
|
||||||
|
datasets: [{
|
||||||
|
label: 'Ventas ($)',
|
||||||
|
data: totals,
|
||||||
|
backgroundColor: 'rgba(245, 166, 35, 0.7)',
|
||||||
|
borderColor: 'rgba(245, 166, 35, 1)',
|
||||||
|
borderWidth: 1,
|
||||||
|
borderRadius: 4,
|
||||||
|
}]
|
||||||
|
},
|
||||||
|
options: {
|
||||||
|
responsive: true,
|
||||||
|
maintainAspectRatio: false,
|
||||||
|
plugins: { legend: { display: false } },
|
||||||
|
scales: {
|
||||||
|
x: { ticks: { color: '#888', font: { size: 10 } }, grid: { display: false } },
|
||||||
|
y: { ticks: { color: '#888', font: { size: 10 }, callback: function (v) { return '$' + (v / 1000).toFixed(0) + 'k'; } }, grid: { color: '#333' } }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderTopProductsChart(topProducts) {
|
||||||
|
const ctx = document.getElementById('topProductsChart');
|
||||||
|
if (!ctx) return;
|
||||||
|
const labels = topProducts.map(function (p) { return p.name.substring(0, 20); });
|
||||||
|
const revenues = topProducts.map(function (p) { return p.revenue; });
|
||||||
|
|
||||||
|
new Chart(ctx, {
|
||||||
|
type: 'doughnut',
|
||||||
|
data: {
|
||||||
|
labels: labels,
|
||||||
|
datasets: [{
|
||||||
|
data: revenues,
|
||||||
|
backgroundColor: [
|
||||||
|
'#F5A623', '#E85D75', '#4ECDC4', '#556270', '#C7F464',
|
||||||
|
'#FF6B6B', '#45B7D1', '#96CEB4', '#FFEAA7', '#DDA0DD'
|
||||||
|
],
|
||||||
|
borderWidth: 0,
|
||||||
|
}]
|
||||||
|
},
|
||||||
|
options: {
|
||||||
|
responsive: true,
|
||||||
|
maintainAspectRatio: false,
|
||||||
|
plugins: {
|
||||||
|
legend: {
|
||||||
|
position: 'right',
|
||||||
|
labels: { color: '#ccc', font: { size: 10 }, boxWidth: 10 }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (document.readyState === 'loading') {
|
||||||
|
document.addEventListener('DOMContentLoaded', loadStats);
|
||||||
|
} else {
|
||||||
|
loadStats();
|
||||||
|
}
|
||||||
|
})();
|
||||||
2
pos/static/js/fleet.min.js
vendored
2
pos/static/js/fleet.min.js
vendored
File diff suppressed because one or more lines are too long
2
pos/static/js/inventory.min.js
vendored
2
pos/static/js/inventory.min.js
vendored
File diff suppressed because one or more lines are too long
2
pos/static/js/pos-utils.min.js
vendored
2
pos/static/js/pos-utils.min.js
vendored
File diff suppressed because one or more lines are too long
101
pos/static/js/pwa-install.js
Normal file
101
pos/static/js/pwa-install.js
Normal file
@@ -0,0 +1,101 @@
|
|||||||
|
/**
|
||||||
|
* Nexus POS — PWA Install Prompt
|
||||||
|
* Captures beforeinstallprompt and shows a dismissible banner.
|
||||||
|
*/
|
||||||
|
(function () {
|
||||||
|
'use strict';
|
||||||
|
|
||||||
|
const STORAGE_KEY = 'nexus_pwa_install_dismissed';
|
||||||
|
let deferredPrompt = null;
|
||||||
|
|
||||||
|
function createBanner() {
|
||||||
|
if (document.getElementById('pwa-install-banner')) return;
|
||||||
|
|
||||||
|
const banner = document.createElement('div');
|
||||||
|
banner.id = 'pwa-install-banner';
|
||||||
|
banner.innerHTML = `
|
||||||
|
<span>📲 Instala Nexus POS para acceso rápido y modo offline.</span>
|
||||||
|
<button id="pwa-install-btn">Instalar</button>
|
||||||
|
<button id="pwa-dismiss-btn" aria-label="Cerrar">×</button>
|
||||||
|
`;
|
||||||
|
document.body.appendChild(banner);
|
||||||
|
|
||||||
|
const style = document.createElement('style');
|
||||||
|
style.textContent = `
|
||||||
|
#pwa-install-banner {
|
||||||
|
position: fixed;
|
||||||
|
bottom: 0; left: 0; right: 0;
|
||||||
|
background: var(--color-surface, #1a1a1a);
|
||||||
|
color: var(--color-text, #eee);
|
||||||
|
border-top: 1px solid var(--color-border, #333);
|
||||||
|
padding: 12px 16px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 12px;
|
||||||
|
font-size: 14px;
|
||||||
|
z-index: 9999;
|
||||||
|
box-shadow: 0 -4px 12px rgba(0,0,0,0.3);
|
||||||
|
}
|
||||||
|
#pwa-install-banner span { flex: 1; }
|
||||||
|
#pwa-install-btn {
|
||||||
|
background: var(--color-primary, #F5A623);
|
||||||
|
color: #000;
|
||||||
|
border: none;
|
||||||
|
padding: 8px 16px;
|
||||||
|
border-radius: 6px;
|
||||||
|
font-weight: 600;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
#pwa-dismiss-btn {
|
||||||
|
background: none;
|
||||||
|
border: none;
|
||||||
|
color: var(--color-text-muted, #888);
|
||||||
|
font-size: 20px;
|
||||||
|
cursor: pointer;
|
||||||
|
line-height: 1;
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
document.head.appendChild(style);
|
||||||
|
|
||||||
|
document.getElementById('pwa-install-btn').addEventListener('click', function () {
|
||||||
|
if (!deferredPrompt) return;
|
||||||
|
deferredPrompt.prompt();
|
||||||
|
deferredPrompt.userChoice.then(function (choice) {
|
||||||
|
if (choice.outcome === 'accepted') {
|
||||||
|
console.log('[PWA] User accepted install');
|
||||||
|
}
|
||||||
|
deferredPrompt = null;
|
||||||
|
banner.remove();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
document.getElementById('pwa-dismiss-btn').addEventListener('click', function () {
|
||||||
|
localStorage.setItem(STORAGE_KEY, Date.now().toString());
|
||||||
|
banner.remove();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
window.addEventListener('beforeinstallprompt', function (e) {
|
||||||
|
e.preventDefault();
|
||||||
|
deferredPrompt = e;
|
||||||
|
|
||||||
|
const dismissed = localStorage.getItem(STORAGE_KEY);
|
||||||
|
if (dismissed && (Date.now() - parseInt(dismissed)) < 7 * 24 * 60 * 60 * 1000) {
|
||||||
|
return; // dismissed within 7 days
|
||||||
|
}
|
||||||
|
|
||||||
|
// Wait for DOM to be ready
|
||||||
|
if (document.readyState === 'loading') {
|
||||||
|
document.addEventListener('DOMContentLoaded', createBanner);
|
||||||
|
} else {
|
||||||
|
createBanner();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
window.addEventListener('appinstalled', function () {
|
||||||
|
console.log('[PWA] App installed');
|
||||||
|
deferredPrompt = null;
|
||||||
|
const b = document.getElementById('pwa-install-banner');
|
||||||
|
if (b) b.remove();
|
||||||
|
});
|
||||||
|
})();
|
||||||
2
pos/static/js/sidebar.min.js
vendored
2
pos/static/js/sidebar.min.js
vendored
File diff suppressed because one or more lines are too long
1
pos/static/js/virtual-scroll.min.js
vendored
Normal file
1
pos/static/js/virtual-scroll.min.js
vendored
Normal file
@@ -0,0 +1 @@
|
|||||||
|
!function(t){"use strict";function e(t){this.container=t.container,this.rowHeight=t.rowHeight||48,this.buffer=t.buffer||5,this.renderRow=t.renderRow||function(){return""},this.emptyHtml=t.emptyHtml||"",this.data=[],this._scrollHandler=this._onScroll.bind(this),this._resizeHandler=this._onResize.bind(this),this._isTbody="TBODY"===this.container.tagName,this._init()}e.prototype._init=function(){var e=this.container;if(this._isTbody){var i=e.closest("table");if(i){var r=i.parentElement;r&&r.classList.contains("vs-container")&&r.addEventListener("scroll",this._scrollHandler,{passive:!0})}}else e.style.overflowY="auto",e.style.position="relative",e.style.maxHeight||e.style.height||(e.style.maxHeight="60vh");t.addEventListener("resize",this._resizeHandler,{passive:!0})},e.prototype.setData=function(t){this.data=t||[],this._render()},e.prototype.refresh=function(){this._render()},e.prototype._onScroll=function(){this._render()},e.prototype._onResize=function(){this._render()},e.prototype._getScrollTop=function(){if(this._isTbody){var t=this.container.closest("table");if(t){var e=t.parentElement;if(e&&e.classList.contains("vs-container"))return e.scrollTop}return 0}return this.container.scrollTop},e.prototype._getContainerHeight=function(){if(this._isTbody){var t=this.container.closest("table");if(t){var e=t.parentElement;if(e&&e.classList.contains("vs-container"))return e.clientHeight}return 600}return this.container.clientHeight},e.prototype._render=function(){var t=this.data,e=this.rowHeight,i=this.buffer;if(!t.length)return this._isTbody,void(this.container.innerHTML=this.emptyHtml);var r=this._getScrollTop(),n=this._getContainerHeight(),s=Math.max(0,Math.floor(r/e)-i),o=Math.min(t.length,Math.ceil((r+n)/e)+i),a="";if(this._isTbody){var l=s*e;l>0&&(a+='<tr style="height:'+l+'px;"><td colspan="99" style="padding:0;border:0;"></td></tr>');for(var h=s;h<o;h++)a+=this.renderRow(t[h],h);var c=(t.length-o)*e;c>0&&(a+='<tr style="height:'+c+'px;"><td colspan="99" style="padding:0;border:0;"></td></tr>')}else for(var d=s;d<o;d++)a+=this.renderRow(t[d],d);this.container.innerHTML=a},e.prototype.destroy=function(){if(this._isTbody){var e=this.container.closest("table");if(e){var i=e.parentElement;i&&i.classList.contains("vs-container")&&i.removeEventListener("scroll",this._scrollHandler)}}else this.container.removeEventListener("scroll",this._scrollHandler);t.removeEventListener("resize",this._resizeHandler)},t.VirtualScroll=e}(window);
|
||||||
@@ -5,6 +5,7 @@
|
|||||||
<meta charset="UTF-8" />
|
<meta charset="UTF-8" />
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
<title>Contabilidad — Nexus Autoparts POS</title>
|
<title>Contabilidad — Nexus Autoparts POS</title>
|
||||||
|
<link rel="stylesheet" href="/pos/static/css/chat.css" />
|
||||||
<link rel="stylesheet" href="/pos/static/css/tokens.css" />
|
<link rel="stylesheet" href="/pos/static/css/tokens.css" />
|
||||||
<link rel="stylesheet" href="/pos/static/css/common.css" />
|
<link rel="stylesheet" href="/pos/static/css/common.css" />
|
||||||
<link rel="stylesheet" href="/pos/static/css/sidebar.css" />
|
<link rel="stylesheet" href="/pos/static/css/sidebar.css" />
|
||||||
@@ -494,6 +495,8 @@
|
|||||||
<script src="/pos/static/js/accounting.js" defer></script>
|
<script src="/pos/static/js/accounting.js" defer></script>
|
||||||
<script src="/pos/static/js/sync-engine.js" defer></script>
|
<script src="/pos/static/js/sync-engine.js" defer></script>
|
||||||
<script>if('serviceWorker' in navigator){navigator.serviceWorker.register('/pos/sw.js',{scope:'/pos/'});}</script>
|
<script>if('serviceWorker' in navigator){navigator.serviceWorker.register('/pos/sw.js',{scope:'/pos/'});}</script>
|
||||||
|
<script src="/pos/static/js/pwa-install.js" defer></script>
|
||||||
|
|
||||||
|
<script src="/pos/static/js/chat.js" defer></script>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
@@ -279,5 +279,6 @@
|
|||||||
<script src="/pos/static/js/sync-engine.js" defer></script>
|
<script src="/pos/static/js/sync-engine.js" defer></script>
|
||||||
<script src="/pos/static/js/onboarding.js" defer></script>
|
<script src="/pos/static/js/onboarding.js" defer></script>
|
||||||
<script>if('serviceWorker' in navigator){navigator.serviceWorker.register('/pos/sw.js',{scope:'/pos/'});}</script>
|
<script>if('serviceWorker' in navigator){navigator.serviceWorker.register('/pos/sw.js',{scope:'/pos/'});}</script>
|
||||||
|
<script src="/pos/static/js/pwa-install.js" defer></script>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
@@ -5,6 +5,7 @@
|
|||||||
<meta charset="UTF-8" />
|
<meta charset="UTF-8" />
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
<title>Configuración — Nexus Autoparts POS</title>
|
<title>Configuración — Nexus Autoparts POS</title>
|
||||||
|
<link rel="stylesheet" href="/pos/static/css/chat.css" />
|
||||||
<link rel="stylesheet" href="/pos/static/css/tokens.css" />
|
<link rel="stylesheet" href="/pos/static/css/tokens.css" />
|
||||||
<link rel="stylesheet" href="/pos/static/css/common.css" />
|
<link rel="stylesheet" href="/pos/static/css/common.css" />
|
||||||
<link rel="stylesheet" href="/pos/static/css/sidebar.css" />
|
<link rel="stylesheet" href="/pos/static/css/sidebar.css" />
|
||||||
@@ -687,6 +688,8 @@
|
|||||||
<script src="/pos/static/js/config.js" defer></script>
|
<script src="/pos/static/js/config.js" defer></script>
|
||||||
<script src="/pos/static/js/sync-engine.js" defer></script>
|
<script src="/pos/static/js/sync-engine.js" defer></script>
|
||||||
<script>if('serviceWorker' in navigator){navigator.serviceWorker.register('/pos/sw.js',{scope:'/pos/'});}</script>
|
<script>if('serviceWorker' in navigator){navigator.serviceWorker.register('/pos/sw.js',{scope:'/pos/'});}</script>
|
||||||
|
<script src="/pos/static/js/pwa-install.js" defer></script>
|
||||||
|
|
||||||
|
<script src="/pos/static/js/chat.js" defer></script>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
@@ -5,6 +5,7 @@
|
|||||||
<meta charset="UTF-8" />
|
<meta charset="UTF-8" />
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
<title>Nexus Autoparts — Clientes</title>
|
<title>Nexus Autoparts — Clientes</title>
|
||||||
|
<link rel="stylesheet" href="/pos/static/css/chat.css" />
|
||||||
<link rel="stylesheet" href="/pos/static/css/tokens.css" />
|
<link rel="stylesheet" href="/pos/static/css/tokens.css" />
|
||||||
<link rel="stylesheet" href="/pos/static/css/common.css" />
|
<link rel="stylesheet" href="/pos/static/css/common.css" />
|
||||||
<link rel="stylesheet" href="/pos/static/css/sidebar.css" />
|
<link rel="stylesheet" href="/pos/static/css/sidebar.css" />
|
||||||
@@ -623,5 +624,7 @@
|
|||||||
<script src="/pos/static/js/offline-banner.js" defer></script>
|
<script src="/pos/static/js/offline-banner.js" defer></script>
|
||||||
<script src="/pos/static/js/sync-engine.js" defer></script>
|
<script src="/pos/static/js/sync-engine.js" defer></script>
|
||||||
<script>if('serviceWorker' in navigator){navigator.serviceWorker.register('/pos/sw.js',{scope:'/pos/'});}</script>
|
<script>if('serviceWorker' in navigator){navigator.serviceWorker.register('/pos/sw.js',{scope:'/pos/'});}</script>
|
||||||
|
<script src="/pos/static/js/pwa-install.js" defer></script>
|
||||||
|
<script src="/pos/static/js/chat.js" defer></script>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
@@ -5,6 +5,7 @@
|
|||||||
<meta charset="UTF-8" />
|
<meta charset="UTF-8" />
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
<title>Nexus Autoparts — Dashboard</title>
|
<title>Nexus Autoparts — Dashboard</title>
|
||||||
|
<link rel="stylesheet" href="/pos/static/css/chat.css" />
|
||||||
<link rel="stylesheet" href="/pos/static/css/tokens.css" />
|
<link rel="stylesheet" href="/pos/static/css/tokens.css" />
|
||||||
<link rel="stylesheet" href="/pos/static/css/common.css" />
|
<link rel="stylesheet" href="/pos/static/css/common.css" />
|
||||||
<link rel="stylesheet" href="/pos/static/css/sidebar.css" />
|
<link rel="stylesheet" href="/pos/static/css/sidebar.css" />
|
||||||
@@ -351,6 +352,33 @@
|
|||||||
</div><!-- end chart-card -->
|
</div><!-- end chart-card -->
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
|
<!-- =================================================================
|
||||||
|
ESTADÍSTICAS EN TIEMPO REAL
|
||||||
|
================================================================= -->
|
||||||
|
<section>
|
||||||
|
<div class="section-header">
|
||||||
|
<span class="section-title">Rendimiento Hoy</span>
|
||||||
|
</div>
|
||||||
|
<div class="two-col-grid">
|
||||||
|
<div class="rank-card">
|
||||||
|
<div style="display:flex;align-items:center;justify-content:space-between;margin-bottom:var(--space-4);">
|
||||||
|
<div style="font-family:var(--font-heading);font-weight:var(--heading-weight-secondary);font-size:var(--text-body-sm);letter-spacing:var(--tracking-wider);text-transform:uppercase;color:var(--color-text-secondary);">
|
||||||
|
Ventas por Hora
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<canvas id="hourlySalesChart" height="180"></canvas>
|
||||||
|
</div>
|
||||||
|
<div class="rank-card">
|
||||||
|
<div style="display:flex;align-items:center;justify-content:space-between;margin-bottom:var(--space-4);">
|
||||||
|
<div style="font-family:var(--font-heading);font-weight:var(--heading-weight-secondary);font-size:var(--text-body-sm);letter-spacing:var(--tracking-wider);text-transform:uppercase;color:var(--color-text-secondary);">
|
||||||
|
Top Productos (Hoy)
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<canvas id="topProductsChart" height="180"></canvas>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
<!-- =================================================================
|
<!-- =================================================================
|
||||||
TOP PRODUCTOS / TOP CLIENTES
|
TOP PRODUCTOS / TOP CLIENTES
|
||||||
================================================================= -->
|
================================================================= -->
|
||||||
@@ -452,13 +480,17 @@
|
|||||||
</div><!-- end app-shell -->
|
</div><!-- end app-shell -->
|
||||||
|
|
||||||
|
|
||||||
|
<script src="/pos/static/js/chart.umd.min.js" defer></script>
|
||||||
<script src="/pos/static/js/i18n.js" defer></script>
|
<script src="/pos/static/js/i18n.js" defer></script>
|
||||||
<script src="/pos/static/js/app-init.js" defer></script>
|
<script src="/pos/static/js/app-init.js" defer></script>
|
||||||
<script src="/pos/static/js/pos-utils.js" defer></script>
|
<script src="/pos/static/js/pos-utils.js" defer></script>
|
||||||
<script src="/pos/static/js/sidebar.js" defer></script>
|
<script src="/pos/static/js/sidebar.js" defer></script>
|
||||||
|
<script src="/pos/static/js/dashboard-stats.js" defer></script>
|
||||||
<script src="/pos/static/js/dashboard.js" defer></script>
|
<script src="/pos/static/js/dashboard.js" defer></script>
|
||||||
<script src="/pos/static/js/sync-engine.js" defer></script>
|
<script src="/pos/static/js/sync-engine.js" defer></script>
|
||||||
<script>if('serviceWorker' in navigator){navigator.serviceWorker.register('/pos/sw.js',{scope:'/pos/'});}</script>
|
<script>if('serviceWorker' in navigator){navigator.serviceWorker.register('/pos/sw.js',{scope:'/pos/'});}</script>
|
||||||
|
<script src="/pos/static/js/pwa-install.js" defer></script>
|
||||||
|
|
||||||
|
<script src="/pos/static/js/chat.js" defer></script>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
@@ -5,11 +5,11 @@
|
|||||||
<meta charset="UTF-8" />
|
<meta charset="UTF-8" />
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
<title>Diagramas — Nexus Autoparts POS</title>
|
<title>Diagramas — Nexus Autoparts POS</title>
|
||||||
|
<link rel="stylesheet" href="/pos/static/css/chat.css" />
|
||||||
<link rel="stylesheet" href="/pos/static/css/tokens.css" />
|
<link rel="stylesheet" href="/pos/static/css/tokens.css" />
|
||||||
<link rel="stylesheet" href="/pos/static/css/common.css" />
|
<link rel="stylesheet" href="/pos/static/css/common.css" />
|
||||||
<link rel="stylesheet" href="/pos/static/css/sidebar.css" />
|
<link rel="stylesheet" href="/pos/static/css/sidebar.css" />
|
||||||
<link rel="stylesheet" href="/pos/static/css/pos-glass.css" />
|
<link rel="stylesheet" href="/pos/static/css/pos-glass.css" />
|
||||||
<link rel="stylesheet" href="/pos/static/css/chat.css" />
|
|
||||||
<link rel="stylesheet" href="/pos/static/css/onboarding.css" />
|
<link rel="stylesheet" href="/pos/static/css/onboarding.css" />
|
||||||
<link rel="manifest" href="/pos/static/pwa/manifest.json" />
|
<link rel="manifest" href="/pos/static/pwa/manifest.json" />
|
||||||
<meta name="theme-color" content="#F5A623" />
|
<meta name="theme-color" content="#F5A623" />
|
||||||
@@ -154,5 +154,7 @@
|
|||||||
<script src="/pos/static/js/sidebar.js" defer></script>
|
<script src="/pos/static/js/sidebar.js" defer></script>
|
||||||
<script src="/pos/static/js/diagrams.js" defer></script>
|
<script src="/pos/static/js/diagrams.js" defer></script>
|
||||||
<script>if('serviceWorker' in navigator){navigator.serviceWorker.register('/pos/sw.js',{scope:'/pos/'});}</script>
|
<script>if('serviceWorker' in navigator){navigator.serviceWorker.register('/pos/sw.js',{scope:'/pos/'});}</script>
|
||||||
|
<script src="/pos/static/js/pwa-install.js" defer></script>
|
||||||
|
<script src="/pos/static/js/chat.js" defer></script>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
@@ -5,6 +5,7 @@
|
|||||||
<meta charset="UTF-8" />
|
<meta charset="UTF-8" />
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
<title>Flotillas — Nexus Autoparts POS</title>
|
<title>Flotillas — Nexus Autoparts POS</title>
|
||||||
|
<link rel="stylesheet" href="/pos/static/css/chat.css" />
|
||||||
<link rel="stylesheet" href="/pos/static/css/tokens.css" />
|
<link rel="stylesheet" href="/pos/static/css/tokens.css" />
|
||||||
<link rel="stylesheet" href="/pos/static/css/common.css" />
|
<link rel="stylesheet" href="/pos/static/css/common.css" />
|
||||||
<link rel="stylesheet" href="/pos/static/css/sidebar.css" />
|
<link rel="stylesheet" href="/pos/static/css/sidebar.css" />
|
||||||
@@ -307,6 +308,8 @@
|
|||||||
<script src="/pos/static/js/fleet.js" defer></script>
|
<script src="/pos/static/js/fleet.js" defer></script>
|
||||||
<script src="/pos/static/js/offline-banner.js" defer></script>
|
<script src="/pos/static/js/offline-banner.js" defer></script>
|
||||||
<script>if('serviceWorker' in navigator){navigator.serviceWorker.register('/pos/sw.js',{scope:'/pos/'});}</script>
|
<script>if('serviceWorker' in navigator){navigator.serviceWorker.register('/pos/sw.js',{scope:'/pos/'});}</script>
|
||||||
|
<script src="/pos/static/js/pwa-install.js" defer></script>
|
||||||
|
|
||||||
|
<script src="/pos/static/js/chat.js" defer></script>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
@@ -5,6 +5,7 @@
|
|||||||
<meta charset="UTF-8" />
|
<meta charset="UTF-8" />
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
<title>Inventario — Nexus Autoparts POS</title>
|
<title>Inventario — Nexus Autoparts POS</title>
|
||||||
|
<link rel="stylesheet" href="/pos/static/css/chat.css" />
|
||||||
<link rel="stylesheet" href="/pos/static/css/tokens.css" />
|
<link rel="stylesheet" href="/pos/static/css/tokens.css" />
|
||||||
<link rel="stylesheet" href="/pos/static/css/common.css" />
|
<link rel="stylesheet" href="/pos/static/css/common.css" />
|
||||||
<link rel="stylesheet" href="/pos/static/css/sidebar.css" />
|
<link rel="stylesheet" href="/pos/static/css/sidebar.css" />
|
||||||
@@ -818,5 +819,7 @@
|
|||||||
<script src="/pos/static/js/offline-banner.js" defer></script>
|
<script src="/pos/static/js/offline-banner.js" defer></script>
|
||||||
<script src="/pos/static/js/sync-engine.js" defer></script>
|
<script src="/pos/static/js/sync-engine.js" defer></script>
|
||||||
<script>if('serviceWorker' in navigator){navigator.serviceWorker.register('/pos/sw.js',{scope:'/pos/'});}</script>
|
<script>if('serviceWorker' in navigator){navigator.serviceWorker.register('/pos/sw.js',{scope:'/pos/'});}</script>
|
||||||
|
<script src="/pos/static/js/pwa-install.js" defer></script>
|
||||||
|
<script src="/pos/static/js/chat.js" defer></script>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
@@ -5,6 +5,7 @@
|
|||||||
<meta charset="UTF-8" />
|
<meta charset="UTF-8" />
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
<title>Facturación CFDI — Nexus Autoparts POS</title>
|
<title>Facturación CFDI — Nexus Autoparts POS</title>
|
||||||
|
<link rel="stylesheet" href="/pos/static/css/chat.css" />
|
||||||
<link rel="stylesheet" href="/pos/static/css/tokens.css" />
|
<link rel="stylesheet" href="/pos/static/css/tokens.css" />
|
||||||
<link rel="stylesheet" href="/pos/static/css/common.css" />
|
<link rel="stylesheet" href="/pos/static/css/common.css" />
|
||||||
<link rel="stylesheet" href="/pos/static/css/sidebar.css" />
|
<link rel="stylesheet" href="/pos/static/css/sidebar.css" />
|
||||||
@@ -1056,6 +1057,8 @@
|
|||||||
<script src="/pos/static/js/invoicing.js" defer></script>
|
<script src="/pos/static/js/invoicing.js" defer></script>
|
||||||
<script src="/pos/static/js/sync-engine.js" defer></script>
|
<script src="/pos/static/js/sync-engine.js" defer></script>
|
||||||
<script>if('serviceWorker' in navigator){navigator.serviceWorker.register('/pos/sw.js',{scope:'/pos/'});}</script>
|
<script>if('serviceWorker' in navigator){navigator.serviceWorker.register('/pos/sw.js',{scope:'/pos/'});}</script>
|
||||||
|
<script src="/pos/static/js/pwa-install.js" defer></script>
|
||||||
|
|
||||||
|
<script src="/pos/static/js/chat.js" defer></script>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
@@ -532,6 +532,7 @@
|
|||||||
<script src="/pos/static/js/kiosk.js" defer></script>
|
<script src="/pos/static/js/kiosk.js" defer></script>
|
||||||
<script src="/pos/static/js/sync-engine.js" defer></script>
|
<script src="/pos/static/js/sync-engine.js" defer></script>
|
||||||
<script>if('serviceWorker' in navigator){navigator.serviceWorker.register('/pos/sw.js',{scope:'/pos/'});}</script>
|
<script>if('serviceWorker' in navigator){navigator.serviceWorker.register('/pos/sw.js',{scope:'/pos/'});}</script>
|
||||||
|
<script src="/pos/static/js/pwa-install.js" defer></script>
|
||||||
|
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
@@ -5,6 +5,7 @@
|
|||||||
<meta charset="UTF-8" />
|
<meta charset="UTF-8" />
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
<title>Marketplace B2B — Nexus Autoparts POS</title>
|
<title>Marketplace B2B — Nexus Autoparts POS</title>
|
||||||
|
<link rel="stylesheet" href="/pos/static/css/chat.css" />
|
||||||
<link rel="stylesheet" href="/pos/static/css/tokens.css" />
|
<link rel="stylesheet" href="/pos/static/css/tokens.css" />
|
||||||
<link rel="stylesheet" href="/pos/static/css/common.css" />
|
<link rel="stylesheet" href="/pos/static/css/common.css" />
|
||||||
<link rel="stylesheet" href="/pos/static/css/sidebar.css" />
|
<link rel="stylesheet" href="/pos/static/css/sidebar.css" />
|
||||||
@@ -512,5 +513,6 @@
|
|||||||
}
|
}
|
||||||
})();
|
})();
|
||||||
</script>
|
</script>
|
||||||
|
<script src="/pos/static/js/chat.js" defer></script>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
@@ -560,5 +560,6 @@
|
|||||||
<script src="/pos/static/js/chat.js" defer></script>
|
<script src="/pos/static/js/chat.js" defer></script>
|
||||||
<script src="/pos/static/js/sync-engine.js" defer></script>
|
<script src="/pos/static/js/sync-engine.js" defer></script>
|
||||||
<script>if('serviceWorker' in navigator){navigator.serviceWorker.register('/pos/sw.js',{scope:'/pos/'});}</script>
|
<script>if('serviceWorker' in navigator){navigator.serviceWorker.register('/pos/sw.js',{scope:'/pos/'});}</script>
|
||||||
|
<script src="/pos/static/js/pwa-install.js" defer></script>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
@@ -5,6 +5,7 @@
|
|||||||
<meta charset="UTF-8" />
|
<meta charset="UTF-8" />
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
<title>Cotizaciones — Nexus Autoparts POS</title>
|
<title>Cotizaciones — Nexus Autoparts POS</title>
|
||||||
|
<link rel="stylesheet" href="/pos/static/css/chat.css" />
|
||||||
<link rel="stylesheet" href="/pos/static/css/tokens.css" />
|
<link rel="stylesheet" href="/pos/static/css/tokens.css" />
|
||||||
<link rel="stylesheet" href="/pos/static/css/common.css" />
|
<link rel="stylesheet" href="/pos/static/css/common.css" />
|
||||||
<link rel="stylesheet" href="/pos/static/css/sidebar.css" />
|
<link rel="stylesheet" href="/pos/static/css/sidebar.css" />
|
||||||
@@ -147,5 +148,6 @@
|
|||||||
loadQuotes();
|
loadQuotes();
|
||||||
})();
|
})();
|
||||||
</script>
|
</script>
|
||||||
|
<script src="/pos/static/js/chat.js" defer></script>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
@@ -5,6 +5,7 @@
|
|||||||
<meta charset="UTF-8" />
|
<meta charset="UTF-8" />
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
<title>Reportes — Nexus Autoparts POS</title>
|
<title>Reportes — Nexus Autoparts POS</title>
|
||||||
|
<link rel="stylesheet" href="/pos/static/css/chat.css" />
|
||||||
<link rel="stylesheet" href="/pos/static/css/tokens.css" />
|
<link rel="stylesheet" href="/pos/static/css/tokens.css" />
|
||||||
<link rel="stylesheet" href="/pos/static/css/common.css" />
|
<link rel="stylesheet" href="/pos/static/css/common.css" />
|
||||||
<link rel="stylesheet" href="/pos/static/css/sidebar.css" />
|
<link rel="stylesheet" href="/pos/static/css/sidebar.css" />
|
||||||
@@ -322,6 +323,8 @@
|
|||||||
<script src="/pos/static/js/reports.js" defer></script>
|
<script src="/pos/static/js/reports.js" defer></script>
|
||||||
<script src="/pos/static/js/sync-engine.js" defer></script>
|
<script src="/pos/static/js/sync-engine.js" defer></script>
|
||||||
<script>if('serviceWorker' in navigator){navigator.serviceWorker.register('/pos/sw.js',{scope:'/pos/'});}</script>
|
<script>if('serviceWorker' in navigator){navigator.serviceWorker.register('/pos/sw.js',{scope:'/pos/'});}</script>
|
||||||
|
<script src="/pos/static/js/pwa-install.js" defer></script>
|
||||||
|
|
||||||
|
<script src="/pos/static/js/chat.js" defer></script>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
@@ -5,6 +5,7 @@
|
|||||||
<meta charset="UTF-8" />
|
<meta charset="UTF-8" />
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
<title>WhatsApp — Nexus Autoparts POS</title>
|
<title>WhatsApp — Nexus Autoparts POS</title>
|
||||||
|
<link rel="stylesheet" href="/pos/static/css/chat.css" />
|
||||||
<link rel="stylesheet" href="/pos/static/css/tokens.css" />
|
<link rel="stylesheet" href="/pos/static/css/tokens.css" />
|
||||||
<link rel="stylesheet" href="/pos/static/css/common.css" />
|
<link rel="stylesheet" href="/pos/static/css/common.css" />
|
||||||
<link rel="stylesheet" href="/pos/static/css/sidebar.css" />
|
<link rel="stylesheet" href="/pos/static/css/sidebar.css" />
|
||||||
@@ -130,5 +131,6 @@ function posLogout(){localStorage.removeItem('pos_token');window.location.href='
|
|||||||
<script src="/pos/static/js/pos-utils.js" defer></script>
|
<script src="/pos/static/js/pos-utils.js" defer></script>
|
||||||
<script src="/pos/static/js/sidebar.js" defer></script>
|
<script src="/pos/static/js/sidebar.js" defer></script>
|
||||||
|
|
||||||
|
<script src="/pos/static/js/chat.js" defer></script>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
@@ -4,16 +4,23 @@
|
|||||||
|
|
||||||
set -e
|
set -e
|
||||||
|
|
||||||
echo "=== Minifying JS assets ==="
|
echo "=== Minifying POS JS assets ==="
|
||||||
for f in /home/Autopartes/pos/static/js/*.js; do
|
for f in /home/Autopartes/pos/static/js/*.js; do
|
||||||
|
# Skip already-minified files to avoid generating .min.min.js
|
||||||
|
case "$f" in
|
||||||
|
*.min.js) continue ;;
|
||||||
|
esac
|
||||||
base=$(basename "$f" .js)
|
base=$(basename "$f" .js)
|
||||||
out="/home/Autopartes/pos/static/js/${base}.min.js"
|
out="/home/Autopartes/pos/static/js/${base}.min.js"
|
||||||
echo " $base.js -> ${base}.min.js"
|
echo " $base.js -> ${base}.min.js"
|
||||||
terser "$f" -o "$out" --compress --mangle 2>/dev/null || cp "$f" "$out"
|
terser "$f" -o "$out" --compress --mangle 2>/dev/null || cp "$f" "$out"
|
||||||
done
|
done
|
||||||
|
|
||||||
echo "=== Minifying CSS assets ==="
|
echo "=== Minifying POS CSS assets ==="
|
||||||
for f in /home/Autopartes/pos/static/css/*.css; do
|
for f in /home/Autopartes/pos/static/css/*.css; do
|
||||||
|
case "$f" in
|
||||||
|
*.min.css) continue ;;
|
||||||
|
esac
|
||||||
base=$(basename "$f" .css)
|
base=$(basename "$f" .css)
|
||||||
out="/home/Autopartes/pos/static/css/${base}.min.css"
|
out="/home/Autopartes/pos/static/css/${base}.min.css"
|
||||||
echo " $base.css -> ${base}.min.css"
|
echo " $base.css -> ${base}.min.css"
|
||||||
@@ -24,15 +31,22 @@ for f in /home/Autopartes/pos/static/css/*.css; do
|
|||||||
fi
|
fi
|
||||||
done
|
done
|
||||||
|
|
||||||
echo "=== Minifying Dashboard assets ==="
|
echo "=== Minifying Dashboard JS assets ==="
|
||||||
for f in /home/Autopartes/dashboard/*.js; do
|
for f in /home/Autopartes/dashboard/*.js; do
|
||||||
|
case "$f" in
|
||||||
|
*.min.js) continue ;;
|
||||||
|
esac
|
||||||
base=$(basename "$f" .js)
|
base=$(basename "$f" .js)
|
||||||
out="/home/Autopartes/dashboard/${base}.min.js"
|
out="/home/Autopartes/dashboard/${base}.min.js"
|
||||||
echo " $base.js -> ${base}.min.js"
|
echo " $base.js -> ${base}.min.js"
|
||||||
terser "$f" -o "$out" --compress --mangle 2>/dev/null || cp "$f" "$out"
|
terser "$f" -o "$out" --compress --mangle 2>/dev/null || cp "$f" "$out"
|
||||||
done
|
done
|
||||||
|
|
||||||
|
echo "=== Minifying Dashboard CSS assets ==="
|
||||||
for f in /home/Autopartes/dashboard/*.css; do
|
for f in /home/Autopartes/dashboard/*.css; do
|
||||||
|
case "$f" in
|
||||||
|
*.min.css) continue ;;
|
||||||
|
esac
|
||||||
base=$(basename "$f" .css)
|
base=$(basename "$f" .css)
|
||||||
out="/home/Autopartes/dashboard/${base}.min.css"
|
out="/home/Autopartes/dashboard/${base}.min.css"
|
||||||
echo " $base.css -> ${base}.min.css"
|
echo " $base.css -> ${base}.min.css"
|
||||||
|
|||||||
@@ -30,7 +30,7 @@ from datetime import datetime
|
|||||||
import psycopg2
|
import psycopg2
|
||||||
|
|
||||||
DSN = os.environ.get('MASTER_DB_URL', 'postgresql://postgres@/nexus_autoparts')
|
DSN = os.environ.get('MASTER_DB_URL', 'postgresql://postgres@/nexus_autoparts')
|
||||||
BATCH_SIZE = 500_000
|
BATCH_SIZE = 10_000_000
|
||||||
PARTITIONS = 16
|
PARTITIONS = 16
|
||||||
|
|
||||||
|
|
||||||
@@ -124,8 +124,7 @@ def create_indexes(cur):
|
|||||||
CREATE INDEX idx_vp_new_mye ON vehicle_parts_new(model_year_engine_id);
|
CREATE INDEX idx_vp_new_mye ON vehicle_parts_new(model_year_engine_id);
|
||||||
""")
|
""")
|
||||||
cur.execute("""
|
cur.execute("""
|
||||||
ALTER TABLE vehicle_parts_new ADD CONSTRAINT uq_vp_new_mye_part
|
CREATE INDEX idx_vp_new_mye_part ON vehicle_parts_new(model_year_engine_id, part_id);
|
||||||
UNIQUE (model_year_engine_id, part_id);
|
|
||||||
""")
|
""")
|
||||||
log("Indexes created.")
|
log("Indexes created.")
|
||||||
|
|
||||||
@@ -167,6 +166,8 @@ def main():
|
|||||||
conn = get_conn()
|
conn = get_conn()
|
||||||
conn.autocommit = False
|
conn.autocommit = False
|
||||||
cur = conn.cursor()
|
cur = conn.cursor()
|
||||||
|
cur.execute("SET synchronous_commit = off;")
|
||||||
|
cur.execute("SET work_mem = '256MB';")
|
||||||
|
|
||||||
try:
|
try:
|
||||||
# Check prerequisites
|
# Check prerequisites
|
||||||
|
|||||||
11
systemd/nexus-cache-warm.service
Normal file
11
systemd/nexus-cache-warm.service
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
[Unit]
|
||||||
|
Description=Warm Redis cache for Nexus vehicle info
|
||||||
|
After=postgresql.service redis-server.service
|
||||||
|
|
||||||
|
[Service]
|
||||||
|
Type=oneshot
|
||||||
|
User=postgres
|
||||||
|
WorkingDirectory=/home/Autopartes
|
||||||
|
ExecStart=/usr/bin/python3 /home/Autopartes/scripts/warm_vehicle_cache.py --batch-size 10000 --ttl 7200
|
||||||
|
StandardOutput=append:/var/log/nexus-pos/cache_warm.log
|
||||||
|
StandardError=append:/var/log/nexus-pos/cache_warm.log
|
||||||
9
systemd/nexus-cache-warm.timer
Normal file
9
systemd/nexus-cache-warm.timer
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
[Unit]
|
||||||
|
Description=Daily Redis cache warming at 04:00 UTC (1h after MV refresh)
|
||||||
|
|
||||||
|
[Timer]
|
||||||
|
OnCalendar=*-*-* 04:00:00
|
||||||
|
Persistent=true
|
||||||
|
|
||||||
|
[Install]
|
||||||
|
WantedBy=timers.target
|
||||||
19
systemd/nexus-celery.service
Normal file
19
systemd/nexus-celery.service
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
[Unit]
|
||||||
|
Description=Nexus POS Celery Worker
|
||||||
|
After=network.target postgresql.service redis.service
|
||||||
|
|
||||||
|
[Service]
|
||||||
|
Type=simple
|
||||||
|
User=postgres
|
||||||
|
WorkingDirectory=/home/Autopartes/pos
|
||||||
|
Environment=MASTER_DB_URL=postgresql://postgres@/nexus_autoparts
|
||||||
|
Environment=REDIS_URL=redis://localhost:6379/0
|
||||||
|
Environment=PYTHONPATH=/home/Autopartes/pos
|
||||||
|
ExecStart=/usr/bin/python3 -m celery -A celery_app worker --loglevel=info --concurrency=4 -n nexus-worker@%h
|
||||||
|
Restart=on-failure
|
||||||
|
RestartSec=10
|
||||||
|
StandardOutput=append:/var/log/nexus-pos/celery.log
|
||||||
|
StandardError=append:/var/log/nexus-pos/celery.log
|
||||||
|
|
||||||
|
[Install]
|
||||||
|
WantedBy=multi-user.target
|
||||||
10
systemd/nexus-mv-refresh.service
Normal file
10
systemd/nexus-mv-refresh.service
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
[Unit]
|
||||||
|
Description=Refresh Nexus part_vehicle_preview materialized view
|
||||||
|
After=postgresql.service
|
||||||
|
|
||||||
|
[Service]
|
||||||
|
Type=oneshot
|
||||||
|
User=postgres
|
||||||
|
ExecStart=/usr/bin/python3 /home/Autopartes/scripts/refresh_part_vehicle_preview.py
|
||||||
|
StandardOutput=append:/var/log/nexus-pos/mv_refresh.log
|
||||||
|
StandardError=append:/var/log/nexus-pos/mv_refresh.log
|
||||||
9
systemd/nexus-mv-refresh.timer
Normal file
9
systemd/nexus-mv-refresh.timer
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
[Unit]
|
||||||
|
Description=Daily refresh of part_vehicle_preview materialized view at 03:00
|
||||||
|
|
||||||
|
[Timer]
|
||||||
|
OnCalendar=*-*-* 03:00:00
|
||||||
|
Persistent=true
|
||||||
|
|
||||||
|
[Install]
|
||||||
|
WantedBy=timers.target
|
||||||
23
systemd/nexus-pos.service
Normal file
23
systemd/nexus-pos.service
Normal file
@@ -0,0 +1,23 @@
|
|||||||
|
[Unit]
|
||||||
|
Description=Nexus POS (Gunicorn)
|
||||||
|
After=network.target postgresql.service redis-server.service
|
||||||
|
|
||||||
|
[Service]
|
||||||
|
Type=simple
|
||||||
|
User=root
|
||||||
|
WorkingDirectory=/home/Autopartes/pos
|
||||||
|
ExecStart=/usr/local/bin/gunicorn -c gunicorn.conf.py "app:create_app()"
|
||||||
|
Restart=always
|
||||||
|
RestartSec=5
|
||||||
|
Environment=PYTHONUNBUFFERED=1
|
||||||
|
Environment=PYTHONPATH=/home/Autopartes/pos
|
||||||
|
Environment=MASTER_DB_URL=postgresql://postgres@/nexus_autoparts
|
||||||
|
Environment=TENANT_DB_URL_TEMPLATE=postgresql://postgres@/nexus_autoparts
|
||||||
|
Environment=POS_JWT_SECRET=nexus-pos-jwt-secret-12345678901234567890123456789012
|
||||||
|
Environment=REDIS_URL=redis://localhost:6379/0
|
||||||
|
Environment=REDIS_ENABLED=true
|
||||||
|
Environment=MEILI_URL=http://localhost:7700
|
||||||
|
Environment=MEILI_ENABLED=true
|
||||||
|
|
||||||
|
[Install]
|
||||||
|
WantedBy=multi-user.target
|
||||||
16
systemd/nexus-quart.service
Normal file
16
systemd/nexus-quart.service
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
[Unit]
|
||||||
|
Description=Nexus Quart Async Catalog (hypercorn)
|
||||||
|
After=network.target postgresql.service
|
||||||
|
|
||||||
|
[Service]
|
||||||
|
Type=simple
|
||||||
|
User=root
|
||||||
|
WorkingDirectory=/home/Autopartes/pos
|
||||||
|
ExecStart=/usr/local/bin/hypercorn async_catalog:app --bind 0.0.0.0:5002
|
||||||
|
Restart=always
|
||||||
|
RestartSec=5
|
||||||
|
Environment=PYTHONUNBUFFERED=1
|
||||||
|
Environment=MASTER_DB_URL=postgresql://postgres@localhost/nexus_autoparts
|
||||||
|
|
||||||
|
[Install]
|
||||||
|
WantedBy=multi-user.target
|
||||||
17
systemd/nexus.service
Normal file
17
systemd/nexus.service
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
[Unit]
|
||||||
|
Description=Nexus Autoparts Dashboard
|
||||||
|
After=network.target postgresql.service
|
||||||
|
|
||||||
|
[Service]
|
||||||
|
Type=simple
|
||||||
|
User=root
|
||||||
|
WorkingDirectory=/home/Autopartes/dashboard
|
||||||
|
ExecStart=/usr/bin/python3 server.py
|
||||||
|
Restart=always
|
||||||
|
RestartSec=5
|
||||||
|
Environment=PYTHONUNBUFFERED=1
|
||||||
|
Environment=DATABASE_URL=postgresql://postgres@localhost/nexus_autoparts
|
||||||
|
Environment=JWT_SECRET=nexus-dashboard-jwt-secret-12345678901234567890123456789012
|
||||||
|
|
||||||
|
[Install]
|
||||||
|
WantedBy=multi-user.target
|
||||||
25
tests/e2e/login.spec.js
Normal file
25
tests/e2e/login.spec.js
Normal file
@@ -0,0 +1,25 @@
|
|||||||
|
const { test, expect } = require('@playwright/test');
|
||||||
|
|
||||||
|
test.describe('Nexus POS — Login', () => {
|
||||||
|
test('login page loads with form', async ({ page }) => {
|
||||||
|
await page.goto('/pos/login');
|
||||||
|
await expect(page).toHaveTitle(/Nexus|Login/i);
|
||||||
|
await expect(page.locator('input[name="username"], input[name="email"], #username, #email')).toBeVisible();
|
||||||
|
await expect(page.locator('input[type="password"], #password')).toBeVisible();
|
||||||
|
await expect(page.locator('button[type="submit"], .btn--primary')).toBeVisible();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('invalid credentials show error', async ({ page }) => {
|
||||||
|
await page.goto('/pos/login');
|
||||||
|
const userInput = page.locator('input[name="username"], input[name="email"], #username, #email').first();
|
||||||
|
const passInput = page.locator('input[type="password"], #password').first();
|
||||||
|
const submitBtn = page.locator('button[type="submit"], .btn--primary').first();
|
||||||
|
|
||||||
|
await userInput.fill('invalid_user');
|
||||||
|
await passInput.fill('wrong_password');
|
||||||
|
await submitBtn.click();
|
||||||
|
|
||||||
|
// Expect error toast or message
|
||||||
|
await expect(page.locator('.toast, .alert, .error, [role="alert"]')).toBeVisible({ timeout: 5000 });
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user