Files
projet_gestion_commande/frontend-admin/src/screens/admin/DeliveryScreen.tsx
T
Xor290 22a8d5026c
Frontend Admin - EAS Build / build (push) Canceled after 0s
Frontend Client - EAS Build / build (push) Canceled after 0s
Backend - Build & Lint / build (push) Failing after 25m18s
Frontend Web - Build & Lint / build (push) Failing after 9m58s
chore: build
2026-08-06 12:06:05 +02:00

1320 lines
54 KiB
TypeScript

import React, {
useState,
useEffect,
useCallback,
useRef,
useMemo,
} from "react";
import {
View,
Text,
StyleSheet,
FlatList,
RefreshControl,
TouchableOpacity,
Modal,
StatusBar,
useWindowDimensions,
ScrollView,
Pressable,
} from "react-native";
import { Ionicons } from "@expo/vector-icons";
import { spacing, fontSize, borderRadius } from "../../theme";
import { useTheme } from "../../context/ThemeContext";
import {
getAllDeliveryPersonsWithDetails,
getCommandByID,
getLivreurRatings,
getLivreurLoginHistory,
} from "../../api/api_admin";
import type { LoginHistoryWeek } from "../../api/api_admin";
import { geocodeAddress, calculateRoute } from "../../api/tomtom";
import type { RouteInfo } from "../../api/tomtom";
import type { DeliveryPerson } from "../../api/types";
import { STATUS_LABELS, getStatusColors } from "../../utils/constants";
import LoadingSpinner from "../../components/ui/LoadingSpinner";
import Card from "../../components/ui/Card";
import Badge from "../../components/ui/Badge";
import TomTomMap, {
TomTomMapRef,
TomTomMarker,
} from "../../components/TomTomMap";
export default function DeliveryScreen() {
const { colors } = useTheme();
const { width: screenWidth, height: screenHeight } = useWindowDimensions();
const MAP_HEIGHT = screenHeight < 700 ? 200 : screenWidth < 380 ? 220 : 280;
const statusColors = getStatusColors(colors);
const [livreurs, setLivreurs] = useState<DeliveryPerson[]>([]);
const [stats, setStats] = useState({
total: 0,
available: 0,
busy: 0,
offline: 0,
});
const [loading, setLoading] = useState(true);
const [refreshing, setRefreshing] = useState(false);
// TomTomMap refs
const mapRef = useRef<TomTomMapRef>(null);
const fullscreenMapRef = useRef<TomTomMapRef>(null);
const [mapFullscreen, setMapFullscreen] = useState(false);
// Selected livreur route tracking
const [selectedLivreur, setSelectedLivreur] =
useState<DeliveryPerson | null>(null);
const [routeInfo, setRouteInfo] = useState<RouteInfo | null>(null);
const [routeLoading, setRouteLoading] = useState(false);
// Avis livreur
const [ratingsModal, setRatingsModal] = useState<{
username: string;
ratings: { id: number; order_id: number; client_username: string; rating: number; comment: string; created_at: string }[];
average: number;
count: number;
} | null>(null);
const [ratingsLoading, setRatingsLoading] = useState(false);
const openRatings = async (username: string) => {
setRatingsLoading(true);
const data = await getLivreurRatings(username);
setRatingsModal({ username, ...data });
setRatingsLoading(false);
};
// Historique de connexion livreur
const [loginHistoryModal, setLoginHistoryModal] = useState<{
username: string;
year: number;
month: number;
weeks: LoginHistoryWeek[];
} | null>(null);
const [loginHistoryLoading, setLoginHistoryLoading] = useState(false);
const loginHistoryRequestRef = useRef(0);
const fetchLoginHistory = async (
username: string,
year: number,
month: number,
) => {
const requestId = ++loginHistoryRequestRef.current;
setLoginHistoryLoading(true);
const res = await getLivreurLoginHistory(username, year, month);
if (requestId !== loginHistoryRequestRef.current) return;
setLoginHistoryModal({
username,
year: res.year,
month: res.month,
weeks: res.weeks,
});
setLoginHistoryLoading(false);
};
const openLoginHistory = (username: string) => {
const now = new Date();
fetchLoginHistory(username, now.getFullYear(), now.getMonth() + 1);
};
const changeLoginHistoryMonth = (delta: number) => {
if (!loginHistoryModal || loginHistoryLoading) return;
let year = loginHistoryModal.year;
let month = loginHistoryModal.month + delta;
if (month < 1) {
month = 12;
year -= 1;
} else if (month > 12) {
month = 1;
year += 1;
}
const now = new Date();
if (
year > now.getFullYear() ||
(year === now.getFullYear() && month > now.getMonth() + 1)
) {
return;
}
fetchLoginHistory(loginHistoryModal.username, year, month);
};
const loadData = useCallback(async () => {
try {
const result = await getAllDeliveryPersonsWithDetails();
setLivreurs(result.deliveryPersons);
setStats(result.stats);
} catch {
/* ignore */
}
setLoading(false);
}, []);
useEffect(() => {
loadData();
const interval = setInterval(loadData, 15000);
return () => clearInterval(interval);
}, [loadData]);
const onRefresh = async () => {
setRefreshing(true);
await loadData();
setRefreshing(false);
};
const livreursWithGPS = livreurs.filter(
(l) => l.location.latitude !== 0 && l.location.longitude !== 0,
);
// --------------------------------------------------
// Construire les markers TomTom pour tous les livreurs
// --------------------------------------------------
const tomtomMarkers: TomTomMarker[] = useMemo(() => {
return livreursWithGPS.map((l) => ({
id: l.username,
latitude: l.location.latitude,
longitude: l.location.longitude,
color: statusColors[l.status] || colors.textMuted,
label: l.username,
description: `${STATUS_LABELS[l.status] || l.status}${
l.stats.current_command
? ` · Cmd #${l.stats.current_command}`
: ""
}`,
isSelected: selectedLivreur?.username === l.username,
}));
}, [livreursWithGPS, selectedLivreur, statusColors, colors.textMuted]);
// --------------------------------------------------
// Track livreur — calcul de route via ref TomTomMap
// --------------------------------------------------
const trackLivreur = useCallback(
async (livreur: DeliveryPerson) => {
setSelectedLivreur(livreur);
setRouteInfo(null);
if (!livreur.stats.current_command) return;
setRouteLoading(true);
try {
const cmdRes = await getCommandByID(
livreur.stats.current_command,
);
const cmd = cmdRes.command;
if (!cmd?.adresse) {
setRouteLoading(false);
return;
}
const dest = await geocodeAddress(cmd.adresse);
if (!dest) {
setRouteLoading(false);
return;
}
const origin = {
latitude: livreur.location.latitude,
longitude: livreur.location.longitude,
};
const result = await calculateRoute(origin, dest);
if (result) {
setRouteInfo(result.route);
const activeRef = mapFullscreen ? fullscreenMapRef : mapRef;
activeRef.current?.calcRoute(origin, dest);
}
} catch {
/* silent */
}
setRouteLoading(false);
},
[mapFullscreen],
);
const clearRoute = () => {
setSelectedLivreur(null);
setRouteInfo(null);
};
// --------------------------------------------------
// onMarkerPress depuis TomTomMap
// --------------------------------------------------
const handleMarkerPress = useCallback(
(markerId: string) => {
const livreur = livreursWithGPS.find(
(l) => l.username === markerId,
);
if (livreur) {
if (selectedLivreur?.username === markerId) {
clearRoute();
} else {
trackLivreur(livreur);
}
}
},
[livreursWithGPS, selectedLivreur, trackLivreur],
);
// --------------------------------------------------
// Styles
// --------------------------------------------------
const styles = useMemo(
() =>
StyleSheet.create({
container: { flex: 1, backgroundColor: colors.bgPrimary },
summaryRow: {
flexDirection: "row",
padding: screenWidth < 380 ? spacing.m : spacing.l,
gap: screenWidth < 380 ? spacing.xs : spacing.s,
},
summaryCard: {
flex: 1,
backgroundColor: colors.bgCard,
borderRadius: borderRadius.sm,
padding: spacing.m,
borderLeftWidth: 3,
alignItems: "center",
},
summaryValue: {
fontSize: screenWidth < 380 ? fontSize.lg : fontSize.xl,
fontWeight: "bold",
color: colors.textWhite,
},
summaryLabel: {
fontSize: screenWidth < 380 ? 10 : fontSize.xs,
color: colors.textMuted,
textAlign: "center",
},
mapContainer: {
borderRadius: borderRadius.md,
overflow: "hidden",
marginBottom: spacing.m,
position: "relative",
height: MAP_HEIGHT,
},
map: { flex: 1 },
routeOverlay: {
position: "absolute",
top: spacing.s,
left: spacing.s,
backgroundColor: "rgba(0,0,0,0.75)",
borderRadius: borderRadius.sm,
padding: spacing.s,
paddingHorizontal: spacing.m,
},
routeOverlayUser: {
color: colors.white,
fontSize: fontSize.sm,
fontWeight: "700",
},
routeChips: {
flexDirection: "row",
gap: spacing.s,
marginTop: 4,
},
routeChip: {
flexDirection: "row",
alignItems: "center",
gap: 3,
},
routeChipText: {
color: colors.accent,
fontSize: fontSize.xs,
fontWeight: "600",
},
routeLoadingOverlay: {
position: "absolute",
top: 0,
left: 0,
right: 0,
bottom: 0,
backgroundColor: "rgba(0,0,0,0.3)",
justifyContent: "center",
alignItems: "center",
},
routeLoadingText: {
color: colors.white,
fontSize: fontSize.sm,
fontWeight: "600",
},
mapBtns: {
position: "absolute",
top: spacing.s,
right: spacing.s,
gap: spacing.xs,
},
mapBtn: {
width: 36,
height: 36,
borderRadius: 18,
backgroundColor: "rgba(0,0,0,0.6)",
justifyContent: "center",
alignItems: "center",
},
noMapBox: {
alignItems: "center",
paddingVertical: spacing.xl,
marginBottom: spacing.m,
backgroundColor: colors.bgSecondary,
borderRadius: borderRadius.md,
},
noMapText: {
color: colors.textMuted,
fontSize: fontSize.sm,
marginTop: spacing.s,
},
sectionTitle: {
color: colors.textWhite,
fontSize: fontSize.lg,
fontWeight: "700",
marginBottom: spacing.m,
},
row: {
flexDirection: "row",
alignItems: "center",
gap: spacing.s,
marginBottom: spacing.s,
},
statusDot: { width: 10, height: 10, borderRadius: 5 },
username: {
flex: 1,
fontSize: fontSize.lg,
fontWeight: "600",
color: colors.textWhite,
},
statsRow: {
flexDirection: "row",
flexWrap: "wrap",
gap: screenWidth < 380 ? spacing.s : spacing.l,
marginTop: spacing.s,
},
stat: {
flexDirection: "row",
alignItems: "center",
gap: spacing.xs,
},
statText: {
color: colors.textSecondary,
fontSize: fontSize.sm,
},
location: {
color: colors.textMuted,
fontSize: fontSize.xs,
marginTop: spacing.s,
},
currentCmd: {
color: colors.info,
fontSize: fontSize.sm,
marginTop: spacing.xs,
fontWeight: "500",
},
ratingsBtn: {
flexDirection: "row",
alignItems: "center",
justifyContent: "center",
gap: spacing.s,
marginTop: spacing.m,
paddingVertical: spacing.s,
paddingHorizontal: spacing.m,
borderRadius: borderRadius.sm,
borderWidth: 1,
borderColor: "#f59e0b",
},
ratingsBtnText: {
color: "#f59e0b",
fontSize: fontSize.sm,
fontWeight: "600",
},
historyBtn: {
flexDirection: "row",
alignItems: "center",
justifyContent: "center",
gap: spacing.s,
marginTop: spacing.m,
paddingVertical: spacing.s,
paddingHorizontal: spacing.m,
borderRadius: borderRadius.sm,
borderWidth: 1,
borderColor: colors.accent,
},
historyBtnText: {
color: colors.accent,
fontSize: fontSize.sm,
fontWeight: "600",
},
historyWeekLabel: {
color: colors.textMuted,
fontSize: fontSize.xs,
fontWeight: "700",
textTransform: "uppercase",
marginBottom: spacing.xs,
marginTop: spacing.m,
},
historyEntryRow: {
flexDirection: "row",
justifyContent: "space-between",
paddingVertical: spacing.xs,
borderTopWidth: 1,
borderTopColor: colors.borderLight,
},
historyEntryDate: {
color: colors.textPrimary,
fontSize: fontSize.sm,
},
historyEntryTime: {
color: colors.textMuted,
fontSize: fontSize.sm,
},
trackBtn: {
flexDirection: "row",
alignItems: "center",
justifyContent: "center",
gap: spacing.s,
marginTop: spacing.m,
paddingVertical: spacing.s,
paddingHorizontal: spacing.m,
borderRadius: borderRadius.sm,
borderWidth: 1,
borderColor: colors.accent,
},
trackBtnText: {
color: colors.accent,
fontSize: fontSize.sm,
fontWeight: "600",
},
ratingsOverlay: {
flex: 1,
backgroundColor: "rgba(0,0,0,0.7)",
justifyContent: "flex-end",
},
ratingsSheet: {
backgroundColor: colors.bgCard,
borderTopLeftRadius: borderRadius.xl,
borderTopRightRadius: borderRadius.xl,
padding: spacing.l,
maxHeight: "80%",
},
ratingsHeader: {
flexDirection: "row",
justifyContent: "space-between",
alignItems: "center",
marginBottom: spacing.s,
},
ratingsTitle: {
color: colors.textWhite,
fontSize: fontSize.lg,
fontWeight: "700",
},
ratingsAvg: {
flexDirection: "row",
alignItems: "center",
gap: spacing.xs,
marginBottom: spacing.l,
},
ratingsAvgText: {
color: "#f59e0b",
fontSize: fontSize.md,
fontWeight: "700",
},
ratingsCount: {
color: colors.textMuted,
fontSize: fontSize.sm,
},
ratingItem: {
borderTopWidth: 1,
borderTopColor: colors.borderLight,
paddingVertical: spacing.m,
},
ratingItemHeader: {
flexDirection: "row",
justifyContent: "space-between",
alignItems: "center",
marginBottom: spacing.xs,
},
ratingItemClient: {
color: colors.textPrimary,
fontSize: fontSize.sm,
fontWeight: "600",
},
ratingItemDate: {
color: colors.textMuted,
fontSize: fontSize.xs,
},
ratingStarsRow: {
flexDirection: "row",
gap: 2,
marginBottom: spacing.xs,
},
ratingItemComment: {
color: colors.textSecondary,
fontSize: fontSize.sm,
fontStyle: "italic",
},
ratingsEmpty: {
color: colors.textMuted,
textAlign: "center",
paddingVertical: spacing.xl,
},
empty: {
color: colors.textMuted,
textAlign: "center",
marginTop: spacing.xxl,
},
// Fullscreen
fullscreenContainer: {
flex: 1,
backgroundColor: colors.bgPrimary,
},
fullscreenMap: { ...StyleSheet.absoluteFillObject },
fullscreenTopBar: {
position: "absolute",
top: 0,
left: 0,
right: 0,
flexDirection: "row",
alignItems: "center",
justifyContent: "space-between",
paddingTop: 50,
paddingHorizontal: spacing.l,
paddingBottom: spacing.m,
backgroundColor: "rgba(0,0,0,0.5)",
},
closeBtn: {
width: 40,
height: 40,
borderRadius: 20,
backgroundColor: "rgba(255,255,255,0.15)",
justifyContent: "center",
alignItems: "center",
},
fullscreenTitle: {
color: colors.white,
fontSize: fontSize.lg,
fontWeight: "700",
},
fullscreenRouteBar: {
position: "absolute",
top: 110,
left: spacing.m,
right: spacing.m,
flexDirection: "row",
alignItems: "center",
backgroundColor: "rgba(0,0,0,0.8)",
padding: spacing.m,
borderRadius: borderRadius.md,
},
fullscreenRouteUser: {
color: colors.white,
fontSize: fontSize.md,
fontWeight: "700",
},
fullscreenRouteInfo: {
color: colors.accent,
fontSize: fontSize.sm,
marginTop: 2,
},
clearRouteBtn: { padding: spacing.xs },
fullscreenBottomBar: {
position: "absolute",
bottom: 0,
left: 0,
right: 0,
backgroundColor: "rgba(0,0,0,0.7)",
paddingHorizontal: spacing.l,
paddingTop: spacing.m,
paddingBottom: 40,
},
legendRow: {
flexDirection: "row",
flexWrap: "wrap",
justifyContent: "center",
gap: screenWidth < 380 ? spacing.m : spacing.l,
},
legendItem: {
flexDirection: "row",
alignItems: "center",
gap: 6,
},
legendDot: { width: 10, height: 10, borderRadius: 5 },
legendText: { color: colors.white, fontSize: fontSize.sm },
}),
[colors, screenWidth, screenHeight, MAP_HEIGHT],
);
// --------------------------------------------------
// Render livreur card
// --------------------------------------------------
const renderLivreur = ({ item }: { item: DeliveryPerson }) => {
const isSelected = selectedLivreur?.username === item.username;
const hasGPS = item.location.latitude !== 0;
return (
<Card
style={[
{ marginBottom: spacing.m },
isSelected && {
borderWidth: 1,
borderColor: colors.accent,
},
]}
>
<View style={styles.row}>
<View
style={[
styles.statusDot,
{
backgroundColor:
statusColors[item.status] ||
colors.textMuted,
},
]}
/>
<Text style={styles.username}>{item.username}</Text>
<Badge
label={STATUS_LABELS[item.status] || item.status}
color={statusColors[item.status] || colors.textMuted}
/>
</View>
<View style={styles.statsRow}>
<View style={styles.stat}>
<Ionicons
name="cube-outline"
size={16}
color={colors.textSecondary}
/>
<Text style={styles.statText}>
{item.stats.queue_size} en queue
</Text>
</View>
<View style={styles.stat}>
<Ionicons
name="checkmark-outline"
size={16}
color={colors.success}
/>
<Text style={styles.statText}>
{item.stats.completed_today} aujourd'hui
</Text>
</View>
<View style={styles.stat}>
<Ionicons
name="trophy-outline"
size={16}
color={colors.warning}
/>
<Text style={styles.statText}>
{item.stats.total_deliveries} total
</Text>
</View>
</View>
{hasGPS && (
<Text style={styles.location}>
GPS: {item.location.latitude.toFixed(4)},{" "}
{item.location.longitude.toFixed(4)}
{item.location.is_recent ? " (récent)" : " (ancien)"}
</Text>
)}
{item.stats.current_command && (
<Text style={styles.currentCmd}>
Commande en cours: #{item.stats.current_command}
</Text>
)}
<TouchableOpacity
style={styles.ratingsBtn}
onPress={() => openRatings(item.username)}
activeOpacity={0.7}
>
<Ionicons name="star-outline" size={14} color="#f59e0b" />
<Text style={styles.ratingsBtnText}>Voir les avis</Text>
</TouchableOpacity>
<TouchableOpacity
style={styles.historyBtn}
onPress={() => openLoginHistory(item.username)}
activeOpacity={0.7}
>
<Ionicons
name="time-outline"
size={14}
color={colors.accent}
/>
<Text style={styles.historyBtnText}>
Historique de connexion
</Text>
</TouchableOpacity>
{hasGPS && (
<TouchableOpacity
style={[
styles.trackBtn,
isSelected && { backgroundColor: colors.accent },
]}
onPress={() =>
isSelected ? clearRoute() : trackLivreur(item)
}
activeOpacity={0.7}
>
<Ionicons
name={
isSelected
? "close-circle-outline"
: "navigate-outline"
}
size={16}
color={isSelected ? colors.white : colors.accent}
/>
<Text
style={[
styles.trackBtnText,
isSelected && { color: colors.white },
]}
>
{isSelected
? "Arrêter le suivi"
: item.stats.current_command
? "Suivre l'itinéraire"
: "Voir sur la carte"}
</Text>
</TouchableOpacity>
)}
</Card>
);
};
// --------------------------------------------------
// Header avec TomTomMap
// --------------------------------------------------
const renderHeader = () => (
<View>
{livreursWithGPS.length > 0 ? (
<View style={styles.mapContainer}>
<TomTomMap
ref={mapRef}
style={styles.map}
markers={tomtomMarkers}
initialCenter={{
latitude: livreursWithGPS[0].location.latitude,
longitude: livreursWithGPS[0].location.longitude,
}}
initialZoom={12}
onMarkerPress={handleMarkerPress}
/>
{/* Overlay info route sélectionnée */}
{routeInfo && selectedLivreur && (
<View style={styles.routeOverlay}>
<Text style={styles.routeOverlayUser}>
{selectedLivreur.username}
</Text>
<View style={styles.routeChips}>
<View style={styles.routeChip}>
<Ionicons
name="speedometer-outline"
size={12}
color={colors.accent}
/>
<Text style={styles.routeChipText}>
{routeInfo.distance}
</Text>
</View>
<View style={styles.routeChip}>
<Ionicons
name="time-outline"
size={12}
color={colors.accent}
/>
<Text style={styles.routeChipText}>
{routeInfo.duration}
</Text>
</View>
</View>
</View>
)}
{routeLoading && (
<View style={styles.routeLoadingOverlay}>
<Text style={styles.routeLoadingText}>
Calcul itinéraire...
</Text>
</View>
)}
<View style={styles.mapBtns}>
<TouchableOpacity
style={styles.mapBtn}
onPress={() => mapRef.current?.fitAllMarkers()}
>
<Ionicons
name="locate-outline"
size={18}
color={colors.white}
/>
</TouchableOpacity>
<TouchableOpacity
style={styles.mapBtn}
onPress={() => setMapFullscreen(true)}
>
<Ionicons
name="expand-outline"
size={18}
color={colors.white}
/>
</TouchableOpacity>
</View>
</View>
) : !loading ? (
<View style={styles.noMapBox}>
<Ionicons
name="location-outline"
size={32}
color={colors.textMuted}
/>
<Text style={styles.noMapText}>
Aucun livreur avec GPS actif
</Text>
</View>
) : null}
<Text style={styles.sectionTitle}>
Livreurs ({livreurs.length})
</Text>
</View>
);
if (loading) return <LoadingSpinner message="Chargement livreurs..." />;
return (
<View style={styles.container}>
{/* ── Modal plein écran TomTomMap ── */}
<Modal
visible={mapFullscreen}
animationType="fade"
onRequestClose={() => setMapFullscreen(false)}
statusBarTranslucent
>
<StatusBar hidden={mapFullscreen} />
<View style={styles.fullscreenContainer}>
{livreursWithGPS.length > 0 && (
<TomTomMap
ref={fullscreenMapRef}
style={styles.fullscreenMap}
markers={tomtomMarkers}
initialCenter={{
latitude: livreursWithGPS[0].location.latitude,
longitude:
livreursWithGPS[0].location.longitude,
}}
initialZoom={12}
onMarkerPress={handleMarkerPress}
/>
)}
{/* Top bar */}
<View style={styles.fullscreenTopBar}>
<TouchableOpacity
style={styles.closeBtn}
onPress={() => setMapFullscreen(false)}
>
<Ionicons
name="close"
size={24}
color={colors.white}
/>
</TouchableOpacity>
<Text style={styles.fullscreenTitle}>
Suivi des livreurs
</Text>
<TouchableOpacity
style={styles.closeBtn}
onPress={() =>
fullscreenMapRef.current?.fitAllMarkers()
}
>
<Ionicons
name="locate-outline"
size={20}
color={colors.white}
/>
</TouchableOpacity>
</View>
{/* Route bar plein écran */}
{routeInfo && selectedLivreur && (
<View style={styles.fullscreenRouteBar}>
<View style={{ flex: 1 }}>
<Text style={styles.fullscreenRouteUser}>
{selectedLivreur.username}
</Text>
<Text style={styles.fullscreenRouteInfo}>
{routeInfo.distance} · {routeInfo.duration}
{selectedLivreur.stats.current_command
? ` · Cmd #${selectedLivreur.stats.current_command}`
: ""}
</Text>
</View>
<TouchableOpacity
style={styles.clearRouteBtn}
onPress={clearRoute}
>
<Ionicons
name="close-circle"
size={24}
color={colors.textMuted}
/>
</TouchableOpacity>
</View>
)}
{/* Bottom legend */}
<View style={styles.fullscreenBottomBar}>
<View style={styles.legendRow}>
<View style={styles.legendItem}>
<View
style={[
styles.legendDot,
{ backgroundColor: colors.success },
]}
/>
<Text style={styles.legendText}>
Dispo ({stats.available})
</Text>
</View>
<View style={styles.legendItem}>
<View
style={[
styles.legendDot,
{ backgroundColor: colors.warning },
]}
/>
<Text style={styles.legendText}>
Occupé ({stats.busy})
</Text>
</View>
<View style={styles.legendItem}>
<View
style={[
styles.legendDot,
{ backgroundColor: colors.textMuted },
]}
/>
<Text style={styles.legendText}>
Offline ({stats.offline})
</Text>
</View>
</View>
</View>
</View>
</Modal>
{/* ── Résumé stats ── */}
<View style={styles.summaryRow}>
<View
style={[
styles.summaryCard,
{ borderLeftColor: colors.success },
]}
>
<Text style={styles.summaryValue}>{stats.available}</Text>
<Text style={styles.summaryLabel}>Dispo</Text>
</View>
<View
style={[
styles.summaryCard,
{ borderLeftColor: colors.warning },
]}
>
<Text style={styles.summaryValue}>{stats.busy}</Text>
<Text style={styles.summaryLabel}>Occupés</Text>
</View>
<View
style={[
styles.summaryCard,
{ borderLeftColor: colors.textMuted },
]}
>
<Text style={styles.summaryValue}>{stats.offline}</Text>
<Text style={styles.summaryLabel}>Hors ligne</Text>
</View>
</View>
{/* ── Liste livreurs ── */}
<FlatList
data={livreurs}
keyExtractor={(item) => item.username}
renderItem={renderLivreur}
refreshControl={
<RefreshControl
refreshing={refreshing}
onRefresh={onRefresh}
tintColor={colors.accent}
/>
}
contentContainerStyle={{ padding: spacing.l, paddingTop: 0 }}
ListHeaderComponent={renderHeader()}
ListEmptyComponent={
<Text style={styles.empty}>Aucun livreur</Text>
}
/>
{/* ── Modal avis livreur ── */}
<Modal
visible={ratingsModal !== null}
transparent
animationType="slide"
onRequestClose={() => setRatingsModal(null)}
>
<Pressable style={styles.ratingsOverlay} onPress={() => setRatingsModal(null)}>
<Pressable onPress={() => {}}>
<View style={styles.ratingsSheet}>
<View style={styles.ratingsHeader}>
<Text style={styles.ratingsTitle}>
Avis — {ratingsModal?.username}
</Text>
<TouchableOpacity onPress={() => setRatingsModal(null)}>
<Ionicons name="close" size={22} color={colors.textMuted} />
</TouchableOpacity>
</View>
{ratingsLoading ? (
<Text style={styles.ratingsEmpty}>Chargement...</Text>
) : ratingsModal && ratingsModal.count > 0 ? (
<>
<View style={styles.ratingsAvg}>
{[1,2,3,4,5].map((s) => (
<Ionicons
key={s}
name={s <= Math.round(ratingsModal.average) ? "star" : "star-outline"}
size={20}
color="#f59e0b"
/>
))}
<Text style={styles.ratingsAvgText}>
{ratingsModal.average.toFixed(1)}
</Text>
<Text style={styles.ratingsCount}>
({ratingsModal.count} avis)
</Text>
</View>
<ScrollView showsVerticalScrollIndicator={false}>
{ratingsModal.ratings.map((r) => (
<View key={r.id} style={styles.ratingItem}>
<View style={styles.ratingItemHeader}>
<Text style={styles.ratingItemClient}>{r.client_username}</Text>
<Text style={styles.ratingItemDate}>
{new Date(r.created_at).toLocaleDateString("fr-FR")}
</Text>
</View>
<View style={styles.ratingStarsRow}>
{[1,2,3,4,5].map((s) => (
<Ionicons
key={s}
name={s <= r.rating ? "star" : "star-outline"}
size={14}
color="#f59e0b"
/>
))}
</View>
{r.comment !== "" && (
<Text style={styles.ratingItemComment}>"{r.comment}"</Text>
)}
</View>
))}
</ScrollView>
</>
) : (
<Text style={styles.ratingsEmpty}>Aucun avis pour ce livreur</Text>
)}
</View>
</Pressable>
</Pressable>
</Modal>
{/* ── Modal historique de connexion livreur ── */}
<Modal
visible={loginHistoryModal !== null}
transparent
animationType="slide"
onRequestClose={() => setLoginHistoryModal(null)}
>
<Pressable
style={styles.ratingsOverlay}
onPress={() => setLoginHistoryModal(null)}
>
<Pressable onPress={() => {}}>
<View style={styles.ratingsSheet}>
<View style={styles.ratingsHeader}>
<Text style={styles.ratingsTitle}>
Connexions — {loginHistoryModal?.username}
</Text>
<TouchableOpacity
onPress={() => setLoginHistoryModal(null)}
>
<Ionicons
name="close"
size={22}
color={colors.textMuted}
/>
</TouchableOpacity>
</View>
{(() => {
const now = new Date();
const isCurrentMonth =
!!loginHistoryModal &&
loginHistoryModal.year ===
now.getFullYear() &&
loginHistoryModal.month ===
now.getMonth() + 1;
const canGoBack = !loginHistoryLoading;
const canGoForward =
!loginHistoryLoading && !isCurrentMonth;
return (
<View
style={{
flexDirection: "row",
alignItems: "center",
justifyContent: "space-between",
marginBottom: spacing.m,
}}
>
<TouchableOpacity
disabled={!canGoBack}
onPress={() =>
changeLoginHistoryMonth(-1)
}
hitSlop={8}
>
<Ionicons
name="chevron-back"
size={20}
color={
canGoBack
? colors.textPrimary
: colors.textMuted
}
/>
</TouchableOpacity>
<Text
style={{
color: colors.textWhite,
fontWeight: "700",
fontSize: fontSize.md,
}}
>
{loginHistoryModal &&
new Date(
loginHistoryModal.year,
loginHistoryModal.month -
1,
1,
)
.toLocaleDateString(
"fr-FR",
{
month: "long",
year: "numeric",
},
)
.replace(/^./, (c) =>
c.toUpperCase(),
)}
</Text>
<TouchableOpacity
disabled={!canGoForward}
onPress={() =>
changeLoginHistoryMonth(1)
}
hitSlop={8}
>
<Ionicons
name="chevron-forward"
size={20}
color={
canGoForward
? colors.textPrimary
: colors.textMuted
}
/>
</TouchableOpacity>
</View>
);
})()}
{loginHistoryLoading ? (
<Text style={styles.ratingsEmpty}>
Chargement...
</Text>
) : loginHistoryModal &&
loginHistoryModal.weeks.length > 0 ? (
<ScrollView showsVerticalScrollIndicator={false}>
{loginHistoryModal.weeks.map((week) => (
<View key={week.week}>
<Text style={styles.historyWeekLabel}>
Semaine {week.week}
</Text>
{week.entries.map((entry) => (
<View
key={entry.id}
style={styles.historyEntryRow}
>
<Text
style={
styles.historyEntryDate
}
>
{new Date(
entry.created_at,
).toLocaleDateString(
"fr-FR",
{
weekday: "short",
day: "2-digit",
month: "2-digit",
},
)}
</Text>
<Text
style={
styles.historyEntryTime
}
>
{new Date(
entry.created_at,
).toLocaleTimeString(
"fr-FR",
{
hour: "2-digit",
minute: "2-digit",
},
)}
</Text>
</View>
))}
</View>
))}
</ScrollView>
) : (
<Text style={styles.ratingsEmpty}>
Aucune connexion ce mois-ci
</Text>
)}
</View>
</Pressable>
</Pressable>
</Modal>
</View>
);
}