feat: Implementar PWA, Analytics, Reportes PDF y mejoras OCR

FASE 1 - PWA y Frontend:
- Crear templates/base.html, dashboard.html, analytics.html, executive.html
- Crear static/css/main.css con diseño responsivo
- Agregar static/js/app.js, pwa.js, camera.js, charts.js
- Implementar manifest.json y service-worker.js para PWA
- Soporte para captura de tickets desde cámara móvil

FASE 2 - Analytics:
- Crear módulo analytics/ con predictions.py, trends.py, comparisons.py
- Implementar predicción básica con promedio móvil + tendencia lineal
- Agregar endpoints /api/analytics/trends, predictions, comparisons
- Integrar Chart.js para gráficas interactivas

FASE 3 - Reportes PDF:
- Crear módulo reports/ con pdf_generator.py
- Implementar SalesReportPDF con generar_reporte_diario y ejecutivo
- Agregar comando /reporte [diario|semanal|ejecutivo]
- Agregar endpoints /api/reports/generate y /api/reports/download

FASE 4 - Mejoras OCR:
- Crear módulo ocr/ con processor.py, preprocessor.py, patterns.py
- Implementar AmountDetector con patrones múltiples de montos
- Agregar preprocesador adaptativo con pipelines para diferentes condiciones
- Soporte para corrección de rotación (deskew) y threshold Otsu

Dependencias agregadas:
- reportlab, matplotlib (PDF)
- scipy, pandas (analytics)
- imutils, deskew, cachetools (OCR)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
2026-01-19 03:26:16 +00:00
parent ed1658eb2b
commit 9936deaa90
25 changed files with 5501 additions and 282 deletions

View File

