583 lines
30 KiB
TypeScript
583 lines
30 KiB
TypeScript
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>
|
||
</>
|
||
);
|
||
}
|