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,168 @@
import React, { useState, useEffect, useCallback, useMemo } from "react";
import {
View,
Text,
StyleSheet,
FlatList,
RefreshControl,
TouchableOpacity,
} from "react-native";
import { Ionicons } from "@expo/vector-icons";
import { spacing, fontSize } from "../../theme";
import { useTheme } from "../../context/ThemeContext";
import { getAllAlerts, getActiveAlerts } from "../../api/api_cabine";
import type { Alert as AlertType } from "../../api/types";
import LoadingSpinner from "../../components/ui/LoadingSpinner";
import Card from "../../components/ui/Card";
import Badge from "../../components/ui/Badge";
export default function AlertsScreen() {
const { colors } = useTheme();
const [alerts, setAlerts] = useState<AlertType[]>([]);
const [loading, setLoading] = useState(true);
const [refreshing, setRefreshing] = useState(false);
const [filter, setFilter] = useState<"all" | "active">("all");
const loadData = useCallback(async () => {
try {
const result =
filter === "active"
? await getActiveAlerts()
: await getAllAlerts();
setAlerts(result.alerts);
} catch {
/* ignore */
}
setLoading(false);
}, [filter]);
useEffect(() => {
setLoading(true);
loadData();
}, [loadData]);
const onRefresh = async () => {
setRefreshing(true);
await loadData();
setRefreshing(false);
};
const styles = useMemo(
() =>
StyleSheet.create({
container: { flex: 1, backgroundColor: colors.bgPrimary },
row: { flexDirection: "row", alignItems: "center" },
username: {
color: colors.textWhite,
fontSize: fontSize.md,
fontWeight: "600",
},
date: {
color: colors.textMuted,
fontSize: fontSize.xs,
marginTop: 2,
},
empty: {
color: colors.textMuted,
textAlign: "center",
marginTop: spacing.xxl,
fontSize: fontSize.md,
},
filterRow: {
flexDirection: "row",
paddingHorizontal: spacing.l,
paddingTop: spacing.m,
gap: spacing.s,
},
filterBtn: {
paddingVertical: spacing.xs,
paddingHorizontal: spacing.m,
borderRadius: 20,
backgroundColor: colors.bgSecondary,
},
filterActive: { backgroundColor: colors.info },
filterText: {
color: colors.textMuted,
fontSize: fontSize.sm,
},
filterTextActive: {
color: colors.textWhite,
fontWeight: "600",
},
}),
[colors],
);
const renderAlert = ({ item }: { item: AlertType }) => (
<Card style={{ marginBottom: spacing.m }}>
<View style={styles.row}>
<Ionicons
name="alert-circle"
size={24}
color={
item.status === "true"
? colors.danger
: colors.textMuted
}
/>
<View style={{ flex: 1, marginLeft: spacing.m }}>
<Text style={styles.username}>
Livreur: {item.username}
</Text>
<Text style={styles.date}>
{new Date(item.created_at).toLocaleString("fr-FR")}
</Text>
</View>
<Badge
label={item.status === "true" ? "Active" : "Terminée"}
color={
item.status === "true" ? colors.danger : colors.success
}
/>
</View>
</Card>
);
if (loading) return <LoadingSpinner message="Chargement alertes..." />;
return (
<View style={styles.container}>
<View style={styles.filterRow}>
{(["all", "active"] as const).map((f) => (
<TouchableOpacity
key={f}
onPress={() => setFilter(f)}
style={[
styles.filterBtn,
filter === f && styles.filterActive,
]}
>
<Text
style={[
styles.filterText,
filter === f && styles.filterTextActive,
]}
>
{f === "all" ? "Toutes" : "Actives"}
</Text>
</TouchableOpacity>
))}
</View>
<FlatList
data={alerts}
keyExtractor={(item) => item.id.toString()}
renderItem={renderAlert}
refreshControl={
<RefreshControl
refreshing={refreshing}
onRefresh={onRefresh}
tintColor={colors.info}
/>
}
contentContainerStyle={{ padding: spacing.l }}
ListEmptyComponent={
<Text style={styles.empty}>Aucune alerte</Text>
}
/>
</View>
);
}
@@ -0,0 +1,187 @@
import React, { useState, useEffect, useCallback, useMemo } from "react";
import {
View,
Text,
StyleSheet,
ScrollView,
RefreshControl,
} from "react-native";
import { Ionicons } from "@expo/vector-icons";
import { spacing, fontSize, borderRadius } from "../../theme";
import { useTheme } from "../../context/ThemeContext";
import { shadows } from "../../theme/shadows";
import { useAuth } from "../../auth/AuthContext";
import { getAllCommands } from "../../api/api_admin";
import { getAllDeliveryPersonsWithDetails } from "../../api/api_cabine";
import LoadingSpinner from "../../components/ui/LoadingSpinner";
export default function DashboardScreen() {
const { colors } = useTheme();
const { username } = useAuth();
const [stats, setStats] = useState<
Array<{
label: string;
value: number;
icon: keyof typeof Ionicons.glyphMap;
color: string;
}>
>([]);
const [loading, setLoading] = useState(true);
const [refreshing, setRefreshing] = useState(false);
const loadStats = useCallback(async () => {
try {
const [allCmd, livreursRes] = await Promise.all([
getAllCommands(),
getAllDeliveryPersonsWithDetails(),
]);
const cmds = allCmd.commands;
setStats([
{
label: "Commandes actives",
value: cmds.filter(
(c: any) =>
!["approved", "cancelled"].includes(c.status),
).length,
icon: "receipt-outline",
color: colors.info,
},
{
label: "En route",
value: cmds.filter((c: any) => c.status === "en_route")
.length,
icon: "navigate-outline",
color: colors.warning,
},
{
label: "En attente",
value: cmds.filter((c: any) => c.status === "pending")
.length,
icon: "time-outline",
color: colors.accent,
},
{
label: "Livreurs dispo",
value: livreursRes.stats.available,
icon: "bicycle-outline",
color: colors.success,
},
{
label: "Livreurs occupés",
value: livreursRes.stats.busy,
icon: "bicycle",
color: colors.warning,
},
{
label: "Total terminées",
value: cmds.filter((c: any) => c.status === "approved")
.length,
icon: "checkmark-circle-outline",
color: colors.successDark,
},
]);
} catch {
/* ignore */
}
setLoading(false);
}, [colors]);
useEffect(() => {
loadStats();
}, [loadStats]);
const onRefresh = async () => {
setRefreshing(true);
await loadStats();
setRefreshing(false);
};
const styles = useMemo(
() =>
StyleSheet.create({
container: {
flex: 1,
backgroundColor: colors.bgPrimary,
padding: spacing.l,
},
welcome: {
fontSize: fontSize.xl,
fontWeight: "bold",
color: colors.textWhite,
marginBottom: spacing.xs,
},
subtitle: {
fontSize: fontSize.md,
color: colors.textSecondary,
marginBottom: spacing.xl,
},
grid: {
flexDirection: "row",
flexWrap: "wrap",
gap: spacing.m,
},
card: {
width: "47%",
backgroundColor: colors.bgCard,
borderRadius: borderRadius.md,
padding: spacing.l,
borderWidth: 1,
borderColor: colors.borderLight,
alignItems: "center",
},
iconCircle: {
width: 48,
height: 48,
borderRadius: 24,
justifyContent: "center",
alignItems: "center",
marginBottom: spacing.s,
},
cardValue: {
fontSize: fontSize.xxl,
fontWeight: "bold",
color: colors.textWhite,
},
cardLabel: {
fontSize: fontSize.sm,
color: colors.textSecondary,
marginTop: spacing.xs,
textAlign: "center",
},
}),
[colors],
);
if (loading) return <LoadingSpinner message="Chargement..." />;
return (
<ScrollView
style={styles.container}
refreshControl={
<RefreshControl
refreshing={refreshing}
onRefresh={onRefresh}
tintColor={colors.info}
/>
}
>
<Text style={styles.welcome}>Cabine - {username}</Text>
<Text style={styles.subtitle}>Suivi des opérations</Text>
<View style={styles.grid}>
{stats.map((s, i) => (
<View key={i} style={[styles.card, shadows.md]}>
<View
style={[
styles.iconCircle,
{ backgroundColor: s.color + "20" },
]}
>
<Ionicons name={s.icon} size={24} color={s.color} />
</View>
<Text style={styles.cardValue}>{s.value}</Text>
<Text style={styles.cardLabel}>{s.label}</Text>
</View>
))}
</View>
</ScrollView>
);
}
@@ -0,0 +1,912 @@
import React, {
useState,
useEffect,
useCallback,
useRef,
useMemo,
} from "react";
import {
View,
Text,
StyleSheet,
FlatList,
RefreshControl,
TouchableOpacity,
Modal,
StatusBar,
Dimensions,
} from "react-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 {
getAllDeliveryPersonsWithDetails,
getDeliverymanLocationForCommand,
} from "../../api/api_cabine";
import { geocodeAddress, calculateRoute } from "../../api/tomtom";
import type { RouteInfo, LatLng } 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";
const MAP_HEIGHT = 280;
export default function DeliveryScreen() {
const { colors } = useTheme();
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);
// Map
const mapRef = useRef<MapView | null>(null);
const fullscreenMapRef = useRef<MapView | null>(null);
const [mapFullscreen, setMapFullscreen] = useState(false);
// Selected livreur route
const [selectedLivreur, setSelectedLivreur] =
useState<DeliveryPerson | null>(null);
const [routeInfo, setRouteInfo] = useState<RouteInfo | null>(null);
const [destinationCoords, setDestinationCoords] = useState<LatLng | null>(
null,
);
const [routeLoading, setRouteLoading] = useState(false);
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,
);
// Track livreur route using their current command
const trackLivreur = useCallback(
async (livreur: DeliveryPerson) => {
setSelectedLivreur(livreur);
setRouteInfo(null);
setDestinationCoords(null);
if (!livreur.stats.current_command) return;
setRouteLoading(true);
try {
const locRes = await getDeliverymanLocationForCommand(
livreur.stats.current_command,
);
const cmdAddress =
locRes.data?.delivery_address || locRes.data?.adresse;
if (!cmdAddress) {
setRouteLoading(false);
return;
}
const dest = await geocodeAddress(cmdAddress);
if (!dest) {
setRouteLoading(false);
return;
}
setDestinationCoords(dest);
const origin: LatLng = {
latitude: livreur.location.latitude,
longitude: livreur.location.longitude,
};
const result = await calculateRoute(origin, dest);
if (result) {
setRouteInfo(result.route);
const ref = mapFullscreen ? fullscreenMapRef : mapRef;
if (ref.current) {
ref.current.fitToCoordinates(
[
{
latitude: origin.latitude,
longitude: origin.longitude,
},
{
latitude: dest.latitude,
longitude: dest.longitude,
},
],
{
edgePadding: {
top: 80,
right: 60,
bottom: 80,
left: 60,
},
animated: true,
},
);
}
}
} catch {
/* silent */
}
setRouteLoading(false);
},
[mapFullscreen],
);
const clearRoute = () => {
setSelectedLivreur(null);
setRouteInfo(null);
setDestinationCoords(null);
};
const fitAllMarkers = (ref: React.RefObject<MapView | null>) => {
if (ref.current && livreursWithGPS.length > 0) {
ref.current.fitToCoordinates(
livreursWithGPS.map((l) => ({
latitude: l.location.latitude,
longitude: l.location.longitude,
})),
{
edgePadding: { top: 60, right: 60, bottom: 60, left: 60 },
animated: true,
},
);
}
};
// Render map markers point bleu pour chaque livreur
const renderMapMarkers = () =>
livreursWithGPS.map((l) => {
const isSelected = selectedLivreur?.username === l.username;
return (
<Marker
key={l.username}
coordinate={{
latitude: l.location.latitude,
longitude: l.location.longitude,
}}
title={l.username}
description={`${STATUS_LABELS[l.status] || l.status}${l.stats.current_command ? ` · Cmd #${l.stats.current_command}` : ""}`}
onPress={() => trackLivreur(l)}
>
<View
style={[
styles.markerOuter,
isSelected && styles.markerSelected,
]}
>
<View style={styles.markerInner}>
<Ionicons
name="bicycle"
size={14}
color={colors.white}
/>
</View>
</View>
</Marker>
);
});
const renderRouteOverlay = () => {
if (!destinationCoords) return null;
return (
<>
<Marker
coordinate={{
latitude: destinationCoords.latitude,
longitude: destinationCoords.longitude,
}}
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}
/>
)}
</>
);
};
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.info },
]}
>
<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}>
<Text style={styles.statText}>
Queue: {item.stats.queue_size}
</Text>
<Text style={styles.statText}>
Total: {item.stats.total_deliveries}
</Text>
</View>
{hasGPS && (
<Text style={styles.location}>
GPS: {item.location.latitude.toFixed(4)},{" "}
{item.location.longitude.toFixed(4)}
</Text>
)}
{item.stats.current_command && (
<Text style={styles.currentCmd}>
Commande: #{item.stats.current_command}
</Text>
)}
{/* Buttons */}
{hasGPS && (
<View style={styles.btnRow}>
<TouchableOpacity
style={[
styles.trackBtn,
isSelected && { backgroundColor: colors.info },
]}
onPress={() =>
isSelected ? clearRoute() : trackLivreur(item)
}
activeOpacity={0.7}
>
<Ionicons
name={
isSelected
? "close-circle-outline"
: "navigate-outline"
}
size={15}
color={isSelected ? colors.white : colors.info}
/>
<Text
style={[
styles.trackBtnText,
isSelected && { color: colors.white },
]}
>
{isSelected ? "Arrêter" : "Suivre"}
</Text>
</TouchableOpacity>
</View>
)}
</Card>
);
};
const renderHeader = () => (
<View>
{livreursWithGPS.length > 0 && (
<View style={styles.mapContainer}>
<MapView
ref={mapRef}
provider={PROVIDER_DEFAULT}
style={styles.map}
initialRegion={{
latitude: livreursWithGPS[0].location.latitude,
longitude: livreursWithGPS[0].location.longitude,
latitudeDelta: 0.05,
longitudeDelta: 0.05,
}}
onMapReady={() => fitAllMarkers(mapRef)}
showsUserLocation={false}
>
{renderMapMarkers()}
{renderRouteOverlay()}
</MapView>
{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.info}
/>
<Text style={styles.routeChipText}>
{routeInfo.distance}
</Text>
</View>
<View style={styles.routeChip}>
<Ionicons
name="time-outline"
size={12}
color={colors.info}
/>
<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={() => fitAllMarkers(mapRef)}
>
<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>
)}
{livreursWithGPS.length === 0 && !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>
)}
<Text style={styles.sectionTitle}>
Livreurs ({livreurs.length})
</Text>
</View>
);
const styles = useMemo(
() =>
StyleSheet.create({
container: { flex: 1, backgroundColor: colors.bgPrimary },
summaryRow: {
flexDirection: "row",
padding: spacing.l,
gap: spacing.s,
},
summaryCard: {
flex: 1,
backgroundColor: colors.bgCard,
borderRadius: borderRadius.sm,
padding: spacing.m,
borderLeftWidth: 3,
alignItems: "center",
},
summaryValue: {
fontSize: fontSize.xl,
fontWeight: "bold",
color: colors.textWhite,
},
summaryLabel: {
fontSize: fontSize.xs,
color: colors.textMuted,
},
mapContainer: {
borderRadius: borderRadius.md,
overflow: "hidden",
marginBottom: spacing.m,
position: "relative",
},
map: { width: "100%", height: MAP_HEIGHT },
// Point bleu pour les livreurs
markerOuter: {
width: 34,
height: 34,
borderRadius: 17,
backgroundColor: "rgba(33, 150, 243, 0.3)",
justifyContent: "center",
alignItems: "center",
},
markerSelected: {
borderWidth: 2,
borderColor: colors.info,
width: 40,
height: 40,
borderRadius: 20,
},
markerInner: {
width: 26,
height: 26,
borderRadius: 13,
backgroundColor: "#2196F3",
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",
},
routeChips: {
flexDirection: "row",
gap: spacing.s,
marginTop: 4,
},
routeChip: {
flexDirection: "row",
alignItems: "center",
gap: 3,
},
routeChipText: {
color: colors.info,
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",
gap: spacing.l,
marginTop: 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",
},
btnRow: {
flexDirection: "row",
gap: spacing.s,
marginTop: spacing.m,
},
trackBtn: {
flex: 1,
flexDirection: "row",
alignItems: "center",
justifyContent: "center",
gap: spacing.xs,
paddingVertical: spacing.s,
borderRadius: borderRadius.sm,
borderWidth: 1,
borderColor: colors.info,
},
trackBtnText: {
color: colors.info,
fontSize: fontSize.sm,
fontWeight: "600",
},
empty: {
color: colors.textMuted,
textAlign: "center",
marginTop: spacing.xxl,
},
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.info,
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",
justifyContent: "center",
gap: spacing.l,
},
legendItem: {
flexDirection: "row",
alignItems: "center",
gap: 6,
},
legendDot: { width: 10, height: 10, borderRadius: 5 },
legendText: { color: colors.white, fontSize: fontSize.sm },
}),
[colors],
);
if (loading) return <LoadingSpinner message="Chargement livreurs..." />;
return (
<View style={styles.container}>
{/* Fullscreen map */}
<Modal
visible={mapFullscreen}
animationType="fade"
onRequestClose={() => setMapFullscreen(false)}
statusBarTranslucent
>
<StatusBar hidden={mapFullscreen} />
<View style={styles.fullscreenContainer}>
{livreursWithGPS.length > 0 && (
<MapView
ref={fullscreenMapRef}
provider={PROVIDER_DEFAULT}
style={styles.fullscreenMap}
initialRegion={{
latitude: livreursWithGPS[0].location.latitude,
longitude:
livreursWithGPS[0].location.longitude,
latitudeDelta: 0.05,
longitudeDelta: 0.05,
}}
onMapReady={() => fitAllMarkers(fullscreenMapRef)}
showsUserLocation={false}
showsCompass
showsScale
>
{renderMapMarkers()}
{renderRouteOverlay()}
</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}>
Suivi des livreurs
</Text>
<TouchableOpacity
style={styles.closeBtn}
onPress={() => fitAllMarkers(fullscreenMapRef)}
>
<Ionicons
name="locate-outline"
size={20}
color={colors.white}
/>
</TouchableOpacity>
</View>
{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>
)}
<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>
{/* Summary */}
<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}>Offline</Text>
</View>
</View>
<FlatList
data={livreurs}
keyExtractor={(item) => item.username}
renderItem={renderLivreur}
refreshControl={
<RefreshControl
refreshing={refreshing}
onRefresh={onRefresh}
tintColor={colors.info}
/>
}
contentContainerStyle={{ padding: spacing.l, paddingTop: 0 }}
ListHeaderComponent={renderHeader()}
ListEmptyComponent={
<Text style={styles.empty}>Aucun livreur</Text>
}
/>
</View>
);
}
@@ -0,0 +1,251 @@
import React, { useState, useEffect, useCallback, useMemo } from "react";
import {
View,
Text,
StyleSheet,
FlatList,
TouchableOpacity,
RefreshControl,
} from "react-native";
import { spacing, fontSize, borderRadius } from "../../theme";
import { useTheme } from "../../context/ThemeContext";
import { getAllCommands } from "../../api/api_admin";
import {
getCommandItems,
updateItemStatus,
deleteCommand,
} from "../../api/api_cabine";
import type { CommandResponse } from "../../api/types";
import StatusBadge from "../../components/StatusBadge";
import LoadingSpinner from "../../components/ui/LoadingSpinner";
import Modal from "../../components/ui/Modal";
import Button from "../../components/ui/Button";
import Card from "../../components/ui/Card";
import AlertModal from "../../components/ui/AlertModal";
import { useAlert } from "../../hooks/useAlert";
const ITEM_STATUSES = ["pending", "preparing", "ready"];
export default function OrdersScreen() {
const { colors } = useTheme();
const [commands, setCommands] = useState<CommandResponse[]>([]);
const [loading, setLoading] = useState(true);
const [refreshing, setRefreshing] = useState(false);
const { alert, showError, showConfirm, hideAlert } = useAlert();
const [itemsModal, setItemsModal] = useState<{
visible: boolean;
commandId: number | null;
items: any[];
}>({ visible: false, commandId: null, items: [] });
const loadData = useCallback(async () => {
try {
const result = await getAllCommands();
setCommands(
result.commands.filter(
(c: CommandResponse) =>
!["approved", "cancelled"].includes(c.status),
),
);
} catch {
/* ignore */
}
setLoading(false);
}, []);
useEffect(() => {
loadData();
}, [loadData]);
const onRefresh = async () => {
setRefreshing(true);
await loadData();
setRefreshing(false);
};
const openItems = async (commandId: number) => {
try {
const result = await getCommandItems(commandId);
setItemsModal({ visible: true, commandId, items: result.items });
} catch (e: any) {
showError("Erreur", e.message);
}
};
const handleItemStatus = async (itemId: number, status: string) => {
try {
await updateItemStatus(itemId, status);
if (itemsModal.commandId) await openItems(itemsModal.commandId);
} catch (e: any) {
showError("Erreur", e.message);
}
};
const handleDelete = (commandId: number) => {
showConfirm(
"Supprimer",
`Supprimer la commande #${commandId} ?`,
async () => {
try {
await deleteCommand(commandId);
await loadData();
} catch (e: any) {
showError("Erreur", e.message);
}
},
"Supprimer",
);
};
const styles = useMemo(
() =>
StyleSheet.create({
container: { flex: 1, backgroundColor: colors.bgPrimary },
row: {
flexDirection: "row",
justifyContent: "space-between",
alignItems: "center",
marginBottom: spacing.s,
},
orderId: {
fontSize: fontSize.lg,
fontWeight: "bold",
color: colors.textWhite,
},
info: {
color: colors.textSecondary,
fontSize: fontSize.sm,
marginTop: 2,
},
actions: {
flexDirection: "row",
gap: spacing.s,
marginTop: spacing.m,
},
itemRow: {
flexDirection: "row",
alignItems: "center",
padding: spacing.m,
backgroundColor: colors.bgCard,
borderRadius: borderRadius.sm,
marginBottom: spacing.s,
},
itemName: {
color: colors.textWhite,
fontSize: fontSize.md,
fontWeight: "600",
},
empty: {
color: colors.textMuted,
textAlign: "center",
marginTop: spacing.xl,
},
}),
[colors],
);
const renderOrder = ({ item }: { item: CommandResponse }) => (
<Card style={{ marginBottom: spacing.m }}>
<View style={styles.row}>
<Text style={styles.orderId}>#{item.id}</Text>
<StatusBadge status={item.status} />
</View>
<Text style={styles.info}>Client: {item.username}</Text>
<Text style={styles.info}>Adresse: {item.adresse}</Text>
<Text style={styles.info}>
Total: {item.total_prix.toFixed(2)}
</Text>
<View style={styles.actions}>
<Button
title="Voir items"
onPress={() => openItems(item.id)}
size="sm"
variant="primary"
/>
<Button
title="Supprimer"
onPress={() => handleDelete(item.id)}
size="sm"
variant="danger"
/>
</View>
</Card>
);
if (loading) return <LoadingSpinner message="Chargement..." />;
return (
<View style={styles.container}>
<FlatList
data={commands}
keyExtractor={(item) => item.id.toString()}
renderItem={renderOrder}
refreshControl={
<RefreshControl
refreshing={refreshing}
onRefresh={onRefresh}
tintColor={colors.info}
/>
}
contentContainerStyle={{ padding: spacing.l }}
ListEmptyComponent={
<Text style={styles.empty}>Aucune commande active</Text>
}
/>
<Modal
visible={itemsModal.visible}
onClose={() =>
setItemsModal({
visible: false,
commandId: null,
items: [],
})
}
title={`Items #${itemsModal.commandId}`}
icon="list-outline"
>
{itemsModal.items.map((item: any) => (
<View key={item.id} style={styles.itemRow}>
<View style={{ flex: 1 }}>
<Text style={styles.itemName}>
{item.product_name}
</Text>
<Text style={styles.info}>
Qté: {item.quantity} | {item.price?.toFixed(2)}{" "}
</Text>
<StatusBadge status={item.status} />
</View>
<View style={{ gap: spacing.xs }}>
{ITEM_STATUSES.filter((s) => s !== item.status).map(
(s) => (
<Button
key={s}
title={s}
onPress={() =>
handleItemStatus(item.id, s)
}
size="sm"
variant="outline"
/>
),
)}
</View>
</View>
))}
{itemsModal.items.length === 0 && (
<Text style={styles.empty}>Aucun item</Text>
)}
</Modal>
<AlertModal
visible={alert.visible}
type={alert.type}
title={alert.title}
message={alert.message}
onClose={hideAlert}
onConfirm={alert.onConfirm}
confirmText={alert.confirmText}
cancelText={alert.cancelText}
/>
</View>
);
}
@@ -0,0 +1,239 @@
import React, { useState, useEffect, useCallback, useMemo } from "react";
import { View, Text, StyleSheet, FlatList, RefreshControl } from "react-native";
import { spacing, fontSize } from "../../theme";
import { useTheme } from "../../context/ThemeContext";
import { getAllClients } from "../../api/api_admin";
import { applyClientPenalty, resetClientPenalties } from "../../api/api_cabine";
import type { ClientResponse } from "../../api/types";
import LoadingSpinner from "../../components/ui/LoadingSpinner";
import Card from "../../components/ui/Card";
import Button from "../../components/ui/Button";
import Modal from "../../components/ui/Modal";
import TextInput from "../../components/ui/TextInput";
import AlertModal from "../../components/ui/AlertModal";
import { useAlert } from "../../hooks/useAlert";
export default function UsersScreen() {
const { colors } = useTheme();
const [clients, setClients] = useState<ClientResponse[]>([]);
const [loading, setLoading] = useState(true);
const [refreshing, setRefreshing] = useState(false);
const [penaltyModal, setPenaltyModal] = useState<{
visible: boolean;
username: string;
}>({ visible: false, username: "" });
const [reason, setReason] = useState("");
const [penaltyAmount, setPenaltyAmount] = useState("");
const { alert, showError, showSuccess, showConfirm, hideAlert } =
useAlert();
const loadData = useCallback(async () => {
try {
setClients(await getAllClients());
} catch {
/* ignore */
}
setLoading(false);
}, []);
useEffect(() => {
loadData();
}, [loadData]);
const onRefresh = async () => {
setRefreshing(true);
await loadData();
setRefreshing(false);
};
const handleApplyPenalty = async () => {
if (!reason.trim()) {
showError("Erreur", "Raison requise");
return;
}
const amount = parseInt(penaltyAmount, 10);
if (!penaltyAmount.trim() || isNaN(amount) || amount <= 0) {
showError("Erreur", "Nombre de points invalide");
return;
}
try {
await applyClientPenalty(penaltyModal.username, reason, amount);
setPenaltyModal({ visible: false, username: "" });
setReason("");
setPenaltyAmount("");
await loadData();
showSuccess("Succès", "Pénalité appliquée");
} catch (e: any) {
showError("Erreur", e.message);
}
};
const handleReset = (username: string) => {
showConfirm(
"Reset",
`Réinitialiser les pénalités de ${username} ?`,
async () => {
try {
await resetClientPenalties(username);
await loadData();
} catch (e: any) {
showError("Erreur", e.message);
}
},
"Reset",
);
};
const styles = useMemo(
() =>
StyleSheet.create({
container: { flex: 1, backgroundColor: colors.bgPrimary },
username: {
fontSize: fontSize.lg,
fontWeight: "bold",
color: colors.textWhite,
},
info: {
color: colors.textSecondary,
fontSize: fontSize.sm,
marginTop: 2,
},
statsRow: {
flexDirection: "row",
marginTop: spacing.m,
gap: spacing.l,
},
stat: { alignItems: "center" },
statValue: { fontSize: fontSize.lg, fontWeight: "bold" },
statLabel: { fontSize: fontSize.xs, color: colors.textMuted },
actions: {
flexDirection: "row",
gap: spacing.s,
marginTop: spacing.m,
},
empty: {
color: colors.textMuted,
textAlign: "center",
marginTop: spacing.xxl,
},
}),
[colors],
);
const renderClient = ({ item }: { item: ClientResponse }) => (
<Card style={{ marginBottom: spacing.m }}>
<Text style={styles.username}>{item.username}</Text>
<Text style={styles.info}>
{item.prenom} {item.nom} - {item.telephone}
</Text>
<View style={styles.statsRow}>
<View style={styles.stat}>
<Text style={[styles.statValue, { color: colors.success }]}>
{item.point}
</Text>
<Text style={styles.statLabel}>Points</Text>
</View>
<View style={styles.stat}>
<Text style={[styles.statValue, { color: colors.info }]}>
{item.points_zipette}
</Text>
<Text style={styles.statLabel}>Zipette</Text>
</View>
<View style={styles.stat}>
<Text style={[styles.statValue, { color: colors.warning }]}>
{item.amende}
</Text>
<Text style={styles.statLabel}>Amendes</Text>
</View>
<View style={styles.stat}>
<Text style={[styles.statValue, { color: colors.danger }]}>
{item.cancellations_count}
</Text>
<Text style={styles.statLabel}>Annul.</Text>
</View>
</View>
<View style={styles.actions}>
<Button
title="Pénalité"
onPress={() => {
setPenaltyModal({
visible: true,
username: item.username,
});
setReason("");
setPenaltyAmount("");
}}
size="sm"
variant="danger"
/>
<Button
title="Reset"
onPress={() => handleReset(item.username)}
size="sm"
variant="outline"
/>
</View>
</Card>
);
if (loading) return <LoadingSpinner message="Chargement..." />;
return (
<View style={styles.container}>
<FlatList
data={clients}
keyExtractor={(item) => item.id.toString()}
renderItem={renderClient}
refreshControl={
<RefreshControl
refreshing={refreshing}
onRefresh={onRefresh}
tintColor={colors.info}
/>
}
contentContainerStyle={{ padding: spacing.l }}
ListEmptyComponent={
<Text style={styles.empty}>Aucun client</Text>
}
/>
<Modal
visible={penaltyModal.visible}
onClose={() =>
setPenaltyModal({ visible: false, username: "" })
}
title={`Pénalité - ${penaltyModal.username}`}
icon="warning-outline"
iconColor={colors.danger}
>
<TextInput
label="Nombre de points"
value={penaltyAmount}
onChangeText={setPenaltyAmount}
placeholder="Ex: 5"
keyboardType="numeric"
/>
<TextInput
label="Raison"
value={reason}
onChangeText={setReason}
placeholder="Raison de la pénalité"
/>
<Button
title="Appliquer"
onPress={handleApplyPenalty}
variant="danger"
fullWidth
/>
</Modal>
<AlertModal
visible={alert.visible}
type={alert.type}
title={alert.title}
message={alert.message}
onClose={hideAlert}
onConfirm={alert.onConfirm}
confirmText={alert.confirmText}
cancelText={alert.cancelText}
/>
</View>
);
}