@@ -0,0 +1,591 @@
/* ==================== RESET & BASE ==================== */
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
:root {
--primary: #00d4ff;
--secondary: #00ff88;
--warning: #ffaa00;
--purple: #aa00ff;
--bg-dark: #1a1a2e;
--bg-darker: #16213e;
--bg-card: rgba(255, 255, 255, 0.05);
--border-color: rgba(255, 255, 255, 0.1);
--text-primary: #ffffff;
--text-secondary: #888888;
--text-muted: #666666;
}
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, sans-serif;
background: linear-gradient(135deg, var(--bg-dark) 0%, var(--bg-darker) 100%);
min-height: 100vh;
color: var(--text-primary);
}
/* ==================== NAVBAR ==================== */
.navbar {
display: flex;
justify-content: space-between;
align-items: center;
padding: 15px 20px;
background: rgba(0, 0, 0, 0.3);
border-bottom: 1px solid var(--border-color);
position: sticky;
top: 0;
z-index: 100;
backdrop-filter: blur(10px);
}
.nav-brand {
display: flex;
align-items: center;
gap: 10px;
}
.brand-icon {
width: 36px;
height: 36px;
background: linear-gradient(135deg, var(--primary), var(--secondary));
border-radius: 8px;
display: flex;
align-items: center;
justify-content: center;
font-weight: bold;
font-size: 18px;
}
.brand-text {
font-size: 20px;
font-weight: 600;
}
.nav-links {
display: flex;
gap: 5px;
}
.nav-link {
color: var(--text-secondary);
text-decoration: none;
padding: 8px 16px;
border-radius: 8px;
transition: all 0.2s;
font-size: 14px;
}
.nav-link:hover {
color: var(--text-primary);
background: var(--bg-card);
}
.nav-link.active {
color: var(--primary);
background: rgba(0, 212, 255, 0.1);
}
.nav-actions {
display: flex;
gap: 10px;
}
/* ==================== CONTAINER ==================== */
.container {
max-width: 1400px;
margin: 0 auto;
padding: 20px;
}
/* ==================== HEADER ==================== */
.page-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 30px;
padding-bottom: 20px;
border-bottom: 1px solid var(--border-color);
}
.page-header h1 {
font-size: 28px;
font-weight: 600;
}
.page-header h1 span {
color: var(--primary);
}
.fecha {
color: var(--text-secondary);
font-size: 14px;
}
/* ==================== BUTTONS ==================== */
.btn {
padding: 10px 20px;
border-radius: 8px;
font-size: 14px;
cursor: pointer;
transition: all 0.2s;
border: none;
font-weight: 500;
}
.btn-primary {
background: var(--primary);
color: #000;
}
.btn-primary:hover {
background: #33ddff;
transform: translateY(-2px);
}
.btn-secondary {
background: var(--bg-card);
border: 1px solid var(--primary);
color: var(--primary);
}
.btn-secondary:hover {
background: rgba(0, 212, 255, 0.2);
}
.refresh-btn {
background: rgba(0, 212, 255, 0.2);
border: 1px solid var(--primary);
color: var(--primary);
padding: 8px 16px;
border-radius: 8px;
cursor: pointer;
font-size: 14px;
transition: all 0.2s;
}
.refresh-btn:hover {
background: rgba(0, 212, 255, 0.3);
}
/* ==================== STATS GRID ==================== */
.stats-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
gap: 20px;
margin-bottom: 30px;
}
.stat-card {
background: var(--bg-card);
border-radius: 16px;
padding: 24px;
border: 1px solid var(--border-color);
transition: transform 0.2s, box-shadow 0.2s;
}
.stat-card:hover {
transform: translateY(-5px);
box-shadow: 0 10px 40px rgba(0, 212, 255, 0.1);
}
.stat-card .label {
color: var(--text-secondary);
font-size: 12px;
text-transform: uppercase;
letter-spacing: 1px;
margin-bottom: 8px;
}
.stat-card .value {
font-size: 32px;
font-weight: 700;
color: var(--primary);
}
.stat-card .subvalue {
font-size: 14px;
color: var(--text-muted);
margin-top: 4px;
}
.stat-card.green .value { color: var(--secondary); }
.stat-card.orange .value { color: var(--warning); }
.stat-card.purple .value { color: var(--purple); }
/* ==================== PANELS ==================== */
.main-grid {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 20px;
}
@media (max-width: 900px) {
.main-grid {
grid-template-columns: 1fr;
}
}
.panel {
background: var(--bg-card);
border-radius: 16px;
padding: 24px;
border: 1px solid var(--border-color);
}
.panel h2 {
font-size: 18px;
margin-bottom: 20px;
display: flex;
align-items: center;
gap: 10px;
}
.panel h2 .icon {
font-size: 24px;
}
/* ==================== RANKING ==================== */
.ranking-list {
list-style: none;
}
.ranking-item {
display: flex;
align-items: center;
padding: 12px 0;
border-bottom: 1px solid rgba(255, 255, 255, 0.05);
}
.ranking-item:last-child {
border-bottom: none;
}
.ranking-position {
width: 32px;
height: 32px;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
font-weight: 700;
font-size: 14px;
margin-right: 12px;
}
.ranking-position.gold { background: linear-gradient(135deg, #ffd700, #ffaa00); color: #000; }
.ranking-position.silver { background: linear-gradient(135deg, #c0c0c0, #888); color: #000; }
.ranking-position.bronze { background: linear-gradient(135deg, #cd7f32, #8b4513); color: #fff; }
.ranking-position.default { background: rgba(255, 255, 255, 0.1); color: var(--text-secondary); }
.ranking-info {
flex: 1;
}
.ranking-name {
font-weight: 600;
margin-bottom: 2px;
}
.ranking-stats {
font-size: 12px;
color: var(--text-secondary);
}
.ranking-value {
text-align: right;
}
.ranking-tubos {
font-size: 24px;
font-weight: 700;
color: var(--primary);
}
.ranking-comision {
font-size: 12px;
color: var(--secondary);
}
/* ==================== VENTAS RECIENTES ==================== */
.ventas-list {
max-height: 400px;
overflow-y: auto;
}
.venta-item {
display: flex;
justify-content: space-between;
align-items: center;
padding: 12px;
margin-bottom: 8px;
background: rgba(255, 255, 255, 0.03);
border-radius: 8px;
}
.venta-info .vendedor {
font-weight: 600;
color: var(--primary);
}
.venta-info .cliente {
font-size: 12px;
color: var(--text-secondary);
}
.venta-monto {
font-size: 18px;
font-weight: 700;
color: var(--secondary);
}
/* ==================== CHARTS ==================== */
.chart-container {
position: relative;
height: 300px;
margin: 20px 0;
}
.chart-container canvas {
max-height: 100%;
}
/* ==================== LOADING ==================== */
.loading {
text-align: center;
padding: 40px;
color: var(--text-secondary);
}
.loading-spinner {
display: inline-block;
width: 30px;
height: 30px;
border: 3px solid rgba(255, 255, 255, 0.1);
border-radius: 50%;
border-top-color: var(--primary);
animation: spin 1s ease-in-out infinite;
}
@keyframes spin {
to { transform: rotate(360deg); }
}
/* ==================== PROGRESS BAR ==================== */
.meta-progress {
height: 8px;
background: rgba(255, 255, 255, 0.1);
border-radius: 4px;
overflow: hidden;
margin-top: 8px;
}
.meta-progress-bar {
height: 100%;
background: linear-gradient(90deg, var(--primary), var(--secondary));
border-radius: 4px;
transition: width 0.5s;
}
/* ==================== KPI CARDS ==================== */
.kpi-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(150px, 1fr));
gap: 15px;
margin-bottom: 20px;
}
.kpi-card {
background: var(--bg-card);
border-radius: 12px;
padding: 16px;
text-align: center;
border: 1px solid var(--border-color);
}
.kpi-card .kpi-value {
font-size: 28px;
font-weight: 700;
color: var(--primary);
}
.kpi-card .kpi-label {
font-size: 11px;
color: var(--text-secondary);
text-transform: uppercase;
letter-spacing: 1px;
margin-top: 4px;
}
.kpi-card .kpi-trend {
font-size: 12px;
margin-top: 8px;
}
.kpi-card .kpi-trend.up { color: var(--secondary); }
.kpi-card .kpi-trend.down { color: #ff4444; }
.kpi-card .kpi-trend.stable { color: var(--text-secondary); }
/* ==================== TABLES ==================== */
.data-table {
width: 100%;
border-collapse: collapse;
margin-top: 15px;
}
.data-table th,
.data-table td {
padding: 12px;
text-align: left;
border-bottom: 1px solid var(--border-color);
}
.data-table th {
background: rgba(0, 0, 0, 0.2);
font-weight: 600;
font-size: 12px;
text-transform: uppercase;
letter-spacing: 1px;
color: var(--text-secondary);
}
.data-table tr:hover {
background: rgba(255, 255, 255, 0.02);
}
/* ==================== CAMERA MODAL ==================== */
.camera-modal {
display: none;
position: fixed;
top: 0;
left: 0;
width: 100%;
height: 100%;
background: rgba(0, 0, 0, 0.9);
z-index: 1000;
align-items: center;
justify-content: center;
}
.camera-modal.active {
display: flex;
}
.camera-container {
position: relative;
max-width: 90%;
max-height: 90%;
}
.camera-container video {
max-width: 100%;
max-height: 80vh;
border-radius: 12px;
}
.camera-controls {
display: flex;
justify-content: center;
gap: 20px;
margin-top: 20px;
}
.camera-btn {
width: 60px;
height: 60px;
border-radius: 50%;
border: none;
font-size: 24px;
cursor: pointer;
transition: transform 0.2s;
}
.camera-btn:hover {
transform: scale(1.1);
}
.camera-btn.capture {
background: var(--primary);
color: #000;
}
.camera-btn.close {
background: rgba(255, 255, 255, 0.2);
color: #fff;
}
/* ==================== MOBILE RESPONSIVE ==================== */
@media (max-width: 768px) {
.navbar {
flex-wrap: wrap;
gap: 10px;
}
.nav-links {
order: 3;
width: 100%;
justify-content: center;
}
.nav-link {
padding: 6px 12px;
font-size: 12px;
}
.stats-grid {
grid-template-columns: repeat(2, 1fr);
}
.stat-card .value {
font-size: 24px;
}
.page-header h1 {
font-size: 22px;
}
.panel {
padding: 16px;
}
}
@media (max-width: 480px) {
.stats-grid {
grid-template-columns: 1fr;
}
.container {
padding: 10px;
}
}
/* ==================== PWA INSTALL PROMPT ==================== */
.install-prompt {
display: none;
position: fixed;
bottom: 20px;
left: 50%;
transform: translateX(-50%);
background: var(--bg-darker);
border: 1px solid var(--primary);
border-radius: 12px;
padding: 16px 24px;
z-index: 1000;
box-shadow: 0 10px 40px rgba(0, 0, 0, 0.5);
}
.install-prompt.show {
display: flex;
align-items: center;
gap: 15px;
}
.install-prompt p {
margin: 0;
font-size: 14px;
}
.install-prompt .btn {
white-space: nowrap;
}

238
sales-bot/static/js/app.js Normal file
View File

@@ -0,0 +1,238 @@
/**
* Sales Bot - Main Application JavaScript
*/
// Utility functions
const Utils = {
formatMoney(amount) {
return new Intl.NumberFormat('es-MX', { style: 'currency', currency: 'MXN' }).format(amount || 0);
},
formatDate(dateStr) {
if (!dateStr) return '';
const date = new Date(dateStr);
return date.toLocaleDateString('es-MX', {
year: 'numeric',
month: 'short',
day: 'numeric'
});
},
formatTime(dateStr) {
if (!dateStr) return '';
const date = new Date(dateStr);
return date.toLocaleTimeString('es-MX', { hour: '2-digit', minute: '2-digit' });
},
formatDateTime(dateStr) {
if (!dateStr) return '';
return `${this.formatDate(dateStr)} ${this.formatTime(dateStr)}`;
},
debounce(func, wait) {
let timeout;
return function executedFunction(...args) {
const later = () => {
clearTimeout(timeout);
func(...args);
};
clearTimeout(timeout);
timeout = setTimeout(later, wait);
};
},
showNotification(message, type = 'info') {
// Create notification element
const notification = document.createElement('div');
notification.className = `notification notification-${type}`;
notification.textContent = message;
notification.style.cssText = `
position: fixed;
top: 20px;
right: 20px;
padding: 15px 25px;
border-radius: 8px;
color: white;
font-weight: 500;
z-index: 9999;
animation: slideIn 0.3s ease;
background: ${type === 'success' ? '#00ff88' : type === 'error' ? '#ff4444' : '#00d4ff'};
color: ${type === 'success' || type === 'info' ? '#000' : '#fff'};
`;
document.body.appendChild(notification);
setTimeout(() => {
notification.style.animation = 'slideOut 0.3s ease';
setTimeout(() => notification.remove(), 300);
}, 3000);
}
};
// API Client
const API = {
async get(endpoint) {
try {
const response = await fetch(endpoint);
if (!response.ok) throw new Error(`HTTP ${response.status}`);
return await response.json();
} catch (error) {
console.error(`API GET ${endpoint}:`, error);
throw error;
}
},
async post(endpoint, data) {
try {
const response = await fetch(endpoint, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(data)
});
if (!response.ok) throw new Error(`HTTP ${response.status}`);
return await response.json();
} catch (error) {
console.error(`API POST ${endpoint}:`, error);
throw error;
}
}
};
// Dashboard module
const Dashboard = {
async loadSummary() {
try {
const data = await API.get('/api/dashboard/resumen');
return data;
} catch (error) {
console.error('Error loading summary:', error);
return null;
}
},
async loadRanking() {
try {
const data = await API.get('/api/dashboard/ranking');
return data;
} catch (error) {
console.error('Error loading ranking:', error);
return [];
}
},
async loadRecentSales() {
try {
const data = await API.get('/api/dashboard/ventas-recientes');
return data;
} catch (error) {
console.error('Error loading recent sales:', error);
return [];
}
}
};
// Analytics module
const Analytics = {
async loadTrends(days = 30) {
try {
const data = await API.get(`/api/analytics/trends?days=${days}`);
return data;
} catch (error) {
console.error('Error loading trends:', error);
return null;
}
},
async loadPredictions(period = 30) {
try {
const data = await API.get(`/api/analytics/predictions?period=${period}`);
return data;
} catch (error) {
console.error('Error loading predictions:', error);
return null;
}
},
async loadComparisons(type = 'monthly') {
try {
const data = await API.get(`/api/analytics/comparisons?type=${type}`);
return data;
} catch (error) {
console.error('Error loading comparisons:', error);
return null;
}
}
};
// Offline support
const OfflineManager = {
isOnline: navigator.onLine,
init() {
window.addEventListener('online', () => {
this.isOnline = true;
Utils.showNotification('Conexion restaurada', 'success');
this.syncData();
});
window.addEventListener('offline', () => {
this.isOnline = false;
Utils.showNotification('Sin conexion - Modo offline', 'error');
});
},
async cacheData(key, data) {
try {
localStorage.setItem(`salesbot_${key}`, JSON.stringify({
data,
timestamp: Date.now()
}));
} catch (e) {
console.error('Error caching data:', e);
}
},
getCachedData(key, maxAge = 300000) { // 5 minutes default
try {
const cached = localStorage.getItem(`salesbot_${key}`);
if (!cached) return null;
const { data, timestamp } = JSON.parse(cached);
if (Date.now() - timestamp > maxAge) return null;
return data;
} catch (e) {
return null;
}
},
syncData() {
// Sync any pending data when back online
console.log('Syncing data...');
}
};
// Initialize
document.addEventListener('DOMContentLoaded', () => {
OfflineManager.init();
});
// Add CSS animations
const style = document.createElement('style');
style.textContent = `
@keyframes slideIn {
from { transform: translateX(100%); opacity: 0; }
to { transform: translateX(0); opacity: 1; }
}
@keyframes slideOut {
from { transform: translateX(0); opacity: 1; }
to { transform: translateX(100%); opacity: 0; }
}
`;
document.head.appendChild(style);
// Export for use in templates
window.Utils = Utils;
window.API = API;
window.Dashboard = Dashboard;
window.Analytics = Analytics;

View File

@@ -0,0 +1,236 @@
/**
* Sales Bot - Camera Capture for Ticket Processing
*/
let cameraStream = null;
async function abrirCamara() {
const modal = document.getElementById('camera-modal');
const video = document.getElementById('camera-video');
if (!modal || !video) {
console.error('Camera elements not found');
return;
}
try {
// Request camera access
cameraStream = await navigator.mediaDevices.getUserMedia({
video: {
facingMode: 'environment', // Use back camera on mobile
width: { ideal: 1920 },
height: { ideal: 1080 }
},
audio: false
});
video.srcObject = cameraStream;
modal.classList.add('active');
} catch (error) {
console.error('Error accessing camera:', error);
if (error.name === 'NotAllowedError') {
alert('Permiso de camara denegado. Por favor, permite el acceso a la camara en la configuracion del navegador.');
} else if (error.name === 'NotFoundError') {
alert('No se encontro una camara en este dispositivo.');
} else {
alert('Error al acceder a la camara: ' + error.message);
}
}
}
function cerrarCamara() {
const modal = document.getElementById('camera-modal');
const video = document.getElementById('camera-video');
if (cameraStream) {
cameraStream.getTracks().forEach(track => track.stop());
cameraStream = null;
}
if (video) {
video.srcObject = null;
}
if (modal) {
modal.classList.remove('active');
}
}
async function capturarFoto() {
const video = document.getElementById('camera-video');
const canvas = document.getElementById('camera-canvas');
if (!video || !canvas) {
console.error('Video or canvas not found');
return;
}
// Set canvas size to video size
canvas.width = video.videoWidth;
canvas.height = video.videoHeight;
// Draw video frame to canvas
const ctx = canvas.getContext('2d');
ctx.drawImage(video, 0, 0);
// Get base64 image
const imageData = canvas.toDataURL('image/jpeg', 0.9);
// Close camera
cerrarCamara();
// Show loading
if (window.Utils) {
window.Utils.showNotification('Procesando imagen...', 'info');
}
try {
// Send to server for OCR processing
const response = await fetch('/api/capture/ticket', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
image: imageData
})
});
const result = await response.json();
if (result.success) {
if (window.Utils) {
window.Utils.showNotification('Ticket procesado correctamente', 'success');
}
// Show detected data
mostrarResultadoOCR(result);
} else {
if (window.Utils) {
window.Utils.showNotification('Error procesando ticket: ' + (result.error || 'Error desconocido'), 'error');
}
}
} catch (error) {
console.error('Error sending image:', error);
if (window.Utils) {
window.Utils.showNotification('Error enviando imagen al servidor', 'error');
}
}
}
function mostrarResultadoOCR(result) {
// Create modal to show OCR results
const modal = document.createElement('div');
modal.className = 'camera-modal active';
modal.style.cssText = 'display: flex; align-items: center; justify-content: center;';
const monto = result.monto ? window.Utils.formatMoney(result.monto) : 'No detectado';
const productos = result.productos || [];
const tubos = productos.filter(p =>
p.nombre && p.nombre.toLowerCase().includes('tinte')
).length;
modal.innerHTML = `
<div style="background: #1a1a2e; padding: 30px; border-radius: 16px; max-width: 400px; width: 90%;">
<h2 style="margin-bottom: 20px; color: #00d4ff;">Resultado del Ticket</h2>
<div style="margin-bottom: 20px;">
<label style="color: #888; font-size: 12px; text-transform: uppercase;">Monto Detectado</label>
<div style="font-size: 32px; font-weight: bold; color: #00ff88;">${monto}</div>
</div>
<div style="margin-bottom: 20px;">
<label style="color: #888; font-size: 12px; text-transform: uppercase;">Tubos de Tinte</label>
<div style="font-size: 24px; font-weight: bold; color: #00d4ff;">${tubos}</div>
</div>
${productos.length > 0 ? `
<div style="margin-bottom: 20px;">
<label style="color: #888; font-size: 12px; text-transform: uppercase;">Productos (${productos.length})</label>
<ul style="list-style: none; margin-top: 10px; max-height: 150px; overflow-y: auto;">
${productos.slice(0, 5).map(p => `
<li style="padding: 8px 0; border-bottom: 1px solid rgba(255,255,255,0.1);">
${p.nombre || 'Producto'} - ${window.Utils.formatMoney(p.importe || 0)}
</li>
`).join('')}
</ul>
</div>
` : ''}
<div style="display: flex; gap: 10px; margin-top: 20px;">
<button class="btn btn-secondary" onclick="this.closest('.camera-modal').remove()" style="flex: 1;">
Cerrar
</button>
<button class="btn btn-primary" onclick="confirmarVenta(${result.monto || 0}, ${tubos}); this.closest('.camera-modal').remove();" style="flex: 1;">
Registrar Venta
</button>
</div>
</div>
`;
document.body.appendChild(modal);
}
async function confirmarVenta(monto, tubos) {
// This would integrate with the main sales flow
// For now, just show a notification
if (window.Utils) {
window.Utils.showNotification(`Venta de ${window.Utils.formatMoney(monto)} lista para confirmar`, 'info');
}
// Here you could redirect to Mattermost or show a form
// to complete the sale registration
}
// File input fallback for devices without camera API
function createFileInput() {
const input = document.createElement('input');
input.type = 'file';
input.accept = 'image/*';
input.capture = 'environment';
input.style.display = 'none';
input.addEventListener('change', async (e) => {
const file = e.target.files[0];
if (!file) return;
const reader = new FileReader();
reader.onload = async (event) => {
const imageData = event.target.result;
if (window.Utils) {
window.Utils.showNotification('Procesando imagen...', 'info');
}
try {
const response = await fetch('/api/capture/ticket', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ image: imageData })
});
const result = await response.json();
if (result.success) {
mostrarResultadoOCR(result);
} else {
if (window.Utils) {
window.Utils.showNotification('Error procesando imagen', 'error');
}
}
} catch (error) {
console.error('Error:', error);
}
};
reader.readAsDataURL(file);
});
return input;
}
// Export functions
window.abrirCamara = abrirCamara;
window.cerrarCamara = cerrarCamara;
window.capturarFoto = capturarFoto;

