2249 lines
89 KiB
TypeScript
2249 lines
89 KiB
TypeScript
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<string, string> = {
|
|
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<DeliveryStatus | null>(null);
|
|
const [deliveries, setDeliveries] = useState<EnrichedDelivery[]>([]);
|
|
const [queue, setQueue] = useState<QueueInfo | null>(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<Date | null>(null);
|
|
const locationInterval = useRef<ReturnType<typeof setInterval> | null>(
|
|
null,
|
|
);
|
|
const appState = useRef(AppState.currentState);
|
|
|
|
// TomTom map refs
|
|
const mapRef = useRef<TomTomMapRef>(null);
|
|
const fullscreenMapRef = useRef<TomTomMapRef>(null);
|
|
const [mapFullscreen, setMapFullscreen] = useState(false);
|
|
|
|
const [routeLoading, setRouteLoading] = useState(false);
|
|
const [routeInfo, setRouteInfo] = useState<RouteInfo | null>(null);
|
|
const pendingRouteAddress = useRef<string | null>(null);
|
|
const { alert, showError, showSuccess, hideAlert } = useAlert();
|
|
|
|
const [detailsDelivery, setDetailsDelivery] =
|
|
useState<EnrichedDelivery | null>(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<Record<number, number>>({});
|
|
const [elapsedSeconds, setElapsedSeconds] = useState<
|
|
Record<number, number>
|
|
>({});
|
|
|
|
const STATUS_COLORS: Record<string, string> = 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<TomTomMapRef | null>) => {
|
|
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<EnrichedDelivery> => {
|
|
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<number, number> = {};
|
|
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<Location.LocationObject> =>
|
|
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 (
|
|
<Card style={{ marginBottom: spacing.m }}>
|
|
<View style={styles.cardHeader}>
|
|
<View style={{ flex: 1 }}>
|
|
<Text style={styles.deliveryId}>
|
|
Commande #{item.id}
|
|
</Text>
|
|
{item.clientName && (
|
|
<Text style={styles.clientName}>
|
|
{item.clientName}
|
|
</Text>
|
|
)}
|
|
</View>
|
|
<Badge
|
|
label={
|
|
item.status === "completed" ||
|
|
item.status === "livre"
|
|
? "Terminée"
|
|
: item.status === "arrived"
|
|
? "Arrivé"
|
|
: item.status === "en_route"
|
|
? "En cours"
|
|
: "En attente"
|
|
}
|
|
color={
|
|
item.status === "completed" ||
|
|
item.status === "livre"
|
|
? colors.success
|
|
: item.status === "arrived"
|
|
? colors.accent
|
|
: item.status === "en_route"
|
|
? colors.warning
|
|
: colors.info
|
|
}
|
|
/>
|
|
</View>
|
|
|
|
<TouchableOpacity
|
|
style={styles.addressRow}
|
|
onPress={() => openNavigation(item.id, item.adresse)}
|
|
activeOpacity={0.7}
|
|
>
|
|
<Ionicons
|
|
name="location-outline"
|
|
size={16}
|
|
color={colors.accent}
|
|
/>
|
|
<Text style={styles.address} numberOfLines={2}>
|
|
{item.adresse}
|
|
</Text>
|
|
<Ionicons
|
|
name="navigate-outline"
|
|
size={18}
|
|
color={colors.accent}
|
|
/>
|
|
</TouchableOpacity>
|
|
|
|
{isActive && lastCoords && (
|
|
<TouchableOpacity
|
|
style={styles.calcRouteBtn}
|
|
onPress={() => calcRoute(item.adresse)}
|
|
activeOpacity={0.7}
|
|
>
|
|
<Ionicons
|
|
name="map-outline"
|
|
size={16}
|
|
color={colors.white}
|
|
/>
|
|
<Text style={styles.calcRouteBtnText}>
|
|
{routeLoading
|
|
? "Calcul..."
|
|
: "Calculer l'itinéraire"}
|
|
</Text>
|
|
</TouchableOpacity>
|
|
)}
|
|
|
|
{item.items && item.items.length > 0 && (
|
|
<View style={styles.itemsSection}>
|
|
<View style={styles.itemsHeader}>
|
|
<Ionicons
|
|
name="bag-outline"
|
|
size={16}
|
|
color={colors.success}
|
|
/>
|
|
<Text style={styles.itemsTitle}>
|
|
Produits ({item.items.length})
|
|
</Text>
|
|
</View>
|
|
{item.items.some(p => p.is_reward) && (
|
|
<View style={{ flexDirection: "row", alignItems: "center", gap: 6, backgroundColor: "rgba(245,158,11,0.12)", borderRadius: 6, padding: 6, marginBottom: 6 }}>
|
|
<Ionicons name="gift-outline" size={15} color="#f59e0b" />
|
|
<Text style={{ fontSize: 13, color: "#f59e0b", fontWeight: "700" }}>
|
|
Cette commande contient un article offert (récompense client)
|
|
</Text>
|
|
</View>
|
|
)}
|
|
{item.items.map((prod, idx) => (
|
|
<View key={idx} style={styles.itemRow}>
|
|
<View style={{ flex: 1 }}>
|
|
<View style={{ flexDirection: "row", alignItems: "center", gap: 4 }}>
|
|
<Text style={styles.itemName}>{prod.produit}</Text>
|
|
{prod.is_reward && (
|
|
<View style={{ flexDirection: "row", alignItems: "center", gap: 2, backgroundColor: "rgba(245,158,11,0.15)", borderRadius: 4, paddingHorizontal: 4, paddingVertical: 1 }}>
|
|
<Ionicons name="gift-outline" size={10} color="#f59e0b" />
|
|
<Text style={{ fontSize: 10, color: "#f59e0b", fontWeight: "700" }}>Offert</Text>
|
|
</View>
|
|
)}
|
|
</View>
|
|
<Text style={styles.itemQty}>
|
|
Quantité: {prod.quantite}{prod.unit || ""}
|
|
</Text>
|
|
</View>
|
|
<Text style={[styles.itemPrice, prod.is_reward ? { color: "#10b981" } : {}]}>
|
|
{prod.is_reward ? "Offert" : `${(prod.prix ?? 0).toFixed(2)}€`}
|
|
</Text>
|
|
</View>
|
|
))}
|
|
<View style={styles.totalRow}>
|
|
<Text style={styles.totalLabel}>Total</Text>
|
|
<Text style={styles.totalValue}>
|
|
{item.total_prix ?? 0}€
|
|
</Text>
|
|
</View>
|
|
{(item.referral_used ?? 0) > 0 && (
|
|
<>
|
|
<View style={[styles.totalRow, { marginTop: 2 }]}>
|
|
<Text
|
|
style={[
|
|
styles.totalLabel,
|
|
{ color: colors.success },
|
|
]}
|
|
>
|
|
Parrainage client
|
|
</Text>
|
|
<Text
|
|
style={[
|
|
styles.totalValue,
|
|
{ color: colors.success },
|
|
]}
|
|
>
|
|
-{(item.referral_used ?? 0).toFixed(2)}€
|
|
</Text>
|
|
</View>
|
|
<View style={[styles.totalRow, { marginTop: 2 }]}>
|
|
<Text style={[styles.totalLabel, { fontWeight: "700" }]}>
|
|
Net à encaisser
|
|
</Text>
|
|
<Text style={[styles.totalValue, { fontWeight: "700" }]}>
|
|
{((item.total_prix ?? 0) - (item.referral_used ?? 0)).toFixed(2)}€
|
|
</Text>
|
|
</View>
|
|
</>
|
|
)}
|
|
</View>
|
|
)}
|
|
|
|
{(!item.items || item.items.length === 0) && (
|
|
<>
|
|
<Text style={styles.priceOnly}>
|
|
{(item.referral_used ?? 0) > 0 ? "Brut : " : ""}
|
|
{(item.total_prix ?? 0)}€
|
|
</Text>
|
|
{(item.referral_used ?? 0) > 0 && (
|
|
<>
|
|
<Text
|
|
style={[
|
|
styles.priceOnly,
|
|
{ color: colors.success, marginTop: 2 },
|
|
]}
|
|
>
|
|
Parrainage: -{(item.referral_used ?? 0).toFixed(2)}€
|
|
</Text>
|
|
<Text
|
|
style={[
|
|
styles.priceOnly,
|
|
{ fontWeight: "700", marginTop: 2 },
|
|
]}
|
|
>
|
|
Net à encaisser: {((item.total_prix ?? 0) - (item.referral_used ?? 0)).toFixed(2)}€
|
|
</Text>
|
|
</>
|
|
)}
|
|
</>
|
|
)}
|
|
|
|
{item.clientPhone && (
|
|
<TouchableOpacity
|
|
style={styles.phoneRow}
|
|
onPress={() =>
|
|
Linking.openURL(`tel:${item.clientPhone}`)
|
|
}
|
|
>
|
|
<Ionicons
|
|
name="call-outline"
|
|
size={16}
|
|
color={colors.info}
|
|
/>
|
|
<Text style={styles.phoneText}>{item.clientPhone}</Text>
|
|
</TouchableOpacity>
|
|
)}
|
|
|
|
<TouchableOpacity
|
|
style={styles.detailsBtn}
|
|
onPress={() => setDetailsDelivery(item)}
|
|
activeOpacity={0.7}
|
|
>
|
|
<Ionicons
|
|
name="list-outline"
|
|
size={16}
|
|
color={colors.accent}
|
|
/>
|
|
<Text style={styles.detailsBtnText}>Détails</Text>
|
|
</TouchableOpacity>
|
|
|
|
{(item.status === "pending" || item.status === "assigned") && (
|
|
<Button
|
|
title="Démarrer la livraison"
|
|
onPress={() => handleStartDelivery(item.id)}
|
|
style={{
|
|
marginTop: spacing.s,
|
|
backgroundColor: colors.success,
|
|
}}
|
|
/>
|
|
)}
|
|
{(item.status === "in_progress" ||
|
|
item.status === "en_route") && (
|
|
<Button
|
|
title="J'arrive"
|
|
onPress={() => handleArrivedDelivery(item.id)}
|
|
style={{
|
|
marginTop: spacing.s,
|
|
backgroundColor: colors.warning,
|
|
}}
|
|
/>
|
|
)}
|
|
{item.status === "arrived" && (() => {
|
|
const elapsed = elapsedSeconds[item.id] || 0;
|
|
const remaining = Math.max(0, ABSENT_TIMEOUT_SECS - elapsed);
|
|
const showAbsent = elapsed >= ABSENT_TIMEOUT_SECS;
|
|
const mm = String(Math.floor(remaining / 60)).padStart(2, "0");
|
|
const ss = String(remaining % 60).padStart(2, "0");
|
|
return (
|
|
<View style={{ marginTop: spacing.s, gap: spacing.s }}>
|
|
<View style={{ flexDirection: "row", gap: spacing.s }}>
|
|
<Button
|
|
title="Terminer"
|
|
onPress={() => handleCompleteDelivery(item.id)}
|
|
style={{ flex: 1, backgroundColor: colors.accent }}
|
|
/>
|
|
<Button
|
|
title="Annuler"
|
|
onPress={() =>
|
|
setCancelModal({
|
|
visible: true,
|
|
deliveryId: item.id,
|
|
issueType: null,
|
|
description: "",
|
|
})
|
|
}
|
|
style={{ flex: 1, backgroundColor: colors.danger }}
|
|
/>
|
|
</View>
|
|
<Button
|
|
title="Client pas là"
|
|
onPress={() => handleClientAbsent(item.id)}
|
|
style={{ backgroundColor: colors.warning }}
|
|
/>
|
|
{showAbsent ? (
|
|
<Button
|
|
title="Client absent (5 min écoulées)"
|
|
onPress={() => handleClientAbsent(item.id)}
|
|
style={{ backgroundColor: colors.danger }}
|
|
/>
|
|
) : (
|
|
<Text style={{ color: colors.textMuted, fontSize: fontSize.sm, textAlign: "center" }}>
|
|
Client absent dans {mm}:{ss}
|
|
</Text>
|
|
)}
|
|
</View>
|
|
);
|
|
})()}
|
|
</Card>
|
|
);
|
|
};
|
|
|
|
// --------------------------------------------------
|
|
// Header avec TomTomMap
|
|
// --------------------------------------------------
|
|
const handleLinkTelegram = async () => {
|
|
setTgLoading(true);
|
|
const res = await generateLivreurLinkToken();
|
|
setTgLoading(false);
|
|
if (res.error || !res.link_url) {
|
|
showError("Erreur", res.error || "Service Telegram non disponible");
|
|
return;
|
|
}
|
|
Linking.openURL(res.link_url);
|
|
};
|
|
|
|
const handleUnlinkTelegram = async () => {
|
|
await unlinkLivreurTelegram();
|
|
setTgLinked(false);
|
|
showSuccess(
|
|
"Telegram délié",
|
|
"Vous ne recevrez plus de notifications Telegram.",
|
|
);
|
|
};
|
|
|
|
const renderHeader = () => (
|
|
<View>
|
|
{/* Carte TomTom — toujours montée pour que le ref soit disponible */}
|
|
<View style={styles.mapContainer}>
|
|
<TomTomMap
|
|
ref={mapRef}
|
|
style={styles.map}
|
|
markers={driverMarkers}
|
|
initialCenter={
|
|
lastCoords
|
|
? {
|
|
latitude: lastCoords.lat,
|
|
longitude: lastCoords.lng,
|
|
}
|
|
: { latitude: 48.8566, longitude: 2.3522 }
|
|
}
|
|
initialZoom={14}
|
|
/>
|
|
|
|
{/* Overlay GPS */}
|
|
{lastCoords ? (
|
|
<View style={styles.gpsOverlay}>
|
|
<View
|
|
style={[
|
|
styles.gpsDot,
|
|
{
|
|
backgroundColor: locationEnabled
|
|
? colors.success
|
|
: colors.danger,
|
|
},
|
|
]}
|
|
/>
|
|
<Text style={styles.gpsOverlayText}>
|
|
{lastCoords.lat.toFixed(4)},{" "}
|
|
{lastCoords.lng.toFixed(4)}
|
|
</Text>
|
|
{lastUpdate && (
|
|
<Text style={styles.gpsOverlayTime}>
|
|
{lastUpdate.toLocaleTimeString("fr-FR", {
|
|
hour: "2-digit",
|
|
minute: "2-digit",
|
|
})}
|
|
</Text>
|
|
)}
|
|
</View>
|
|
) : (
|
|
<View style={styles.gpsOverlay}>
|
|
<Ionicons
|
|
name="location-outline"
|
|
size={16}
|
|
color={colors.textMuted}
|
|
/>
|
|
<Text style={styles.gpsOverlayText}>
|
|
Récupération GPS...
|
|
</Text>
|
|
</View>
|
|
)}
|
|
|
|
{/* Overlay infos trajet */}
|
|
{routeInfo && !routeLoading && (
|
|
<View style={styles.routeInfoOverlay}>
|
|
<View style={styles.routeInfoChip}>
|
|
<Ionicons
|
|
name="time-outline"
|
|
size={13}
|
|
color={colors.white}
|
|
/>
|
|
<Text style={styles.routeInfoOverlayText}>
|
|
{routeInfo.duration}
|
|
</Text>
|
|
</View>
|
|
<View style={styles.routeInfoChip}>
|
|
<Ionicons
|
|
name="navigate-outline"
|
|
size={13}
|
|
color={colors.white}
|
|
/>
|
|
<Text style={styles.routeInfoOverlayText}>
|
|
{routeInfo.distance}
|
|
</Text>
|
|
</View>
|
|
</View>
|
|
)}
|
|
|
|
{/* Bouton plein écran */}
|
|
<TouchableOpacity
|
|
style={styles.expandBtn}
|
|
onPress={() => setMapFullscreen(true)}
|
|
activeOpacity={0.7}
|
|
>
|
|
<Ionicons
|
|
name="expand-outline"
|
|
size={20}
|
|
color={colors.white}
|
|
/>
|
|
</TouchableOpacity>
|
|
|
|
{/* Indicateur calcul de route */}
|
|
{routeLoading && (
|
|
<View style={styles.routeLoadingOverlay}>
|
|
<Text style={styles.routeLoadingText}>
|
|
Calcul de l'itinéraire...
|
|
</Text>
|
|
</View>
|
|
)}
|
|
</View>
|
|
|
|
{/* Queue info */}
|
|
{queue && queue.queue_size > 0 && (
|
|
<View style={styles.queueBar}>
|
|
<Ionicons
|
|
name="layers-outline"
|
|
size={18}
|
|
color={colors.accent}
|
|
/>
|
|
<Text style={styles.queueText}>
|
|
{queue.queue_size} commande
|
|
{queue.queue_size > 1 ? "s" : ""} dans la queue
|
|
</Text>
|
|
</View>
|
|
)}
|
|
|
|
{/* Carte Telegram */}
|
|
{tgEnabled && (
|
|
<View
|
|
style={{
|
|
marginHorizontal: spacing.l,
|
|
marginBottom: spacing.m,
|
|
backgroundColor: colors.bgCard,
|
|
borderRadius: borderRadius.md,
|
|
padding: spacing.l,
|
|
borderWidth: 1,
|
|
borderColor: colors.borderLight,
|
|
}}
|
|
>
|
|
<View
|
|
style={{
|
|
flexDirection: "row",
|
|
alignItems: "center",
|
|
gap: spacing.s,
|
|
marginBottom: spacing.s,
|
|
}}
|
|
>
|
|
<Ionicons
|
|
name="paper-plane-outline"
|
|
size={18}
|
|
color="#2AABEE"
|
|
/>
|
|
<Text
|
|
style={{
|
|
color: colors.textPrimary,
|
|
fontSize: fontSize.md,
|
|
fontWeight: "600",
|
|
}}
|
|
>
|
|
Notifications Telegram
|
|
</Text>
|
|
</View>
|
|
{tgLinked ? (
|
|
<View>
|
|
<View
|
|
style={{
|
|
flexDirection: "row",
|
|
alignItems: "center",
|
|
gap: spacing.s,
|
|
marginBottom: spacing.s,
|
|
}}
|
|
>
|
|
<Ionicons
|
|
name="checkmark-circle"
|
|
size={14}
|
|
color={colors.success}
|
|
/>
|
|
<Text
|
|
style={{
|
|
color: colors.success,
|
|
fontSize: fontSize.sm,
|
|
}}
|
|
>
|
|
Compte lié
|
|
</Text>
|
|
</View>
|
|
<TouchableOpacity
|
|
onPress={handleUnlinkTelegram}
|
|
style={{
|
|
flexDirection: "row",
|
|
alignItems: "center",
|
|
gap: spacing.s,
|
|
padding: spacing.s,
|
|
borderRadius: borderRadius.sm,
|
|
borderWidth: 1,
|
|
borderColor: colors.danger + "66",
|
|
}}
|
|
>
|
|
<Ionicons
|
|
name="unlink-outline"
|
|
size={14}
|
|
color={colors.danger}
|
|
/>
|
|
<Text
|
|
style={{
|
|
color: colors.danger,
|
|
fontSize: fontSize.sm,
|
|
}}
|
|
>
|
|
Délier
|
|
</Text>
|
|
</TouchableOpacity>
|
|
</View>
|
|
) : (
|
|
<TouchableOpacity
|
|
onPress={handleLinkTelegram}
|
|
disabled={tgLoading}
|
|
style={{
|
|
flexDirection: "row",
|
|
alignItems: "center",
|
|
justifyContent: "center",
|
|
gap: spacing.s,
|
|
padding: spacing.m,
|
|
borderRadius: borderRadius.sm,
|
|
backgroundColor: "#2AABEE",
|
|
}}
|
|
>
|
|
<Ionicons
|
|
name="paper-plane-outline"
|
|
size={14}
|
|
color="#fff"
|
|
/>
|
|
<Text
|
|
style={{
|
|
color: "#fff",
|
|
fontSize: fontSize.sm,
|
|
fontWeight: "600",
|
|
}}
|
|
>
|
|
{tgLoading ? "Génération..." : "Lier Telegram"}
|
|
</Text>
|
|
</TouchableOpacity>
|
|
)}
|
|
</View>
|
|
)}
|
|
|
|
<Text style={styles.sectionTitle}>
|
|
Mes livraisons ({deliveries.length})
|
|
</Text>
|
|
</View>
|
|
);
|
|
|
|
// --------------------------------------------------
|
|
// Styles
|
|
// --------------------------------------------------
|
|
const styles = useMemo(
|
|
() =>
|
|
StyleSheet.create({
|
|
container: { flex: 1, backgroundColor: colors.bgPrimary },
|
|
|
|
statusBar: {
|
|
padding: spacing.l,
|
|
backgroundColor: colors.bgSecondary,
|
|
borderBottomWidth: 1,
|
|
borderBottomColor: colors.border,
|
|
},
|
|
statusLabel: {
|
|
color: colors.textWhite,
|
|
fontSize: fontSize.md,
|
|
fontWeight: "700",
|
|
marginBottom: spacing.s,
|
|
},
|
|
statusBtns: { flexDirection: "row", gap: spacing.s },
|
|
statusBtn: {
|
|
flexDirection: "row",
|
|
alignItems: "center",
|
|
paddingVertical: spacing.xs,
|
|
paddingHorizontal: spacing.m,
|
|
borderRadius: 20,
|
|
borderWidth: 1,
|
|
borderColor: colors.border,
|
|
},
|
|
dot: {
|
|
width: 8,
|
|
height: 8,
|
|
borderRadius: 4,
|
|
marginRight: spacing.xs,
|
|
},
|
|
statusText: { color: colors.textMuted, fontSize: fontSize.sm },
|
|
|
|
// Map
|
|
mapContainer: {
|
|
borderRadius: borderRadius.md,
|
|
overflow: "hidden",
|
|
marginBottom: spacing.m,
|
|
position: "relative",
|
|
height: MAP_HEIGHT,
|
|
},
|
|
map: { flex: 1 },
|
|
gpsOverlay: {
|
|
position: "absolute",
|
|
bottom: spacing.s,
|
|
left: spacing.s,
|
|
flexDirection: "row",
|
|
alignItems: "center",
|
|
gap: 6,
|
|
backgroundColor: "rgba(0,0,0,0.7)",
|
|
paddingVertical: 4,
|
|
paddingHorizontal: spacing.s,
|
|
borderRadius: borderRadius.sm,
|
|
},
|
|
gpsDot: { width: 8, height: 8, borderRadius: 4 },
|
|
gpsOverlayText: {
|
|
color: colors.textWhite,
|
|
fontSize: 10,
|
|
fontFamily: "monospace",
|
|
},
|
|
gpsOverlayTime: { color: colors.textMuted, fontSize: 10 },
|
|
|
|
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,
|
|
},
|
|
|
|
routeLoadingOverlay: {
|
|
position: "absolute",
|
|
top: 0,
|
|
left: 0,
|
|
right: 0,
|
|
bottom: 0,
|
|
backgroundColor: "rgba(0,0,0,0.4)",
|
|
justifyContent: "center",
|
|
alignItems: "center",
|
|
},
|
|
routeLoadingText: {
|
|
color: colors.white,
|
|
fontSize: fontSize.sm,
|
|
fontWeight: "600",
|
|
},
|
|
|
|
expandBtn: {
|
|
position: "absolute",
|
|
top: spacing.s,
|
|
right: spacing.s,
|
|
width: 36,
|
|
height: 36,
|
|
borderRadius: 18,
|
|
backgroundColor: "rgba(0,0,0,0.6)",
|
|
justifyContent: "center",
|
|
alignItems: "center",
|
|
},
|
|
|
|
routeInfoOverlay: {
|
|
position: "absolute",
|
|
top: spacing.s,
|
|
left: spacing.s,
|
|
flexDirection: "row",
|
|
gap: spacing.xs,
|
|
},
|
|
routeInfoChip: {
|
|
flexDirection: "row",
|
|
alignItems: "center",
|
|
gap: 4,
|
|
backgroundColor: "rgba(0,0,0,0.7)",
|
|
paddingVertical: 4,
|
|
paddingHorizontal: spacing.s,
|
|
borderRadius: 12,
|
|
},
|
|
routeInfoOverlayText: {
|
|
color: colors.white,
|
|
fontSize: fontSize.xs,
|
|
fontWeight: "700",
|
|
},
|
|
|
|
// Instructions
|
|
instructionBar: {
|
|
backgroundColor: colors.bgSecondary,
|
|
borderRadius: borderRadius.md,
|
|
padding: spacing.m,
|
|
marginBottom: spacing.m,
|
|
borderWidth: 1,
|
|
borderColor: colors.border,
|
|
},
|
|
currentInstruction: {
|
|
flexDirection: "row",
|
|
alignItems: "center",
|
|
gap: spacing.m,
|
|
},
|
|
instructionIconBox: {
|
|
width: screenWidth < 380 ? 34 : 42,
|
|
height: screenWidth < 380 ? 34 : 42,
|
|
borderRadius: 10,
|
|
backgroundColor: colors.accent,
|
|
justifyContent: "center",
|
|
alignItems: "center",
|
|
flexShrink: 0,
|
|
},
|
|
instructionText: {
|
|
color: colors.textWhite,
|
|
fontSize: screenWidth < 380 ? fontSize.sm : fontSize.md,
|
|
fontWeight: "600",
|
|
flex: 1,
|
|
},
|
|
instructionStreet: {
|
|
color: colors.textMuted,
|
|
fontSize: fontSize.xs,
|
|
marginTop: 2,
|
|
},
|
|
instructionDist: {
|
|
color: colors.accent,
|
|
fontSize: fontSize.sm,
|
|
fontWeight: "700",
|
|
},
|
|
nextInstruction: {
|
|
flexDirection: "row",
|
|
alignItems: "center",
|
|
gap: spacing.s,
|
|
marginTop: spacing.s,
|
|
paddingTop: spacing.s,
|
|
borderTopWidth: 1,
|
|
borderTopColor: colors.borderSubtle,
|
|
},
|
|
nextLabel: {
|
|
color: colors.textMuted,
|
|
fontSize: fontSize.xs,
|
|
fontWeight: "600",
|
|
},
|
|
nextText: {
|
|
color: colors.textSecondary,
|
|
fontSize: fontSize.xs,
|
|
flex: 1,
|
|
},
|
|
routeSummary: {
|
|
flexDirection: "row",
|
|
alignItems: "center",
|
|
gap: spacing.m,
|
|
marginTop: spacing.s,
|
|
paddingTop: spacing.s,
|
|
borderTopWidth: 1,
|
|
borderTopColor: colors.borderSubtle,
|
|
},
|
|
showStepsBtn: {
|
|
flexDirection: "row",
|
|
alignItems: "center",
|
|
gap: 4,
|
|
marginLeft: "auto",
|
|
backgroundColor: colors.bgInput,
|
|
paddingVertical: 4,
|
|
paddingHorizontal: spacing.s,
|
|
borderRadius: 12,
|
|
},
|
|
showStepsBtnText: {
|
|
color: colors.textSecondary,
|
|
fontSize: fontSize.xs,
|
|
fontWeight: "600",
|
|
},
|
|
|
|
// All instructions
|
|
allInstructionsBox: {
|
|
backgroundColor: colors.bgSecondary,
|
|
borderRadius: borderRadius.md,
|
|
padding: spacing.m,
|
|
marginBottom: spacing.m,
|
|
borderWidth: 1,
|
|
borderColor: colors.border,
|
|
},
|
|
allInstructionsHeader: {
|
|
flexDirection: "row",
|
|
justifyContent: "space-between",
|
|
alignItems: "center",
|
|
marginBottom: spacing.m,
|
|
},
|
|
allInstructionsTitle: {
|
|
color: colors.textWhite,
|
|
fontSize: fontSize.md,
|
|
fontWeight: "700",
|
|
},
|
|
stepRow: {
|
|
flexDirection: "row",
|
|
alignItems: "center",
|
|
gap: spacing.m,
|
|
paddingVertical: spacing.s,
|
|
borderBottomWidth: 1,
|
|
borderBottomColor: colors.borderSubtle,
|
|
},
|
|
stepRowActive: {
|
|
backgroundColor: colors.accent + "10",
|
|
borderRadius: borderRadius.sm,
|
|
paddingHorizontal: spacing.s,
|
|
},
|
|
stepRowPassed: { opacity: 0.5 },
|
|
stepIcon: {
|
|
width: 32,
|
|
height: 32,
|
|
borderRadius: 8,
|
|
backgroundColor: colors.bgInput,
|
|
justifyContent: "center",
|
|
alignItems: "center",
|
|
},
|
|
stepText: { color: colors.textWhite, fontSize: fontSize.sm },
|
|
stepStreet: {
|
|
color: colors.textMuted,
|
|
fontSize: fontSize.xs,
|
|
marginTop: 1,
|
|
},
|
|
stepDist: {
|
|
color: colors.textMuted,
|
|
fontSize: fontSize.xs,
|
|
fontWeight: "600",
|
|
},
|
|
|
|
// Calc route button
|
|
calcRouteBtn: {
|
|
flexDirection: "row",
|
|
alignItems: "center",
|
|
justifyContent: "center",
|
|
gap: spacing.s,
|
|
backgroundColor: colors.accent,
|
|
paddingVertical: spacing.s,
|
|
paddingHorizontal: spacing.m,
|
|
borderRadius: borderRadius.sm,
|
|
marginTop: spacing.s,
|
|
},
|
|
calcRouteBtnText: {
|
|
color: colors.white,
|
|
fontSize: fontSize.sm,
|
|
fontWeight: "600",
|
|
},
|
|
|
|
// Queue
|
|
queueBar: {
|
|
flexDirection: "row",
|
|
alignItems: "center",
|
|
gap: spacing.s,
|
|
backgroundColor: colors.accent + "15",
|
|
paddingVertical: spacing.s,
|
|
paddingHorizontal: spacing.m,
|
|
borderRadius: borderRadius.sm,
|
|
marginBottom: spacing.m,
|
|
},
|
|
queueText: {
|
|
color: colors.accent,
|
|
fontSize: fontSize.sm,
|
|
fontWeight: "600",
|
|
},
|
|
|
|
sectionTitle: {
|
|
color: colors.textWhite,
|
|
fontSize: fontSize.lg,
|
|
fontWeight: "700",
|
|
marginBottom: spacing.m,
|
|
},
|
|
|
|
// Card
|
|
cardHeader: {
|
|
flexDirection: "row",
|
|
alignItems: "center",
|
|
marginBottom: spacing.s,
|
|
},
|
|
deliveryId: {
|
|
color: colors.textWhite,
|
|
fontSize: fontSize.md,
|
|
fontWeight: "700",
|
|
},
|
|
clientName: {
|
|
color: colors.textSecondary,
|
|
fontSize: fontSize.sm,
|
|
marginTop: 2,
|
|
},
|
|
|
|
addressRow: {
|
|
flexDirection: "row",
|
|
alignItems: "center",
|
|
gap: spacing.s,
|
|
backgroundColor: colors.bgInput,
|
|
paddingVertical: spacing.s,
|
|
paddingHorizontal: spacing.m,
|
|
borderRadius: borderRadius.sm,
|
|
marginBottom: spacing.s,
|
|
},
|
|
address: {
|
|
flex: 1,
|
|
color: colors.textSecondary,
|
|
fontSize: screenWidth < 380 ? fontSize.xs : fontSize.sm,
|
|
},
|
|
|
|
itemsSection: {
|
|
backgroundColor: colors.bgInput,
|
|
borderRadius: borderRadius.sm,
|
|
padding: spacing.m,
|
|
marginTop: spacing.s,
|
|
},
|
|
itemsHeader: {
|
|
flexDirection: "row",
|
|
alignItems: "center",
|
|
gap: spacing.s,
|
|
marginBottom: spacing.s,
|
|
},
|
|
itemsTitle: {
|
|
color: colors.success,
|
|
fontSize: fontSize.sm,
|
|
fontWeight: "700",
|
|
},
|
|
itemRow: {
|
|
flexDirection: "row",
|
|
alignItems: "center",
|
|
paddingVertical: spacing.xs,
|
|
borderBottomWidth: 1,
|
|
borderBottomColor: colors.borderSubtle,
|
|
flexWrap: "nowrap",
|
|
},
|
|
itemName: {
|
|
flex: 1,
|
|
color: colors.textWhite,
|
|
fontSize: screenWidth < 380 ? fontSize.xs : fontSize.sm,
|
|
fontWeight: "600",
|
|
},
|
|
itemQty: {
|
|
color: colors.textMuted,
|
|
fontSize: fontSize.xs,
|
|
marginTop: 1,
|
|
},
|
|
itemPrice: {
|
|
color: colors.success,
|
|
fontSize: fontSize.sm,
|
|
fontWeight: "600",
|
|
},
|
|
totalRow: {
|
|
flexDirection: "row",
|
|
justifyContent: "space-between",
|
|
alignItems: "center",
|
|
marginTop: spacing.s,
|
|
paddingTop: spacing.s,
|
|
borderTopWidth: 1,
|
|
borderTopColor: colors.border,
|
|
},
|
|
totalLabel: {
|
|
color: colors.textSecondary,
|
|
fontSize: fontSize.sm,
|
|
fontWeight: "600",
|
|
},
|
|
totalValue: {
|
|
color: colors.textWhite,
|
|
fontSize: fontSize.md,
|
|
fontWeight: "700",
|
|
},
|
|
|
|
priceOnly: {
|
|
color: colors.success,
|
|
fontSize: fontSize.sm,
|
|
fontWeight: "600",
|
|
marginTop: spacing.xs,
|
|
},
|
|
|
|
phoneRow: {
|
|
flexDirection: "row",
|
|
alignItems: "center",
|
|
gap: spacing.s,
|
|
marginTop: spacing.s,
|
|
},
|
|
phoneText: { color: colors.info, fontSize: fontSize.sm },
|
|
|
|
empty: {
|
|
color: colors.textMuted,
|
|
textAlign: "center",
|
|
marginTop: spacing.xxl,
|
|
fontSize: fontSize.md,
|
|
},
|
|
|
|
// Fullscreen map modal
|
|
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)",
|
|
},
|
|
closeFullscreenBtn: {
|
|
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",
|
|
},
|
|
fullscreenInstructionBar: {
|
|
position: "absolute",
|
|
top: 110,
|
|
left: spacing.m,
|
|
right: spacing.m,
|
|
flexDirection: "row",
|
|
alignItems: "center",
|
|
gap: spacing.m,
|
|
backgroundColor: "rgba(0,0,0,0.8)",
|
|
padding: spacing.m,
|
|
borderRadius: borderRadius.md,
|
|
},
|
|
fullscreenBottomBar: {
|
|
position: "absolute",
|
|
bottom: 0,
|
|
left: 0,
|
|
right: 0,
|
|
backgroundColor: "rgba(0,0,0,0.7)",
|
|
paddingHorizontal: spacing.l,
|
|
paddingTop: spacing.m,
|
|
paddingBottom: 40,
|
|
gap: spacing.s,
|
|
},
|
|
fullscreenInfoRow: {
|
|
flexDirection: "row",
|
|
alignItems: "center",
|
|
gap: 6,
|
|
},
|
|
fullscreenInfoText: {
|
|
color: colors.white,
|
|
fontSize: fontSize.sm,
|
|
fontWeight: "600",
|
|
},
|
|
fullscreenCoords: {
|
|
color: colors.textMuted,
|
|
fontSize: fontSize.sm,
|
|
fontFamily: "monospace",
|
|
},
|
|
// Bouton détails
|
|
detailsBtn: {
|
|
flexDirection: "row",
|
|
alignItems: "center",
|
|
justifyContent: "center",
|
|
gap: spacing.xs,
|
|
borderWidth: 1,
|
|
borderColor: colors.accent,
|
|
paddingVertical: spacing.s,
|
|
borderRadius: borderRadius.sm,
|
|
marginTop: spacing.m,
|
|
},
|
|
detailsBtnText: {
|
|
color: colors.accent,
|
|
fontSize: fontSize.sm,
|
|
fontWeight: "600",
|
|
},
|
|
|
|
// Modal détails
|
|
detailSection: { marginBottom: spacing.m },
|
|
detailSectionTitle: {
|
|
color: colors.textWhite,
|
|
fontSize: fontSize.sm,
|
|
fontWeight: "700",
|
|
marginBottom: spacing.s,
|
|
},
|
|
detailRow: {
|
|
flexDirection: "row",
|
|
justifyContent: "space-between",
|
|
alignItems: "center",
|
|
paddingVertical: spacing.xs,
|
|
borderBottomWidth: 1,
|
|
borderBottomColor: colors.borderSubtle,
|
|
},
|
|
detailLabel: {
|
|
color: colors.textMuted,
|
|
fontSize: fontSize.sm,
|
|
},
|
|
detailValue: {
|
|
color: colors.textWhite,
|
|
fontSize: fontSize.sm,
|
|
fontWeight: "600",
|
|
},
|
|
detailDivider: {
|
|
height: 1,
|
|
backgroundColor: colors.border,
|
|
marginBottom: spacing.m,
|
|
},
|
|
detailProductRow: {
|
|
flexDirection: "row",
|
|
alignItems: "center",
|
|
paddingVertical: spacing.s,
|
|
borderBottomWidth: 1,
|
|
borderBottomColor: colors.borderSubtle,
|
|
},
|
|
detailProductName: {
|
|
color: colors.textWhite,
|
|
fontSize: fontSize.sm,
|
|
fontWeight: "600",
|
|
},
|
|
detailProductQty: {
|
|
color: colors.textMuted,
|
|
fontSize: fontSize.xs,
|
|
marginTop: 2,
|
|
},
|
|
detailProductPrice: {
|
|
color: colors.success,
|
|
fontSize: fontSize.sm,
|
|
fontWeight: "700",
|
|
},
|
|
detailEmpty: {
|
|
color: colors.textMuted,
|
|
fontSize: fontSize.sm,
|
|
textAlign: "center",
|
|
paddingVertical: spacing.m,
|
|
},
|
|
detailTotalRow: {
|
|
flexDirection: "row",
|
|
justifyContent: "space-between",
|
|
alignItems: "center",
|
|
marginTop: spacing.m,
|
|
paddingTop: spacing.m,
|
|
borderTopWidth: 1,
|
|
borderTopColor: colors.border,
|
|
},
|
|
detailTotalLabel: {
|
|
color: colors.textSecondary,
|
|
fontSize: fontSize.md,
|
|
fontWeight: "700",
|
|
},
|
|
detailTotalValue: {
|
|
color: colors.textWhite,
|
|
fontSize: fontSize.lg,
|
|
fontWeight: "700",
|
|
},
|
|
|
|
fullscreenStatusBadge: {
|
|
flexDirection: "row",
|
|
alignItems: "center",
|
|
alignSelf: "flex-start",
|
|
gap: 6,
|
|
paddingVertical: spacing.xs,
|
|
paddingHorizontal: spacing.m,
|
|
borderRadius: 20,
|
|
marginTop: spacing.xs,
|
|
},
|
|
fullscreenStatusText: {
|
|
fontSize: fontSize.sm,
|
|
fontWeight: "600",
|
|
},
|
|
|
|
cancelInput: {
|
|
borderWidth: 1,
|
|
borderColor: colors.border,
|
|
borderRadius: borderRadius.md,
|
|
padding: spacing.m,
|
|
color: colors.textWhite,
|
|
backgroundColor: colors.bgSecondary,
|
|
minHeight: 80,
|
|
textAlignVertical: "top",
|
|
marginBottom: spacing.m,
|
|
},
|
|
cancelConfirmBtn: {
|
|
backgroundColor: colors.danger,
|
|
borderRadius: borderRadius.md,
|
|
padding: spacing.m,
|
|
alignItems: "center",
|
|
},
|
|
cancelConfirmText: {
|
|
color: colors.textWhite,
|
|
fontWeight: "700",
|
|
fontSize: fontSize.md,
|
|
},
|
|
}),
|
|
[colors, screenWidth, screenHeight, MAP_HEIGHT],
|
|
);
|
|
|
|
if (loading) return <LoadingSpinner message="Chargement..." />;
|
|
|
|
return (
|
|
<View style={styles.container}>
|
|
{/* ── Modal plein écran avec TomTomMap ── */}
|
|
<Modal
|
|
visible={mapFullscreen}
|
|
animationType="fade"
|
|
onRequestClose={() => setMapFullscreen(false)}
|
|
statusBarTranslucent
|
|
>
|
|
<StatusBar hidden={mapFullscreen} />
|
|
<View style={styles.fullscreenContainer}>
|
|
<TomTomMap
|
|
ref={fullscreenMapRef}
|
|
style={styles.fullscreenMap}
|
|
markers={driverMarkers}
|
|
initialCenter={
|
|
lastCoords
|
|
? {
|
|
latitude: lastCoords.lat,
|
|
longitude: lastCoords.lng,
|
|
}
|
|
: { latitude: 48.8566, longitude: 2.3522 }
|
|
}
|
|
initialZoom={15}
|
|
/>
|
|
|
|
{/* Top bar */}
|
|
<View style={styles.fullscreenTopBar}>
|
|
<TouchableOpacity
|
|
style={styles.closeFullscreenBtn}
|
|
onPress={() => setMapFullscreen(false)}
|
|
>
|
|
<Ionicons
|
|
name="close"
|
|
size={24}
|
|
color={colors.white}
|
|
/>
|
|
</TouchableOpacity>
|
|
<Text style={styles.fullscreenTitle}>
|
|
{pendingRouteAddress.current
|
|
? "Itinéraire en cours"
|
|
: "GPS en direct"}
|
|
</Text>
|
|
<View style={{ width: 40 }} />
|
|
</View>
|
|
|
|
{/* Bottom info */}
|
|
<View style={styles.fullscreenBottomBar}>
|
|
{/* Adresse de destination */}
|
|
{pendingRouteAddress.current && (
|
|
<View style={styles.fullscreenInfoRow}>
|
|
<Ionicons
|
|
name="location"
|
|
size={16}
|
|
color={colors.danger}
|
|
/>
|
|
<Text
|
|
style={[
|
|
styles.fullscreenInfoText,
|
|
{ flex: 1 },
|
|
]}
|
|
numberOfLines={2}
|
|
>
|
|
{pendingRouteAddress.current}
|
|
</Text>
|
|
</View>
|
|
)}
|
|
|
|
{/* Infos trajet */}
|
|
{routeInfo && (
|
|
<View style={styles.fullscreenInfoRow}>
|
|
<View style={styles.routeInfoChip}>
|
|
<Ionicons
|
|
name="time-outline"
|
|
size={14}
|
|
color={colors.white}
|
|
/>
|
|
<Text style={styles.routeInfoOverlayText}>
|
|
{routeInfo.duration}
|
|
</Text>
|
|
</View>
|
|
<View
|
|
style={[
|
|
styles.routeInfoChip,
|
|
{ marginLeft: spacing.s },
|
|
]}
|
|
>
|
|
<Ionicons
|
|
name="navigate-outline"
|
|
size={14}
|
|
color={colors.white}
|
|
/>
|
|
<Text style={styles.routeInfoOverlayText}>
|
|
{routeInfo.distance}
|
|
</Text>
|
|
</View>
|
|
</View>
|
|
)}
|
|
|
|
{/* GPS status */}
|
|
<View style={styles.fullscreenInfoRow}>
|
|
<View
|
|
style={[
|
|
styles.gpsDot,
|
|
{
|
|
backgroundColor: locationEnabled
|
|
? colors.success
|
|
: colors.danger,
|
|
},
|
|
]}
|
|
/>
|
|
<Text style={styles.fullscreenInfoText}>
|
|
GPS {locationEnabled ? "actif" : "inactif"}
|
|
</Text>
|
|
{lastCoords && (
|
|
<Text
|
|
style={[
|
|
styles.fullscreenCoords,
|
|
{ marginLeft: 8 },
|
|
]}
|
|
>
|
|
{lastCoords.lat.toFixed(4)},{" "}
|
|
{lastCoords.lng.toFixed(4)}
|
|
</Text>
|
|
)}
|
|
</View>
|
|
|
|
{/* Bouton recalculer */}
|
|
{pendingRouteAddress.current && lastCoords && (
|
|
<TouchableOpacity
|
|
style={styles.calcRouteBtn}
|
|
onPress={() =>
|
|
calcRoute(
|
|
pendingRouteAddress.current!,
|
|
fullscreenMapRef,
|
|
)
|
|
}
|
|
activeOpacity={0.7}
|
|
>
|
|
<Ionicons
|
|
name="navigate"
|
|
size={16}
|
|
color={colors.white}
|
|
/>
|
|
<Text style={styles.calcRouteBtnText}>
|
|
Recalculer l'itinéraire
|
|
</Text>
|
|
</TouchableOpacity>
|
|
)}
|
|
</View>
|
|
</View>
|
|
</Modal>
|
|
|
|
{/* ── Modal détails commande ── */}
|
|
<DetailsModal
|
|
visible={detailsDelivery !== null}
|
|
onClose={() => setDetailsDelivery(null)}
|
|
title={`Commande #${detailsDelivery?.id}`}
|
|
icon="receipt-outline"
|
|
iconColor={colors.accent}
|
|
>
|
|
<ScrollView showsVerticalScrollIndicator={false}>
|
|
{/* Infos client */}
|
|
<View style={styles.detailSection}>
|
|
<Text style={styles.detailSectionTitle}>
|
|
<Ionicons
|
|
name="person-outline"
|
|
size={14}
|
|
color={colors.accent}
|
|
/>
|
|
{" "}Client
|
|
</Text>
|
|
<View style={styles.detailRow}>
|
|
<Text style={styles.detailLabel}>Prénom</Text>
|
|
<Text style={styles.detailValue}>
|
|
{detailsDelivery?.clientPrenom || "—"}
|
|
</Text>
|
|
</View>
|
|
<View style={styles.detailRow}>
|
|
<Text style={styles.detailLabel}>Nom</Text>
|
|
<Text style={styles.detailValue}>
|
|
{detailsDelivery?.clientNom || "—"}
|
|
</Text>
|
|
</View>
|
|
</View>
|
|
|
|
{/* Séparateur */}
|
|
<View style={styles.detailDivider} />
|
|
|
|
{/* Produits */}
|
|
<Text style={styles.detailSectionTitle}>
|
|
<Ionicons
|
|
name="bag-outline"
|
|
size={14}
|
|
color={colors.success}
|
|
/>
|
|
{" "}Produits
|
|
</Text>
|
|
{detailsDelivery?.items &&
|
|
detailsDelivery.items.length > 0 ? (
|
|
<>
|
|
{detailsDelivery.items.some(p => p.is_reward) && (
|
|
<View style={{ flexDirection: "row", alignItems: "center", gap: 6, backgroundColor: "rgba(245,158,11,0.12)", borderRadius: 8, padding: 8, marginBottom: 8 }}>
|
|
<Ionicons name="gift-outline" size={16} color="#f59e0b" />
|
|
<Text style={{ fontSize: 13, color: "#f59e0b", fontWeight: "700" }}>
|
|
Cette commande contient un article offert (récompense client)
|
|
</Text>
|
|
</View>
|
|
)}
|
|
{detailsDelivery.items.map((prod, idx) => (
|
|
<View key={idx} style={styles.detailProductRow}>
|
|
<View style={{ flex: 1 }}>
|
|
<View style={{ flexDirection: "row", alignItems: "center", gap: 4 }}>
|
|
<Text style={styles.detailProductName}>{prod.produit}</Text>
|
|
{prod.is_reward && (
|
|
<View style={{ flexDirection: "row", alignItems: "center", gap: 2, backgroundColor: "rgba(245,158,11,0.15)", borderRadius: 4, paddingHorizontal: 4, paddingVertical: 1 }}>
|
|
<Ionicons name="gift-outline" size={11} color="#f59e0b" />
|
|
<Text style={{ fontSize: 11, color: "#f59e0b", fontWeight: "700" }}>Offert</Text>
|
|
</View>
|
|
)}
|
|
</View>
|
|
<Text style={styles.detailProductQty}>
|
|
Quantité : {prod.quantite}{prod.unit || ""}
|
|
</Text>
|
|
</View>
|
|
<Text style={[styles.detailProductPrice, prod.is_reward ? { color: "#10b981" } : {}]}>
|
|
{prod.is_reward ? "Offert" : `${(prod.prix ?? 0).toFixed(2)}€`}
|
|
</Text>
|
|
</View>
|
|
))}
|
|
</>
|
|
) : (
|
|
<Text style={styles.detailEmpty}>Aucun produit</Text>
|
|
)}
|
|
|
|
{/* Total */}
|
|
<View style={styles.detailTotalRow}>
|
|
<Text style={styles.detailTotalLabel}>Total</Text>
|
|
<Text style={styles.detailTotalValue}>
|
|
{detailsDelivery?.total_prix ?? 0}€
|
|
</Text>
|
|
</View>
|
|
{(detailsDelivery?.referral_used ?? 0) > 0 && (
|
|
<>
|
|
<View style={[styles.detailTotalRow, { marginTop: 4 }]}>
|
|
<Text
|
|
style={[
|
|
styles.detailTotalLabel,
|
|
{ color: colors.success },
|
|
]}
|
|
>
|
|
Parrainage client
|
|
</Text>
|
|
<Text
|
|
style={[
|
|
styles.detailTotalValue,
|
|
{ color: colors.success },
|
|
]}
|
|
>
|
|
-{detailsDelivery?.referral_used?.toFixed(2)}€
|
|
</Text>
|
|
</View>
|
|
<View style={[styles.detailTotalRow, { marginTop: 4 }]}>
|
|
<Text style={[styles.detailTotalLabel, { fontWeight: "700" }]}>
|
|
Net à encaisser
|
|
</Text>
|
|
<Text style={[styles.detailTotalValue, { fontWeight: "700" }]}>
|
|
{((detailsDelivery?.total_prix ?? 0) - (detailsDelivery?.referral_used ?? 0)).toFixed(2)}€
|
|
</Text>
|
|
</View>
|
|
</>
|
|
)}
|
|
</ScrollView>
|
|
</DetailsModal>
|
|
|
|
{/* ── Barre de statut ── */}
|
|
<View style={styles.statusBar}>
|
|
<Text style={styles.statusLabel}>Mon statut:</Text>
|
|
<View style={styles.statusBtns}>
|
|
{(["available", "busy", "offline"] as const).map((s) => (
|
|
<TouchableOpacity
|
|
key={s}
|
|
onPress={() => handleStatusChange(s)}
|
|
style={[
|
|
styles.statusBtn,
|
|
status?.status === s && {
|
|
backgroundColor: STATUS_COLORS[s] + "33",
|
|
borderColor: STATUS_COLORS[s],
|
|
},
|
|
]}
|
|
>
|
|
<View
|
|
style={[
|
|
styles.dot,
|
|
{ backgroundColor: STATUS_COLORS[s] },
|
|
]}
|
|
/>
|
|
<Text
|
|
style={[
|
|
styles.statusText,
|
|
status?.status === s && {
|
|
color: STATUS_COLORS[s],
|
|
},
|
|
]}
|
|
>
|
|
{STATUS_LABELS[s]}
|
|
</Text>
|
|
</TouchableOpacity>
|
|
))}
|
|
</View>
|
|
</View>
|
|
|
|
{/* ── Liste des livraisons ── */}
|
|
<FlatList
|
|
data={deliveries}
|
|
keyExtractor={(item, index) => (item.id ?? index).toString()}
|
|
renderItem={renderDelivery}
|
|
refreshControl={
|
|
<RefreshControl
|
|
refreshing={refreshing}
|
|
onRefresh={onRefresh}
|
|
tintColor={colors.success}
|
|
/>
|
|
}
|
|
contentContainerStyle={{
|
|
padding: spacing.l,
|
|
paddingBottom: 100,
|
|
}}
|
|
ListEmptyComponent={
|
|
<Text style={styles.empty}>
|
|
Aucune livraison en attente
|
|
</Text>
|
|
}
|
|
ListHeaderComponent={renderHeader()}
|
|
/>
|
|
|
|
{/* Modal annulation livraison */}
|
|
<DetailsModal
|
|
visible={cancelModal.visible}
|
|
onClose={() =>
|
|
setCancelModal({
|
|
visible: false,
|
|
deliveryId: null,
|
|
issueType: null,
|
|
description: "",
|
|
})
|
|
}
|
|
title="Motif de non-livraison"
|
|
icon="close-circle-outline"
|
|
>
|
|
<Text
|
|
style={[
|
|
styles.cancelInput,
|
|
{
|
|
color: colors.textSecondary,
|
|
fontSize: 13,
|
|
marginBottom: spacing.s,
|
|
backgroundColor: "transparent",
|
|
borderWidth: 0,
|
|
padding: 0,
|
|
},
|
|
]}
|
|
>
|
|
Sélectionnez un motif
|
|
</Text>
|
|
<View
|
|
style={{
|
|
flexDirection: "row",
|
|
flexWrap: "wrap",
|
|
gap: spacing.s,
|
|
marginBottom: spacing.m,
|
|
}}
|
|
>
|
|
{(Object.keys(ISSUE_LABELS) as IssueType[]).map((type) => {
|
|
const selected = cancelModal.issueType === type;
|
|
return (
|
|
<TouchableOpacity
|
|
key={type}
|
|
onPress={() =>
|
|
setCancelModal((prev) => ({
|
|
...prev,
|
|
issueType: type,
|
|
}))
|
|
}
|
|
style={{
|
|
paddingHorizontal: spacing.m,
|
|
paddingVertical: spacing.s,
|
|
borderRadius: 20,
|
|
borderWidth: 1,
|
|
borderColor: selected
|
|
? colors.danger
|
|
: colors.border,
|
|
backgroundColor: selected
|
|
? colors.danger + "22"
|
|
: colors.bgCard,
|
|
}}
|
|
>
|
|
<Text
|
|
style={{
|
|
color: selected
|
|
? colors.danger
|
|
: colors.textSecondary,
|
|
fontSize: 13,
|
|
}}
|
|
>
|
|
{ISSUE_LABELS[type]}
|
|
</Text>
|
|
</TouchableOpacity>
|
|
);
|
|
})}
|
|
</View>
|
|
<TextInput
|
|
style={styles.cancelInput}
|
|
placeholder="Précisions (facultatif)..."
|
|
placeholderTextColor={colors.textMuted}
|
|
value={cancelModal.description}
|
|
onChangeText={(t) =>
|
|
setCancelModal((prev) => ({ ...prev, description: t }))
|
|
}
|
|
multiline
|
|
/>
|
|
<TouchableOpacity
|
|
style={[
|
|
styles.cancelConfirmBtn,
|
|
!cancelModal.issueType && { opacity: 0.4 },
|
|
]}
|
|
onPress={handleCancelDelivery}
|
|
disabled={!cancelModal.issueType}
|
|
>
|
|
<Text style={styles.cancelConfirmText}>
|
|
Confirmer l'annulation
|
|
</Text>
|
|
</TouchableOpacity>
|
|
</DetailsModal>
|
|
|
|
<AlertModal
|
|
visible={alert.visible}
|
|
type={alert.type}
|
|
title={alert.title}
|
|
message={alert.message}
|
|
onClose={hideAlert}
|
|
/>
|
|
</View>
|
|
);
|
|
}
|