@@ -0,0 +1,336 @@
import React , { useState , useCallback , useMemo } from "react" ;
import {
View ,
Text ,
ScrollView ,
StyleSheet ,
RefreshControl ,
TouchableOpacity ,
} from "react-native" ;
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 } from "../../api/api_admin" ;
import type { AdminStats , WeekdayStat , DayStat , ProductStat } 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 ( 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 (
< View style = { hBarStyles . row } >
< Text style = { [ hBarStyles . label , { color : colors.textMuted } ] } numberOfLines = { 1 } >
{ label }
< / Text >
< View style = { [ hBarStyles . track , { backgroundColor : colors.borderLight } ] } >
< View style = { [ hBarStyles . fill , { width : ` ${ pct } % ` , backgroundColor : color } ] } / >
< / View >
< Text style = { [ hBarStyles . value , { color : colors.textPrimary } ] } >
{ right ? ? fmtNum ( value ) }
< / Text >
< / View >
) ;
}
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 (
< View >
< View style = { { flexDirection : "row" , alignItems : "flex-end" , height : BAR_H , gap : 2 } } >
{ data . map ( ( d , i ) = > (
< View
key = { i }
style = { {
flex : 1 ,
height : max > 0 ? Math . max ( ( d . count / max ) * BAR_H , d . count > 0 ? 3 : 0 ) : 0 ,
backgroundColor : color ,
borderRadius : 2 ,
opacity : 0.85 ,
} }
/ >
) ) }
< / View >
{ /* Labels début / fin */ }
< View style = { { flexDirection : "row" , justifyContent : "space-between" , marginTop : 4 } } >
< Text style = { { color : colors.textMuted , fontSize : 9 } } > { data [ 0 ] ? . label } < / Text >
< Text style = { { color : colors.textMuted , fontSize : 9 } } > { data [ data . length - 1 ] ? . label } < / Text >
< / View >
< / View >
) ;
}
// ── Carte résumé ──────────────────────────────────────────────────────────────
function SummaryCard ( {
icon , label , value , color ,
} : {
icon : keyof typeof Ionicons . glyphMap ; label : string ; value : string ; color : string ;
} ) {
const { colors } = useTheme ( ) ;
return (
< View style = { [ sumStyles . card , shadows . sm , { backgroundColor : colors.bgCard , borderColor : colors.borderLight } ] } >
< View style = { [ sumStyles . iconWrap , { backgroundColor : color + "22" } ] } >
< Ionicons name = { icon } size = { 20 } color = { color } / >
< / View >
< Text style = { [ sumStyles . val , { color : colors.textPrimary } ] } > { value } < / Text >
< Text style = { [ sumStyles . lbl , { color : colors.textMuted } ] } > { label } < / Text >
< / View >
) ;
}
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 (
< View style = { [ secStyles . card , { backgroundColor : colors.bgCard , borderColor : colors.borderLight } ] } >
< View style = { secStyles . header } >
< Ionicons name = { icon } size = { 16 } color = { CHART_ACCENT } / >
< Text style = { [ secStyles . title , { color : colors.textPrimary } ] } > { title } < / Text >
< / View >
{ children }
< / View >
) ;
}
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 < AdminStats | null > ( null ) ;
const [ loading , setLoading ] = useState ( true ) ;
const [ refreshing , setRefreshing ] = useState ( false ) ;
const [ prodSort , setProdSort ] = useState < ProdSort > ( "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 < ProductStat [ ] > ( ( ) = > {
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 < LoadingSpinner message = "Chargement des statistiques..." / > ;
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 (
< ScrollView
style = { styles . container }
contentContainerStyle = { styles . content }
refreshControl = { < RefreshControl refreshing = { refreshing } onRefresh = { onRefresh } tintColor = { CHART_ACCENT } / > }
>
< Text style = { styles . pageTitle } > Statistiques < / Text >
< Text style = { styles . pageSubtitle } > Activité globale & produits < / Text >
{ /* ── Cartes résumé ── */ }
< View style = { styles . summaryRow } >
< SummaryCard
icon = "receipt-outline"
label = "Commandes totales"
value = { fmtNum ( s ? . total_orders ? ? 0 ) }
color = { CHART_ACCENT }
/ >
< SummaryCard
icon = "cash-outline"
label = "Revenus (terminées)"
value = { fmtEuro ( s ? . total_revenue ? ? 0 ) }
color = { CHART_GREEN }
/ >
< / View >
< View style = { styles . summaryRow } >
< SummaryCard
icon = "trending-up-outline"
label = "Moy. commandes/jour"
value = { ( s ? . avg_per_day ? ? 0 ) . toFixed ( 1 ) }
color = { CHART_BLUE }
/ >
< SummaryCard
icon = "trophy-outline"
label = "Jour de pointe"
value = { s ? . peak_weekday ? ? "—" }
color = { CHART_AMBER }
/ >
< / View >
{ /* ── Évolution 30 jours ── */ }
< Section title = "30 derniers jours" icon = "bar-chart-outline" >
{ stats ? . by_day_30 ? . length ? (
< SparkLine data = { stats . by_day_30 } color = { CHART_ACCENT } / >
) : (
< Text style = { styles . emptyText } > Aucune donnée < / Text >
) }
< / Section >
{ /* ── Commandes par jour de la semaine ── */ }
< Section title = "Jours d'affluence" icon = "calendar-outline" >
{ ( stats ? . by_weekday ? ? [ ] ) . map ( ( w : WeekdayStat ) = > (
< HBar
key = { w . weekday }
label = { w . weekday . slice ( 0 , 3 ) }
value = { w . count }
max = { wdMax }
color = { w . count === wdMax && wdMax > 0 ? CHART_AMBER : CHART_ACCENT }
right = { String ( w . count ) }
/ >
) ) }
{ wdMax > 0 && s ? . peak_weekday && (
< View style = { { flexDirection : "row" , alignItems : "center" , gap : spacing.xs , marginTop : spacing.s } } >
< Ionicons name = "flame-outline" size = { 13 } color = { CHART_AMBER } / >
< Text style = { { color : colors.textMuted , fontSize : fontSize.xs } } >
Pic d ' activité : < Text style = { { color : CHART_AMBER , fontWeight : "600" } } > { s . peak_weekday } < / Text >
< / Text >
< / View >
) }
< / Section >
{ /* ── Top produits ── */ }
< Section title = "Top produits" icon = "cube-outline" >
{ /* Sélecteur tri */ }
< View style = { styles . sortRow } >
{ ( [ "quantity" , "orders" , "revenue" ] as ProdSort [ ] ) . map ( ( key ) = > {
const labels = { quantity : "Quantité" , orders : "Commandes" , revenue : "Revenus" } ;
const active = prodSort === key ;
return (
< TouchableOpacity
key = { key }
style = { [
styles . sortBtn ,
{
backgroundColor : active ? CHART_ACCENT + "22" : "transparent" ,
borderColor : active ? CHART_ACCENT : colors.borderLight ,
} ,
] }
onPress = { ( ) = > setProdSort ( key ) }
>
< Text style = { [ styles . sortBtnText , { color : active ? CHART_ACCENT : colors.textMuted } ] } >
{ labels [ key ] }
< / Text >
< / TouchableOpacity >
) ;
} ) }
< / View >
{ sortedProducts . length === 0 ? (
< Text style = { styles . emptyText } > Aucune donnée produit < / Text >
) : (
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 (
< HBar
key = { p . product_id }
label = { p . name }
value = { val }
max = { prodMax }
color = { color }
right = { rightLabel }
/ >
) ;
} )
) }
{ /* Produit le moins vendu */ }
{ sortedProducts . length > 1 && prodSort === "quantity" && (
< View style = { { flexDirection : "row" , alignItems : "center" , gap : spacing.xs , marginTop : spacing.s } } >
< Ionicons name = "arrow-down-circle-outline" size = { 13 } color = { CHART_RED } / >
< Text style = { { color : colors.textMuted , fontSize : fontSize.xs } } >
Moins vendu : { " " }
< Text style = { { color : CHART_RED , fontWeight : "600" } } >
{ sortedProducts [ sortedProducts . length - 1 ] ? . name }
< / Text >
< / Text >
< / View >
) }
< / Section >
< / ScrollView >
) ;
}