feat: add stats page for admin
This commit is contained in:
@@ -0,0 +1,150 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"gestion/db"
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
type weekdayRow struct {
|
||||
DOW int `gorm:"column:dow"`
|
||||
Count int `gorm:"column:count"`
|
||||
}
|
||||
|
||||
type dayRow struct {
|
||||
Day time.Time `gorm:"column:day"`
|
||||
Count int `gorm:"column:count"`
|
||||
}
|
||||
|
||||
type productRow struct {
|
||||
ProductID int `gorm:"column:product_id"`
|
||||
Name string `gorm:"column:name"`
|
||||
Quantity float64 `gorm:"column:total_quantity"`
|
||||
OrderCount int `gorm:"column:order_count"`
|
||||
Revenue float64 `gorm:"column:revenue"`
|
||||
}
|
||||
|
||||
var weekdayNames = []string{"Dimanche", "Lundi", "Mardi", "Mercredi", "Jeudi", "Vendredi", "Samedi"}
|
||||
|
||||
// GetAdminStats returns aggregated order & product statistics for the admin dashboard.
|
||||
func GetAdminStats(c *gin.Context) {
|
||||
database := c.MustGet("database").(*db.Database)
|
||||
gdb := database.GDB
|
||||
|
||||
// ── Commandes par jour de la semaine (all time, non annulées) ──────────────
|
||||
var wdRows []weekdayRow
|
||||
gdb.Raw(`
|
||||
SELECT EXTRACT(DOW FROM created_at)::int AS dow, COUNT(*) AS count
|
||||
FROM commandes
|
||||
WHERE status != 'cancelled'
|
||||
GROUP BY dow
|
||||
ORDER BY dow
|
||||
`).Scan(&wdRows)
|
||||
|
||||
byWeekday := make([]gin.H, 7)
|
||||
wdMap := make(map[int]int, len(wdRows))
|
||||
for _, r := range wdRows {
|
||||
wdMap[r.DOW] = r.Count
|
||||
}
|
||||
peakCount, peakWeekday := 0, ""
|
||||
for i := 0; i < 7; i++ {
|
||||
cnt := wdMap[i]
|
||||
byWeekday[i] = gin.H{"weekday": weekdayNames[i], "count": cnt}
|
||||
if cnt > peakCount {
|
||||
peakCount = cnt
|
||||
peakWeekday = weekdayNames[i]
|
||||
}
|
||||
}
|
||||
|
||||
// ── Commandes par jour sur 30 jours ───────────────────────────────────────
|
||||
var dayRows []dayRow
|
||||
gdb.Raw(`
|
||||
SELECT DATE(created_at) AS day, COUNT(*) AS count
|
||||
FROM commandes
|
||||
WHERE created_at >= NOW() - INTERVAL '30 days'
|
||||
AND status != 'cancelled'
|
||||
GROUP BY DATE(created_at)
|
||||
ORDER BY day
|
||||
`).Scan(&dayRows)
|
||||
|
||||
byDay := make([]gin.H, len(dayRows))
|
||||
for i, r := range dayRows {
|
||||
byDay[i] = gin.H{
|
||||
"day": r.Day.Format("2006-01-02"),
|
||||
"label": r.Day.Format("02/01"),
|
||||
"count": r.Count,
|
||||
}
|
||||
}
|
||||
|
||||
// ── Top produits (quantité vendue, commandes terminées) ───────────────────
|
||||
var prodRows []productRow
|
||||
gdb.Raw(`
|
||||
SELECT
|
||||
ci.product_id,
|
||||
ci.produit AS name,
|
||||
SUM(ci.quantite) AS total_quantity,
|
||||
COUNT(DISTINCT ci.command_id) AS order_count,
|
||||
SUM(ci.prix * ci.quantite) AS revenue
|
||||
FROM command_items ci
|
||||
JOIN commandes c ON c.id = ci.command_id
|
||||
WHERE c.status != 'cancelled'
|
||||
GROUP BY ci.product_id, ci.produit
|
||||
ORDER BY total_quantity DESC
|
||||
LIMIT 15
|
||||
`).Scan(&prodRows)
|
||||
|
||||
topProducts := make([]gin.H, len(prodRows))
|
||||
topProductName := ""
|
||||
for i, r := range prodRows {
|
||||
topProducts[i] = gin.H{
|
||||
"product_id": r.ProductID,
|
||||
"name": r.Name,
|
||||
"quantity": r.Quantity,
|
||||
"order_count": r.OrderCount,
|
||||
"revenue": r.Revenue,
|
||||
}
|
||||
if i == 0 {
|
||||
topProductName = r.Name
|
||||
}
|
||||
}
|
||||
|
||||
// ── Résumé global ─────────────────────────────────────────────────────────
|
||||
var totalOrders int64
|
||||
var totalRevenue float64
|
||||
gdb.Raw(`SELECT COUNT(*) FROM commandes WHERE status != 'cancelled'`).Scan(&totalOrders)
|
||||
gdb.Raw(`SELECT COALESCE(SUM(total_prix), 0) FROM commandes WHERE status = 'approved'`).Scan(&totalRevenue)
|
||||
|
||||
avgPerDay := 0.0
|
||||
if totalOrders > 0 {
|
||||
// average over the last 30 days with data
|
||||
var activeDays int64
|
||||
gdb.Raw(`
|
||||
SELECT COUNT(DISTINCT DATE(created_at))
|
||||
FROM commandes
|
||||
WHERE created_at >= NOW() - INTERVAL '30 days' AND status != 'cancelled'
|
||||
`).Scan(&activeDays)
|
||||
if activeDays > 0 {
|
||||
var last30Count int64
|
||||
gdb.Raw(`
|
||||
SELECT COUNT(*) FROM commandes
|
||||
WHERE created_at >= NOW() - INTERVAL '30 days' AND status != 'cancelled'
|
||||
`).Scan(&last30Count)
|
||||
avgPerDay = float64(last30Count) / float64(activeDays)
|
||||
}
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"summary": gin.H{
|
||||
"total_orders": totalOrders,
|
||||
"total_revenue": totalRevenue,
|
||||
"peak_weekday": peakWeekday,
|
||||
"top_product": topProductName,
|
||||
"avg_per_day": avgPerDay,
|
||||
},
|
||||
"by_weekday": byWeekday,
|
||||
"by_day_30": byDay,
|
||||
"top_products": topProducts,
|
||||
})
|
||||
}
|
||||
@@ -194,6 +194,11 @@ func SetupRoutes(router *gin.Engine, database *db.Database, geoService *services
|
||||
adminGroupV2.POST("/categories", handlers.CreateCategory)
|
||||
adminGroupV2.PUT("/categories/:id", handlers.UpdateCategory)
|
||||
adminGroupV2.DELETE("/categories/:id", handlers.DeleteCategory)
|
||||
// ============================================
|
||||
// STATISTIQUES ADMIN
|
||||
// ============================================
|
||||
adminGroupV2.GET("/stats", handlers.GetAdminStats)
|
||||
|
||||
// ============================================
|
||||
// COMMANDES - GESTION DE BASE
|
||||
// ============================================
|
||||
|
||||
@@ -53,6 +53,30 @@ export const logoutAdmin = async (): Promise<void> => {
|
||||
}
|
||||
};
|
||||
|
||||
// ── Types stats ──────────────────────────────────────────────────────────────
|
||||
export interface StatsSummary {
|
||||
total_orders: number;
|
||||
total_revenue: number;
|
||||
peak_weekday: string;
|
||||
top_product: string;
|
||||
avg_per_day: number;
|
||||
}
|
||||
export interface WeekdayStat { weekday: string; count: number; }
|
||||
export interface DayStat { day: string; label: string; count: number; }
|
||||
export interface ProductStat { product_id: number; name: string; quantity: number; order_count: number; revenue: number; }
|
||||
|
||||
export interface AdminStats {
|
||||
summary: StatsSummary;
|
||||
by_weekday: WeekdayStat[];
|
||||
by_day_30: DayStat[];
|
||||
top_products: ProductStat[];
|
||||
}
|
||||
|
||||
export const getAdminStats = async (): Promise<AdminStats> => {
|
||||
const { data } = await apiClient.get(`${V2}/admin/protected/stats`);
|
||||
return data;
|
||||
};
|
||||
|
||||
export const getAllClients = async (): Promise<ClientResponse[]> => {
|
||||
const { data } = await apiClient.get(`${V2}/admin/protected/all/clients`);
|
||||
return data.clients || [];
|
||||
|
||||
@@ -24,6 +24,7 @@ import { fontSize, spacing } from "../theme";
|
||||
import type { AdminTabParamList, AdminStackParamList } from "./types";
|
||||
|
||||
import DashboardScreen from "../screens/admin/DashboardScreen";
|
||||
import StatsScreen from "../screens/admin/StatsScreen";
|
||||
import OrdersScreen from "../screens/admin/OrdersScreen";
|
||||
import OrderDetailScreen from "../screens/admin/OrderDetailScreen";
|
||||
import UsersScreen from "../screens/admin/UsersScreen";
|
||||
@@ -175,6 +176,16 @@ function AdminTabs() {
|
||||
),
|
||||
}}
|
||||
/>
|
||||
<Tab.Screen
|
||||
name="Stats"
|
||||
component={StatsScreen}
|
||||
options={{
|
||||
title: "Stats",
|
||||
tabBarIcon: ({ color, size }) => (
|
||||
<Ionicons name="bar-chart-outline" size={size} color={color} />
|
||||
),
|
||||
}}
|
||||
/>
|
||||
<Tab.Screen
|
||||
name="Orders"
|
||||
component={OrdersScreen}
|
||||
|
||||
@@ -7,6 +7,7 @@ export type AuthStackParamList = {
|
||||
|
||||
export type AdminTabParamList = {
|
||||
Dashboard: undefined;
|
||||
Stats: undefined;
|
||||
Orders: undefined;
|
||||
Users: undefined;
|
||||
Products: undefined;
|
||||
|
||||
@@ -0,0 +1,336 @@
|
||||
import React, { useState, useCallback, useMemo } from "react";
|
||||
import {
|
||||
View,
|
||||
Text,
|
||||
ScrollView,
|
||||
StyleSheet,
|
||||
RefreshControl,
|
||||
TouchableOpacity,
|
||||
} from "react-native";
|
||||
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 } from "../../api/api_admin";
|
||||
import type { AdminStats, WeekdayStat, DayStat, ProductStat } 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(1)}k€` : `${Math.round(n)}€`;
|
||||
|
||||
// ── 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) ────────────────────────────────────
|
||||
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>
|
||||
{/* Labels début / fin */}
|
||||
<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" },
|
||||
});
|
||||
|
||||
// ── 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 [prodSort, setProdSort] = useState<ProdSort>("quantity");
|
||||
|
||||
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);
|
||||
};
|
||||
|
||||
// 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 },
|
||||
}), [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 (
|
||||
<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 ── */}
|
||||
<Section title="30 derniers jours" icon="bar-chart-outline">
|
||||
{stats?.by_day_30?.length ? (
|
||||
<SparkLine data={stats.by_day_30} color={CHART_ACCENT} />
|
||||
) : (
|
||||
<Text style={styles.emptyText}>Aucune donnée</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>
|
||||
|
||||
{/* ── Top produits ── */}
|
||||
<Section title="Top produits" icon="cube-outline">
|
||||
{/* 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, i) => {
|
||||
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)}`;
|
||||
const color =
|
||||
i === 0 ? CHART_AMBER
|
||||
: i === 1 ? "#94a3b8"
|
||||
: i === 2 ? "#b45309"
|
||||
: CHART_ACCENT;
|
||||
return (
|
||||
<HBar
|
||||
key={p.product_id}
|
||||
label={p.name}
|
||||
value={val}
|
||||
max={prodMax}
|
||||
color={color}
|
||||
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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user