diff --git a/backend/gestion/handlers/stats.go b/backend/gestion/handlers/stats.go index cd08295e..7e8444cc 100644 --- a/backend/gestion/handlers/stats.go +++ b/backend/gestion/handlers/stats.go @@ -147,6 +147,76 @@ func GetAdminStats(c *gin.Context) { } } + // ── Répartition des doses/quantités par produit ─────────────────────────── + var qtyRows []models.QuantityBreakdownRow + gdb.Raw(` + SELECT + ci.product_id, + ci.produit AS product_name, + ci.quantite AS quantity, + COUNT(DISTINCT ci.command_id) AS order_count, + SUM(ci.quantite) AS total_sold, + SUM(ci.prix * ci.quantite) AS revenue, + COALESCE(cat.color, '#7c3aed') AS category_color + FROM command_items ci + JOIN commandes c ON c.id = ci.command_id + LEFT JOIN products p ON p.id = ci.product_id + LEFT JOIN categories cat ON cat.name = p.category + WHERE c.status != 'cancelled' + GROUP BY ci.product_id, ci.produit, ci.quantite, cat.color + ORDER BY ci.product_id, COUNT(DISTINCT ci.command_id) DESC + `).Scan(&qtyRows) + + type productGroup struct { + ProductID int + Name string + CategoryColor string + TotalOrders int + Quantities []gin.H + } + var groups []productGroup + groupIdx := map[int]int{} + for _, r := range qtyRows { + idx, ok := groupIdx[r.ProductID] + if !ok { + idx = len(groups) + groups = append(groups, productGroup{ + ProductID: r.ProductID, + Name: r.ProductName, + CategoryColor: r.CategoryColor, + }) + groupIdx[r.ProductID] = idx + } + groups[idx].TotalOrders += r.OrderCount + groups[idx].Quantities = append(groups[idx].Quantities, gin.H{ + "quantity": r.Quantity, + "order_count": r.OrderCount, + "total_sold": r.TotalSold, + "revenue": r.Revenue, + }) + } + // Trier par total de commandes décroissant, garder 15 max + for i := 0; i < len(groups)-1; i++ { + for j := i + 1; j < len(groups); j++ { + if groups[j].TotalOrders > groups[i].TotalOrders { + groups[i], groups[j] = groups[j], groups[i] + } + } + } + if len(groups) > 15 { + groups = groups[:15] + } + byQuantity := make([]gin.H, len(groups)) + for i, g := range groups { + byQuantity[i] = gin.H{ + "product_id": g.ProductID, + "name": g.Name, + "category_color": g.CategoryColor, + "total_orders": g.TotalOrders, + "quantities": g.Quantities, + } + } + // ── Résumé global ───────────────────────────────────────────────────────── var totalOrders int64 var totalRevenue float64 @@ -180,10 +250,11 @@ func GetAdminStats(c *gin.Context) { "top_product": topProductName, "avg_per_day": avgPerDay, }, - "by_weekday": byWeekday, - "by_day_30": byDay, + "by_weekday": byWeekday, + "by_day_30": byDay, "by_day_revenue": byDayRevenue, "by_hour": byHour, - "top_products": topProducts, + "top_products": topProducts, + "by_quantity": byQuantity, }) } diff --git a/backend/gestion/models/stats.go b/backend/gestion/models/stats.go index 2b707688..c8b3a2ee 100644 --- a/backend/gestion/models/stats.go +++ b/backend/gestion/models/stats.go @@ -28,6 +28,16 @@ type HourRow struct { Revenue float64 `gorm:"column:revenue"` } +type QuantityBreakdownRow struct { + ProductID int `gorm:"column:product_id"` + ProductName string `gorm:"column:product_name"` + Quantity float64 `gorm:"column:quantity"` + OrderCount int `gorm:"column:order_count"` + TotalSold float64 `gorm:"column:total_sold"` + Revenue float64 `gorm:"column:revenue"` + CategoryColor string `gorm:"column:category_color"` +} + type DayRevenueRow struct { Day time.Time `gorm:"column:day"` Revenue float64 `gorm:"column:revenue"` diff --git a/frontend-admin/src/api/api_admin.ts b/frontend-admin/src/api/api_admin.ts index 4ca2cab5..ed9507e9 100644 --- a/frontend-admin/src/api/api_admin.ts +++ b/frontend-admin/src/api/api_admin.ts @@ -90,6 +90,20 @@ export interface ProductStat { category_color: string; } +export interface QuantityStat { + quantity: number; + order_count: number; + total_sold: number; + revenue: number; +} +export interface ProductQuantityBreakdown { + product_id: number; + name: string; + category_color: string; + total_orders: number; + quantities: QuantityStat[]; +} + export interface AdminStats { summary: StatsSummary; by_weekday: WeekdayStat[]; @@ -97,6 +111,7 @@ export interface AdminStats { by_day_revenue: DayRevenueStat[]; by_hour: HourStat[]; top_products: ProductStat[]; + by_quantity: ProductQuantityBreakdown[]; } export const getAdminStats = async (): Promise => { diff --git a/frontend-admin/src/screens/admin/StatsScreen.tsx b/frontend-admin/src/screens/admin/StatsScreen.tsx index 7c6d8447..9635c1c2 100644 --- a/frontend-admin/src/screens/admin/StatsScreen.tsx +++ b/frontend-admin/src/screens/admin/StatsScreen.tsx @@ -14,7 +14,7 @@ import { spacing, fontSize, borderRadius } from "../../theme"; import { shadows } from "../../theme/shadows"; import LoadingSpinner from "../../components/ui/LoadingSpinner"; import { getAdminStats } from "../../api/api_admin"; -import type { AdminStats, WeekdayStat, DayStat, DayRevenueStat, HourStat, ProductStat } from "../../api/api_admin"; +import type { AdminStats, WeekdayStat, DayStat, DayRevenueStat, HourStat, ProductStat, ProductQuantityBreakdown } from "../../api/api_admin"; // ── Palette graphiques ──────────────────────────────────────────────────────── const CHART_ACCENT = "#6366f1"; @@ -372,6 +372,64 @@ export default function StatsScreen() { )} + {/* ── Doses les plus populaires par produit ── */} + {(() => { + const qtyData: ProductQuantityBreakdown[] = (stats?.by_quantity ?? []).filter( + (p) => p.quantities.length >= 1, + ); + if (!qtyData.length) return null; + return ( +
+ {qtyData.map((product) => { + const peakCount = product.quantities[0]?.order_count ?? 1; + return ( + + {/* Nom du produit avec pastille couleur */} + + + + {product.name} + + + {/* Barre par dose */} + {product.quantities.map((q, i) => { + const pct = peakCount > 0 ? Math.max((q.order_count / peakCount) * 100, q.order_count > 0 ? 2 : 0) : 0; + const isPeak = i === 0; + const label = Number.isInteger(q.quantity) + ? `${q.quantity}g` + : `${q.quantity}g`; + return ( + + + {label} + + + + + + {q.order_count} cmd + + {isPeak && ( + + )} + + ); + })} + + ); + })} +
+ ); + })()} + {/* ── Top produits ── */}
{/* Sélecteur tri */}