View File

@@ -0,0 +1,266 @@
/**
* Sales Bot - Chart.js Integration and Chart Utilities
*/
// Chart default configuration
Chart.defaults.color = '#888';
Chart.defaults.borderColor = 'rgba(255, 255, 255, 0.1)';
Chart.defaults.font.family = "-apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif";
// Color palette
const ChartColors = {
primary: '#00d4ff',
secondary: '#00ff88',
warning: '#ffaa00',
danger: '#ff4444',
purple: '#aa00ff',
gradient: (ctx, color1, color2) => {
const gradient = ctx.createLinearGradient(0, 0, 0, 300);
gradient.addColorStop(0, color1);
gradient.addColorStop(1, color2);
return gradient;
}
};
// Chart factory
const ChartFactory = {
// Line chart for trends
createTrendChart(canvasId, data, options = {}) {
const ctx = document.getElementById(canvasId);
if (!ctx) return null;
return new Chart(ctx.getContext('2d'), {
type: 'line',
data: {
labels: data.labels || [],
datasets: [{
label: data.label || 'Datos',
data: data.values || [],
borderColor: options.color || ChartColors.primary,
backgroundColor: options.fill ?
ChartColors.gradient(ctx.getContext('2d'), 'rgba(0, 212, 255, 0.3)', 'rgba(0, 212, 255, 0)') :
'transparent',
fill: options.fill !== false,
tension: 0.4,
pointRadius: options.points ? 4 : 0,
pointHoverRadius: 6
}]
},
options: {
responsive: true,
maintainAspectRatio: false,
interaction: {
intersect: false,
mode: 'index'
},
plugins: {
legend: {
display: options.legend !== false,
labels: { color: '#888' }
},
tooltip: {
backgroundColor: 'rgba(26, 26, 46, 0.9)',
titleColor: '#fff',
bodyColor: '#888',
borderColor: ChartColors.primary,
borderWidth: 1,
padding: 12,
displayColors: false,
callbacks: {
label: (ctx) => options.formatValue ?
options.formatValue(ctx.parsed.y) :
ctx.parsed.y
}
}
},
scales: {
x: {
grid: { color: 'rgba(255,255,255,0.05)' },
ticks: { color: '#888', maxRotation: 45 }
},
y: {
grid: { color: 'rgba(255,255,255,0.05)' },
ticks: {
color: '#888',
callback: options.formatYAxis || ((value) => value)
},
beginAtZero: options.beginAtZero !== false
}
}
}
});
},
// Bar chart for comparisons
createBarChart(canvasId, data, options = {}) {
const ctx = document.getElementById(canvasId);
if (!ctx) return null;
return new Chart(ctx.getContext('2d'), {
type: options.horizontal ? 'bar' : 'bar',
data: {
labels: data.labels || [],
datasets: [{
label: data.label || 'Datos',
data: data.values || [],
backgroundColor: data.colors || [
ChartColors.primary,
ChartColors.secondary,
ChartColors.warning,
ChartColors.purple
],
borderRadius: 8,
barThickness: options.barThickness || 'flex'
}]
},
options: {
responsive: true,
maintainAspectRatio: false,
indexAxis: options.horizontal ? 'y' : 'x',
plugins: {
legend: { display: false },
tooltip: {
backgroundColor: 'rgba(26, 26, 46, 0.9)',
titleColor: '#fff',
bodyColor: '#888',
borderColor: ChartColors.primary,
borderWidth: 1,
padding: 12,
callbacks: {
label: (ctx) => options.formatValue ?
options.formatValue(ctx.parsed[options.horizontal ? 'x' : 'y']) :
ctx.parsed[options.horizontal ? 'x' : 'y']
}
}
},
scales: {
x: {
grid: { display: !options.horizontal },
ticks: { color: '#888' }
},
y: {
grid: { color: options.horizontal ? 'transparent' : 'rgba(255,255,255,0.05)' },
ticks: { color: '#888' }
}
}
}
});
},
// Doughnut chart for distribution
createDoughnutChart(canvasId, data, options = {}) {
const ctx = document.getElementById(canvasId);
if (!ctx) return null;
return new Chart(ctx.getContext('2d'), {
type: 'doughnut',
data: {
labels: data.labels || [],
datasets: [{
data: data.values || [],
backgroundColor: data.colors || [
ChartColors.primary,
ChartColors.secondary,
ChartColors.warning,
ChartColors.purple,
ChartColors.danger
],
borderWidth: 0,
cutout: options.cutout || '70%'
}]
},
options: {
responsive: true,
maintainAspectRatio: false,
plugins: {
legend: {
display: options.legend !== false,
position: options.legendPosition || 'bottom',
labels: { color: '#888', padding: 15 }
},
tooltip: {
backgroundColor: 'rgba(26, 26, 46, 0.9)',
titleColor: '#fff',
bodyColor: '#888',
borderColor: ChartColors.primary,
borderWidth: 1,
padding: 12
}
}
}
});
},
// Multi-line chart for comparisons
createMultiLineChart(canvasId, datasets, labels, options = {}) {
const ctx = document.getElementById(canvasId);
if (!ctx) return null;
const colors = [ChartColors.primary, ChartColors.secondary, ChartColors.warning, ChartColors.purple];
return new Chart(ctx.getContext('2d'), {
type: 'line',
data: {
labels: labels,
datasets: datasets.map((ds, i) => ({
label: ds.label,
data: ds.values,
borderColor: ds.color || colors[i % colors.length],
backgroundColor: 'transparent',
borderDash: ds.dashed ? [5, 5] : [],
tension: 0.4,
pointRadius: 0,
pointHoverRadius: 6
}))
},
options: {
responsive: true,
maintainAspectRatio: false,
interaction: {
intersect: false,
mode: 'index'
},
plugins: {
legend: {
display: true,
labels: { color: '#888' }
},
tooltip: {
backgroundColor: 'rgba(26, 26, 46, 0.9)',
titleColor: '#fff',
bodyColor: '#888',
borderColor: ChartColors.primary,
borderWidth: 1,
padding: 12
}
},
scales: {
x: {
grid: { color: 'rgba(255,255,255,0.05)' },
ticks: { color: '#888' }
},
y: {
grid: { color: 'rgba(255,255,255,0.05)' },
ticks: { color: '#888' },
beginAtZero: true
}
}
}
});
}
};
// Helper to format currency in charts
function formatCurrency(value) {
return new Intl.NumberFormat('es-MX', {
style: 'currency',
currency: 'MXN',
minimumFractionDigits: 0,
maximumFractionDigits: 0
}).format(value);
}
// Export
window.ChartFactory = ChartFactory;
window.ChartColors = ChartColors;
window.formatCurrency = formatCurrency;

