Files
projet_gestion_commande/frontend-admin/src/screens/admin/StatsScreen.tsx
T
Xor290 22a8d5026c
Frontend Admin - EAS Build / build (push) Canceled after 0s
Frontend Client - EAS Build / build (push) Canceled after 0s
Backend - Build & Lint / build (push) Failing after 25m18s
Frontend Web - Build & Lint / build (push) Failing after 9m58s
chore: build
2026-08-06 12:06:05 +02:00

2186 lines
92 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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 (
<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 — 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 (
<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>
<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>
);
}
// ── 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 (
<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.revenue / max) * BAR_H,
d.revenue > 0 ? 3 : 0,
)
: 0,
backgroundColor: color,
borderRadius: 2,
opacity: 0.85,
}}
/>
))}
</View>
<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" },
});
// ── Bouton reset de section ───────────────────────────────────────────────────
function SectionResetBtn({
onPress,
loading,
resetAt,
}: {
onPress: () => void;
loading: boolean;
resetAt?: string;
}) {
const { colors } = useTheme();
return (
<View style={{ alignItems: "flex-end", marginBottom: 6 }}>
<TouchableOpacity
onPress={onPress}
disabled={loading}
style={{
flexDirection: "row",
alignItems: "center",
gap: 4,
paddingHorizontal: 8,
paddingVertical: 3,
borderRadius: 6,
borderWidth: 1,
borderColor: CHART_RED + "66",
backgroundColor: CHART_RED + "11",
}}
>
<Ionicons name="refresh-outline" size={11} color={CHART_RED} />
<Text
style={{
fontSize: 10,
fontWeight: "600",
color: CHART_RED,
}}
>
{loading ? "..." : "Réinitialiser"}
</Text>
</TouchableOpacity>
{resetAt ? (
<Text
style={{
fontSize: 9,
color: colors.textMuted,
marginTop: 2,
}}
>
Depuis le{" "}
{new Date(resetAt).toLocaleDateString("fr-FR", {
day: "2-digit",
month: "2-digit",
year: "numeric",
})}
</Text>
) : null}
</View>
);
}
// ── 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é */}
<View style={ddStyles.chipRow}>
<View
style={[
ddStyles.chip,
{
backgroundColor: CHART_ACCENT + "18",
borderColor: CHART_ACCENT + "44",
},
]}
>
<Ionicons
name="receipt-outline"
size={13}
color={CHART_ACCENT}
/>
<Text
style={[
ddStyles.chipVal,
{ color: colors.textPrimary },
]}
>
{daily.total_orders}
</Text>
<Text
style={[ddStyles.chipLbl, { color: colors.textMuted }]}
>
commandes
</Text>
</View>
<View
style={[
ddStyles.chip,
{
backgroundColor: CHART_BLUE + "18",
borderColor: CHART_BLUE + "44",
},
]}
>
<Ionicons
name="scale-outline"
size={13}
color={CHART_BLUE}
/>
<Text
style={[
ddStyles.chipVal,
{ color: colors.textPrimary },
]}
>
{daily.total_quantity}g
</Text>
<Text
style={[ddStyles.chipLbl, { color: colors.textMuted }]}
>
vendues
</Text>
</View>
<View
style={[
ddStyles.chip,
{
backgroundColor: CHART_GREEN + "18",
borderColor: CHART_GREEN + "44",
},
]}
>
<Ionicons
name="cash-outline"
size={13}
color={CHART_GREEN}
/>
<Text
style={[
ddStyles.chipVal,
{ color: colors.textPrimary },
]}
>
{fmtEuro(daily.total_revenue)}
</Text>
<Text
style={[ddStyles.chipLbl, { color: colors.textMuted }]}
>
revenus
</Text>
</View>
</View>
{!hasData ? (
<Text style={[ddStyles.empty, { color: colors.textMuted }]}>
Aucune commande ce jour-
</Text>
) : (
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 (
<View
key={cat.category}
style={[
ddStyles.catBlock,
isLast && {
marginBottom: 0,
borderBottomWidth: 0,
},
]}
>
{/* En-tête catégorie */}
<View style={ddStyles.catHeader}>
<View
style={{
flexDirection: "row",
alignItems: "center",
gap: spacing.s,
}}
>
<View
style={[
ddStyles.catDot,
{
backgroundColor:
cat.category_color,
},
]}
/>
<Text
style={[
ddStyles.catName,
{ color: colors.textPrimary },
]}
>
{cat.category}
</Text>
</View>
<View
style={{
flexDirection: "row",
alignItems: "center",
gap: spacing.m,
}}
>
<Text
style={[
ddStyles.catMeta,
{ color: colors.textMuted },
]}
>
{cat.total_quantity}g
</Text>
<Text
style={[
ddStyles.catRevenue,
{ color: cat.category_color },
]}
>
{fmtEuro(cat.total_revenue)}
</Text>
</View>
</View>
{/* 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 (
<View
key={prod.product_id}
style={[
ddStyles.prodRow,
pi === cat.products.length - 1 && {
marginBottom: 0,
},
]}
>
<View style={ddStyles.prodTopLine}>
<Text
style={[
ddStyles.prodName,
{
color: isTopInCat
? colors.textPrimary
: colors.textSecondary,
fontWeight: isTopInCat
? "600"
: "400",
},
]}
numberOfLines={1}
>
{prod.name}
</Text>
<View style={ddStyles.prodMeta}>
<Text
style={[
ddStyles.prodQty,
{
color: colors.textMuted,
},
]}
>
{prod.quantity}g ·{" "}
{prod.order_count} cmd
</Text>
<Text
style={[
ddStyles.prodRevenue,
{
color: isTopInCat
? cat.category_color
: colors.textMuted,
},
]}
>
{fmtEuro(prod.revenue)}
</Text>
</View>
</View>
<View
style={[
ddStyles.bar,
{
backgroundColor:
colors.borderLight,
},
]}
>
<View
style={[
ddStyles.barFill,
{
width: `${pct}%`,
backgroundColor:
cat.category_color,
opacity: isTopInCat
? 1
: 0.45,
},
]}
/>
</View>
</View>
);
})}
</View>
);
})
)}
</>
);
}
// ── Section détail du jour (aujourd'hui, dans le flux principal) ────────────
function DailyDetailSection({ daily }: { daily: DailyDetail }) {
const { colors } = useTheme();
return (
<View
style={[
ddStyles.card,
{
backgroundColor: colors.bgCard,
borderColor: colors.borderLight,
},
]}
>
{/* En-tête */}
<View style={ddStyles.header}>
<View
style={{
flexDirection: "row",
alignItems: "center",
gap: spacing.s,
}}
>
<Ionicons
name="today-outline"
size={16}
color={CHART_ACCENT}
/>
<Text
style={[ddStyles.title, { color: colors.textPrimary }]}
>
Activité du jour
</Text>
</View>
<Text style={[ddStyles.date, { color: colors.textMuted }]}>
{daily.date}
</Text>
</View>
<DailyDetailContent daily={daily} />
</View>
);
}
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<DailyDetail | null>(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 (
<Modal
visible={visible}
transparent
animationType="fade"
onRequestClose={onClose}
>
<View style={dayModalStyles.overlay}>
<View
style={[
dayModalStyles.sheet,
{
backgroundColor: colors.bgCard,
borderColor: colors.borderLight,
},
]}
>
<View style={dayModalStyles.header}>
<View
style={{
flexDirection: "row",
alignItems: "center",
gap: spacing.s,
flex: 1,
}}
>
<Ionicons
name="calendar-outline"
size={16}
color={CHART_ACCENT}
/>
<Text
style={[
dayModalStyles.title,
{ color: colors.textPrimary },
]}
numberOfLines={1}
>
{prettyDate}
</Text>
</View>
<TouchableOpacity
onPress={onClose}
style={dayModalStyles.closeBtn}
>
<Ionicons
name="close"
size={18}
color={colors.textMuted}
/>
</TouchableOpacity>
</View>
<ScrollView
style={{ maxHeight: 480 }}
showsVerticalScrollIndicator={false}
>
{loading ? (
<Text
style={{
color: colors.textMuted,
fontSize: fontSize.sm,
textAlign: "center",
paddingVertical: spacing.l,
}}
>
Chargement...
</Text>
) : errored ? (
<Text
style={{
color: CHART_RED,
fontSize: fontSize.sm,
textAlign: "center",
paddingVertical: spacing.l,
}}
>
Impossible de charger le détail de ce jour
</Text>
) : detail ? (
<DailyDetailContent daily={detail} />
) : null}
</ScrollView>
</View>
</View>
</Modal>
);
}
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<string>(() =>
monthKey(new Date()),
);
const [monthly, setMonthly] = useState<MonthlyStats | null>(null);
const [loadingMonth, setLoadingMonth] = useState(false);
const [expanded, setExpanded] = useState(false);
const [selectedDay, setSelectedDay] = useState<string | null>(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 (
<View
style={[
secStyles.card,
{
backgroundColor: colors.bgCard,
borderColor: colors.borderLight,
},
]}
>
<TouchableOpacity
onPress={() => setExpanded((e) => !e)}
style={{
flexDirection: "row",
alignItems: "center",
justifyContent: "space-between",
}}
>
<View style={secStyles.header}>
<Ionicons
name="calendar-number-outline"
size={16}
color={CHART_ACCENT}
/>
<Text
style={[secStyles.title, { color: colors.textPrimary }]}
>
Historique mensuel
</Text>
</View>
<Ionicons
name={
expanded ? "chevron-up-outline" : "chevron-down-outline"
}
size={18}
color={colors.textMuted}
/>
</TouchableOpacity>
{expanded && (
<>
{/* Sélecteur de mois */}
<View
style={{
flexDirection: "row",
alignItems: "center",
justifyContent: "space-between",
marginBottom: spacing.m,
}}
>
<TouchableOpacity
onPress={goPrevMonth}
style={{
padding: spacing.xs,
borderRadius: borderRadius.sm,
borderWidth: 1,
borderColor: colors.borderLight,
}}
>
<Ionicons
name="chevron-back-outline"
size={16}
color={colors.textPrimary}
/>
</TouchableOpacity>
<Text
style={{
color: colors.textPrimary,
fontSize: fontSize.sm,
fontWeight: "600",
textTransform: "capitalize",
}}
>
{monthLabel(monthKeyState)}
</Text>
<TouchableOpacity
onPress={goNextMonth}
disabled={isCurrentMonth}
style={{
padding: spacing.xs,
borderRadius: borderRadius.sm,
borderWidth: 1,
borderColor: colors.borderLight,
opacity: isCurrentMonth ? 0.35 : 1,
}}
>
<Ionicons
name="chevron-forward-outline"
size={16}
color={colors.textPrimary}
/>
</TouchableOpacity>
</View>
{loadingMonth ? (
<Text
style={{
color: colors.textMuted,
fontSize: fontSize.sm,
textAlign: "center",
paddingVertical: spacing.l,
}}
>
Chargement...
</Text>
) : !days.length ? (
<Text
style={{
color: colors.textMuted,
fontSize: fontSize.sm,
textAlign: "center",
paddingVertical: spacing.l,
}}
>
Aucune donnée pour ce mois
</Text>
) : (
<>
{/* Mini-résumé du mois */}
<View
style={{
flexDirection: "row",
gap: spacing.s,
marginBottom: spacing.m,
}}
>
<View
style={[
ddStyles.chip,
{
flex: 1,
backgroundColor:
CHART_ACCENT + "18",
borderColor: CHART_ACCENT + "44",
},
]}
>
<Ionicons
name="receipt-outline"
size={13}
color={CHART_ACCENT}
/>
<Text
style={[
ddStyles.chipVal,
{ color: colors.textPrimary },
]}
>
{monthly?.summary.total_orders ?? 0}
</Text>
<Text
style={[
ddStyles.chipLbl,
{ color: colors.textMuted },
]}
>
commandes
</Text>
</View>
<View
style={[
ddStyles.chip,
{
flex: 1,
backgroundColor: CHART_BLUE + "18",
borderColor: CHART_BLUE + "44",
},
]}
>
<Ionicons
name="scale-outline"
size={13}
color={CHART_BLUE}
/>
<Text
style={[
ddStyles.chipVal,
{ color: colors.textPrimary },
]}
>
{fmtNum(
monthly?.summary.total_quantity ??
0,
)}
g
</Text>
<Text
style={[
ddStyles.chipLbl,
{ color: colors.textMuted },
]}
>
vendues
</Text>
</View>
<View
style={[
ddStyles.chip,
{
flex: 1,
backgroundColor: CHART_GREEN + "18",
borderColor: CHART_GREEN + "44",
},
]}
>
<Ionicons
name="cash-outline"
size={13}
color={CHART_GREEN}
/>
<Text
style={[
ddStyles.chipVal,
{ color: colors.textPrimary },
]}
>
{fmtEuro(
monthly?.summary.total_revenue ?? 0,
)}
</Text>
<Text
style={[
ddStyles.chipLbl,
{ color: colors.textMuted },
]}
>
revenus
</Text>
</View>
</View>
{/* Sparkline revenus du mois */}
<View
style={{
flexDirection: "row",
alignItems: "flex-end",
height: 56,
gap: 1,
marginBottom: 4,
}}
>
{days.map((d) => (
<View
key={d.day}
style={{
flex: 1,
height: Math.max(
(d.revenue / revMax) * 56,
d.revenue > 0 ? 3 : 0,
),
backgroundColor:
best && d.day === best.day
? CHART_AMBER
: CHART_GREEN,
borderRadius: 2,
opacity: 0.85,
}}
/>
))}
</View>
<View
style={{
flexDirection: "row",
justifyContent: "space-between",
marginBottom: spacing.m,
}}
>
<Text
style={{
color: colors.textMuted,
fontSize: 9,
}}
>
{days[0]?.label}
</Text>
<Text
style={{
color: colors.textMuted,
fontSize: 9,
}}
>
{days[days.length - 1]?.label}
</Text>
</View>
{/* 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 (
<TouchableOpacity
key={d.day}
onPress={() =>
hasData && setSelectedDay(d.day)
}
disabled={!hasData}
activeOpacity={0.6}
style={{
flexDirection: "row",
alignItems: "center",
marginBottom: spacing.s,
gap: spacing.s,
}}
>
<Text
style={{
width: 42,
fontSize: fontSize.xs,
color: hasData
? colors.textPrimary
: colors.textMuted,
fontWeight: hasData
? "600"
: "400",
textDecorationLine: hasData
? "underline"
: "none",
}}
>
{d.label}
</Text>
<View
style={{
flex: 1,
height: 14,
borderRadius: 3,
backgroundColor:
colors.borderLight,
overflow: "hidden",
}}
>
<View
style={{
width: `${pct}%`,
height: "100%",
borderRadius: 3,
backgroundColor: isBest
? CHART_AMBER
: CHART_ACCENT,
}}
/>
</View>
<Text
style={{
width: 36,
fontSize: fontSize.xs,
textAlign: "right",
color: colors.textPrimary,
}}
>
{d.count}
</Text>
<Text
style={{
width: 36,
fontSize: fontSize.xs,
textAlign: "right",
color: colors.textMuted,
}}
>
{fmtNum(d.quantity)}g
</Text>
<Text
style={{
width: 60,
fontSize: fontSize.xs,
textAlign: "right",
fontWeight: isBest
? "700"
: "500",
color: isBest
? CHART_AMBER
: colors.textMuted,
}}
>
{fmtEuro(d.revenue)}
</Text>
{isBest && (
<Ionicons
name="flame"
size={11}
color={CHART_AMBER}
/>
)}
{hasData && (
<Ionicons
name="chevron-forward"
size={12}
color={colors.textMuted}
/>
)}
</TouchableOpacity>
);
})}
{best && (
<View
style={{
flexDirection: "row",
alignItems: "center",
gap: spacing.xs,
marginTop: spacing.s,
}}
>
<Ionicons
name="star-outline"
size={13}
color={CHART_AMBER}
/>
<Text
style={{
color: colors.textMuted,
fontSize: fontSize.xs,
}}
>
Meilleure journée du mois :{" "}
<Text
style={{
color: CHART_AMBER,
fontWeight: "600",
}}
>
{best.label} ·{" "}
{fmtEuro(best.revenue)}
</Text>
</Text>
</View>
)}
</>
)}
</>
)}
<DayDetailModal
visible={selectedDay !== null}
dateLabel={selectedDay}
onClose={() => setSelectedDay(null)}
/>
</View>
);
}
// ── 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 [resetting, setResetting] = useState<StatSection | null>(null);
const [prodSort, setProdSort] = useState<ProdSort>("quantity");
const [confirmModal, setConfirmModal] = useState<{
section: StatSection;
label: string;
} | null>(null);
const [errorModal, setErrorModal] = useState<string | null>(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<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,
},
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 <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 (
<>
<AlertModal
visible={confirmModal !== null}
type="confirm"
title={`Réinitialiser — ${confirmModal?.label ?? ""}`}
message={`Les données "${confirmModal?.label ?? ""}" repartiront de zéro.\nLes commandes restent en base.`}
confirmText="Réinitialiser"
cancelText="Annuler"
onClose={() => setConfirmModal(null)}
onConfirm={doReset}
/>
<AlertModal
visible={errorModal !== null}
type="error"
title="Erreur"
message={errorModal ?? ""}
onClose={() => setErrorModal(null)}
/>
<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>
{/* ── Activité du jour ── */}
{stats?.daily_detail && (
<DailyDetailSection daily={stats.daily_detail} />
)}
{/* ── Historique mensuel (jour par jour sur un mois) ── */}
<MonthlyHistorySection />
{/* ── Évolution 30 jours (commandes) ── */}
<Section
title="30 derniers jours — commandes"
icon="bar-chart-outline"
>
<SectionResetBtn
onPress={() => handleReset("commandes", "Commandes")}
loading={resetting === "commandes"}
resetAt={stats?.reset_at_commandes}
/>
{stats?.by_day_30?.length ? (
<SparkLine
data={stats.by_day_30}
color={CHART_ACCENT}
/>
) : (
<Text style={styles.emptyText}>Aucune donnée</Text>
)}
</Section>
{/* ── 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 (
<Section
title="Revenus par jour (30j)"
icon="trending-up-outline"
>
<SectionResetBtn
onPress={() =>
handleReset("revenus", "Revenus")
}
loading={resetting === "revenus"}
resetAt={stats?.reset_at_revenus}
/>
{revData.length ? (
<>
<SparkLineRevenue
data={revData}
color={CHART_GREEN}
/>
<View style={{ marginTop: spacing.m }}>
{[...revData].reverse().map((d) => (
<HBar
key={d.day}
label={d.label}
value={d.revenue}
max={revMax}
color={
best && d.day === best.day
? CHART_GREEN
: CHART_ACCENT + "bb"
}
right={fmtEuro(d.revenue)}
/>
))}
</View>
{best && (
<View
style={{
flexDirection: "row",
alignItems: "center",
gap: spacing.xs,
marginTop: spacing.s,
}}
>
<Ionicons
name="star-outline"
size={13}
color={CHART_GREEN}
/>
<Text
style={{
color: colors.textMuted,
fontSize: fontSize.xs,
}}
>
Meilleure journée :{" "}
<Text
style={{
color: CHART_GREEN,
fontWeight: "600",
}}
>
{best.label} ·{" "}
{fmtEuro(best.revenue)}
</Text>
</Text>
</View>
)}
</>
) : (
<Text style={styles.emptyText}>
Aucune donnée de revenu
</Text>
)}
</Section>
);
})()}
{/* ── Heures d'affluence ── */}
{(() => {
const hourData: HourStat[] = stats?.by_hour ?? [];
const peakHour = hourData.reduce<HourStat | null>(
(best, h) => (!best || h.count > best.count ? h : best),
null,
);
const hourMax = maxOf(hourData.map((h) => h.count));
return (
<Section title="Heures d'affluence" icon="time-outline">
<SectionResetBtn
onPress={() =>
handleReset("heures", "Heures d'affluence")
}
loading={resetting === "heures"}
resetAt={stats?.reset_at_heures}
/>
{hourData.some((h) => h.count > 0) ? (
<>
{hourData.map((h) => (
<HBar
key={h.hour}
label={h.label}
value={h.count}
max={hourMax}
color={
h.count === hourMax &&
hourMax > 0
? CHART_AMBER
: CHART_BLUE
}
right={
h.count > 0
? String(h.count)
: "—"
}
/>
))}
{peakHour && peakHour.count > 0 && (
<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,
}}
>
Heure de pointe :{" "}
<Text
style={{
color: CHART_AMBER,
fontWeight: "600",
}}
>
{peakHour.label} ·{" "}
{peakHour.count} cmd ·{" "}
{fmtEuro(peakHour.revenue)}
</Text>
</Text>
</View>
)}
</>
) : (
<Text style={styles.emptyText}>
Aucune donnée horaire
</Text>
)}
</Section>
);
})()}
{/* ── Commandes par jour de la semaine ── */}
<Section title="Jours d'affluence" icon="calendar-outline">
<SectionResetBtn
onPress={() =>
handleReset("jours", "Jours d'affluence")
}
loading={resetting === "jours"}
resetAt={stats?.reset_at_jours}
/>
{(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>
{/* ── Doses les plus populaires par produit ── */}
{(() => {
const qtyData: ProductQuantityBreakdown[] = (
stats?.by_quantity ?? []
).filter((p) => p.quantities.length >= 1);
if (!qtyData.length) return null;
return (
<Section
title="Doses populaires par produit"
icon="flask-outline"
>
<SectionResetBtn
onPress={() =>
handleReset("doses", "Doses populaires")
}
loading={resetting === "doses"}
resetAt={stats?.reset_at_doses}
/>
{qtyData.map((product) => {
const peakCount =
product.quantities[0]?.order_count ?? 1;
return (
<View
key={product.product_id}
style={{
marginBottom: spacing.m,
paddingBottom: spacing.m,
borderBottomWidth: 1,
borderBottomColor:
colors.borderLight,
}}
>
{/* Nom du produit avec pastille couleur */}
<View
style={{
flexDirection: "row",
alignItems: "center",
gap: spacing.xs,
marginBottom: spacing.s,
}}
>
<View
style={{
width: 10,
height: 10,
borderRadius: 5,
backgroundColor:
product.category_color,
}}
/>
<Text
style={{
color: colors.textPrimary,
fontSize: fontSize.sm,
fontWeight: "600",
}}
numberOfLines={1}
>
{product.name}
</Text>
</View>
{/* 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 (
<View
key={q.quantity}
style={{
flexDirection: "row",
alignItems: "center",
marginBottom: 4,
gap: spacing.s,
}}
>
<Text
style={{
width: 44,
fontSize:
fontSize.xs,
color: isPeak
? product.category_color
: colors.textMuted,
fontWeight: isPeak
? "700"
: "400",
}}
>
{label}
</Text>
<View
style={{
flex: 1,
height: 14,
borderRadius: 3,
backgroundColor:
colors.borderLight,
overflow: "hidden",
}}
>
<View
style={{
width: `${pct}%`,
height: "100%",
borderRadius: 3,
backgroundColor:
isPeak
? product.category_color
: product.category_color +
"66",
}}
/>
</View>
<Text
style={{
width: 54,
fontSize:
fontSize.xs,
textAlign: "right",
color: isPeak
? product.category_color
: colors.textMuted,
fontWeight: isPeak
? "700"
: "400",
}}
>
{q.order_count} cmd
</Text>
{isPeak && (
<Ionicons
name="flame"
size={11}
color={CHART_AMBER}
/>
)}
</View>
);
})}
</View>
);
})}
</Section>
);
})()}
{/* ── Top produits ── */}
<Section title="Top produits" icon="cube-outline">
<SectionResetBtn
onPress={() => handleReset("produits", "Produits")}
loading={resetting === "produits"}
resetAt={stats?.reset_at_produits}
/>
{/* 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) => {
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 (
<HBar
key={p.product_id}
label={p.name}
value={val}
max={prodMax}
color={p.category_color || CHART_ACCENT}
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>
</>
);
}