From 3c16ca015d8b006f2cab0307b9690313d54ea82d Mon Sep 17 00:00:00 2001 From: Xor290 Date: Sun, 10 May 2026 15:39:26 +0200 Subject: [PATCH] feat: add stats page for admin --- backend/gestion/handlers/stats.go | 150 ++++++++ backend/gestion/routes/routes.go | 5 + frontend-admin/src/api/api_admin.ts | 24 ++ .../src/navigation/AdminNavigator.tsx | 11 + frontend-admin/src/navigation/types.ts | 1 + .../src/screens/admin/StatsScreen.tsx | 336 ++++++++++++++++++ 6 files changed, 527 insertions(+) create mode 100644 backend/gestion/handlers/stats.go create mode 100644 frontend-admin/src/screens/admin/StatsScreen.tsx diff --git a/backend/gestion/handlers/stats.go b/backend/gestion/handlers/stats.go new file mode 100644 index 00000000..a476c533 --- /dev/null +++ b/backend/gestion/handlers/stats.go @@ -0,0 +1,150 @@ +package handlers + +import ( + "gestion/db" + "net/http" + "time" + + "github.com/gin-gonic/gin" +) + +type weekdayRow struct { + DOW int `gorm:"column:dow"` + Count int `gorm:"column:count"` +} + +type dayRow struct { + Day time.Time `gorm:"column:day"` + Count int `gorm:"column:count"` +} + +type productRow struct { + ProductID int `gorm:"column:product_id"` + Name string `gorm:"column:name"` + Quantity float64 `gorm:"column:total_quantity"` + OrderCount int `gorm:"column:order_count"` + Revenue float64 `gorm:"column:revenue"` +} + +var weekdayNames = []string{"Dimanche", "Lundi", "Mardi", "Mercredi", "Jeudi", "Vendredi", "Samedi"} + +// GetAdminStats returns aggregated order & product statistics for the admin dashboard. +func GetAdminStats(c *gin.Context) { + database := c.MustGet("database").(*db.Database) + gdb := database.GDB + + // ── Commandes par jour de la semaine (all time, non annulées) ────────────── + var wdRows []weekdayRow + gdb.Raw(` + SELECT EXTRACT(DOW FROM created_at)::int AS dow, COUNT(*) AS count + FROM commandes + WHERE status != 'cancelled' + GROUP BY dow + ORDER BY dow + `).Scan(&wdRows) + + byWeekday := make([]gin.H, 7) + wdMap := make(map[int]int, len(wdRows)) + for _, r := range wdRows { + wdMap[r.DOW] = r.Count + } + peakCount, peakWeekday := 0, "" + for i := 0; i < 7; i++ { + cnt := wdMap[i] + byWeekday[i] = gin.H{"weekday": weekdayNames[i], "count": cnt} + if cnt > peakCount { + peakCount = cnt + peakWeekday = weekdayNames[i] + } + } + + // ── Commandes par jour sur 30 jours ─────────────────────────────────────── + var dayRows []dayRow + gdb.Raw(` + SELECT DATE(created_at) AS day, COUNT(*) AS count + FROM commandes + WHERE created_at >= NOW() - INTERVAL '30 days' + AND status != 'cancelled' + GROUP BY DATE(created_at) + ORDER BY day + `).Scan(&dayRows) + + byDay := make([]gin.H, len(dayRows)) + for i, r := range dayRows { + byDay[i] = gin.H{ + "day": r.Day.Format("2006-01-02"), + "label": r.Day.Format("02/01"), + "count": r.Count, + } + } + + // ── Top produits (quantité vendue, commandes terminées) ─────────────────── + var prodRows []productRow + gdb.Raw(` + SELECT + ci.product_id, + ci.produit AS name, + SUM(ci.quantite) AS total_quantity, + COUNT(DISTINCT ci.command_id) AS order_count, + SUM(ci.prix * ci.quantite) AS revenue + FROM command_items ci + JOIN commandes c ON c.id = ci.command_id + WHERE c.status != 'cancelled' + GROUP BY ci.product_id, ci.produit + ORDER BY total_quantity DESC + LIMIT 15 + `).Scan(&prodRows) + + topProducts := make([]gin.H, len(prodRows)) + topProductName := "" + for i, r := range prodRows { + topProducts[i] = gin.H{ + "product_id": r.ProductID, + "name": r.Name, + "quantity": r.Quantity, + "order_count": r.OrderCount, + "revenue": r.Revenue, + } + if i == 0 { + topProductName = r.Name + } + } + + // ── Résumé global ───────────────────────────────────────────────────────── + var totalOrders int64 + var totalRevenue float64 + gdb.Raw(`SELECT COUNT(*) FROM commandes WHERE status != 'cancelled'`).Scan(&totalOrders) + gdb.Raw(`SELECT COALESCE(SUM(total_prix), 0) FROM commandes WHERE status = 'approved'`).Scan(&totalRevenue) + + avgPerDay := 0.0 + if totalOrders > 0 { + // average over the last 30 days with data + var activeDays int64 + gdb.Raw(` + SELECT COUNT(DISTINCT DATE(created_at)) + FROM commandes + WHERE created_at >= NOW() - INTERVAL '30 days' AND status != 'cancelled' + `).Scan(&activeDays) + if activeDays > 0 { + var last30Count int64 + gdb.Raw(` + SELECT COUNT(*) FROM commandes + WHERE created_at >= NOW() - INTERVAL '30 days' AND status != 'cancelled' + `).Scan(&last30Count) + avgPerDay = float64(last30Count) / float64(activeDays) + } + } + + c.JSON(http.StatusOK, gin.H{ + "summary": gin.H{ + "total_orders": totalOrders, + "total_revenue": totalRevenue, + "peak_weekday": peakWeekday, + "top_product": topProductName, + "avg_per_day": avgPerDay, + }, + "by_weekday": byWeekday, + "by_day_30": byDay, + "top_products": topProducts, + }) +} diff --git a/backend/gestion/routes/routes.go b/backend/gestion/routes/routes.go index 2507b66b..f2299808 100644 --- a/backend/gestion/routes/routes.go +++ b/backend/gestion/routes/routes.go @@ -194,6 +194,11 @@ func SetupRoutes(router *gin.Engine, database *db.Database, geoService *services adminGroupV2.POST("/categories", handlers.CreateCategory) adminGroupV2.PUT("/categories/:id", handlers.UpdateCategory) adminGroupV2.DELETE("/categories/:id", handlers.DeleteCategory) + // ============================================ + // STATISTIQUES ADMIN + // ============================================ + adminGroupV2.GET("/stats", handlers.GetAdminStats) + // ============================================ // COMMANDES - GESTION DE BASE // ============================================ diff --git a/frontend-admin/src/api/api_admin.ts b/frontend-admin/src/api/api_admin.ts index 6e87d48a..c9546b73 100644 --- a/frontend-admin/src/api/api_admin.ts +++ b/frontend-admin/src/api/api_admin.ts @@ -53,6 +53,30 @@ export const logoutAdmin = async (): Promise => { } }; +// ── Types stats ────────────────────────────────────────────────────────────── +export interface StatsSummary { + total_orders: number; + total_revenue: number; + peak_weekday: string; + top_product: string; + avg_per_day: number; +} +export interface WeekdayStat { weekday: string; count: number; } +export interface DayStat { day: string; label: string; count: number; } +export interface ProductStat { product_id: number; name: string; quantity: number; order_count: number; revenue: number; } + +export interface AdminStats { + summary: StatsSummary; + by_weekday: WeekdayStat[]; + by_day_30: DayStat[]; + top_products: ProductStat[]; +} + +export const getAdminStats = async (): Promise => { + const { data } = await apiClient.get(`${V2}/admin/protected/stats`); + return data; +}; + export const getAllClients = async (): Promise => { const { data } = await apiClient.get(`${V2}/admin/protected/all/clients`); return data.clients || []; diff --git a/frontend-admin/src/navigation/AdminNavigator.tsx b/frontend-admin/src/navigation/AdminNavigator.tsx index 5d14938f..52d81b3d 100644 --- a/frontend-admin/src/navigation/AdminNavigator.tsx +++ b/frontend-admin/src/navigation/AdminNavigator.tsx @@ -24,6 +24,7 @@ import { fontSize, spacing } from "../theme"; import type { AdminTabParamList, AdminStackParamList } from "./types"; import DashboardScreen from "../screens/admin/DashboardScreen"; +import StatsScreen from "../screens/admin/StatsScreen"; import OrdersScreen from "../screens/admin/OrdersScreen"; import OrderDetailScreen from "../screens/admin/OrderDetailScreen"; import UsersScreen from "../screens/admin/UsersScreen"; @@ -175,6 +176,16 @@ function AdminTabs() { ), }} /> + ( + + ), + }} + /> arr.length ? Math.max(...arr) : 1; +const fmtNum = (n: number) => + n >= 1000 ? `${(n / 1000).toFixed(1)}k` : String(Math.round(n)); +const fmtEuro = (n: number) => + n >= 1000 ? `${(n / 1000).toFixed(1)}k€` : `${Math.round(n)}€`; + +// ── Barre horizontale ───────────────────────────────────────────────────────── +function HBar({ + label, value, max, color, right, +}: { + label: string; value: number; max: number; color: string; right?: string; +}) { + const pct = max > 0 ? Math.max((value / max) * 100, value > 0 ? 2 : 0) : 0; + const { colors } = useTheme(); + return ( + + + {label} + + + + + + {right ?? fmtNum(value)} + + + ); +} +const hBarStyles = StyleSheet.create({ + row: { flexDirection: "row", alignItems: "center", marginBottom: spacing.s, gap: spacing.s }, + label: { width: 80, fontSize: fontSize.xs, flexShrink: 0 }, + track: { flex: 1, height: 18, borderRadius: 4, overflow: "hidden" }, + fill: { height: "100%", borderRadius: 4 }, + value: { width: 46, fontSize: fontSize.xs, textAlign: "right" }, +}); + +// ── Barres verticales (sparkline 30 jours) ──────────────────────────────────── +function SparkLine({ data, color }: { data: DayStat[]; color: string }) { + const { colors } = useTheme(); + if (!data.length) return null; + const max = maxOf(data.map((d) => d.count)); + const BAR_H = 56; + return ( + + + {data.map((d, i) => ( + 0 ? Math.max((d.count / max) * BAR_H, d.count > 0 ? 3 : 0) : 0, + backgroundColor: color, + borderRadius: 2, + opacity: 0.85, + }} + /> + ))} + + {/* Labels début / fin */} + + {data[0]?.label} + {data[data.length - 1]?.label} + + + ); +} + +// ── Carte résumé ────────────────────────────────────────────────────────────── +function SummaryCard({ + icon, label, value, color, +}: { + icon: keyof typeof Ionicons.glyphMap; label: string; value: string; color: string; +}) { + const { colors } = useTheme(); + return ( + + + + + {value} + {label} + + ); +} +const sumStyles = StyleSheet.create({ + card: { flex: 1, borderRadius: borderRadius.md, padding: spacing.m, borderWidth: 1, alignItems: "center", minWidth: "45%" }, + iconWrap:{ width: 36, height: 36, borderRadius: 18, justifyContent: "center", alignItems: "center", marginBottom: spacing.xs }, + val: { fontSize: fontSize.lg, fontWeight: "700" }, + lbl: { fontSize: fontSize.xs, marginTop: 2, textAlign: "center" }, +}); + +// ── Séparateur de section ───────────────────────────────────────────────────── +function Section({ title, icon, children }: { title: string; icon: keyof typeof Ionicons.glyphMap; children: React.ReactNode }) { + const { colors } = useTheme(); + return ( + + + + {title} + + {children} + + ); +} +const secStyles = StyleSheet.create({ + card: { borderRadius: borderRadius.md, padding: spacing.l, marginBottom: spacing.m, borderWidth: 1 }, + header: { flexDirection: "row", alignItems: "center", gap: spacing.s, marginBottom: spacing.m }, + title: { fontSize: fontSize.md, fontWeight: "600" }, +}); + +// ── Sélecteur de période top produits ──────────────────────────────────────── +type ProdSort = "quantity" | "orders" | "revenue"; + +// ═══════════════════════════════════════════════════════════════════════════════ +export default function StatsScreen() { + const { colors } = useTheme(); + const [stats, setStats] = useState(null); + const [loading, setLoading] = useState(true); + const [refreshing, setRefreshing] = useState(false); + const [prodSort, setProdSort] = useState("quantity"); + + const loadStats = useCallback(async () => { + try { + const data = await getAdminStats(); + setStats(data); + } catch { /* ignore */ } + setLoading(false); + }, []); + + useFocusEffect(useCallback(() => { loadStats(); }, [loadStats])); + + const onRefresh = async () => { + setRefreshing(true); + await loadStats(); + setRefreshing(false); + }; + + // Produits triés selon le sélecteur + const sortedProducts = useMemo(() => { + if (!stats?.top_products) return []; + return [...stats.top_products].sort((a, b) => { + if (prodSort === "orders") return b.order_count - a.order_count; + if (prodSort === "revenue") return b.revenue - a.revenue; + return b.quantity - a.quantity; + }); + }, [stats, prodSort]); + + const styles = useMemo(() => StyleSheet.create({ + container: { flex: 1, backgroundColor: colors.bgPrimary }, + content: { padding: spacing.l, paddingBottom: spacing.xxxl }, + pageTitle: { fontSize: fontSize.xl, fontWeight: "700", color: colors.textPrimary, marginBottom: spacing.xs }, + pageSubtitle: { fontSize: fontSize.sm, color: colors.textMuted, marginBottom: spacing.l }, + summaryRow: { flexDirection: "row", gap: spacing.m, marginBottom: spacing.m, flexWrap: "wrap" }, + sortRow: { flexDirection: "row", gap: spacing.s, marginBottom: spacing.m }, + sortBtn: { paddingHorizontal: spacing.m, paddingVertical: spacing.xs, borderRadius: borderRadius.sm, borderWidth: 1 }, + sortBtnText:{ fontSize: fontSize.xs, fontWeight: "600" }, + emptyText: { color: colors.textMuted, fontSize: fontSize.sm, textAlign: "center", paddingVertical: spacing.l }, + }), [colors]); + + if (loading) return ; + + const s = stats?.summary; + const wdMax = maxOf((stats?.by_weekday ?? []).map((w: WeekdayStat) => w.count)); + const prodMax = maxOf(sortedProducts.map((p) => + prodSort === "orders" ? p.order_count : prodSort === "revenue" ? p.revenue : p.quantity, + )); + + return ( + } + > + Statistiques + Activité globale & produits + + {/* ── Cartes résumé ── */} + + + + + + + + + + {/* ── Évolution 30 jours ── */} +
+ {stats?.by_day_30?.length ? ( + + ) : ( + Aucune donnée + )} +
+ + {/* ── Commandes par jour de la semaine ── */} +
+ {(stats?.by_weekday ?? []).map((w: WeekdayStat) => ( + 0 ? CHART_AMBER : CHART_ACCENT} + right={String(w.count)} + /> + ))} + {wdMax > 0 && s?.peak_weekday && ( + + + + Pic d'activité : {s.peak_weekday} + + + )} +
+ + {/* ── Top produits ── */} +
+ {/* Sélecteur tri */} + + {(["quantity", "orders", "revenue"] as ProdSort[]).map((key) => { + const labels = { quantity: "Quantité", orders: "Commandes", revenue: "Revenus" }; + const active = prodSort === key; + return ( + setProdSort(key)} + > + + {labels[key]} + + + ); + })} + + + {sortedProducts.length === 0 ? ( + Aucune donnée produit + ) : ( + sortedProducts.map((p, i) => { + const val = + prodSort === "orders" ? p.order_count + : prodSort === "revenue" ? p.revenue + : p.quantity; + const rightLabel = + prodSort === "revenue" ? fmtEuro(p.revenue) + : prodSort === "orders" ? `${p.order_count} cmd` + : `×${fmtNum(p.quantity)}`; + const color = + i === 0 ? CHART_AMBER + : i === 1 ? "#94a3b8" + : i === 2 ? "#b45309" + : CHART_ACCENT; + return ( + + ); + }) + )} + + {/* Produit le moins vendu */} + {sortedProducts.length > 1 && prodSort === "quantity" && ( + + + + Moins vendu :{" "} + + {sortedProducts[sortedProducts.length - 1]?.name} + + + + )} +
+
+ ); +}