import React, { useState, useCallback, useEffect, useMemo } from "react"; import { View, Text, ScrollView, StyleSheet, RefreshControl, TouchableOpacity, Modal, } 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, getAdminStatsByMonth, getAdminDailyDetail, } from "../../api/api_admin"; import type { AdminStats, WeekdayStat, DayStat, DayRevenueStat, HourStat, ProductStat, ProductQuantityBreakdown, StatSection, DailyDetail, DailyCategoryDetail, MonthlyStats, } 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} ); } // ── Contenu détaillé d'un jour (catégories → produits) ─────────────────────── // Réutilisé à la fois par la section "Activité du jour" (aujourd'hui) et par // la modal de détail d'un jour cliqué dans l'historique mensuel. function DailyDetailContent({ daily }: { daily: DailyDetail }) { const { colors } = useTheme(); const hasData = daily.categories.length > 0; return ( <> {/* Mini-résumé */} {daily.total_orders} commandes {daily.total_quantity}g vendues {fmtEuro(daily.total_revenue)} revenus {!hasData ? ( Aucune commande ce jour-là ) : ( daily.categories.map((cat: DailyCategoryDetail, ci: number) => { const catMax = Math.max( ...cat.products.map((p) => p.quantity), 1, ); const isLast = ci === daily.categories.length - 1; return ( {/* En-tête catégorie */} {cat.category} {cat.total_quantity}g {fmtEuro(cat.total_revenue)} {/* Lignes produits */} {cat.products.map((prod, pi) => { const pct = catMax > 0 ? Math.max( (prod.quantity / catMax) * 100, prod.quantity > 0 ? 3 : 0, ) : 0; const isTopInCat = pi === 0; return ( {prod.name} {prod.quantity}g ·{" "} {prod.order_count} cmd {fmtEuro(prod.revenue)} ); })} ); }) )} ); } // ── Section détail du jour (aujourd'hui, dans le flux principal) ──────────── function DailyDetailSection({ daily }: { daily: DailyDetail }) { const { colors } = useTheme(); return ( {/* En-tête */} Activité du jour {daily.date} ); } const ddStyles = StyleSheet.create({ card: { borderRadius: borderRadius.md, padding: spacing.l, marginBottom: spacing.m, borderWidth: 1, }, header: { flexDirection: "row", alignItems: "center", justifyContent: "space-between", marginBottom: spacing.m, }, title: { fontSize: fontSize.md, fontWeight: "600" }, date: { fontSize: fontSize.xs, fontWeight: "500" }, chipRow: { flexDirection: "row", gap: spacing.s, marginBottom: spacing.l }, chip: { flex: 1, alignItems: "center", gap: 3, paddingVertical: spacing.s, borderRadius: borderRadius.sm, borderWidth: 1, }, chipVal: { fontSize: fontSize.sm, fontWeight: "700" }, chipLbl: { fontSize: 9, fontWeight: "500" }, empty: { textAlign: "center", fontSize: fontSize.sm, paddingVertical: spacing.l, }, catBlock: { marginBottom: spacing.l, paddingBottom: spacing.l, borderBottomWidth: 1, }, catHeader: { flexDirection: "row", alignItems: "center", justifyContent: "space-between", marginBottom: spacing.s, paddingBottom: spacing.xs, }, catDot: { width: 10, height: 10, borderRadius: 5 }, catName: { fontSize: fontSize.sm, fontWeight: "700" }, catMeta: { fontSize: fontSize.xs }, catRevenue: { fontSize: fontSize.xs, fontWeight: "700" }, prodRow: { marginBottom: spacing.s }, prodTopLine: { flexDirection: "row", alignItems: "center", justifyContent: "space-between", marginBottom: 4, }, prodName: { flex: 1, fontSize: fontSize.xs, marginRight: spacing.s }, prodMeta: { flexDirection: "row", alignItems: "center", gap: spacing.m }, prodQty: { fontSize: fontSize.xs }, prodRevenue: { fontSize: fontSize.xs, fontWeight: "600", minWidth: 52, textAlign: "right", }, bar: { height: 5, borderRadius: 3, overflow: "hidden" }, barFill: { height: "100%", borderRadius: 3 }, }); // ── Modal — détail d'un jour cliqué dans l'historique mensuel ─────────────── function DayDetailModal({ visible, dateLabel, onClose, }: { visible: boolean; dateLabel: string | null; // "2026-06-05" onClose: () => void; }) { const { colors } = useTheme(); const [detail, setDetail] = useState(null); const [loading, setLoading] = useState(false); const [errored, setErrored] = useState(false); useEffect(() => { if (!visible || !dateLabel) return; setLoading(true); setErrored(false); getAdminDailyDetail(dateLabel) .then((data) => setDetail(data)) .catch(() => setErrored(true)) .finally(() => setLoading(false)); }, [visible, dateLabel]); const prettyDate = useMemo(() => { if (!dateLabel) return ""; const [y, m, d] = dateLabel.split("-").map(Number); return new Date(y, m - 1, d).toLocaleDateString("fr-FR", { weekday: "long", day: "2-digit", month: "long", year: "numeric", }); }, [dateLabel]); return ( {prettyDate} {loading ? ( Chargement... ) : errored ? ( Impossible de charger le détail de ce jour ) : detail ? ( ) : null} ); } const dayModalStyles = StyleSheet.create({ overlay: { flex: 1, backgroundColor: "rgba(0,0,0,0.55)", justifyContent: "center", padding: spacing.l, }, sheet: { borderRadius: borderRadius.md, borderWidth: 1, padding: spacing.l, maxHeight: "85%", }, header: { flexDirection: "row", alignItems: "center", marginBottom: spacing.m, }, title: { fontSize: fontSize.md, fontWeight: "700", textTransform: "capitalize", }, closeBtn: { padding: spacing.xs, }, }); // ── Helpers de mois (clé "YYYY-MM") ─────────────────────────────────────────── function monthKey(date: Date): string { const y = date.getFullYear(); const m = String(date.getMonth() + 1).padStart(2, "0"); return `${y}-${m}`; } function monthLabel(key: string): string { const [y, m] = key.split("-").map(Number); const d = new Date(y, m - 1, 1); return d.toLocaleDateString("fr-FR", { month: "long", year: "numeric" }); } function shiftMonth(key: string, delta: number): string { const [y, m] = key.split("-").map(Number); const d = new Date(y, m - 1 + delta, 1); return monthKey(d); } // ── Section historique mensuel (jour par jour sur un mois) ────────────────── function MonthlyHistorySection() { const { colors } = useTheme(); const [monthKeyState, setMonthKeyState] = useState(() => monthKey(new Date()), ); const [monthly, setMonthly] = useState(null); const [loadingMonth, setLoadingMonth] = useState(false); const [expanded, setExpanded] = useState(false); const [selectedDay, setSelectedDay] = useState(null); const currentMonthKey = monthKey(new Date()); const isCurrentMonth = monthKeyState === currentMonthKey; const loadMonth = useCallback(async (key: string) => { setLoadingMonth(true); try { const data = await getAdminStatsByMonth(key); setMonthly(data); } catch { setMonthly(null); } setLoadingMonth(false); }, []); useEffect(() => { if (expanded) loadMonth(monthKeyState); }, [expanded, monthKeyState, loadMonth]); const goPrevMonth = () => setMonthKeyState((k) => shiftMonth(k, -1)); const goNextMonth = () => setMonthKeyState((k) => shiftMonth(k, 1)); const days = monthly?.by_day ?? []; const revMax = Math.max(...days.map((d) => d.revenue), 1); const cntMax = Math.max(...days.map((d) => d.count), 1); const best = days.length ? [...days].sort((a, b) => b.revenue - a.revenue)[0] : null; return ( setExpanded((e) => !e)} style={{ flexDirection: "row", alignItems: "center", justifyContent: "space-between", }} > Historique mensuel {expanded && ( <> {/* Sélecteur de mois */} {monthLabel(monthKeyState)} {loadingMonth ? ( Chargement... ) : !days.length ? ( Aucune donnée pour ce mois ) : ( <> {/* Mini-résumé du mois */} {monthly?.summary.total_orders ?? 0} commandes {fmtNum( monthly?.summary.total_quantity ?? 0, )} g vendues {fmtEuro( monthly?.summary.total_revenue ?? 0, )} revenus {/* Sparkline revenus du mois */} {days.map((d) => ( 0 ? 3 : 0, ), backgroundColor: best && d.day === best.day ? CHART_AMBER : CHART_GREEN, borderRadius: 2, opacity: 0.85, }} /> ))} {days[0]?.label} {days[days.length - 1]?.label} {/* Liste détaillée jour par jour — cliquable */} {[...days].reverse().map((d) => { const isBest = best && d.day === best.day; const pct = cntMax > 0 ? Math.max( (d.count / cntMax) * 100, d.count > 0 ? 3 : 0, ) : 0; const hasData = d.count > 0; return ( hasData && setSelectedDay(d.day) } disabled={!hasData} activeOpacity={0.6} style={{ flexDirection: "row", alignItems: "center", marginBottom: spacing.s, gap: spacing.s, }} > {d.label} {d.count} {fmtNum(d.quantity)}g {fmtEuro(d.revenue)} {isBest && ( )} {hasData && ( )} ); })} {best && ( Meilleure journée du mois :{" "} {best.label} ·{" "} {fmtEuro(best.revenue)} )} )} )} setSelectedDay(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é ── */} {/* ── Activité du jour ── */} {stats?.daily_detail && ( )} {/* ── Historique mensuel (jour par jour sur un mois) ── */} {/* ── É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 (
handleReset("heures", "Heures d'affluence") } loading={resetting === "heures"} resetAt={stats?.reset_at_heures} /> {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 ── */}
handleReset("jours", "Jours d'affluence") } loading={resetting === "jours"} resetAt={stats?.reset_at_jours} /> {(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 (
handleReset("doses", "Doses populaires") } loading={resetting === "doses"} resetAt={stats?.reset_at_doses} /> {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 } )}
); }