From 304263a95e9157117e04ee6cca92413c9d19b750 Mon Sep 17 00:00:00 2001 From: Xor290 Date: Sat, 27 Jun 2026 14:30:58 +0200 Subject: [PATCH] chore: build --- frontend-admin/src/api/api_admin.ts | 61 +- frontend-admin/src/api/types.ts | 256 +- .../src/screens/admin/StatsScreen.tsx | 2170 +++++++++++++---- 3 files changed, 1913 insertions(+), 574 deletions(-) diff --git a/frontend-admin/src/api/api_admin.ts b/frontend-admin/src/api/api_admin.ts index 6d352e5f..f30be2bc 100644 --- a/frontend-admin/src/api/api_admin.ts +++ b/frontend-admin/src/api/api_admin.ts @@ -8,6 +8,7 @@ import type { DeliveryPerson, DeliveryPersonsStats, Alert, + MonthlyStats, } from "./types"; const V2 = `${API_BASE_URL}/api/v2`; @@ -126,7 +127,13 @@ export interface DailyDetail { categories: DailyCategoryDetail[]; } -export type StatSection = "commandes" | "revenus" | "produits" | "heures" | "jours" | "doses"; +export type StatSection = + | "commandes" + | "revenus" + | "produits" + | "heures" + | "jours" + | "doses"; export interface AdminStats { summary: StatsSummary; @@ -150,8 +157,12 @@ export const getAdminStats = async (): Promise => { return data; }; -export const resetAdminStats = async (section: StatSection): Promise<{ success: boolean; reset_at: string }> => { - const { data } = await apiClient.post(`${V2}/admin/protected/stats/reset/${section}`); +export const resetAdminStats = async ( + section: StatSection, +): Promise<{ success: boolean; reset_at: string }> => { + const { data } = await apiClient.post( + `${V2}/admin/protected/stats/reset/${section}`, + ); return data; }; @@ -273,9 +284,17 @@ export const validateCommand = async (commandId: number) => { `${V2}/admin/protected/orders/${commandId}/force-validate`, { command_id: commandId }, ); - const validated = (data.validated ?? []) as { command_id: number; points_awarded: number }[]; - const points = validated.find((v) => v.command_id === commandId)?.points_awarded ?? 0; - return { success: true, points_awarded: points, validated_count: data.validated_count ?? 0 }; + const validated = (data.validated ?? []) as { + command_id: number; + points_awarded: number; + }[]; + const points = + validated.find((v) => v.command_id === commandId)?.points_awarded ?? 0; + return { + success: true, + points_awarded: points, + validated_count: data.validated_count ?? 0, + }; }; export const proposeAddressChangeAdmin = async ( @@ -300,7 +319,6 @@ export const notifyClientToDescend = async (commandId: number) => { }; }; - // ============================================ export const getAvailableDeliveryPersons = async () => { @@ -335,8 +353,17 @@ export const getDeliveryPersonDetails = async (username: string) => { return data.deliveryman || data; }; -export const getLivreurRatings = async (username: string): Promise<{ - ratings: { id: number; order_id: number; client_username: string; rating: number; comment: string; created_at: string }[]; +export const getLivreurRatings = async ( + username: string, +): Promise<{ + ratings: { + id: number; + order_id: number; + client_username: string; + rating: number; + comment: string; + created_at: string; + }[]; average: number; count: number; }> => { @@ -963,7 +990,9 @@ export const reorderCategoriesAdmin = async ( ids: number[], ): Promise<{ success: boolean; error?: string }> => { try { - await apiClient.put(`${V2}/admin/protected/categories/reorder`, { ids }); + await apiClient.put(`${V2}/admin/protected/categories/reorder`, { + ids, + }); return { success: true }; } catch (error: any) { return { @@ -1223,3 +1252,15 @@ export const updateSettings = async ( }; } }; + +export const getAdminStatsByMonth = async ( + month?: string, +): Promise => { + const { data } = await apiClient.get( + `${V2}/admin/protected/stats/monthly`, + { + params: month ? { month } : undefined, + }, + ); + return data; +}; diff --git a/frontend-admin/src/api/types.ts b/frontend-admin/src/api/types.ts index 59d4539e..0ae5f5ee 100644 --- a/frontend-admin/src/api/types.ts +++ b/frontend-admin/src/api/types.ts @@ -1,166 +1,194 @@ // Shared types for admin panel export interface AdminUser { - id: number; - username: string; - role: string; + id: number; + username: string; + role: string; } export interface AuthResponse { - success: boolean; - message?: string; - access_token?: string; - token_type?: string; - expires_in?: number; - user?: AdminUser; + success: boolean; + message?: string; + access_token?: string; + token_type?: string; + expires_in?: number; + user?: AdminUser; } export interface ClientResponse { - id: number; - username: string; - nom: string; - prenom: string; - telephone: string; - adresse?: string; - role?: string; - created_at?: string; - updated_at?: string; - command: number; - points_extra: Record; - amende: number; - cancellations_count: number; - last_penalty_reason?: string; - referral_balance?: number; + id: number; + username: string; + nom: string; + prenom: string; + telephone: string; + adresse?: string; + role?: string; + created_at?: string; + updated_at?: string; + command: number; + points_extra: Record; + amende: number; + cancellations_count: number; + last_penalty_reason?: string; + referral_balance?: number; } export interface CommandResponse { - id: number; - client_order_number?: number; - username: string; - status: string; - adresse: string; - total_prix: number; - livreur_assign?: string | null; - proposed_address?: string | null; - address_proposal_status?: string; - referral_used?: number; - cancel_reason?: string; - created_at: string; - updated_at: string; + id: number; + client_order_number?: number; + username: string; + status: string; + adresse: string; + total_prix: number; + livreur_assign?: string | null; + proposed_address?: string | null; + address_proposal_status?: string; + referral_used?: number; + cancel_reason?: string; + created_at: string; + updated_at: string; } export interface OrderItem { - id: number; - command_id: number; - product_id: number; - product_name: string; - quantity: number; - price: number; - status: string; - created_at: string; + id: number; + command_id: number; + product_id: number; + product_name: string; + quantity: number; + price: number; + status: string; + created_at: string; } export interface Alert { - id: number; - username: string; - status: string; - message: string; - created_at: string; - updated_at: string; + id: number; + username: string; + status: string; + message: string; + created_at: string; + updated_at: string; } export interface DeliveryPerson { - id: number; - username: string; - status: 'available' | 'busy' | 'offline'; - location: { - latitude: number; - longitude: number; - last_update: string; - is_recent: boolean; - }; - stats: { - total_deliveries: number; - completed_today: number; - queue_size: number; - current_command: number | null; - }; + id: number; + username: string; + status: "available" | "busy" | "offline"; + location: { + latitude: number; + longitude: number; + last_update: string; + is_recent: boolean; + }; + stats: { + total_deliveries: number; + completed_today: number; + queue_size: number; + current_command: number | null; + }; } export interface DeliveryPersonsStats { - total: number; - available: number; - busy: number; - offline: number; - active_deliveries: number; + total: number; + available: number; + busy: number; + offline: number; + active_deliveries: number; } export interface Product { - id: number; - name: string; - description?: string; - category: string; - stock: number; - unit: string; - prices?: Array<{ id?: number; quantity: number; price: number; active_price?: boolean }>; - media?: Array<{ url: string; type: string; id?: number; created_at?: string }>; - coming_soon?: boolean; - created_at?: string; - updated_at?: string; + id: number; + name: string; + description?: string; + category: string; + stock: number; + unit: string; + prices?: Array<{ + id?: number; + quantity: number; + price: number; + active_price?: boolean; + }>; + media?: Array<{ + url: string; + type: string; + id?: number; + created_at?: string; + }>; + coming_soon?: boolean; + created_at?: string; + updated_at?: string; } export interface DeliveryStatus { - status: 'available' | 'busy' | 'offline'; - current_command?: number; - last_update?: number; + status: "available" | "busy" | "offline"; + current_command?: number; + last_update?: number; } export interface QueueInfo { - queue_size: number; - commands: any[]; + queue_size: number; + commands: any[]; } export interface DeliveryItemProduct { - produit: string; - quantite: number; - prix: number; + produit: string; + quantite: number; + prix: number; } export interface DeliveryItem { - id: number; - status: string; - adresse: string; - total_prix: number; - referral_used?: number; - created_at: string; - updated_at: string; - eta?: string; - items?: DeliveryItemProduct[]; - items_count?: number; + id: number; + status: string; + adresse: string; + total_prix: number; + referral_used?: number; + created_at: string; + updated_at: string; + eta?: string; + items?: DeliveryItemProduct[]; + items_count?: number; } export interface ClientInfo { - username: string; - nom?: string; - prenom?: string; - telephone?: string; + username: string; + nom?: string; + prenom?: string; + telephone?: string; } export interface DeliveryDetails { - delivery: DeliveryItem; - client_info: ClientInfo; + delivery: DeliveryItem; + client_info: ClientInfo; } export interface PenaltyInfo { - client_username: string; - current_amende: number; - cancellations_count: number; - next_penalty: number; + client_username: string; + current_amende: number; + cancellations_count: number; + next_penalty: number; } export interface MapLinks { - google_maps: string; - waze: string; - apple_maps: string; - openstreetmap: string; + google_maps: string; + waze: string; + apple_maps: string; + openstreetmap: string; +} + +export interface MonthlyDayStat { + day: string; // "2026-06-05" + label: string; // "05/06" + count: number; + revenue: number; + quantity: number; +} +export interface MonthlyStatsSummary { + total_orders: number; + total_revenue: number; + total_quantity: number; +} +export interface MonthlyStats { + month: string; // "2026-06" + summary: MonthlyStatsSummary; + by_day: MonthlyDayStat[]; } diff --git a/frontend-admin/src/screens/admin/StatsScreen.tsx b/frontend-admin/src/screens/admin/StatsScreen.tsx index 1b7e288a..9cb98a95 100644 --- a/frontend-admin/src/screens/admin/StatsScreen.tsx +++ b/frontend-admin/src/screens/admin/StatsScreen.tsx @@ -1,4 +1,4 @@ -import React, { useState, useCallback, useMemo } from "react"; +import React, { useState, useCallback, useEffect, useMemo } from "react"; import { View, Text, @@ -14,18 +14,34 @@ 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, DailyDetail, DailyCategoryDetail } from "../../api/api_admin"; +import { + getAdminStats, + resetAdminStats, + getAdminStatsByMonth, +} 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"; +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 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) => @@ -33,19 +49,40 @@ const fmtEuro = (n: number) => // ── Barre horizontale ───────────────────────────────────────────────────────── function HBar({ - label, value, max, color, right, + label, + value, + max, + color, + right, }: { - label: string; value: number; max: number; color: string; right?: string; + 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 ( - + {label} - - + + {right ?? fmtNum(value)} @@ -54,10 +91,15 @@ function HBar({ ); } const hBarStyles = StyleSheet.create({ - row: { flexDirection: "row", alignItems: "center", marginBottom: spacing.s, gap: spacing.s }, + 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 }, + fill: { height: "100%", borderRadius: 4 }, value: { width: 46, fontSize: fontSize.xs, textAlign: "right" }, }); @@ -69,13 +111,26 @@ function SparkLine({ data, color }: { data: DayStat[]; color: string }) { const BAR_H = 56; return ( - + {data.map((d, i) => ( 0 ? Math.max((d.count / max) * BAR_H, d.count > 0 ? 3 : 0) : 0, + height: + max > 0 + ? Math.max( + (d.count / max) * BAR_H, + d.count > 0 ? 3 : 0, + ) + : 0, backgroundColor: color, borderRadius: 2, opacity: 0.85, @@ -83,29 +138,58 @@ function SparkLine({ data, color }: { data: DayStat[]; color: string }) { /> ))} - - {data[0]?.label} - {data[data.length - 1]?.label} + + + {data[0]?.label} + + + {data[data.length - 1]?.label} + ); } // ── Sparkline revenus 30 jours ──────────────────────────────────────────────── -function SparkLineRevenue({ data, color }: { data: DayRevenueStat[]; color: string }) { +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 ( - + {data.map((d, i) => ( 0 ? Math.max((d.revenue / max) * BAR_H, d.revenue > 0 ? 3 : 0) : 0, + height: + max > 0 + ? Math.max( + (d.revenue / max) * BAR_H, + d.revenue > 0 ? 3 : 0, + ) + : 0, backgroundColor: color, borderRadius: 2, opacity: 0.85, @@ -113,9 +197,19 @@ function SparkLineRevenue({ data, color }: { data: DayRevenueStat[]; color: stri /> ))} - - {data[0]?.label} - {data[data.length - 1]?.label} + + + {data[0]?.label} + + + {data[data.length - 1]?.label} + ); @@ -123,63 +217,163 @@ function SparkLineRevenue({ data, color }: { data: DayRevenueStat[]; color: stri // ── Carte résumé ────────────────────────────────────────────────────────────── function SummaryCard({ - icon, label, value, color, + icon, + label, + value, + color, }: { - icon: keyof typeof Ionicons.glyphMap; label: string; value: string; color: string; + icon: keyof typeof Ionicons.glyphMap; + label: string; + value: string; + color: string; }) { const { colors } = useTheme(); return ( - - + + - {value} - {label} + + {value} + + + {label} + ); } 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" }, + 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 }) { +function Section({ + title, + icon, + children, +}: { + title: string; + icon: keyof typeof Ionicons.glyphMap; + children: React.ReactNode; +}) { const { colors } = useTheme(); return ( - + - {title} + + {title} + {children} ); } 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" }, + 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 }) { +function SectionResetBtn({ + onPress, + loading, + resetAt, +}: { + onPress: () => void; + loading: boolean; + resetAt?: string; +}) { const { colors } = useTheme(); return ( - {loading ? "..." : "Réinitialiser"} + + {loading ? "..." : "Réinitialiser"} + {resetAt ? ( - - Depuis le {new Date(resetAt).toLocaleDateString("fr-FR", { day: "2-digit", month: "2-digit", year: "numeric" })} + + Depuis le{" "} + {new Date(resetAt).toLocaleDateString("fr-FR", { + day: "2-digit", + month: "2-digit", + year: "numeric", + })} ) : null} @@ -192,79 +386,288 @@ function DailyDetailSection({ daily }: { daily: DailyDetail }) { const hasData = daily.categories.length > 0; return ( - + {/* En-tête */} - - - Activité du jour + + + + Activité du jour + - {daily.date} + + {daily.date} + {/* Mini-résumé */} - - - {daily.total_orders} - commandes + + + + {daily.total_orders} + + + commandes + - - - {daily.total_quantity}g - vendues + + + + {daily.total_quantity}g + + + vendues + - - - {fmtEuro(daily.total_revenue)} - revenus + + + + {fmtEuro(daily.total_revenue)} + + + revenus + {!hasData ? ( - Aucune commande aujourd'hui + + Aucune commande aujourd'hui + ) : ( daily.categories.map((cat: DailyCategoryDetail, ci: number) => { - const catMax = Math.max(...cat.products.map((p) => p.quantity), 1); + const catMax = Math.max( + ...cat.products.map((p) => p.quantity), + 1, + ); const isLast = ci === daily.categories.length - 1; return ( - + {/* En-tête catégorie */} - - - {cat.category} + + + + {cat.category} + - - {cat.total_quantity}g - {fmtEuro(cat.total_revenue)} + + + {cat.total_quantity}g + + + {fmtEuro(cat.total_revenue)} + {/* Lignes produits */} {cat.products.map((prod, pi) => { - const pct = catMax > 0 ? Math.max((prod.quantity / catMax) * 100, prod.quantity > 0 ? 3 : 0) : 0; + const pct = + catMax > 0 + ? Math.max( + (prod.quantity / catMax) * 100, + prod.quantity > 0 ? 3 : 0, + ) + : 0; const isTopInCat = pi === 0; return ( - + {prod.name} - - {prod.quantity}g · {prod.order_count} cmd + + {prod.quantity}g ·{" "} + {prod.order_count} cmd - + {fmtEuro(prod.revenue)} - - + + ); @@ -278,54 +681,581 @@ function DailyDetailSection({ daily }: { daily: DailyDetail }) { } 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 }, + 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 }, + 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 }, }); +// ── 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(() => + monthKey(new Date()), + ); + const [monthly, setMonthly] = useState(null); + const [loadingMonth, setLoadingMonth] = useState(false); + const [expanded, setExpanded] = useState(false); + + 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 ( + + setExpanded((e) => !e)} + style={{ + flexDirection: "row", + alignItems: "center", + justifyContent: "space-between", + }} + > + + + + Historique mensuel + + + + + + {expanded && ( + <> + {/* Sélecteur de mois */} + + + + + + {monthLabel(monthKeyState)} + + + + + + + {loadingMonth ? ( + + Chargement... + + ) : !days.length ? ( + + Aucune donnée pour ce mois + + ) : ( + <> + {/* Mini-résumé du mois */} + + + + + {monthly?.summary.total_orders ?? 0} + + + commandes + + + + + + {fmtNum( + monthly?.summary.total_quantity ?? + 0, + )} + g + + + vendues + + + + + + {fmtEuro( + monthly?.summary.total_revenue ?? 0, + )} + + + revenus + + + + + {/* Sparkline revenus du mois */} + + {days.map((d) => ( + 0 ? 3 : 0, + ), + backgroundColor: + best && d.day === best.day + ? CHART_AMBER + : CHART_GREEN, + borderRadius: 2, + opacity: 0.85, + }} + /> + ))} + + + + {days[0]?.label} + + + {days[days.length - 1]?.label} + + + + {/* Liste détaillée jour par jour */} + {[...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; + return ( + + + {d.label} + + + + + + {d.count} + + + {fmtNum(d.quantity)}g + + + {fmtEuro(d.revenue)} + + {isBest && ( + + )} + + ); + })} + + {best && ( + + + + Meilleure journée du mois :{" "} + + {best.label} ·{" "} + {fmtEuro(best.revenue)} + + + + )} + + )} + + )} + + ); +} + // ── Sélecteur de période top produits ──────────────────────────────────────── type ProdSort = "quantity" | "orders" | "revenue"; // ═══════════════════════════════════════════════════════════════════════════════ export default function StatsScreen() { const { colors } = useTheme(); - const [stats, setStats] = useState(null); - const [loading, setLoading] = useState(true); + const [stats, setStats] = useState(null); + const [loading, setLoading] = useState(true); const [refreshing, setRefreshing] = useState(false); const [resetting, setResetting] = useState(null); const [prodSort, setProdSort] = useState("quantity"); - const [confirmModal, setConfirmModal] = useState<{ section: StatSection; label: string } | null>(null); + const [confirmModal, setConfirmModal] = useState<{ + section: StatSection; + label: string; + } | null>(null); const [errorModal, setErrorModal] = useState(null); const loadStats = useCallback(async () => { try { const data = await getAdminStats(); setStats(data); - } catch { /* ignore */ } + } catch { + /* ignore */ + } setLoading(false); }, []); - useFocusEffect(useCallback(() => { loadStats(); }, [loadStats])); + useFocusEffect( + useCallback(() => { + loadStats(); + }, [loadStats]), + ); const onRefresh = async () => { setRefreshing(true); @@ -346,7 +1276,11 @@ export default function StatsScreen() { await resetAdminStats(section); await loadStats(); } catch (e: any) { - setErrorModal(e?.response?.data?.error || e?.message || "Impossible de réinitialiser"); + setErrorModal( + e?.response?.data?.error || + e?.message || + "Impossible de réinitialiser", + ); } setResetting(null); }; @@ -355,365 +1289,701 @@ export default function StatsScreen() { const sortedProducts = useMemo(() => { if (!stats?.top_products) return []; return [...stats.top_products].sort((a, b) => { - if (prodSort === "orders") return b.order_count - a.order_count; + 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]); + 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 ; + if (loading) + return ; 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, - )); + 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 ( <> - setConfirmModal(null)} - onConfirm={doReset} - /> - setErrorModal(null)} - /> - } - > - Statistiques - Activité globale & produits - - {/* ── Cartes résumé ── */} - - - - - - - - - - {/* ── Activité du jour ── */} - {stats?.daily_detail && ( - - )} - - {/* ── Évolution 30 jours (commandes) ── */} -
- handleReset("commandes", "Commandes")} - loading={resetting === "commandes"} - resetAt={stats?.reset_at_commandes} - /> - {stats?.by_day_30?.length ? ( - - ) : ( - Aucune donnée - )} -
- - {/* ── 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 ( -
- handleReset("revenus", "Revenus")} - loading={resetting === "revenus"} - resetAt={stats?.reset_at_revenus} - /> - {revData.length ? ( - <> - - - {[...revData].reverse().map((d) => ( - - ))} - - {best && ( - - - - Meilleure journée :{" "} - - {best.label} · {fmtEuro(best.revenue)} - - - - )} - - ) : ( - Aucune donnée de revenu - )} -
- ); - })()} - - {/* ── Heures d'affluence ── */} - {(() => { - const hourData: HourStat[] = stats?.by_hour ?? []; - const peakHour = hourData.reduce( - (best, h) => (!best || h.count > best.count ? h : best), - null, - ); - const hourMax = maxOf(hourData.map((h) => h.count)); - return ( -
- handleReset("heures", "Heures d'affluence")} - loading={resetting === "heures"} - resetAt={stats?.reset_at_heures} - /> - {hourData.some((h) => h.count > 0) ? ( - <> - {hourData.map((h) => ( - 0 ? CHART_AMBER : CHART_BLUE} - right={h.count > 0 ? String(h.count) : "—"} - /> - ))} - {peakHour && peakHour.count > 0 && ( - - - - Heure de pointe :{" "} - - {peakHour.label} · {peakHour.count} cmd · {fmtEuro(peakHour.revenue)} - - - - )} - - ) : ( - Aucune donnée horaire - )} -
- ); - })()} - - {/* ── Commandes par jour de la semaine ── */} -
- handleReset("jours", "Jours d'affluence")} - loading={resetting === "jours"} - resetAt={stats?.reset_at_jours} - /> - {(stats?.by_weekday ?? []).map((w: WeekdayStat) => ( - 0 ? CHART_AMBER : CHART_ACCENT} - right={String(w.count)} + setConfirmModal(null)} + onConfirm={doReset} + /> + setErrorModal(null)} + /> + - ))} - {wdMax > 0 && s?.peak_weekday && ( - - - - Pic d'activité : {s.peak_weekday} - - - )} -
+ } + > + Statistiques + + Activité globale & produits + - {/* ── Doses les plus populaires par produit ── */} - {(() => { - const qtyData: ProductQuantityBreakdown[] = (stats?.by_quantity ?? []).filter( - (p) => p.quantities.length >= 1, - ); - if (!qtyData.length) return null; - return ( -
- handleReset("doses", "Doses populaires")} - loading={resetting === "doses"} - resetAt={stats?.reset_at_doses} - /> - {qtyData.map((product) => { - const peakCount = product.quantities[0]?.order_count ?? 1; - return ( - - {/* Nom du produit avec pastille couleur */} - - - - {product.name} - - - {/* 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 ( - - - {label} - - - - - - {q.order_count} cmd - - {isPeak && ( - - )} - - ); - })} - - ); - })} -
- ); - })()} - - {/* ── Top produits ── */} -
- handleReset("produits", "Produits")} - loading={resetting === "produits"} - resetAt={stats?.reset_at_produits} - /> - {/* Sélecteur tri */} - - {(["quantity", "orders", "revenue"] as ProdSort[]).map((key) => { - const labels = { quantity: "Quantité", orders: "Commandes", revenue: "Revenus" }; - const active = prodSort === key; - return ( - setProdSort(key)} - > - - {labels[key]} - - - ); - })} + {/* ── Cartes résumé ── */} + + + + + + + - {sortedProducts.length === 0 ? ( - Aucune donnée produit - ) : ( - 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 ( - - ); - }) + {/* ── Activité du jour ── */} + {stats?.daily_detail && ( + )} - {/* Produit le moins vendu */} - {sortedProducts.length > 1 && prodSort === "quantity" && ( - - - - Moins vendu :{" "} - - {sortedProducts[sortedProducts.length - 1]?.name} + {/* ── Historique mensuel (jour par jour sur un mois) ── */} + + + {/* ── Évolution 30 jours (commandes) ── */} +
+ handleReset("commandes", "Commandes")} + loading={resetting === "commandes"} + resetAt={stats?.reset_at_commandes} + /> + {stats?.by_day_30?.length ? ( + + ) : ( + Aucune donnée + )} +
+ + {/* ── 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 ( +
+ + handleReset("revenus", "Revenus") + } + loading={resetting === "revenus"} + resetAt={stats?.reset_at_revenus} + /> + {revData.length ? ( + <> + + + {[...revData].reverse().map((d) => ( + + ))} + + {best && ( + + + + Meilleure journée :{" "} + + {best.label} ·{" "} + {fmtEuro(best.revenue)} + + + + )} + + ) : ( + + Aucune donnée de revenu + + )} +
+ ); + })()} + + {/* ── Heures d'affluence ── */} + {(() => { + const hourData: HourStat[] = stats?.by_hour ?? []; + const peakHour = hourData.reduce( + (best, h) => (!best || h.count > best.count ? h : best), + null, + ); + const hourMax = maxOf(hourData.map((h) => h.count)); + return ( +
+ + handleReset("heures", "Heures d'affluence") + } + loading={resetting === "heures"} + resetAt={stats?.reset_at_heures} + /> + {hourData.some((h) => h.count > 0) ? ( + <> + {hourData.map((h) => ( + 0 + ? CHART_AMBER + : CHART_BLUE + } + right={ + h.count > 0 + ? String(h.count) + : "—" + } + /> + ))} + {peakHour && peakHour.count > 0 && ( + + + + Heure de pointe :{" "} + + {peakHour.label} ·{" "} + {peakHour.count} cmd ·{" "} + {fmtEuro(peakHour.revenue)} + + + + )} + + ) : ( + + Aucune donnée horaire + + )} +
+ ); + })()} + + {/* ── Commandes par jour de la semaine ── */} +
+ + handleReset("jours", "Jours d'affluence") + } + loading={resetting === "jours"} + resetAt={stats?.reset_at_jours} + /> + {(stats?.by_weekday ?? []).map((w: WeekdayStat) => ( + 0 + ? CHART_AMBER + : CHART_ACCENT + } + right={String(w.count)} + /> + ))} + {wdMax > 0 && s?.peak_weekday && ( + + + + Pic d'activité :{" "} + + {s.peak_weekday} + - + + )} +
+ + {/* ── Doses les plus populaires par produit ── */} + {(() => { + const qtyData: ProductQuantityBreakdown[] = ( + stats?.by_quantity ?? [] + ).filter((p) => p.quantities.length >= 1); + if (!qtyData.length) return null; + return ( +
+ + handleReset("doses", "Doses populaires") + } + loading={resetting === "doses"} + resetAt={stats?.reset_at_doses} + /> + {qtyData.map((product) => { + const peakCount = + product.quantities[0]?.order_count ?? 1; + return ( + + {/* Nom du produit avec pastille couleur */} + + + + {product.name} + + + {/* 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 ( + + + {label} + + + + + + {q.order_count} cmd + + {isPeak && ( + + )} + + ); + })} + + ); + })} +
+ ); + })()} + + {/* ── Top produits ── */} +
+ handleReset("produits", "Produits")} + loading={resetting === "produits"} + resetAt={stats?.reset_at_produits} + /> + {/* Sélecteur tri */} + + {(["quantity", "orders", "revenue"] as ProdSort[]).map( + (key) => { + const labels = { + quantity: "Quantité", + orders: "Commandes", + revenue: "Revenus", + }; + const active = prodSort === key; + return ( + setProdSort(key)} + > + + {labels[key]} + + + ); + }, + )} - )} -
- + + {sortedProducts.length === 0 ? ( + + Aucune donnée produit + + ) : ( + 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 ( + + ); + }) + )} + + {/* Produit le moins vendu */} + {sortedProducts.length > 1 && prodSort === "quantity" && ( + + + + Moins vendu :{" "} + + { + sortedProducts[ + sortedProducts.length - 1 + ]?.name + } + + + + )} +
+
); }