1320 lines
54 KiB
TypeScript
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>
|
|
);
|
|
}
|