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
}
)}
>
);
}