chore: build
Frontend Admin - EAS Build / build (push) Failing after 1h4m59s

This commit is contained in:
2026-06-21 12:24:03 +02:00
parent cee859539e
commit a2a796ffd8
7 changed files with 273 additions and 35 deletions
+2 -32
View File
@@ -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",
+10
View File
@@ -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 || [];
+25
View File
@@ -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 */}
+1
View File
@@ -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>
);
}