feat(dashboard): add real-time in-app stats with Chart.js
- dashboard_stats_bp.py: endpoints /pos/api/dashboard/stats and /pos/api/dashboard/stats/employees (sales today/month, hourly, top products, employee productivity) - dashboard-stats.js: renders hourly sales bar chart and top products doughnut chart using Chart.js - chart.umd.min.js: vendored Chart.js v4.4.2
This commit is contained in:
107
pos/blueprints/dashboard_stats_bp.py
Normal file
107
pos/blueprints/dashboard_stats_bp.py
Normal file
@@ -0,0 +1,107 @@
|
||||
"""Dashboard Stats Blueprint — In-app real-time analytics.
|
||||
|
||||
Endpoints for sales, productivity, and top products charts.
|
||||
"""
|
||||
from flask import Blueprint, request, jsonify, g
|
||||
from functools import wraps
|
||||
from datetime import datetime, timedelta
|
||||
from decimal import Decimal
|
||||
import json
|
||||
|
||||
dashboard_stats_bp = Blueprint('dashboard_stats', __name__, url_prefix='/pos/api/dashboard')
|
||||
|
||||
|
||||
from middleware import require_auth
|
||||
|
||||
|
||||
class DecimalEncoder(json.JSONEncoder):
|
||||
def default(self, o):
|
||||
if isinstance(o, Decimal):
|
||||
return float(o)
|
||||
return super().default(o)
|
||||
|
||||
|
||||
@dashboard_stats_bp.route('/stats', methods=['GET'])
|
||||
@require_auth()
|
||||
def get_stats():
|
||||
"""Summary stats for today and this month."""
|
||||
from tenant_db import get_tenant_db
|
||||
db = get_tenant_db()
|
||||
today = datetime.utcnow().date()
|
||||
month_start = today.replace(day=1)
|
||||
|
||||
# Sales today
|
||||
today_sales = db.execute(
|
||||
"""SELECT COUNT(*) as count, COALESCE(SUM(total), 0) as total
|
||||
FROM sales WHERE DATE(created_at) = %s""", (today,)
|
||||
).fetchone()
|
||||
|
||||
# Sales this month
|
||||
month_sales = db.execute(
|
||||
"""SELECT COUNT(*) as count, COALESCE(SUM(total), 0) as total
|
||||
FROM sales WHERE DATE(created_at) >= %s""", (month_start,)
|
||||
).fetchone()
|
||||
|
||||
# Top 5 products today
|
||||
top_products = db.execute(
|
||||
"""SELECT p.name, SUM(si.quantity) as qty, SUM(si.total) as revenue
|
||||
FROM sale_items si
|
||||
JOIN sales s ON si.sale_id = s.id_sale
|
||||
JOIN parts p ON si.part_id = p.id_part
|
||||
WHERE DATE(s.created_at) = %s
|
||||
GROUP BY p.name
|
||||
ORDER BY revenue DESC
|
||||
LIMIT 5""", (today,)
|
||||
).fetchall()
|
||||
|
||||
# Hourly sales today (0-23)
|
||||
hourly = db.execute(
|
||||
"""SELECT EXTRACT(HOUR FROM created_at)::int as hour,
|
||||
COUNT(*) as count, COALESCE(SUM(total), 0) as total
|
||||
FROM sales WHERE DATE(created_at) = %s
|
||||
GROUP BY hour ORDER BY hour""", (today,)
|
||||
).fetchall()
|
||||
hourly_map = {row['hour']: {'count': row['count'], 'total': row['total']} for row in hourly}
|
||||
|
||||
return jsonify({
|
||||
'today': {
|
||||
'sales_count': today_sales['count'],
|
||||
'sales_total': today_sales['total'],
|
||||
},
|
||||
'month': {
|
||||
'sales_count': month_sales['count'],
|
||||
'sales_total': month_sales['total'],
|
||||
},
|
||||
'top_products': [
|
||||
{'name': row['name'], 'quantity': row['qty'], 'revenue': row['revenue']}
|
||||
for row in top_products
|
||||
],
|
||||
'hourly_sales': [
|
||||
{'hour': h, 'count': hourly_map.get(h, {}).get('count', 0),
|
||||
'total': hourly_map.get(h, {}).get('total', 0)}
|
||||
for h in range(24)
|
||||
],
|
||||
}, cls=DecimalEncoder)
|
||||
|
||||
|
||||
@dashboard_stats_bp.route('/stats/employees', methods=['GET'])
|
||||
@require_auth()
|
||||
def get_employee_stats():
|
||||
"""Sales per employee today."""
|
||||
from tenant_db import get_tenant_db
|
||||
db = get_tenant_db()
|
||||
today = datetime.utcnow().date()
|
||||
rows = db.execute(
|
||||
"""SELECT e.name, COUNT(s.id_sale) as sales, COALESCE(SUM(s.total), 0) as total
|
||||
FROM sales s
|
||||
JOIN employees e ON s.employee_id = e.id_employee
|
||||
WHERE DATE(s.created_at) = %s
|
||||
GROUP BY e.name
|
||||
ORDER BY total DESC""", (today,)
|
||||
).fetchall()
|
||||
return jsonify({
|
||||
'employees': [
|
||||
{'name': row['name'], 'sales': row['sales'], 'total': row['total']}
|
||||
for row in rows
|
||||
]
|
||||
}, cls=DecimalEncoder)
|
||||
20
pos/static/js/chart.umd.min.js
vendored
Normal file
20
pos/static/js/chart.umd.min.js
vendored
Normal file
File diff suppressed because one or more lines are too long
99
pos/static/js/dashboard-stats.js
Normal file
99
pos/static/js/dashboard-stats.js
Normal file
@@ -0,0 +1,99 @@
|
||||
/**
|
||||
* dashboard-stats.js — In-app real-time charts using Chart.js
|
||||
* Fetches /pos/api/dashboard/stats and renders hourly + top-products charts.
|
||||
*/
|
||||
(function () {
|
||||
'use strict';
|
||||
|
||||
const token = localStorage.getItem('pos_token') || '';
|
||||
if (!token) return;
|
||||
|
||||
function headers() {
|
||||
return { 'Authorization': 'Bearer ' + token, 'Content-Type': 'application/json' };
|
||||
}
|
||||
|
||||
function fmt(n) {
|
||||
return '$' + parseFloat(n || 0).toLocaleString('es-MX', { minimumFractionDigits: 2, maximumFractionDigits: 2 });
|
||||
}
|
||||
|
||||
async function loadStats() {
|
||||
try {
|
||||
const res = await fetch('/pos/api/dashboard/stats', { headers: headers() });
|
||||
if (!res.ok) return;
|
||||
const data = await res.json();
|
||||
renderHourlyChart(data.hourly_sales || []);
|
||||
renderTopProductsChart(data.top_products || []);
|
||||
} catch (e) {
|
||||
console.error('[dashboard-stats] failed to load', e);
|
||||
}
|
||||
}
|
||||
|
||||
function renderHourlyChart(hourly) {
|
||||
const ctx = document.getElementById('hourlySalesChart');
|
||||
if (!ctx) return;
|
||||
const labels = hourly.map(function (h) { return h.hour + ':00'; });
|
||||
const totals = hourly.map(function (h) { return h.total; });
|
||||
|
||||
new Chart(ctx, {
|
||||
type: 'bar',
|
||||
data: {
|
||||
labels: labels,
|
||||
datasets: [{
|
||||
label: 'Ventas ($)',
|
||||
data: totals,
|
||||
backgroundColor: 'rgba(245, 166, 35, 0.7)',
|
||||
borderColor: 'rgba(245, 166, 35, 1)',
|
||||
borderWidth: 1,
|
||||
borderRadius: 4,
|
||||
}]
|
||||
},
|
||||
options: {
|
||||
responsive: true,
|
||||
maintainAspectRatio: false,
|
||||
plugins: { legend: { display: false } },
|
||||
scales: {
|
||||
x: { ticks: { color: '#888', font: { size: 10 } }, grid: { display: false } },
|
||||
y: { ticks: { color: '#888', font: { size: 10 }, callback: function (v) { return '$' + (v / 1000).toFixed(0) + 'k'; } }, grid: { color: '#333' } }
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function renderTopProductsChart(topProducts) {
|
||||
const ctx = document.getElementById('topProductsChart');
|
||||
if (!ctx) return;
|
||||
const labels = topProducts.map(function (p) { return p.name.substring(0, 20); });
|
||||
const revenues = topProducts.map(function (p) { return p.revenue; });
|
||||
|
||||
new Chart(ctx, {
|
||||
type: 'doughnut',
|
||||
data: {
|
||||
labels: labels,
|
||||
datasets: [{
|
||||
data: revenues,
|
||||
backgroundColor: [
|
||||
'#F5A623', '#E85D75', '#4ECDC4', '#556270', '#C7F464',
|
||||
'#FF6B6B', '#45B7D1', '#96CEB4', '#FFEAA7', '#DDA0DD'
|
||||
],
|
||||
borderWidth: 0,
|
||||
}]
|
||||
},
|
||||
options: {
|
||||
responsive: true,
|
||||
maintainAspectRatio: false,
|
||||
plugins: {
|
||||
legend: {
|
||||
position: 'right',
|
||||
labels: { color: '#ccc', font: { size: 10 }, boxWidth: 10 }
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
if (document.readyState === 'loading') {
|
||||
document.addEventListener('DOMContentLoaded', loadStats);
|
||||
} else {
|
||||
loadStats();
|
||||
}
|
||||
})();
|
||||
Reference in New Issue
Block a user