122
sales-bot/static/js/pwa.js Normal file
View File

@@ -0,0 +1,122 @@
/**
* Sales Bot - PWA Registration and Install Prompt
*/
// Register Service Worker
if ('serviceWorker' in navigator) {
window.addEventListener('load', async () => {
try {
const registration = await navigator.serviceWorker.register('/service-worker.js');
console.log('Service Worker registered:', registration.scope);
// Check for updates
registration.addEventListener('updatefound', () => {
const newWorker = registration.installing;
newWorker.addEventListener('statechange', () => {
if (newWorker.state === 'installed' && navigator.serviceWorker.controller) {
// New version available
showUpdatePrompt();
}
});
});
} catch (error) {
console.error('Service Worker registration failed:', error);
}
});
}
// Install prompt handling
let deferredPrompt;
window.addEventListener('beforeinstallprompt', (e) => {
e.preventDefault();
deferredPrompt = e;
showInstallPrompt();
});
function showInstallPrompt() {
// Create install prompt UI
const prompt = document.createElement('div');
prompt.id = 'install-prompt';
prompt.className = 'install-prompt show';
prompt.innerHTML = `
<span>Instalar Sales Bot en tu dispositivo</span>
<button class="btn btn-primary" onclick="installPWA()">Instalar</button>
<button class="btn btn-secondary" onclick="dismissInstallPrompt()">Ahora no</button>
`;
document.body.appendChild(prompt);
}
async function installPWA() {
if (!deferredPrompt) return;
deferredPrompt.prompt();
const { outcome } = await deferredPrompt.userChoice;
console.log('Install prompt outcome:', outcome);
deferredPrompt = null;
dismissInstallPrompt();
if (outcome === 'accepted') {
if (window.Utils) {
window.Utils.showNotification('App instalada correctamente', 'success');
}
}
}
function dismissInstallPrompt() {
const prompt = document.getElementById('install-prompt');
if (prompt) {
prompt.remove();
}
}
function showUpdatePrompt() {
const updateBanner = document.createElement('div');
updateBanner.id = 'update-banner';
updateBanner.style.cssText = `
position: fixed;
bottom: 0;
left: 0;
right: 0;
background: #00d4ff;
color: #000;
padding: 15px;
text-align: center;
z-index: 9999;
`;
updateBanner.innerHTML = `
<span>Nueva version disponible</span>
<button onclick="updateApp()" style="margin-left: 15px; padding: 5px 15px; border: none; border-radius: 4px; cursor: pointer;">
Actualizar
</button>
`;
document.body.appendChild(updateBanner);
}
function updateApp() {
if ('serviceWorker' in navigator) {
navigator.serviceWorker.getRegistration().then(registration => {
if (registration && registration.waiting) {
registration.waiting.postMessage({ type: 'SKIP_WAITING' });
}
});
}
window.location.reload();
}
// Detect if running as PWA
function isPWA() {
return window.matchMedia('(display-mode: standalone)').matches ||
window.navigator.standalone === true;
}
// Add PWA class to body if running as installed app
if (isPWA()) {
document.body.classList.add('pwa-mode');
}
// Export
window.installPWA = installPWA;
window.dismissInstallPrompt = dismissInstallPrompt;
window.isPWA = isPWA;

