2186 lines
92 KiB
TypeScript
2186 lines
92 KiB
TypeScript
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-là
|
||
</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>
|
||
</>
|
||
);
|
||
}
|