chore: add quantity stat

This commit is contained in:
2026-05-17 14:54:59 +02:00
parent 94fa9de6a7
commit a8696b12fa
4 changed files with 158 additions and 4 deletions
+71
View File
@@ -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
@@ -185,5 +255,6 @@ func GetAdminStats(c *gin.Context) {
"by_day_revenue": byDayRevenue,
"by_hour": byHour,
"top_products": topProducts,
"by_quantity": byQuantity,
})
}
+10
View File
@@ -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"`
+15
View File
@@ -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<AdminStats> => {
@@ -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() {
)}
</Section>
{/* ── Doses les plus populaires par produit ── */}
{(() => {
const qtyData: ProductQuantityBreakdown[] = (stats?.by_quantity ?? []).filter(
(p) => p.quantities.length >= 1,
);
if (!qtyData.length) return null;
return (
<Section title="Doses populaires par produit" icon="flask-outline">
{qtyData.map((product) => {
const peakCount = product.quantities[0]?.order_count ?? 1;
return (
<View
key={product.product_id}
style={{
marginBottom: spacing.m,
paddingBottom: spacing.m,
borderBottomWidth: 1,
borderBottomColor: colors.borderLight,
}}
>
{/* Nom du produit avec pastille couleur */}
<View style={{ flexDirection: "row", alignItems: "center", gap: spacing.xs, marginBottom: spacing.s }}>
<View style={{ width: 10, height: 10, borderRadius: 5, backgroundColor: product.category_color }} />
<Text style={{ color: colors.textPrimary, fontSize: fontSize.sm, fontWeight: "600" }} numberOfLines={1}>
{product.name}
</Text>
</View>
{/* 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 (
<View key={q.quantity} style={{ flexDirection: "row", alignItems: "center", marginBottom: 4, gap: spacing.s }}>
<Text style={{ width: 44, fontSize: fontSize.xs, color: isPeak ? product.category_color : colors.textMuted, fontWeight: isPeak ? "700" : "400" }}>
{label}
</Text>
<View style={{ flex: 1, height: 14, borderRadius: 3, backgroundColor: colors.borderLight, overflow: "hidden" }}>
<View style={{ width: `${pct}%`, height: "100%", borderRadius: 3, backgroundColor: isPeak ? product.category_color : product.category_color + "66" }} />
</View>
<Text style={{ width: 54, fontSize: fontSize.xs, textAlign: "right", color: isPeak ? product.category_color : colors.textMuted, fontWeight: isPeak ? "700" : "400" }}>
{q.order_count} cmd
</Text>
{isPeak && (
<Ionicons name="flame" size={11} color={CHART_AMBER} />
)}
</View>
);
})}
</View>
);
})}
</Section>
);
})()}
{/* ── Top produits ── */}
<Section title="Top produits" icon="cube-outline">
{/* Sélecteur tri */}