chore: add quantity stat
This commit is contained in:
@@ -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,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -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"`
|
||||
|
||||
@@ -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 */}
|
||||
|
||||
Reference in New Issue
Block a user