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:
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;
|
||||
Reference in New Issue
Block a user