This commit is contained in:
Generated
+2
-32
@@ -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",
|
||||
|
||||
@@ -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<AdminStats> => {
|
||||
@@ -119,6 +124,11 @@ export const getAdminStats = async (): Promise<AdminStats> => {
|
||||
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<ClientResponse[]> => {
|
||||
const { data } = await apiClient.get(`${V2}/admin/protected/all/clients`);
|
||||
return data.clients || [];
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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<DeliveryTabParamList>();
|
||||
|
||||
@@ -201,6 +202,20 @@ export default function DeliveryNavigator() {
|
||||
),
|
||||
}}
|
||||
/>
|
||||
<Tab.Screen
|
||||
name="Ratings"
|
||||
component={RatingsScreen}
|
||||
options={{
|
||||
title: "Mes avis",
|
||||
tabBarIcon: ({ color, size }) => (
|
||||
<Ionicons
|
||||
name="star-outline"
|
||||
size={size}
|
||||
color={color}
|
||||
/>
|
||||
),
|
||||
}}
|
||||
/>
|
||||
</Tab.Navigator>
|
||||
|
||||
{/* Modal notifications */}
|
||||
|
||||
@@ -36,4 +36,5 @@ export type DeliveryTabParamList = {
|
||||
Dashboard: undefined;
|
||||
Stats: undefined;
|
||||
Alerts: undefined;
|
||||
Ratings: undefined;
|
||||
};
|
||||
|
||||
@@ -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 (
|
||||
<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";
|
||||
|
||||
@@ -172,6 +195,7 @@ export default function StatsScreen() {
|
||||
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 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<ProductStat[]>(() => {
|
||||
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 <LoadingSpinner message="Chargement des statistiques..." />;
|
||||
@@ -261,6 +311,11 @@ export default function StatsScreen() {
|
||||
|
||||
{/* ── É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} />
|
||||
) : (
|
||||
@@ -275,6 +330,11 @@ export default function StatsScreen() {
|
||||
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} />
|
||||
@@ -432,6 +492,11 @@ export default function StatsScreen() {
|
||||
|
||||
{/* ── 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) => {
|
||||
|
||||
@@ -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 (
|
||||
<View style={{ flexDirection: "row", gap: 2 }}>
|
||||
{[1, 2, 3, 4, 5].map((i) => (
|
||||
<Ionicons
|
||||
key={i}
|
||||
name={i <= value ? "star" : "star-outline"}
|
||||
size={14}
|
||||
color={i <= value ? STAR_COLOR : STAR_EMPTY}
|
||||
/>
|
||||
))}
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
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<LivreurRating[]>([]);
|
||||
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 <LoadingSpinner message="Chargement des avis..." />;
|
||||
|
||||
return (
|
||||
<ScrollView
|
||||
style={styles.container}
|
||||
contentContainerStyle={styles.content}
|
||||
refreshControl={<RefreshControl refreshing={refreshing} onRefresh={onRefresh} tintColor={STAR_COLOR} />}
|
||||
>
|
||||
{/* Résumé */}
|
||||
<View style={styles.headerCard}>
|
||||
<Text style={styles.avgNumber}>
|
||||
{average > 0 ? average.toFixed(1) : "—"}
|
||||
</Text>
|
||||
<View style={styles.starsRow}>
|
||||
{[1, 2, 3, 4, 5].map((i) => (
|
||||
<Ionicons
|
||||
key={i}
|
||||
name={i <= Math.round(average) ? "star" : "star-outline"}
|
||||
size={22}
|
||||
color={i <= Math.round(average) ? STAR_COLOR : STAR_EMPTY}
|
||||
/>
|
||||
))}
|
||||
</View>
|
||||
<Text style={styles.avgLabel}>Note moyenne</Text>
|
||||
<Text style={styles.countLabel}>{ratings.length} avis client{ratings.length > 1 ? "s" : ""}</Text>
|
||||
</View>
|
||||
|
||||
{/* Liste */}
|
||||
{ratings.length === 0 ? (
|
||||
<View style={styles.emptyWrap}>
|
||||
<Ionicons name="chatbubble-ellipses-outline" size={48} color={colors.textMuted} />
|
||||
<Text style={styles.emptyText}>Aucun avis reçu pour l'instant</Text>
|
||||
</View>
|
||||
) : (
|
||||
ratings.map((r) => (
|
||||
<View key={r.id} style={styles.card}>
|
||||
<View style={styles.cardHeader}>
|
||||
<Text style={styles.client}>{r.client_username}</Text>
|
||||
<Text style={styles.date}>{formatDate(r.created_at)}</Text>
|
||||
</View>
|
||||
<Text style={styles.orderRef}>Commande #{r.order_id}</Text>
|
||||
<Stars value={r.rating} />
|
||||
{r.comment ? (
|
||||
<Text style={styles.comment}>"{r.comment}"</Text>
|
||||
) : null}
|
||||
</View>
|
||||
))
|
||||
)}
|
||||
</ScrollView>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user