chore: add mobile app

This commit is contained in:
2026-02-07 22:33:02 +01:00
parent db34640acc
commit e89384ad4e
29327 changed files with 3886944 additions and 12 deletions
@@ -0,0 +1,635 @@
import React, { useState, useEffect, useRef, useMemo } from "react";
import {
View,
Text,
StyleSheet,
ScrollView,
TouchableOpacity,
Modal,
StatusBar,
} from "react-native";
import { useRoute, type RouteProp } from "@react-navigation/native";
import { Ionicons } from "@expo/vector-icons";
import MapView, { Marker, Polyline, PROVIDER_DEFAULT } from "react-native-maps";
import { spacing, fontSize, borderRadius } from "../../theme";
import { useTheme } from "../../context/ThemeContext";
import {
getCommandByID,
getCommandItems,
updateCommandStatus,
validateCommand,
getDeliveryPersonDetails,
} from "../../api/api_admin";
import { geocodeAddress, calculateRoute } from "../../api/tomtom";
import type { RouteInfo, LatLng } from "../../api/tomtom";
import type { AdminStackParamList } from "../../navigation/types";
import StatusBadge from "../../components/StatusBadge";
import LoadingSpinner from "../../components/ui/LoadingSpinner";
import Button from "../../components/ui/Button";
import Card from "../../components/ui/Card";
import AlertModal from "../../components/ui/AlertModal";
import { useAlert } from "../../hooks/useAlert";
type Route = RouteProp<AdminStackParamList, "OrderDetail">;
const MAP_HEIGHT = 240;
export default function OrderDetailScreen() {
const { colors } = useTheme();
const route = useRoute<Route>();
const { orderId } = route.params;
const [command, setCommand] = useState<any>(null);
const [items, setItems] = useState<any[]>([]);
const [loading, setLoading] = useState(true);
// Map / tracking
const mapRef = useRef<MapView | null>(null);
const fullscreenMapRef = useRef<MapView | null>(null);
const [mapFullscreen, setMapFullscreen] = useState(false);
const [livreurCoords, setLivreurCoords] = useState<LatLng | null>(null);
const [destCoords, setDestCoords] = useState<LatLng | null>(null);
const [routeInfo, setRouteInfo] = useState<RouteInfo | null>(null);
const [mapLoading, setMapLoading] = useState(false);
const { alert, showError, showSuccess, hideAlert } = useAlert();
useEffect(() => {
const load = async () => {
try {
const [cmdRes, itemsRes] = await Promise.all([
getCommandByID(orderId),
getCommandItems(orderId),
]);
setCommand(cmdRes.command);
setItems(itemsRes.items);
// If livreur assigned, fetch their location and calc route
const cmd = cmdRes.command;
if (cmd?.livreur_assign && cmd?.adresse) {
loadLivreurRoute(cmd.livreur_assign, cmd.adresse);
}
} catch {
/* ignore */
}
setLoading(false);
};
load();
}, [orderId]);
const loadLivreurRoute = async (
livreurUsername: string,
deliveryAddress: string,
) => {
setMapLoading(true);
try {
// Get livreur location
const details = await getDeliveryPersonDetails(livreurUsername);
const loc = details?.location;
if (!loc?.latitude || !loc?.longitude) {
setMapLoading(false);
return;
}
const origin: LatLng = {
latitude: loc.latitude,
longitude: loc.longitude,
};
setLivreurCoords(origin);
// Geocode destination
const dest = await geocodeAddress(deliveryAddress);
if (!dest) {
setMapLoading(false);
return;
}
setDestCoords(dest);
// Calculate route
const result = await calculateRoute(origin, dest);
if (result) {
setRouteInfo(result.route);
}
} catch {
/* silent */
}
setMapLoading(false);
};
const fitMapToRoute = (ref: React.RefObject<MapView | null>) => {
if (ref.current && livreurCoords && destCoords) {
ref.current.fitToCoordinates(
[
{
latitude: livreurCoords.latitude,
longitude: livreurCoords.longitude,
},
{
latitude: destCoords.latitude,
longitude: destCoords.longitude,
},
],
{
edgePadding: { top: 80, right: 60, bottom: 80, left: 60 },
animated: true,
},
);
}
};
const handleValidate = async () => {
try {
await validateCommand(orderId);
showSuccess("Succès", "Commande validée");
const res = await getCommandByID(orderId);
setCommand(res.command);
} catch (e: any) {
showError("Erreur", e.message);
}
};
const handleStatusChange = async (status: string) => {
try {
await updateCommandStatus(orderId, status);
const res = await getCommandByID(orderId);
setCommand(res.command);
} catch (e: any) {
showError("Erreur", e.message);
}
};
const styles = useMemo(
() =>
StyleSheet.create({
container: { flex: 1, backgroundColor: colors.bgPrimary },
row: {
flexDirection: "row",
justifyContent: "space-between",
alignItems: "center",
marginBottom: spacing.s,
},
title: {
fontSize: fontSize.xl,
fontWeight: "bold",
color: colors.textWhite,
},
info: {
color: colors.textSecondary,
fontSize: fontSize.sm,
marginTop: 2,
},
date: {
color: colors.textMuted,
fontSize: fontSize.xs,
marginTop: spacing.s,
},
sectionTitle: {
fontSize: fontSize.lg,
fontWeight: "600",
color: colors.textWhite,
marginTop: spacing.xl,
marginBottom: spacing.m,
},
itemName: {
color: colors.textWhite,
fontSize: fontSize.md,
fontWeight: "600",
marginBottom: spacing.xs,
},
itemDetails: {
flexDirection: "row",
justifyContent: "space-between",
},
actions: { marginTop: spacing.m, marginBottom: spacing.xxl },
empty: {
color: colors.textMuted,
textAlign: "center",
marginTop: spacing.xxl,
fontSize: fontSize.md,
},
// Map
mapContainer: {
borderRadius: borderRadius.md,
overflow: "hidden",
position: "relative",
},
map: { width: "100%", height: MAP_HEIGHT },
driverMarkerOuter: {
width: 34,
height: 34,
borderRadius: 17,
backgroundColor: colors.success + "40",
justifyContent: "center",
alignItems: "center",
},
driverMarkerInner: {
width: 26,
height: 26,
borderRadius: 13,
backgroundColor: colors.success,
justifyContent: "center",
alignItems: "center",
},
destMarkerOuter: {
width: 30,
height: 30,
borderRadius: 15,
backgroundColor: colors.danger + "40",
justifyContent: "center",
alignItems: "center",
},
destMarkerInner: {
width: 22,
height: 22,
borderRadius: 11,
backgroundColor: colors.danger,
justifyContent: "center",
alignItems: "center",
},
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",
},
routeOverlayInfo: {
color: colors.accent,
fontSize: fontSize.xs,
marginTop: 2,
},
mapLoadingOverlay: {
position: "absolute",
top: 0,
left: 0,
right: 0,
bottom: 0,
backgroundColor: "rgba(0,0,0,0.3)",
justifyContent: "center",
alignItems: "center",
},
mapLoadingText: {
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",
},
noMapBox: {
alignItems: "center",
paddingVertical: spacing.l,
backgroundColor: colors.bgSecondary,
borderRadius: borderRadius.md,
},
noMapText: {
color: colors.textMuted,
fontSize: fontSize.sm,
marginTop: spacing.xs,
},
// 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",
},
}),
[colors],
);
if (loading) return <LoadingSpinner message="Chargement..." />;
if (!command)
return (
<View style={styles.container}>
<Text style={styles.empty}>Commande introuvable</Text>
</View>
);
const hasMap = livreurCoords !== null;
return (
<ScrollView
style={styles.container}
contentContainerStyle={{ padding: spacing.l }}
>
{/* Fullscreen map modal */}
<Modal
visible={mapFullscreen}
animationType="fade"
onRequestClose={() => setMapFullscreen(false)}
statusBarTranslucent
>
<StatusBar hidden={mapFullscreen} />
<View style={styles.fullscreenContainer}>
{livreurCoords && (
<MapView
ref={fullscreenMapRef}
provider={PROVIDER_DEFAULT}
style={styles.fullscreenMap}
initialRegion={{
latitude: livreurCoords.latitude,
longitude: livreurCoords.longitude,
latitudeDelta: 0.02,
longitudeDelta: 0.02,
}}
onMapReady={() => fitMapToRoute(fullscreenMapRef)}
showsCompass
showsScale
>
<Marker
coordinate={livreurCoords}
title={command.livreur_assign}
>
<View style={styles.driverMarkerOuter}>
<View style={styles.driverMarkerInner}>
<Ionicons
name="bicycle"
size={14}
color={colors.white}
/>
</View>
</View>
</Marker>
{destCoords && (
<Marker
coordinate={destCoords}
title="Destination"
>
<View style={styles.destMarkerOuter}>
<View style={styles.destMarkerInner}>
<Ionicons
name="flag"
size={12}
color={colors.white}
/>
</View>
</View>
</Marker>
)}
{routeInfo && routeInfo.coordinates.length > 0 && (
<Polyline
coordinates={routeInfo.coordinates}
strokeColor="#4285F4"
strokeWidth={5}
/>
)}
</MapView>
)}
<View style={styles.fullscreenTopBar}>
<TouchableOpacity
style={styles.closeBtn}
onPress={() => setMapFullscreen(false)}
>
<Ionicons
name="close"
size={24}
color={colors.white}
/>
</TouchableOpacity>
<Text style={styles.fullscreenTitle}>
{routeInfo
? `${routeInfo.distance} · ${routeInfo.duration}`
: `Livreur: ${command.livreur_assign}`}
</Text>
<View style={{ width: 40 }} />
</View>
</View>
</Modal>
{/* Order info */}
<Card>
<View style={styles.row}>
<Text style={styles.title}>Commande #{command.id}</Text>
<StatusBadge status={command.status} />
</View>
<Text style={styles.info}>Client: {command.username}</Text>
<Text style={styles.info}>Adresse: {command.adresse}</Text>
<Text style={styles.info}>
Total: {command.total_prix?.toFixed(2)}
</Text>
{command.livreur_assign && (
<Text style={styles.info}>
Livreur: {command.livreur_assign}
</Text>
)}
<Text style={styles.date}>
{new Date(command.created_at).toLocaleString("fr-FR")}
</Text>
</Card>
{/* Livreur tracking map */}
{command.livreur_assign && (
<>
<Text style={styles.sectionTitle}>Suivi du livreur</Text>
{hasMap ? (
<View style={styles.mapContainer}>
<MapView
ref={mapRef}
provider={PROVIDER_DEFAULT}
style={styles.map}
initialRegion={{
latitude: livreurCoords!.latitude,
longitude: livreurCoords!.longitude,
latitudeDelta: 0.02,
longitudeDelta: 0.02,
}}
onMapReady={() => fitMapToRoute(mapRef)}
>
<Marker
coordinate={livreurCoords!}
title={command.livreur_assign}
>
<View style={styles.driverMarkerOuter}>
<View style={styles.driverMarkerInner}>
<Ionicons
name="bicycle"
size={14}
color={colors.white}
/>
</View>
</View>
</Marker>
{destCoords && (
<Marker
coordinate={destCoords}
title="Destination"
>
<View style={styles.destMarkerOuter}>
<View
style={styles.destMarkerInner}
>
<Ionicons
name="flag"
size={12}
color={colors.white}
/>
</View>
</View>
</Marker>
)}
{routeInfo &&
routeInfo.coordinates.length > 0 && (
<Polyline
coordinates={routeInfo.coordinates}
strokeColor="#4285F4"
strokeWidth={4}
/>
)}
</MapView>
{/* Route info overlay */}
{routeInfo && (
<View style={styles.routeOverlay}>
<Text style={styles.routeOverlayUser}>
{command.livreur_assign}
</Text>
<Text style={styles.routeOverlayInfo}>
{routeInfo.distance} ·{" "}
{routeInfo.duration}
</Text>
</View>
)}
{mapLoading && (
<View style={styles.mapLoadingOverlay}>
<Text style={styles.mapLoadingText}>
Chargement...
</Text>
</View>
)}
<TouchableOpacity
style={styles.expandBtn}
onPress={() => setMapFullscreen(true)}
>
<Ionicons
name="expand-outline"
size={18}
color={colors.white}
/>
</TouchableOpacity>
</View>
) : mapLoading ? (
<View style={styles.noMapBox}>
<Text style={styles.noMapText}>
Chargement position du livreur...
</Text>
</View>
) : (
<View style={styles.noMapBox}>
<Ionicons
name="location-outline"
size={28}
color={colors.textMuted}
/>
<Text style={styles.noMapText}>
Position GPS du livreur non disponible
</Text>
</View>
)}
</>
)}
{/* Items */}
<Text style={styles.sectionTitle}>Articles ({items.length})</Text>
{items.map((item: any) => (
<Card key={item.id} style={{ marginBottom: spacing.s }}>
<Text style={styles.itemName}>{item.product_name}</Text>
<View style={styles.itemDetails}>
<Text style={styles.info}>
Quantité: {item.quantite}
</Text>
<Text style={styles.info}>
Prix: {item.prix?.toFixed(2)}
</Text>
</View>
</Card>
))}
{/* Actions */}
<Text style={styles.sectionTitle}>Actions</Text>
<View style={styles.actions}>
{command.status !== "approved" &&
command.status !== "cancelled" && (
<Button
title="Valider commande"
onPress={handleValidate}
variant="success"
fullWidth
/>
)}
{command.status === "pending" && (
<Button
title="Passer en assignée"
onPress={() => handleStatusChange("assigned")}
variant="secondary"
fullWidth
style={{ marginTop: spacing.s }}
/>
)}
{command.status === "assigned" && (
<Button
title="Passer en route"
onPress={() => handleStatusChange("en_route")}
variant="secondary"
fullWidth
style={{ marginTop: spacing.s }}
/>
)}
</View>
<AlertModal
visible={alert.visible}
type={alert.type}
title={alert.title}
message={alert.message}
onClose={hideAlert}
/>
</ScrollView>
);
}