View File

@@ -0,0 +1,83 @@
{
"name": "Sales Bot - Sistema de Ventas",
"short_name": "Sales Bot",
"description": "Sistema de seguimiento y automatizacion de ventas",
"start_url": "/dashboard",
"display": "standalone",
"orientation": "portrait-primary",
"background_color": "#1a1a2e",
"theme_color": "#00d4ff",
"scope": "/",
"lang": "es-MX",
"categories": ["business", "productivity"],
"icons": [
{
"src": "/static/icons/icon-72x72.png",
"sizes": "72x72",
"type": "image/png",
"purpose": "any maskable"
},
{
"src": "/static/icons/icon-96x96.png",
"sizes": "96x96",
"type": "image/png",
"purpose": "any maskable"
},
{
"src": "/static/icons/icon-128x128.png",
"sizes": "128x128",
"type": "image/png",
"purpose": "any maskable"
},
{
"src": "/static/icons/icon-144x144.png",
"sizes": "144x144",
"type": "image/png",
"purpose": "any maskable"
},
{
"src": "/static/icons/icon-152x152.png",
"sizes": "152x152",
"type": "image/png",
"purpose": "any maskable"
},
{
"src": "/static/icons/icon-192x192.png",
"sizes": "192x192",
"type": "image/png",
"purpose": "any maskable"
},
{
"src": "/static/icons/icon-384x384.png",
"sizes": "384x384",
"type": "image/png",
"purpose": "any maskable"
},
{
"src": "/static/icons/icon-512x512.png",
"sizes": "512x512",
"type": "image/png",
"purpose": "any maskable"
}
],
"screenshots": [],
"shortcuts": [
{
"name": "Dashboard",
"url": "/dashboard",
"description": "Ver dashboard principal"
},
{
"name": "Analytics",
"url": "/dashboard/analytics",
"description": "Ver analytics y graficas"
},
{
"name": "Ejecutivo",
"url": "/dashboard/executive",
"description": "Ver dashboard ejecutivo"
}
],
"related_applications": [],
"prefer_related_applications": false
}

