diff --git a/frontend-admin/package-lock.json b/frontend-admin/package-lock.json index 12738e27..c36ea046 100644 --- a/frontend-admin/package-lock.json +++ b/frontend-admin/package-lock.json @@ -2757,7 +2757,7 @@ "version": "19.1.17", "resolved": "https://registry.npmjs.org/@types/react/-/react-19.1.17.tgz", "integrity": "sha512-Qec1E3mhALmaspIrhWt9jkQMNdw6bReVu64mjvhbhq2NFPftLPVr+l1SZgmw/66WwBNpDh7ao5AT6gF5v41PFA==", - "devOptional": true, + "dev": true, "dependencies": { "csstype": "^3.0.2" } @@ -3771,7 +3771,7 @@ "version": "3.2.3", "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==", - "devOptional": true + "dev": true }, "node_modules/debug": { "version": "4.4.3", @@ -3966,29 +3966,6 @@ "node": ">= 0.8" } }, - "node_modules/encoding": { - "version": "0.1.13", - "resolved": "https://registry.npmjs.org/encoding/-/encoding-0.1.13.tgz", - "integrity": "sha512-ETBauow1T35Y/WZMkio9jiM0Z5xjHHmJ4XmjZOq1l/dXz3lr2sRn87nJy20RupqSh1F2m3HHPSp8ShIPQJrJ3A==", - "optional": true, - "peer": true, - "dependencies": { - "iconv-lite": "^0.6.2" - } - }, - "node_modules/encoding/node_modules/iconv-lite": { - "version": "0.6.3", - "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.6.3.tgz", - "integrity": "sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==", - "optional": true, - "peer": true, - "dependencies": { - "safer-buffer": ">= 2.1.2 < 3.0.0" - }, - "engines": { - "node": ">=0.10.0" - } - }, "node_modules/end-of-stream": { "version": "1.4.5", "resolved": "https://registry.npmjs.org/end-of-stream/-/end-of-stream-1.4.5.tgz", @@ -7697,13 +7674,6 @@ ], "license": "MIT" }, - "node_modules/safer-buffer": { - "version": "2.1.2", - "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz", - "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==", - "optional": true, - "peer": true - }, "node_modules/sax": { "version": "1.4.4", "resolved": "https://registry.npmjs.org/sax/-/sax-1.4.4.tgz", diff --git a/frontend-admin/src/api/api_admin.ts b/frontend-admin/src/api/api_admin.ts index 0f4d17e5..c5a57d3c 100644 --- a/frontend-admin/src/api/api_admin.ts +++ b/frontend-admin/src/api/api_admin.ts @@ -104,6 +104,8 @@ export interface ProductQuantityBreakdown { quantities: QuantityStat[]; } +export type StatSection = "commandes" | "revenus" | "produits"; + export interface AdminStats { summary: StatsSummary; by_weekday: WeekdayStat[]; @@ -112,6 +114,9 @@ export interface AdminStats { by_hour: HourStat[]; top_products: ProductStat[]; by_quantity: ProductQuantityBreakdown[]; + reset_at_commandes?: string; + reset_at_revenus?: string; + reset_at_produits?: string; } export const getAdminStats = async (): Promise => { @@ -119,6 +124,11 @@ 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}`); + return data; +}; + export const getAllClients = async (): Promise => { const { data } = await apiClient.get(`${V2}/admin/protected/all/clients`); return data.clients || []; diff --git a/frontend-admin/src/api/api_delivery.ts b/frontend-admin/src/api/api_delivery.ts index 427cd367..c4bf6a9e 100644 --- a/frontend-admin/src/api/api_delivery.ts +++ b/frontend-admin/src/api/api_delivery.ts @@ -394,6 +394,31 @@ export const getMyStats = async (): Promise<{ } }; +export interface LivreurRating { + id: number; + order_id: number; + livreur_username: string; + client_username: string; + rating: number; + comment: string; + created_at: string; +} + +export const getMyRatings = async (): Promise<{ + success: boolean; + ratings: LivreurRating[]; + average: number; + count: number; + error?: string; +}> => { + try { + const { data } = await apiClient.get(`${API}/ratings`); + return { success: true, ratings: data.ratings || [], average: data.average || 0, count: data.count || 0 }; + } catch (e: any) { + return { success: false, ratings: [], average: 0, count: 0, error: e?.response?.data?.error || "Erreur" }; + } +}; + export const reportDeliveryIssue = async ( deliveryId: number, issueType: IssueType, diff --git a/frontend-admin/src/navigation/DeliveryNavigator.tsx b/frontend-admin/src/navigation/DeliveryNavigator.tsx index 8086a168..a7697cc7 100644 --- a/frontend-admin/src/navigation/DeliveryNavigator.tsx +++ b/frontend-admin/src/navigation/DeliveryNavigator.tsx @@ -23,6 +23,7 @@ import type { DeliveryTabParamList } from "./types"; import DashboardScreen from "../screens/delivery/DashboardScreen"; import StatsScreen from "../screens/delivery/StatsScreen"; import AlertsScreen from "../screens/delivery/AlertsScreen"; +import RatingsScreen from "../screens/delivery/RatingsScreen"; const Tab = createBottomTabNavigator(); @@ -201,6 +202,20 @@ export default function DeliveryNavigator() { ), }} /> + ( + + ), + }} + /> {/* Modal notifications */} diff --git a/frontend-admin/src/navigation/types.ts b/frontend-admin/src/navigation/types.ts index 3a3b5b56..b6424541 100644 --- a/frontend-admin/src/navigation/types.ts +++ b/frontend-admin/src/navigation/types.ts @@ -36,4 +36,5 @@ export type DeliveryTabParamList = { Dashboard: undefined; Stats: undefined; Alerts: undefined; + Ratings: undefined; }; diff --git a/frontend-admin/src/screens/admin/StatsScreen.tsx b/frontend-admin/src/screens/admin/StatsScreen.tsx index 9635c1c2..357ab9f6 100644 --- a/frontend-admin/src/screens/admin/StatsScreen.tsx +++ b/frontend-admin/src/screens/admin/StatsScreen.tsx @@ -6,6 +6,7 @@ import { StyleSheet, RefreshControl, TouchableOpacity, + Alert, } from "react-native"; import { Ionicons } from "@expo/vector-icons"; import { useFocusEffect } from "@react-navigation/native"; @@ -13,8 +14,8 @@ 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, DayRevenueStat, HourStat, ProductStat, ProductQuantityBreakdown } from "../../api/api_admin"; +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"; @@ -28,7 +29,7 @@ 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)}€`; + n >= 1000 ? `${(n / 1000).toFixed(2)}k€` : `${n.toFixed(2)}€`; // ── Barre horizontale ───────────────────────────────────────────────────────── function HBar({ @@ -163,6 +164,28 @@ const secStyles = StyleSheet.create({ 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 ( + + + + {loading ? "..." : "Réinitialiser"} + + {resetAt ? ( + + Depuis le {new Date(resetAt).toLocaleDateString("fr-FR", { day: "2-digit", month: "2-digit", year: "numeric" })} + + ) : null} + + ); +} + // ── Sélecteur de période top produits ──────────────────────────────────────── type ProdSort = "quantity" | "orders" | "revenue"; @@ -172,6 +195,7 @@ export default function StatsScreen() { 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 loadStats = useCallback(async () => { @@ -190,6 +214,28 @@ export default function StatsScreen() { setRefreshing(false); }; + const handleReset = (section: StatSection, label: string) => { + Alert.alert( + `Réinitialiser — ${label}`, + "Les données de cette section repartiront de zéro. Les commandes restent en base.\n\nConfirmer ?", + [ + { text: "Annuler", style: "cancel" }, + { + text: "Réinitialiser", + style: "destructive", + onPress: async () => { + setResetting(section); + try { + await resetAdminStats(section); + await loadStats(); + } catch { /* ignore */ } + setResetting(null); + }, + }, + ], + ); + }; + // Produits triés selon le sélecteur const sortedProducts = useMemo(() => { if (!stats?.top_products) return []; @@ -210,6 +256,10 @@ export default function StatsScreen() { 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 ; @@ -261,6 +311,11 @@ export default function StatsScreen() { {/* ── Évolution 30 jours (commandes) ── */}
+ handleReset("commandes", "Commandes")} + loading={resetting === "commandes"} + resetAt={stats?.reset_at_commandes} + /> {stats?.by_day_30?.length ? ( ) : ( @@ -275,6 +330,11 @@ export default function StatsScreen() { 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 ? ( <> @@ -432,6 +492,11 @@ export default function StatsScreen() { {/* ── Top produits ── */}
+ handleReset("produits", "Produits")} + loading={resetting === "produits"} + resetAt={stats?.reset_at_produits} + /> {/* Sélecteur tri */} {(["quantity", "orders", "revenue"] as ProdSort[]).map((key) => { diff --git a/frontend-admin/src/screens/delivery/RatingsScreen.tsx b/frontend-admin/src/screens/delivery/RatingsScreen.tsx new file mode 100644 index 00000000..11405f1a --- /dev/null +++ b/frontend-admin/src/screens/delivery/RatingsScreen.tsx @@ -0,0 +1,152 @@ +import React, { useState, useCallback } from "react"; +import { + View, + Text, + StyleSheet, + ScrollView, + RefreshControl, +} from "react-native"; +import { Ionicons } from "@expo/vector-icons"; +import { useFocusEffect } from "@react-navigation/native"; +import { useTheme } from "../../context/ThemeContext"; +import { spacing, fontSize } from "../../theme"; +import { getMyRatings } from "../../api/api_delivery"; +import type { LivreurRating } from "../../api/api_delivery"; +import LoadingSpinner from "../../components/ui/LoadingSpinner"; + +const STAR_COLOR = "#f59e0b"; +const STAR_EMPTY = "#374151"; + +function Stars({ value }: { value: number }) { + return ( + + {[1, 2, 3, 4, 5].map((i) => ( + + ))} + + ); +} + +function formatDate(iso: string): string { + try { + return new Date(iso).toLocaleDateString("fr-FR", { + day: "2-digit", + month: "2-digit", + year: "numeric", + }); + } catch { + return ""; + } +} + +export default function RatingsScreen() { + const { colors } = useTheme(); + const [ratings, setRatings] = useState([]); + const [average, setAverage] = useState(0); + const [loading, setLoading] = useState(true); + const [refreshing, setRefreshing] = useState(false); + + const load = useCallback(async (silent = false) => { + if (!silent) setLoading(true); + const res = await getMyRatings(); + if (res.success) { + setRatings(res.ratings); + setAverage(res.average); + } + setLoading(false); + setRefreshing(false); + }, []); + + useFocusEffect(useCallback(() => { load(); }, [load])); + + const onRefresh = () => { setRefreshing(true); load(true); }; + + const styles = StyleSheet.create({ + container: { flex: 1, backgroundColor: colors.bgPrimary }, + content: { padding: spacing.l, paddingBottom: spacing.xxxl }, + headerCard: { + backgroundColor: colors.bgCard, + borderRadius: 14, + padding: spacing.l, + marginBottom: spacing.l, + alignItems: "center", + borderWidth: 1, + borderColor: colors.borderLight, + }, + avgNumber: { fontSize: 48, fontWeight: "800", color: STAR_COLOR, lineHeight: 56 }, + avgLabel: { fontSize: fontSize.sm, color: colors.textMuted, marginTop: spacing.xs }, + countLabel: { fontSize: fontSize.xs, color: colors.textMuted, marginTop: 4 }, + starsRow: { flexDirection: "row", gap: 4, marginTop: spacing.s }, + card: { + backgroundColor: colors.bgCard, + borderRadius: 12, + padding: spacing.l, + marginBottom: spacing.m, + borderWidth: 1, + borderColor: colors.borderLight, + }, + cardHeader: { flexDirection: "row", justifyContent: "space-between", alignItems: "center", marginBottom: spacing.s }, + client: { fontSize: fontSize.sm, fontWeight: "600", color: colors.textPrimary }, + date: { fontSize: fontSize.xs, color: colors.textMuted }, + orderRef: { fontSize: fontSize.xs, color: colors.textMuted, marginBottom: spacing.s }, + comment: { fontSize: fontSize.sm, color: colors.textSecondary, fontStyle: "italic", marginTop: spacing.s, lineHeight: 20 }, + emptyWrap: { alignItems: "center", paddingVertical: spacing.xxxl }, + emptyText: { color: colors.textMuted, fontSize: fontSize.md, marginTop: spacing.m, textAlign: "center" }, + }); + + if (loading) return ; + + return ( + } + > + {/* Résumé */} + + + {average > 0 ? average.toFixed(1) : "—"} + + + {[1, 2, 3, 4, 5].map((i) => ( + + ))} + + Note moyenne + {ratings.length} avis client{ratings.length > 1 ? "s" : ""} + + + {/* Liste */} + {ratings.length === 0 ? ( + + + Aucun avis reçu pour l'instant + + ) : ( + ratings.map((r) => ( + + + {r.client_username} + {formatDate(r.created_at)} + + Commande #{r.order_id} + + {r.comment ? ( + "{r.comment}" + ) : null} + + )) + )} + + ); +}