import React, { useState, useCallback, useMemo } from "react"; import { View, Text, ScrollView, StyleSheet, RefreshControl, TouchableOpacity, } from "react-native"; import AlertModal from "../../components/ui/AlertModal"; import { Ionicons } from "@expo/vector-icons"; import { useFocusEffect } from "@react-navigation/native"; import { useTheme } from "../../context/ThemeContext"; import { spacing, fontSize, borderRadius } from "../../theme"; import { shadows } from "../../theme/shadows"; import LoadingSpinner from "../../components/ui/LoadingSpinner"; import { getAdminStats, resetAdminStats } from "../../api/api_admin"; import type { AdminStats, WeekdayStat, DayStat, DayRevenueStat, HourStat, ProductStat, ProductQuantityBreakdown, StatSection } from "../../api/api_admin"; // ── Palette graphiques ──────────────────────────────────────────────────────── const CHART_ACCENT = "#6366f1"; const CHART_GREEN = "#10b981"; const CHART_AMBER = "#f59e0b"; const CHART_RED = "#ef4444"; const CHART_BLUE = "#3b82f6"; // ── Utilitaires ─────────────────────────────────────────────────────────────── const maxOf = (arr: number[]) => 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(2)}k€` : `${n.toFixed(2)}€`; // ── 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 — commandes) ─────────────────────── 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, }} /> ))} {data[0]?.label} {data[data.length - 1]?.label} ); } // ── Sparkline revenus 30 jours ──────────────────────────────────────────────── function SparkLineRevenue({ data, color }: { data: DayRevenueStat[]; color: string }) { const { colors } = useTheme(); if (!data.length) return null; const max = Math.max(...data.map((d) => d.revenue), 1); const BAR_H = 56; return ( {data.map((d, i) => ( 0 ? Math.max((d.revenue / max) * BAR_H, d.revenue > 0 ? 3 : 0) : 0, backgroundColor: color, borderRadius: 2, opacity: 0.85, }} /> ))} {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" }, }); // ── Bouton reset de section ─────────────────────────────────────────────────── function SectionResetBtn({ onPress, loading, resetAt }: { onPress: () => void; loading: boolean; resetAt?: string }) { const { colors } = useTheme(); return ( {loading ? "..." : "Réinitialiser"} {resetAt ? ( Depuis le {new Date(resetAt).toLocaleDateString("fr-FR", { day: "2-digit", month: "2-digit", year: "numeric" })} ) : null} ); } // ── 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 [resetting, setResetting] = useState(null); const [prodSort, setProdSort] = useState("quantity"); const [confirmModal, setConfirmModal] = useState<{ section: StatSection; label: string } | null>(null); const [errorModal, setErrorModal] = useState(null); 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); }; const handleReset = (section: StatSection, label: string) => { setConfirmModal({ section, label }); }; const doReset = async () => { if (!confirmModal) return; const { section } = confirmModal; setConfirmModal(null); setResetting(section); try { await resetAdminStats(section); await loadStats(); } catch (e: any) { setErrorModal(e?.response?.data?.error || e?.message || "Impossible de réinitialiser"); } setResetting(null); }; // 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 }, headerRow: { flexDirection: "row", alignItems: "center", justifyContent: "space-between", marginBottom: spacing.xs }, resetBtn: { flexDirection: "row", alignItems: "center", gap: 4, paddingHorizontal: spacing.s, paddingVertical: 3, borderRadius: borderRadius.sm, borderWidth: 1, borderColor: CHART_RED + "66", backgroundColor: CHART_RED + "11" }, resetBtnText:{ fontSize: 10, fontWeight: "600", color: CHART_RED }, resetDate: { fontSize: fontSize.xs, color: colors.textMuted, marginBottom: 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 ( <> setConfirmModal(null)} onConfirm={doReset} /> setErrorModal(null)} /> } > Statistiques Activité globale & produits {/* ── Cartes résumé ── */} {/* ── Évolution 30 jours (commandes) ── */}
handleReset("commandes", "Commandes")} loading={resetting === "commandes"} resetAt={stats?.reset_at_commandes} /> {stats?.by_day_30?.length ? ( ) : ( Aucune donnée )}
{/* ── Revenus par jour (30 jours) ── */} {(() => { const revData = stats?.by_day_revenue ?? []; const revMax = Math.max(...revData.map((d) => d.revenue), 1); const best = [...revData].sort((a, b) => b.revenue - a.revenue)[0]; return (
handleReset("revenus", "Revenus")} loading={resetting === "revenus"} resetAt={stats?.reset_at_revenus} /> {revData.length ? ( <> {[...revData].reverse().map((d) => ( ))} {best && ( Meilleure journée :{" "} {best.label} · {fmtEuro(best.revenue)} )} ) : ( Aucune donnée de revenu )}
); })()} {/* ── Heures d'affluence ── */} {(() => { const hourData: HourStat[] = stats?.by_hour ?? []; const peakHour = hourData.reduce( (best, h) => (!best || h.count > best.count ? h : best), null, ); const hourMax = maxOf(hourData.map((h) => h.count)); return (
{hourData.some((h) => h.count > 0) ? ( <> {hourData.map((h) => ( 0 ? CHART_AMBER : CHART_BLUE} right={h.count > 0 ? String(h.count) : "—"} /> ))} {peakHour && peakHour.count > 0 && ( Heure de pointe :{" "} {peakHour.label} · {peakHour.count} cmd · {fmtEuro(peakHour.revenue)} )} ) : ( Aucune donnée horaire )}
); })()} {/* ── 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} )}
{/* ── 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 ── */}
handleReset("produits", "Produits")} loading={resetting === "produits"} resetAt={stats?.reset_at_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) => { 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)}`; return ( ); }) )} {/* Produit le moins vendu */} {sortedProducts.length > 1 && prodSort === "quantity" && ( Moins vendu :{" "} {sortedProducts[sortedProducts.length - 1]?.name} )}
); }