import React, { useState, useEffect, useCallback, useRef, useMemo, RefObject, } from "react"; import { View, Text, StyleSheet, FlatList, RefreshControl, TouchableOpacity, AppState, Linking, Modal, StatusBar, ScrollView, TextInput, useWindowDimensions, } from "react-native"; import Geolocation from "@react-native-community/geolocation"; import { Ionicons } from "@expo/vector-icons"; import * as Location from "expo-location"; import { spacing, fontSize, borderRadius } from "../../theme"; import { useTheme } from "../../context/ThemeContext"; import { getMyStatus, updateMyStatus, getMyDeliveries, getMyQueue, getDeliveryDetails, startDelivery, updateDeliveryStatus, updateMyLocation, getLivreurTelegramStatus, generateLivreurLinkToken, unlinkLivreurTelegram, getDeliveryNavLink, reportDeliveryIssue, ISSUE_LABELS, } from "../../api/api_delivery"; import type { IssueType } from "../../api/api_delivery"; import { geocodeAddress, calculateRoute } from "../../api/tomtom"; import type { RouteInfo } from "../../api/tomtom"; import type { DeliveryStatus, DeliveryItem, QueueInfo } from "../../api/types"; import LoadingSpinner from "../../components/ui/LoadingSpinner"; import Card from "../../components/ui/Card"; import Badge from "../../components/ui/Badge"; import Button from "../../components/ui/Button"; import AlertModal from "../../components/ui/AlertModal"; import { useAlert } from "../../hooks/useAlert"; import TomTomMap, { TomTomMapRef, TomTomMarker, } from "../../components/TomTomMap"; import DetailsModal from "../../components/ui/Modal"; const LOCATION_INTERVAL_MS = 5000; const STATUS_LABELS: Record = { available: "Disponible", busy: "Occupé", offline: "Hors ligne", }; // -------------------------------------------------- // Types // -------------------------------------------------- interface EnrichedDelivery extends DeliveryItem { clientName?: string; clientPhone?: string; clientUsername?: string; clientNom?: string; clientPrenom?: string; items?: Array<{ produit: string; quantite: number; prix: number; unit?: string; is_reward?: boolean }>; } export default function DashboardScreen() { const { colors } = useTheme(); const { width: screenWidth, height: screenHeight } = useWindowDimensions(); const MAP_HEIGHT = screenHeight < 700 ? 180 : screenWidth < 380 ? 200 : 260; const [status, setStatus] = useState(null); const [deliveries, setDeliveries] = useState([]); const [queue, setQueue] = useState(null); const [loading, setLoading] = useState(true); const [refreshing, setRefreshing] = useState(false); // Geo const [locationEnabled, setLocationEnabled] = useState(false); const [lastCoords, setLastCoords] = useState<{ lat: number; lng: number; } | null>(null); const lastCoordsRef = useRef<{ lat: number; lng: number } | null>(null); const [lastUpdate, setLastUpdate] = useState(null); const locationInterval = useRef | null>( null, ); const appState = useRef(AppState.currentState); // TomTom map refs const mapRef = useRef(null); const fullscreenMapRef = useRef(null); const [mapFullscreen, setMapFullscreen] = useState(false); const [routeLoading, setRouteLoading] = useState(false); const [routeInfo, setRouteInfo] = useState(null); const pendingRouteAddress = useRef(null); const { alert, showError, showSuccess, hideAlert } = useAlert(); const [detailsDelivery, setDetailsDelivery] = useState(null); // Telegram const [tgLinked, setTgLinked] = useState(false); const [tgEnabled, setTgEnabled] = useState(false); const [tgLoading, setTgLoading] = useState(false); const [cancelModal, setCancelModal] = useState<{ visible: boolean; deliveryId: number | null; issueType: IssueType | null; description: string; }>({ visible: false, deliveryId: null, issueType: null, description: "" }); const ABSENT_TIMEOUT_SECS = 300; // 5 minutes const arrivedAtRef = useRef>({}); const [elapsedSeconds, setElapsedSeconds] = useState< Record >({}); const STATUS_COLORS: Record = useMemo( () => ({ available: colors.success, busy: colors.warning, offline: colors.textMuted, }), [colors], ); // Marker du livreur (position courante) const driverMarkers: TomTomMarker[] = useMemo(() => { if (!lastCoords) return []; return [ { id: "driver", latitude: lastCoords.lat, longitude: lastCoords.lng, color: colors.success, label: "Ma position", description: lastUpdate ? lastUpdate.toLocaleTimeString("fr-FR", { hour: "2-digit", minute: "2-digit", }) : undefined, }, ]; }, [lastCoords, lastUpdate, colors.success]); // -------------------------------------------------- // TomTom Route calculation // Geocode + calcul dans DashboardScreen pour récupérer routeInfo // -------------------------------------------------- const calcRoute = useCallback( async (address: string, targetRef?: RefObject) => { const coords = lastCoordsRef.current; if (!coords) { pendingRouteAddress.current = address; return; } pendingRouteAddress.current = address; const ref = targetRef ?? mapRef; if (!ref.current) return; setRouteLoading(true); try { const origin = { latitude: coords.lat, longitude: coords.lng }; const dest = await geocodeAddress(address); if (!dest) { setRouteLoading(false); return; } const result = await calculateRoute(origin, dest); if (result) { setRouteInfo(result.route); ref.current?.calcRoute(origin, dest); } else { setRouteInfo(null); ref.current?.calcRoute(origin, dest); } } catch { /* silent */ } finally { setRouteLoading(false); } }, [], ); // -------------------------------------------------- // Data // -------------------------------------------------- const loadData = useCallback(async () => { try { const [statusRes, deliveriesRes, queueRes] = await Promise.all([ getMyStatus(), getMyDeliveries(), getMyQueue(), ]); if (statusRes.success && statusRes.status) setStatus(statusRes.status); if (queueRes.success && queueRes.queue_info) setQueue(queueRes.queue_info); const rawDeliveries = deliveriesRes.success && deliveriesRes.deliveries ? deliveriesRes.deliveries : []; const queueCommands: DeliveryItem[] = []; if (queueRes.success && queueRes.queue_info?.commands) { for (const cmd of queueRes.queue_info.commands) { const cmdId = cmd.id || cmd.command_id; if (cmdId && !rawDeliveries.find((d) => d.id === cmdId)) { queueCommands.push({ id: cmdId, status: cmd.status || "assigned", adresse: cmd.adresse || cmd.delivery_address || "Adresse inconnue", total_prix: cmd.total_prix || cmd.total_price || 0, created_at: cmd.created_at || "", updated_at: cmd.updated_at || "", }); } } } const allDeliveries = [...rawDeliveries, ...queueCommands].filter( (d) => d.status !== "approved" && d.status !== "cancelled", ); const enriched: EnrichedDelivery[] = await Promise.all( allDeliveries .filter((d) => d && d.id) .map(async (d): Promise => { try { const detailRes = await getDeliveryDetails(d.id); if (detailRes.success && detailRes.delivery) { const detail = detailRes.delivery; const client = detail.client_info; return { ...d, adresse: detail.delivery?.adresse || d.adresse, clientName: client?.nom ? `${client.prenom || ""} ${client.nom}`.trim() : client?.username || undefined, clientPhone: client?.telephone || undefined, clientUsername: client?.username || undefined, clientNom: client?.nom || undefined, clientPrenom: client?.prenom || undefined, items: detail.delivery?.items || (d as any).items || [], referral_used: detail.delivery?.referral_used || 0, }; } } catch { /* ignore */ } return { ...d }; }), ); setDeliveries(enriched); // Enregistrer le timestamp d'arrivée pour les livraisons "arrived" for (const d of enriched) { if ( d.status === "arrived" || (d.status === "livre" && !arrivedAtRef.current[d.id]) ) { arrivedAtRef.current[d.id] = Date.now(); } else if (d.status !== "arrived" && d.status !== "livre") { delete arrivedAtRef.current[d.id]; } } const activeDelivery = enriched.find((d) => d.status === "en_route") || enriched.find((d) => d.status === "assigned"); if (activeDelivery && activeDelivery.adresse) { calcRoute(activeDelivery.adresse); } } catch { /* ignore */ } setLoading(false); }, [calcRoute]); useEffect(() => { loadData(); getLivreurTelegramStatus().then((s) => { setTgLinked(s.linked); setTgEnabled(s.enabled); }); }, [loadData]); useEffect(() => { const interval = setInterval(() => { loadData(); }, 30000); return () => clearInterval(interval); }, [loadData]); useEffect(() => { const interval = setInterval(() => { const now = Date.now(); const updated: Record = {}; for (const [id, ts] of Object.entries(arrivedAtRef.current)) { updated[Number(id)] = Math.floor((now - ts) / 1000); } setElapsedSeconds(updated); }, 1000); return () => clearInterval(interval); }, []); // Quand GPS devient disponible, rejouer la route en attente useEffect(() => { if (lastCoords && pendingRouteAddress.current) { calcRoute(pendingRouteAddress.current); } // eslint-disable-next-line react-hooks/exhaustive-deps }, [lastCoords]); // Quand le fullscreen s'ouvre, rejouer la route sur la carte fullscreen useEffect(() => { if (!mapFullscreen) return; const addr = pendingRouteAddress.current; const coords = lastCoordsRef.current; if (!addr || !coords) return; setTimeout(() => { calcRoute(addr, fullscreenMapRef); }, 1200); // eslint-disable-next-line react-hooks/exhaustive-deps }, [mapFullscreen]); const onRefresh = async () => { setRefreshing(true); await loadData(); setRefreshing(false); }; // -------------------------------------------------- // Location tracking // -------------------------------------------------- const sendingRef = useRef(false); const getLocationWithFallback = useCallback( (): Promise => new Promise((resolve, reject) => { Geolocation.getCurrentPosition( (position) => resolve({ coords: { latitude: position.coords.latitude, longitude: position.coords.longitude, altitude: position.coords.altitude ?? 0, accuracy: position.coords.accuracy ?? 0, altitudeAccuracy: position.coords.altitudeAccuracy ?? 0, heading: position.coords.heading ?? 0, speed: position.coords.speed ?? 0, }, timestamp: position.timestamp, } as Location.LocationObject), (error) => reject(new Error(error.message)), // enableHighAccuracy: false → provider réseau/cell (1-3s) // au lieu du GPS pur qui peut prendre 30-60s (cold start) { enableHighAccuracy: false, timeout: 15000, maximumAge: 10000, }, ); }), [], ); const sendCurrentLocation = useCallback(async () => { if (sendingRef.current) return; sendingRef.current = true; try { const loc = await getLocationWithFallback(); if (!loc?.coords) { return; } const coords = loc.coords; const { latitude, longitude } = coords; const wasNull = !lastCoordsRef.current; lastCoordsRef.current = { lat: latitude, lng: longitude }; setLastCoords({ lat: latitude, lng: longitude }); setLastUpdate(new Date()); await updateMyLocation(latitude, longitude); // Si c'est la première position GPS et qu'une adresse était en attente, rejouer if (wasNull && pendingRouteAddress.current) { const addr = pendingRouteAddress.current; setTimeout(() => { calcRoute(addr); }, 1000); } } catch { /* silent */ } finally { sendingRef.current = false; } }, [getLocationWithFallback]); const startLocationTracking = useCallback(async () => { const { status: fgStatus } = await Location.requestForegroundPermissionsAsync(); if (fgStatus !== "granted") { showError( "Permission requise", "La géolocalisation est nécessaire pour le suivi des livraisons.", ); return; } setLocationEnabled(true); await sendCurrentLocation(); if (locationInterval.current) clearInterval(locationInterval.current); locationInterval.current = setInterval( sendCurrentLocation, LOCATION_INTERVAL_MS, ); }, [sendCurrentLocation]); const stopLocationTracking = useCallback(() => { if (locationInterval.current) { clearInterval(locationInterval.current); locationInterval.current = null; } setLocationEnabled(false); }, []); useEffect(() => { startLocationTracking(); return () => stopLocationTracking(); }, [startLocationTracking, stopLocationTracking]); useEffect(() => { const sub = AppState.addEventListener("change", (nextState) => { if ( appState.current.match(/inactive|background/) && nextState === "active" ) { if (!locationInterval.current) { sendCurrentLocation(); locationInterval.current = setInterval( sendCurrentLocation, LOCATION_INTERVAL_MS, ); setLocationEnabled(true); } } else if (nextState.match(/inactive|background/)) { if (locationInterval.current) { clearInterval(locationInterval.current); locationInterval.current = null; } } appState.current = nextState; }); return () => sub.remove(); }, [sendCurrentLocation]); // -------------------------------------------------- // Actions // -------------------------------------------------- const handleStatusChange = async ( newStatus: "available" | "busy" | "offline", ) => { const res = await updateMyStatus(newStatus); if (res.success) { setStatus((prev) => prev ? { ...prev, status: newStatus } : { status: newStatus }, ); } else { showError("Erreur", res.error || "Impossible de changer le statut"); } }; const handleStartDelivery = async (deliveryId: number) => { const lat = lastCoords?.lat || 0; const lng = lastCoords?.lng || 0; const res = await startDelivery(deliveryId, lat, lng); if (res.success) { showSuccess("Succès", "Livraison démarrée"); loadData(); } else { showError("Erreur", res.error || "Erreur"); } }; const handleArrivedDelivery = async (deliveryId: number) => { const lat = lastCoords?.lat || 0; const lng = lastCoords?.lng || 0; const res = await updateDeliveryStatus(deliveryId, "arrived", lat, lng); if (res.success) { showSuccess("Succès", "Statut mis à jour : arrivé à destination"); loadData(); } else { showError("Erreur", res.error || "Erreur"); } }; const handleCompleteDelivery = async (deliveryId: number) => { const lat = lastCoords?.lat || 0; const lng = lastCoords?.lng || 0; const res = await updateDeliveryStatus(deliveryId, "livre", lat, lng); if (res.success) { showSuccess("Succès", "Livraison terminée"); loadData(); } else { showError("Erreur", res.error || "Erreur"); } }; const handleCancelDelivery = async () => { if (!cancelModal.deliveryId || !cancelModal.issueType) return; const lat = lastCoords?.lat || 0; const lng = lastCoords?.lng || 0; const issueLabel = ISSUE_LABELS[cancelModal.issueType]; const res = await updateDeliveryStatus( cancelModal.deliveryId, "cancelled", lat, lng, cancelModal.description || issueLabel, ); if (res.success) { await reportDeliveryIssue( cancelModal.deliveryId, cancelModal.issueType, cancelModal.description, ); } setCancelModal({ visible: false, deliveryId: null, issueType: null, description: "", }); if (res.success) { showSuccess("Succès", "Livraison annulée"); loadData(); } else { showError("Erreur", res.error || "Erreur"); } }; const handleClientAbsent = async (deliveryId: number) => { const lat = lastCoords?.lat || 0; const lng = lastCoords?.lng || 0; const res = await updateDeliveryStatus( deliveryId, "cancelled", lat, lng, "Client absent", ); if (res.success) { await reportDeliveryIssue( deliveryId, "client_absent", "Client non présent après attente", ); delete arrivedAtRef.current[deliveryId]; showSuccess("Commande annulée", "Une amende a été appliquée au client"); loadData(); } else { showError("Erreur", res.error || "Erreur"); } }; const openNavigation = async (deliveryId: number, address: string) => { const res = await getDeliveryNavLink(deliveryId); const link = res.success && res.waze_app ? res.waze_app : `waze://?q=${encodeURIComponent(address)}&navigate=yes`; Linking.openURL(link); }; // -------------------------------------------------- // Render delivery card // -------------------------------------------------- const renderDelivery = ({ item }: { item: EnrichedDelivery }) => { const isActive = item.status === "en_route" || item.status === "arrived" || item.status === "assigned"; return ( Commande #{item.id} {item.clientName && ( {item.clientName} )} openNavigation(item.id, item.adresse)} activeOpacity={0.7} > {item.adresse} {isActive && lastCoords && ( calcRoute(item.adresse)} activeOpacity={0.7} > {routeLoading ? "Calcul..." : "Calculer l'itinéraire"} )} {item.items && item.items.length > 0 && ( Produits ({item.items.length}) {item.items.some(p => p.is_reward) && ( Cette commande contient un article offert (récompense client) )} {item.items.map((prod, idx) => ( {prod.produit} {prod.is_reward && ( Offert )} Quantité: {prod.quantite}{prod.unit || ""} {prod.is_reward ? "Offert" : `${(prod.prix ?? 0).toFixed(2)}€`} ))} Total {item.total_prix ?? 0}€ {(item.referral_used ?? 0) > 0 && ( <> Parrainage client -{(item.referral_used ?? 0).toFixed(2)}€ Net à encaisser {((item.total_prix ?? 0) - (item.referral_used ?? 0)).toFixed(2)}€ )} )} {(!item.items || item.items.length === 0) && ( <> {(item.referral_used ?? 0) > 0 ? "Brut : " : ""} {(item.total_prix ?? 0)}€ {(item.referral_used ?? 0) > 0 && ( <> Parrainage: -{(item.referral_used ?? 0).toFixed(2)}€ Net à encaisser: {((item.total_prix ?? 0) - (item.referral_used ?? 0)).toFixed(2)}€ )} )} {item.clientPhone && ( Linking.openURL(`tel:${item.clientPhone}`) } > {item.clientPhone} )} setDetailsDelivery(item)} activeOpacity={0.7} > Détails {(item.status === "pending" || item.status === "assigned") && (