Files
projet_gestion_commande/frontend-admin/src/screens/admin/StatsScreen.tsx
T
Xor290 a1a8e6c3f3
Frontend Admin - EAS Build / build (push) Failing after 56m59s
chore: build
2026-06-21 15:07:04 +02:00

583 lines
30 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, 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 (
<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>
);
}
// ── 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>
{/* ── É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">
{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">
{(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">
{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>
</>
);
}