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:
238
sales-bot/static/js/app.js
Normal file
238
sales-bot/static/js/app.js
Normal 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;
|
||||
236
sales-bot/static/js/camera.js
Normal file
236
sales-bot/static/js/camera.js
Normal 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;
|
||||
266
sales-bot/static/js/charts.js
Normal file
266
sales-bot/static/js/charts.js
Normal 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
122
sales-bot/static/js/pwa.js
Normal 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;
|
||||
Reference in New Issue
Block a user