View File

@@ -0,0 +1,217 @@
/**
* Sales Bot - Service Worker for PWA Offline Support
*/
const CACHE_NAME = 'salesbot-v1';
const RUNTIME_CACHE = 'salesbot-runtime-v1';
// Assets to cache on install
const PRECACHE_ASSETS = [
'/dashboard',
'/dashboard/analytics',
'/dashboard/executive',
'/static/css/main.css',
'/static/js/app.js',
'/static/js/pwa.js',
'/static/js/camera.js',
'/static/js/charts.js',
'/static/manifest.json'
];
// API endpoints to cache with network-first strategy
const API_ROUTES = [
'/api/dashboard/resumen',
'/api/dashboard/ranking',
'/api/dashboard/ventas-recientes',
'/api/analytics/trends',
'/api/analytics/predictions'
];
// Install event - cache core assets
self.addEventListener('install', (event) => {
event.waitUntil(
caches.open(CACHE_NAME)
.then(cache => {
console.log('[SW] Precaching assets');
return cache.addAll(PRECACHE_ASSETS);
})
.then(() => self.skipWaiting())
.catch(err => console.error('[SW] Precache failed:', err))
);
});
// Activate event - clean old caches
self.addEventListener('activate', (event) => {
event.waitUntil(
caches.keys()
.then(cacheNames => {
return Promise.all(
cacheNames
.filter(name => name !== CACHE_NAME && name !== RUNTIME_CACHE)
.map(name => {
console.log('[SW] Deleting old cache:', name);
return caches.delete(name);
})
);
})
.then(() => self.clients.claim())
);
});
// Fetch event - handle requests
self.addEventListener('fetch', (event) => {
const { request } = event;
const url = new URL(request.url);
// Skip non-GET requests
if (request.method !== 'GET') {
return;
}
// Skip external requests
if (url.origin !== location.origin) {
return;
}
// API requests - Network first, fall back to cache
if (url.pathname.startsWith('/api/')) {
event.respondWith(networkFirst(request));
return;
}
// Static assets - Cache first, fall back to network
if (url.pathname.startsWith('/static/')) {
event.respondWith(cacheFirst(request));
return;
}
// HTML pages - Network first with cache fallback
if (request.headers.get('Accept')?.includes('text/html')) {
event.respondWith(networkFirst(request));
return;
}
// Default - Network first
event.respondWith(networkFirst(request));
});
// Cache first strategy
async function cacheFirst(request) {
const cachedResponse = await caches.match(request);
if (cachedResponse) {
return cachedResponse;
}
try {
const networkResponse = await fetch(request);
if (networkResponse.ok) {
const cache = await caches.open(CACHE_NAME);
cache.put(request, networkResponse.clone());
}
return networkResponse;
} catch (error) {
console.error('[SW] Cache first failed:', error);
return new Response('Offline', { status: 503 });
}
}
// Network first strategy
async function networkFirst(request) {
try {
const networkResponse = await fetch(request);
// Cache successful responses
if (networkResponse.ok) {
const cache = await caches.open(RUNTIME_CACHE);
cache.put(request, networkResponse.clone());
}
return networkResponse;
} catch (error) {
// Network failed, try cache
const cachedResponse = await caches.match(request);
if (cachedResponse) {
console.log('[SW] Serving from cache:', request.url);
return cachedResponse;
}
// No cache available
if (request.headers.get('Accept')?.includes('text/html')) {
return caches.match('/dashboard'); // Fallback to main page
}
if (request.headers.get('Accept')?.includes('application/json')) {
return new Response(JSON.stringify({
error: 'offline',
message: 'Sin conexion. Mostrando datos en cache.'
}), {
status: 503,
headers: { 'Content-Type': 'application/json' }
});
}
return new Response('Offline', { status: 503 });
}
}
// Handle messages from clients
self.addEventListener('message', (event) => {
if (event.data && event.data.type === 'SKIP_WAITING') {
self.skipWaiting();
}
});
// Background sync for offline actions (if supported)
self.addEventListener('sync', (event) => {
if (event.tag === 'sync-sales') {
event.waitUntil(syncSales());
}
});
async function syncSales() {
// Sync any pending sales when back online
console.log('[SW] Syncing pending sales...');
// Implementation would go here
}
// Push notifications (if implemented)
self.addEventListener('push', (event) => {
if (!event.data) return;
const data = event.data.json();
const options = {
body: data.body || 'Nueva notificacion de Sales Bot',
icon: '/static/icons/icon-192x192.png',
badge: '/static/icons/icon-72x72.png',
vibrate: [100, 50, 100],
data: {
url: data.url || '/dashboard'
}
};
event.waitUntil(
self.registration.showNotification(data.title || 'Sales Bot', options)
);
});
// Handle notification click
self.addEventListener('notificationclick', (event) => {
event.notification.close();
event.waitUntil(
clients.matchAll({ type: 'window' }).then(clientList => {
// Focus existing window if available
for (const client of clientList) {
if (client.url.includes('/dashboard') && 'focus' in client) {
return client.focus();
}
}
// Open new window
if (clients.openWindow) {
return clients.openWindow(event.notification.data?.url || '/dashboard');
}
})
);
});