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 ─────────────────────────────────────────────────────────
|
// ── Résumé global ─────────────────────────────────────────────────────────
|
||||||
var totalOrders int64
|
var totalOrders int64
|
||||||
var totalRevenue float64
|
var totalRevenue float64
|
||||||
@@ -180,10 +250,11 @@ func GetAdminStats(c *gin.Context) {
|
|||||||
"top_product": topProductName,
|
"top_product": topProductName,
|
||||||
"avg_per_day": avgPerDay,
|
"avg_per_day": avgPerDay,
|
||||||
},
|
},
|
||||||
"by_weekday": byWeekday,
|
"by_weekday": byWeekday,
|
||||||
"by_day_30": byDay,
|
"by_day_30": byDay,
|
||||||
"by_day_revenue": byDayRevenue,
|
"by_day_revenue": byDayRevenue,
|
||||||
"by_hour": byHour,
|
"by_hour": byHour,
|
||||||
"top_products": topProducts,
|
"top_products": topProducts,
|
||||||
|
"by_quantity": byQuantity,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -28,6 +28,16 @@ type HourRow struct {
|
|||||||
Revenue float64 `gorm:"column:revenue"`
|
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 {
|
type DayRevenueRow struct {
|
||||||
Day time.Time `gorm:"column:day"`
|
Day time.Time `gorm:"column:day"`
|
||||||
Revenue float64 `gorm:"column:revenue"`
|
Revenue float64 `gorm:"column:revenue"`
|
||||||
|
|||||||
@@ -90,6 +90,20 @@ export interface ProductStat {
|
|||||||
category_color: string;
|
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 {
|
export interface AdminStats {
|
||||||
summary: StatsSummary;
|
summary: StatsSummary;
|
||||||
by_weekday: WeekdayStat[];
|
by_weekday: WeekdayStat[];
|
||||||
@@ -97,6 +111,7 @@ export interface AdminStats {
|
|||||||
by_day_revenue: DayRevenueStat[];
|
by_day_revenue: DayRevenueStat[];
|
||||||
by_hour: HourStat[];
|
by_hour: HourStat[];
|
||||||
top_products: ProductStat[];
|
top_products: ProductStat[];
|
||||||
|
by_quantity: ProductQuantityBreakdown[];
|
||||||
}
|
}
|
||||||
|
|
||||||
export const getAdminStats = async (): Promise<AdminStats> => {
|
export const getAdminStats = async (): Promise<AdminStats> => {
|
||||||
|
|||||||
@@ -14,7 +14,7 @@ import { spacing, fontSize, borderRadius } from "../../theme";
|
|||||||
import { shadows } from "../../theme/shadows";
|
import { shadows } from "../../theme/shadows";
|
||||||
import LoadingSpinner from "../../components/ui/LoadingSpinner";
|
import LoadingSpinner from "../../components/ui/LoadingSpinner";
|
||||||
import { getAdminStats } from "../../api/api_admin";
|
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 ────────────────────────────────────────────────────────
|
// ── Palette graphiques ────────────────────────────────────────────────────────
|
||||||
const CHART_ACCENT = "#6366f1";
|
const CHART_ACCENT = "#6366f1";
|
||||||
@@ -372,6 +372,64 @@ export default function StatsScreen() {
|
|||||||
)}
|
)}
|
||||||
</Section>
|
</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 ── */}
|
{/* ── Top produits ── */}
|
||||||
<Section title="Top produits" icon="cube-outline">
|
<Section title="Top produits" icon="cube-outline">
|
||||||
{/* Sélecteur tri */}
|
{/* Sélecteur tri */}
|
||||||
|
|||||||
Reference in New Issue
Block a user