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:
591
sales-bot/static/css/main.css
Normal file
591
sales-bot/static/css/main.css
Normal 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
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;
|
||||
83
sales-bot/static/manifest.json
Normal file
83
sales-bot/static/manifest.json
Normal 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
|
||||
}
|
||||
217
sales-bot/static/service-worker.js
Normal file
217
sales-bot/static/service-worker.js
Normal 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');
|
||||
}
|
||||
})
|
||||
);
|
||||
});
|
||||
Reference in New Issue
Block a user