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,114 @@
import React, { useState, useEffect, useCallback, useMemo } from "react";
import { View, Text, StyleSheet, FlatList, RefreshControl } from "react-native";
import { Ionicons } from "@expo/vector-icons";
import { spacing, fontSize } from "../../theme";
import { useTheme } from "../../context/ThemeContext";
import { getAdminAlerts } from "../../api/api_admin";
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 loadData = useCallback(async () => {
try {
const result = await getAdminAlerts();
setAlerts(result.alerts);
} catch {
/* ignore */
}
setLoading(false);
}, []);
useEffect(() => {
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,
},
}),
[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}>
<FlatList
data={alerts}
keyExtractor={(item) => item.id.toString()}
renderItem={renderAlert}
refreshControl={
<RefreshControl
refreshing={refreshing}
onRefresh={onRefresh}
tintColor={colors.accent}
/>
}
contentContainerStyle={{ padding: spacing.l }}
ListEmptyComponent={
<Text style={styles.empty}>Aucune alerte</Text>
}
/>
</View>
);
}
@@ -0,0 +1,196 @@
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 { shadows } from "../../theme/shadows";
import { useTheme } from "../../context/ThemeContext";
import { useAuth } from "../../auth/AuthContext";
import {
getAllCommands,
getAllClients,
getAvailableDeliveryPersons,
getCommandCountByStatus,
} from "../../api/api_admin";
import LoadingSpinner from "../../components/ui/LoadingSpinner";
interface StatCard {
label: string;
value: number;
icon: keyof typeof Ionicons.glyphMap;
color: string;
}
export default function DashboardScreen() {
const { colors } = useTheme();
const { username } = useAuth();
const [stats, setStats] = useState<StatCard[]>([]);
const [loading, setLoading] = useState(true);
const [refreshing, setRefreshing] = useState(false);
const loadStats = useCallback(async () => {
try {
const [allCmd, pending, enRoute, completed, clients, livreurs] =
await Promise.all([
getAllCommands().then((r) => r.count),
getCommandCountByStatus("pending"),
getCommandCountByStatus("en_route"),
getCommandCountByStatus("approved"),
getAllClients()
.then((c) => c.length)
.catch(() => 0),
getAvailableDeliveryPersons()
.then((r) => r.count)
.catch(() => 0),
]);
setStats([
{
label: "Total commandes",
value: allCmd,
icon: "receipt-outline",
color: colors.accent,
},
{
label: "En attente",
value: pending,
icon: "time-outline",
color: colors.warning,
},
{
label: "En route",
value: enRoute,
icon: "navigate-outline",
color: colors.info,
},
{
label: "Terminées",
value: completed,
icon: "checkmark-circle-outline",
color: colors.success,
},
{
label: "Clients",
value: clients,
icon: "people-outline",
color: colors.accentLight,
},
{
label: "Livreurs",
value: livreurs,
icon: "bicycle-outline",
color: colors.categoryGros,
},
]);
} 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.accent}
/>
}
>
<Text style={styles.welcome}>Bonjour, {username}</Text>
<Text style={styles.subtitle}>Vue d'ensemble</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,941 @@
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,
getCommandByID,
} from "../../api/api_admin";
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 tracking
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,
);
const trackLivreur = useCallback(
async (livreur: DeliveryPerson) => {
setSelectedLivreur(livreur);
setRouteInfo(null);
setDestinationCoords(null);
if (!livreur.stats.current_command) return;
setRouteLoading(true);
try {
const cmdRes = await getCommandByID(
livreur.stats.current_command,
);
const cmd = cmdRes.command;
if (!cmd?.adresse) {
setRouteLoading(false);
return;
}
const dest = await geocodeAddress(cmd.adresse);
if (!dest) {
setRouteLoading(false);
return;
}
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,
},
);
}
};
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 },
markerOuter: {
width: 34,
height: 34,
borderRadius: 17,
justifyContent: "center",
alignItems: "center",
},
markerSelected: {
borderWidth: 2,
borderColor: colors.accent,
width: 40,
height: 40,
borderRadius: 20,
},
markerInner: {
width: 26,
height: 26,
borderRadius: 13,
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.accent,
fontSize: fontSize.xs,
fontWeight: "600",
},
routeLoadingOverlay: {
position: "absolute",
top: 0,
left: 0,
right: 0,
bottom: 0,
backgroundColor: "rgba(0,0,0,0.3)",
justifyContent: "center",
alignItems: "center",
},
routeLoadingText: {
color: colors.white,
fontSize: fontSize.sm,
fontWeight: "600",
},
mapBtns: {
position: "absolute",
top: spacing.s,
right: spacing.s,
gap: spacing.xs,
},
mapBtn: {
width: 36,
height: 36,
borderRadius: 18,
backgroundColor: "rgba(0,0,0,0.6)",
justifyContent: "center",
alignItems: "center",
},
noMapBox: {
alignItems: "center",
paddingVertical: spacing.xl,
marginBottom: spacing.m,
backgroundColor: colors.bgSecondary,
borderRadius: borderRadius.md,
},
noMapText: {
color: colors.textMuted,
fontSize: fontSize.sm,
marginTop: spacing.s,
},
sectionTitle: {
color: colors.textWhite,
fontSize: fontSize.lg,
fontWeight: "700",
marginBottom: spacing.m,
},
row: {
flexDirection: "row",
alignItems: "center",
gap: spacing.s,
marginBottom: spacing.s,
},
statusDot: { width: 10, height: 10, borderRadius: 5 },
username: {
flex: 1,
fontSize: fontSize.lg,
fontWeight: "600",
color: colors.textWhite,
},
statsRow: {
flexDirection: "row",
gap: spacing.l,
marginTop: spacing.s,
},
stat: {
flexDirection: "row",
alignItems: "center",
gap: spacing.xs,
},
statText: {
color: colors.textSecondary,
fontSize: fontSize.sm,
},
location: {
color: colors.textMuted,
fontSize: fontSize.xs,
marginTop: spacing.s,
},
currentCmd: {
color: colors.info,
fontSize: fontSize.sm,
marginTop: spacing.xs,
fontWeight: "500",
},
trackBtn: {
flexDirection: "row",
alignItems: "center",
justifyContent: "center",
gap: spacing.s,
marginTop: spacing.m,
paddingVertical: spacing.s,
paddingHorizontal: spacing.m,
borderRadius: borderRadius.sm,
borderWidth: 1,
borderColor: colors.accent,
},
trackBtnText: {
color: colors.accent,
fontSize: fontSize.sm,
fontWeight: "600",
},
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.accent,
fontSize: fontSize.sm,
marginTop: 2,
},
clearRouteBtn: { padding: spacing.xs },
fullscreenBottomBar: {
position: "absolute",
bottom: 0,
left: 0,
right: 0,
backgroundColor: "rgba(0,0,0,0.7)",
paddingHorizontal: spacing.l,
paddingTop: spacing.m,
paddingBottom: 40,
},
legendRow: {
flexDirection: "row",
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],
);
const renderMapMarkers = () =>
livreursWithGPS.map((l) => {
const isSelected = selectedLivreur?.username === l.username;
const markerColor = statusColors[l.status] || colors.textMuted;
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,
{ backgroundColor: markerColor + "40" },
isSelected && styles.markerSelected,
]}
>
<View
style={[
styles.markerInner,
{ backgroundColor: markerColor },
]}
>
<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.accent,
},
]}
>
<View style={styles.row}>
<View
style={[
styles.statusDot,
{
backgroundColor:
statusColors[item.status] ||
colors.textMuted,
},
]}
/>
<Text style={styles.username}>{item.username}</Text>
<Badge
label={STATUS_LABELS[item.status] || item.status}
color={statusColors[item.status] || colors.textMuted}
/>
</View>
<View style={styles.statsRow}>
<View style={styles.stat}>
<Ionicons
name="cube-outline"
size={16}
color={colors.textSecondary}
/>
<Text style={styles.statText}>
{item.stats.queue_size} en queue
</Text>
</View>
<View style={styles.stat}>
<Ionicons
name="checkmark-outline"
size={16}
color={colors.success}
/>
<Text style={styles.statText}>
{item.stats.completed_today} aujourd'hui
</Text>
</View>
<View style={styles.stat}>
<Ionicons
name="trophy-outline"
size={16}
color={colors.warning}
/>
<Text style={styles.statText}>
{item.stats.total_deliveries} total
</Text>
</View>
</View>
{hasGPS && (
<Text style={styles.location}>
GPS: {item.location.latitude.toFixed(4)},{" "}
{item.location.longitude.toFixed(4)}
{item.location.is_recent ? " (récent)" : " (ancien)"}
</Text>
)}
{item.stats.current_command && (
<Text style={styles.currentCmd}>
Commande en cours: #{item.stats.current_command}
</Text>
)}
{hasGPS && (
<TouchableOpacity
style={[
styles.trackBtn,
isSelected && { backgroundColor: colors.accent },
]}
onPress={() =>
isSelected ? clearRoute() : trackLivreur(item)
}
activeOpacity={0.7}
>
<Ionicons
name={
isSelected
? "close-circle-outline"
: "navigate-outline"
}
size={16}
color={isSelected ? colors.white : colors.accent}
/>
<Text
style={[
styles.trackBtnText,
isSelected && { color: colors.white },
]}
>
{isSelected
? "Arrêter le suivi"
: item.stats.current_command
? "Suivre l'itinéraire"
: "Voir sur la carte"}
</Text>
</TouchableOpacity>
)}
</Card>
);
};
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.accent}
/>
<Text style={styles.routeChipText}>
{routeInfo.distance}
</Text>
</View>
<View style={styles.routeChip}>
<Ionicons
name="time-outline"
size={12}
color={colors.accent}
/>
<Text style={styles.routeChipText}>
{routeInfo.duration}
</Text>
</View>
</View>
</View>
)}
{routeLoading && (
<View style={styles.routeLoadingOverlay}>
<Text style={styles.routeLoadingText}>
Calcul itinéraire...
</Text>
</View>
)}
<View style={styles.mapBtns}>
<TouchableOpacity
style={styles.mapBtn}
onPress={() => 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>
);
if (loading) return <LoadingSpinner message="Chargement livreurs..." />;
return (
<View style={styles.container}>
{/* Fullscreen map modal */}
<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}>Hors ligne</Text>
</View>
</View>
<FlatList
data={livreurs}
keyExtractor={(item) => item.username}
renderItem={renderLivreur}
refreshControl={
<RefreshControl
refreshing={refreshing}
onRefresh={onRefresh}
tintColor={colors.accent}
/>
}
contentContainerStyle={{ padding: spacing.l, paddingTop: 0 }}
ListHeaderComponent={renderHeader()}
ListEmptyComponent={
<Text style={styles.empty}>Aucun livreur</Text>
}
/>
</View>
);
}
@@ -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>
);
}
@@ -0,0 +1,315 @@
import React, { useState, useEffect, useCallback, useMemo } from "react";
import {
View,
Text,
StyleSheet,
FlatList,
TouchableOpacity,
RefreshControl,
} from "react-native";
import { Ionicons } from "@expo/vector-icons";
import { useNavigation } from "@react-navigation/native";
import type { NativeStackNavigationProp } from "@react-navigation/native-stack";
import { spacing, fontSize, borderRadius } from "../../theme";
import { useTheme } from "../../context/ThemeContext";
import {
getAllCommands,
getAvailableDeliveryPersons,
assignDeliveryPerson,
updateCommandStatus,
} from "../../api/api_admin";
import type { CommandResponse } from "../../api/types";
import type { AdminStackParamList } from "../../navigation/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 AlertModal from "../../components/ui/AlertModal";
import { useAlert } from "../../hooks/useAlert";
type Nav = NativeStackNavigationProp<AdminStackParamList>;
const STATUS_FILTERS = [
"all",
"pending",
"assigned",
"en_route",
"arrived",
"livre",
"approved",
"cancelled",
];
export default function OrdersScreen() {
const { colors } = useTheme();
const navigation = useNavigation<Nav>();
const [commands, setCommands] = useState<CommandResponse[]>([]);
const [loading, setLoading] = useState(true);
const [refreshing, setRefreshing] = useState(false);
const [filter, setFilter] = useState("all");
const [assignModal, setAssignModal] = useState<{
visible: boolean;
commandId: number | null;
}>({ visible: false, commandId: null });
const [livreurs, setLivreurs] = useState<any[]>([]);
const { alert, showError, hideAlert } = useAlert();
const loadData = useCallback(async () => {
try {
const status = filter === "all" ? undefined : filter;
const result = await getAllCommands(status);
setCommands(result.commands);
} catch {
/* ignore */
}
setLoading(false);
}, [filter]);
useEffect(() => {
loadData();
}, [loadData]);
const onRefresh = async () => {
setRefreshing(true);
await loadData();
setRefreshing(false);
};
const openAssignModal = async (commandId: number) => {
try {
const result = await getAvailableDeliveryPersons();
setLivreurs(result.livreurs);
setAssignModal({ visible: true, commandId });
} catch {
showError("Erreur", "Impossible de charger les livreurs");
}
};
const handleAssign = async (livreurUsername: string) => {
if (!assignModal.commandId) return;
try {
await assignDeliveryPerson(assignModal.commandId, livreurUsername);
setAssignModal({ visible: false, commandId: null });
await loadData();
} catch (e: any) {
showError("Erreur", e.message);
}
};
const handleStatusUpdate = async (commandId: number, newStatus: string) => {
try {
await updateCommandStatus(commandId, newStatus);
await loadData();
} catch (e: any) {
showError("Erreur", e.message);
}
};
const styles = useMemo(
() =>
StyleSheet.create({
container: { flex: 1, backgroundColor: colors.bgPrimary },
filterList: {
maxHeight: 50,
paddingHorizontal: spacing.l,
paddingVertical: spacing.s,
},
filterBtn: {
paddingHorizontal: spacing.l,
paddingVertical: spacing.s,
backgroundColor: colors.bgCard,
borderRadius: borderRadius.xl,
marginRight: spacing.s,
borderWidth: 1,
borderColor: colors.border,
},
filterActive: {
backgroundColor: colors.accent,
borderColor: colors.accent,
},
filterText: {
color: colors.textSecondary,
fontSize: fontSize.sm,
},
filterTextActive: { color: colors.textWhite },
card: {
backgroundColor: colors.bgCard,
borderRadius: borderRadius.md,
padding: spacing.l,
marginBottom: spacing.m,
borderWidth: 1,
borderColor: colors.borderLight,
},
cardHeader: {
flexDirection: "row",
justifyContent: "space-between",
alignItems: "center",
marginBottom: spacing.s,
},
orderId: {
fontSize: fontSize.lg,
fontWeight: "bold",
color: colors.textWhite,
},
cardText: {
color: colors.textSecondary,
fontSize: fontSize.sm,
marginTop: 2,
},
cardDate: {
color: colors.textMuted,
fontSize: fontSize.xs,
marginTop: spacing.s,
},
actions: {
flexDirection: "row",
gap: spacing.s,
marginTop: spacing.m,
},
empty: {
color: colors.textMuted,
textAlign: "center",
marginTop: spacing.xxl,
fontSize: fontSize.md,
},
livreurItem: {
flexDirection: "row",
alignItems: "center",
padding: spacing.m,
backgroundColor: colors.bgCard,
borderRadius: borderRadius.sm,
marginBottom: spacing.s,
gap: spacing.m,
},
livreurName: { color: colors.textWhite, fontSize: fontSize.md },
}),
[colors],
);
const renderOrder = ({ item }: { item: CommandResponse }) => (
<TouchableOpacity
style={styles.card}
onPress={() =>
navigation.navigate("OrderDetail", { orderId: item.id })
}
activeOpacity={0.7}
>
<View style={styles.cardHeader}>
<Text style={styles.orderId}>#{item.id}</Text>
<StatusBadge status={item.status} />
</View>
<Text style={styles.cardText}>Client: {item.username}</Text>
<Text style={styles.cardText}>Adresse: {item.adresse}</Text>
<Text style={styles.cardText}>
Total: {item.total_prix.toFixed(2)}
</Text>
{item.livreur_assign && (
<Text style={styles.cardText}>
Livreur: {item.livreur_assign}
</Text>
)}
<Text style={styles.cardDate}>
{new Date(item.created_at).toLocaleString("fr-FR")}
</Text>
<View style={styles.actions}>
{item.status === "pending" && (
<Button
title="Assigner"
onPress={() => openAssignModal(item.id)}
size="sm"
variant="primary"
/>
)}
{item.status === "assigned" && (
<Button
title="En route"
onPress={() => handleStatusUpdate(item.id, "en_route")}
size="sm"
variant="secondary"
/>
)}
</View>
</TouchableOpacity>
);
if (loading) return <LoadingSpinner message="Chargement commandes..." />;
return (
<View style={styles.container}>
<FlatList
horizontal
data={STATUS_FILTERS}
keyExtractor={(i) => i}
renderItem={({ item }) => (
<TouchableOpacity
style={[
styles.filterBtn,
filter === item && styles.filterActive,
]}
onPress={() => setFilter(item)}
>
<Text
style={[
styles.filterText,
filter === item && styles.filterTextActive,
]}
>
{item === "all" ? "Tous" : item}
</Text>
</TouchableOpacity>
)}
style={styles.filterList}
showsHorizontalScrollIndicator={false}
/>
<FlatList
data={commands}
keyExtractor={(item) => item.id.toString()}
renderItem={renderOrder}
refreshControl={
<RefreshControl
refreshing={refreshing}
onRefresh={onRefresh}
tintColor={colors.accent}
/>
}
contentContainerStyle={{ padding: spacing.l, paddingTop: 0 }}
ListEmptyComponent={
<Text style={styles.empty}>Aucune commande</Text>
}
/>
<Modal
visible={assignModal.visible}
onClose={() =>
setAssignModal({ visible: false, commandId: null })
}
title="Assigner un livreur"
icon="bicycle-outline"
>
{livreurs.map((l) => (
<TouchableOpacity
key={l.username}
style={styles.livreurItem}
onPress={() => handleAssign(l.username)}
>
<Ionicons
name="person-outline"
size={20}
color={colors.accent}
/>
<Text style={styles.livreurName}>{l.username}</Text>
</TouchableOpacity>
))}
{livreurs.length === 0 && (
<Text style={styles.empty}>Aucun livreur disponible</Text>
)}
</Modal>
<AlertModal
visible={alert.visible}
type={alert.type}
title={alert.title}
message={alert.message}
onClose={hideAlert}
/>
</View>
);
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,982 @@
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, borderRadius } from "../../theme";
import { useTheme } from "../../context/ThemeContext";
import {
getAllClients,
getAllUsers,
updateClientByAdmin,
updateUserByAdmin,
deleteUserAdmin,
deleteClientAdmin,
createClientByAdmin,
createUserByAdmin,
} from "../../api/api_admin";
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";
// --------------------------------------------------
// Types
// --------------------------------------------------
interface UserItem {
id: number;
username: string;
role: string;
}
type RoleFilter = "all" | "client" | "livreur" | "cabine" | "admin";
// ==================================================
// SCREEN
// ==================================================
export default function UsersScreen() {
const { colors } = useTheme();
const ROLE_TABS: {
key: RoleFilter;
label: string;
icon: keyof typeof Ionicons.glyphMap;
color: string;
}[] = useMemo(
() => [
{
key: "all",
label: "Tous",
icon: "people-outline",
color: colors.accent,
},
{
key: "client",
label: "Clients",
icon: "person-outline",
color: colors.info,
},
{
key: "livreur",
label: "Livreurs",
icon: "bicycle-outline",
color: colors.success,
},
{
key: "cabine",
label: "Cabines",
icon: "desktop-outline",
color: colors.warning,
},
{
key: "admin",
label: "Admins",
icon: "shield-outline",
color: colors.accent,
},
],
[colors],
);
const getRoleBadgeColor = useCallback(
(role: string) => {
switch (role) {
case "client":
return colors.info;
case "livreur":
return colors.success;
case "cabine":
return colors.warning;
case "admin":
return colors.accent;
default:
return colors.textMuted;
}
},
[colors],
);
const getRoleLabel = useCallback((role: string) => {
switch (role) {
case "client":
return "Client";
case "livreur":
return "Livreur";
case "cabine":
return "Cabine";
case "admin":
return "Admin";
default:
return role;
}
}, []);
const [users, setUsers] = useState<UserItem[]>([]);
const [clients, setClients] = useState<ClientResponse[]>([]);
const [loading, setLoading] = useState(true);
const [refreshing, setRefreshing] = useState(false);
const [filter, setFilter] = useState<RoleFilter>("all");
// Edit client modal
const [editClientModal, setEditClientModal] = useState<{
visible: boolean;
client: ClientResponse | null;
}>({ visible: false, client: null });
const [editNom, setEditNom] = useState("");
const [editPrenom, setEditPrenom] = useState("");
const [editTel, setEditTel] = useState("");
// Edit user modal (livreur/cabine)
const [editUserModal, setEditUserModal] = useState<{
visible: boolean;
user: UserItem | null;
}>({ visible: false, user: null });
const [editUsername, setEditUsername] = useState("");
const [editRole, setEditRole] = useState("");
// Create user modal
const [createModal, setCreateModal] = useState(false);
const [createType, setCreateType] = useState<
"client" | "cabine" | "livreur"
>("client");
const [createUsername, setCreateUsername] = useState("");
const [createPassword, setCreatePassword] = useState("");
const [createNom, setCreateNom] = useState("");
const [createPrenom, setCreatePrenom] = useState("");
const [createTel, setCreateTel] = useState("");
const [creating, setCreating] = useState(false);
const { alert, showError, showConfirm, hideAlert } = useAlert();
// --------------------------------------------------
// Data
// --------------------------------------------------
const loadData = useCallback(async () => {
try {
const [usersData, clientsData] = await Promise.all([
getAllUsers(),
getAllClients(),
]);
setUsers(usersData);
setClients(clientsData);
} catch {
/* ignore */
}
setLoading(false);
}, []);
useEffect(() => {
loadData();
}, [loadData]);
const onRefresh = async () => {
setRefreshing(true);
await loadData();
setRefreshing(false);
};
// --------------------------------------------------
// Merged & filtered list
// --------------------------------------------------
const getMergedUsers = () => {
// Build a map of client details by username
const clientMap = new Map<string, ClientResponse>();
clients.forEach((c) => clientMap.set(c.username, c));
// Merge: for each user from getAllUsers, attach client details if role=client
let merged = users.map((u) => ({
...u,
clientData:
u.role === "client" ? clientMap.get(u.username) || null : null,
}));
// Also add clients that might not be in users list
clients.forEach((c) => {
if (!merged.find((m) => m.username === c.username)) {
merged.push({
id: c.id,
username: c.username,
role: "client",
clientData: c,
});
}
});
if (filter !== "all") {
merged = merged.filter((u) => u.role === filter);
}
return merged;
};
const mergedUsers = getMergedUsers();
// Stats
const totalClients =
users.filter((u) => u.role === "client").length || clients.length;
const totalLivreurs = users.filter((u) => u.role === "livreur").length;
const totalCabines = users.filter((u) => u.role === "cabine").length;
const totalAdmins = users.filter((u) => u.role === "admin").length;
const getCountForFilter = (f: RoleFilter) => {
if (f === "all") return mergedUsers.length;
if (f === "client") return totalClients;
if (f === "livreur") return totalLivreurs;
if (f === "cabine") return totalCabines;
if (f === "admin") return totalAdmins;
return 0;
};
// --------------------------------------------------
// Edit client
// --------------------------------------------------
const openEditClient = (client: ClientResponse) => {
setEditNom(client.nom);
setEditPrenom(client.prenom);
setEditTel(client.telephone);
setEditClientModal({ visible: true, client });
};
const handleSaveClient = async () => {
if (!editClientModal.client) return;
try {
await updateClientByAdmin(editClientModal.client.id, {
nom: editNom,
prenom: editPrenom,
telephone: editTel,
});
setEditClientModal({ visible: false, client: null });
await loadData();
} catch (e: any) {
showError("Erreur", e.message);
}
};
// --------------------------------------------------
// Edit user (livreur/cabine)
// --------------------------------------------------
const openEditUser = (user: UserItem) => {
setEditUsername(user.username);
setEditRole(user.role);
setEditUserModal({ visible: true, user });
};
const handleSaveUser = async () => {
if (!editUserModal.user) return;
try {
const updates: Record<string, any> = {};
if (editUsername.trim() !== editUserModal.user.username) {
updates.username = editUsername.trim();
}
if (editRole !== editUserModal.user.role) {
updates.role = editRole;
}
if (Object.keys(updates).length === 0) {
setEditUserModal({ visible: false, user: null });
return;
}
await updateUserByAdmin(editUserModal.user.id, updates);
setEditUserModal({ visible: false, user: null });
await loadData();
} catch (e: any) {
showError("Erreur", e.response?.data?.error || e.message);
}
};
// --------------------------------------------------
// Delete user/client
// --------------------------------------------------
const handleDelete = (item: ReturnType<typeof getMergedUsers>[0]) => {
const label =
item.role === "client"
? "client"
: getRoleLabel(item.role).toLowerCase();
showConfirm(
"Supprimer",
`Supprimer le ${label} "${item.username}" ?`,
async () => {
try {
if (item.role === "client" && item.clientData) {
await deleteClientAdmin(item.clientData.id);
} else {
await deleteUserAdmin(item.id);
}
await loadData();
} catch (e: any) {
showError("Erreur", e.message);
}
},
"Supprimer",
);
};
// --------------------------------------------------
// Create user
// --------------------------------------------------
const openCreateModal = () => {
setCreateType("client");
setCreateUsername("");
setCreatePassword("");
setCreateNom("");
setCreatePrenom("");
setCreateTel("");
setCreateModal(true);
};
const handleCreate = async () => {
if (!createUsername.trim() || !createPassword.trim()) {
showError("Erreur", "Nom d'utilisateur et mot de passe requis");
return;
}
if (createPassword.trim().length < 8) {
showError(
"Erreur",
"Le mot de passe doit contenir au moins 8 caractères",
);
return;
}
if (createType === "client") {
if (
!createNom.trim() ||
!createPrenom.trim() ||
!createTel.trim()
) {
showError(
"Erreur",
"Nom, prénom et téléphone requis pour un client",
);
return;
}
}
setCreating(true);
try {
if (createType === "client") {
await createClientByAdmin({
username: createUsername.trim(),
password: createPassword.trim(),
nom: createNom.trim(),
prenom: createPrenom.trim(),
telephone: createTel.trim(),
});
} else {
await createUserByAdmin({
username: createUsername.trim(),
password: createPassword.trim(),
role: createType,
});
}
setCreateModal(false);
await loadData();
} catch (e: any) {
showError("Erreur", e.response?.data?.error || e.message);
} finally {
setCreating(false);
}
};
// --------------------------------------------------
// Styles
// --------------------------------------------------
const styles = useMemo(
() =>
StyleSheet.create({
container: { flex: 1, backgroundColor: colors.bgPrimary },
// Filter grid
filterGrid: {
flexDirection: "row",
paddingHorizontal: spacing.m,
paddingVertical: spacing.m,
gap: spacing.s,
},
filterCard: {
flex: 1,
alignItems: "center",
paddingVertical: spacing.m,
borderRadius: borderRadius.md,
borderWidth: 1,
borderColor: colors.border,
backgroundColor: colors.bgSecondary,
},
filterIconCircle: {
width: 36,
height: 36,
borderRadius: 18,
justifyContent: "center",
alignItems: "center",
marginBottom: 4,
},
filterCount: {
color: colors.textWhite,
fontSize: fontSize.lg,
fontWeight: "700",
},
filterLabel: {
color: colors.textMuted,
fontSize: fontSize.sm,
fontWeight: "600",
},
// Card
cardHeader: {
flexDirection: "row",
justifyContent: "space-between",
alignItems: "flex-start",
},
userInfo: {
flexDirection: "row",
alignItems: "center",
flex: 1,
gap: spacing.m,
},
roleIcon: {
width: 40,
height: 40,
borderRadius: 20,
justifyContent: "center",
alignItems: "center",
},
username: {
fontSize: fontSize.md,
fontWeight: "700",
color: colors.textWhite,
},
roleBadge: {
alignSelf: "flex-start",
paddingHorizontal: spacing.s,
paddingVertical: 2,
borderRadius: borderRadius.sm,
marginTop: 4,
},
roleBadgeText: { fontSize: fontSize.xs, fontWeight: "600" },
editIconBtn: { padding: spacing.s },
// Client details
clientDetails: { marginTop: spacing.s, marginLeft: 52 },
clientName: {
color: colors.textSecondary,
fontSize: fontSize.sm,
},
clientTel: {
color: colors.textMuted,
fontSize: fontSize.sm,
marginTop: 2,
},
statsRow: {
flexDirection: "row",
marginTop: spacing.m,
gap: spacing.l,
marginLeft: 52,
},
stat: { alignItems: "center" },
statValue: {
fontSize: fontSize.md,
fontWeight: "bold",
color: colors.textWhite,
},
statLabel: { fontSize: fontSize.xs, color: colors.textMuted },
// Non-client
nonClientInfo: {
color: colors.textMuted,
fontSize: fontSize.sm,
marginTop: spacing.xs,
marginLeft: 52,
},
empty: {
color: colors.textMuted,
textAlign: "center",
marginTop: spacing.xxl,
},
// Add button
addButton: {
flexDirection: "row",
alignItems: "center",
justifyContent: "center",
gap: spacing.s,
marginHorizontal: spacing.m,
marginBottom: spacing.s,
paddingVertical: spacing.m,
borderRadius: borderRadius.md,
backgroundColor: colors.accent,
},
addButtonText: {
color: colors.textWhite,
fontSize: fontSize.md,
fontWeight: "700",
},
// Edit user modal
roleEditLabel: {
color: colors.textSecondary,
fontSize: fontSize.sm,
fontWeight: "600",
marginBottom: spacing.s,
marginTop: spacing.s,
},
roleEditRow: {
flexDirection: "row",
gap: spacing.s,
marginBottom: spacing.l,
},
roleEditBtn: {
flex: 1,
alignItems: "center",
paddingVertical: spacing.s,
borderRadius: borderRadius.md,
borderWidth: 1,
borderColor: colors.border,
backgroundColor: colors.bgInput,
},
roleEditBtnText: {
color: colors.textMuted,
fontSize: fontSize.sm,
fontWeight: "600",
},
}),
[colors],
);
// --------------------------------------------------
// Render
// --------------------------------------------------
const renderUser = ({
item,
}: {
item: ReturnType<typeof getMergedUsers>[0];
}) => {
const isClient = item.role === "client" && item.clientData;
const roleColor = getRoleBadgeColor(item.role);
return (
<Card style={{ marginBottom: spacing.m }}>
<View style={styles.cardHeader}>
<View style={styles.userInfo}>
<View
style={[
styles.roleIcon,
{ backgroundColor: roleColor + "20" },
]}
>
<Ionicons
name={
item.role === "client"
? "person"
: item.role === "livreur"
? "bicycle"
: item.role === "cabine"
? "desktop"
: "shield"
}
size={18}
color={roleColor}
/>
</View>
<View style={{ flex: 1 }}>
<Text style={styles.username}>{item.username}</Text>
<View
style={[
styles.roleBadge,
{ backgroundColor: roleColor + "20" },
]}
>
<Text
style={[
styles.roleBadgeText,
{ color: roleColor },
]}
>
{getRoleLabel(item.role)}
</Text>
</View>
</View>
</View>
<View style={{ flexDirection: "row", gap: spacing.xs }}>
{isClient && (
<TouchableOpacity
style={styles.editIconBtn}
onPress={() => openEditClient(item.clientData!)}
>
<Ionicons
name="create-outline"
size={20}
color={colors.info}
/>
</TouchableOpacity>
)}
{(item.role === "livreur" ||
item.role === "cabine") && (
<TouchableOpacity
style={styles.editIconBtn}
onPress={() => openEditUser(item)}
>
<Ionicons
name="create-outline"
size={20}
color={colors.info}
/>
</TouchableOpacity>
)}
{item.role !== "admin" && (
<TouchableOpacity
style={styles.editIconBtn}
onPress={() => handleDelete(item)}
>
<Ionicons
name="trash-outline"
size={20}
color={colors.danger}
/>
</TouchableOpacity>
)}
</View>
</View>
{/* Client details */}
{isClient && item.clientData && (
<>
<View style={styles.clientDetails}>
<Text style={styles.clientName}>
{item.clientData.prenom} {item.clientData.nom}
</Text>
<Text style={styles.clientTel}>
{item.clientData.telephone}
</Text>
</View>
<View style={styles.statsRow}>
<View style={styles.stat}>
<Text style={styles.statValue}>
{item.clientData.command}
</Text>
<Text style={styles.statLabel}>Cmd</Text>
</View>
<View style={styles.stat}>
<Text
style={[
styles.statValue,
{ color: colors.success },
]}
>
{item.clientData.point}
</Text>
<Text style={styles.statLabel}>Points</Text>
</View>
<View style={styles.stat}>
<Text
style={[
styles.statValue,
{ color: colors.info },
]}
>
{item.clientData.points_zipette}
</Text>
<Text style={styles.statLabel}>Zipette</Text>
</View>
<View style={styles.stat}>
<Text
style={[
styles.statValue,
{ color: colors.warning },
]}
>
{item.clientData.amende}
</Text>
<Text style={styles.statLabel}>Amendes</Text>
</View>
<View style={styles.stat}>
<Text
style={[
styles.statValue,
{ color: colors.danger },
]}
>
{item.clientData.cancellations_count}
</Text>
<Text style={styles.statLabel}>Annul.</Text>
</View>
</View>
</>
)}
{/* Non-client: just show role + ID */}
{!isClient && (
<Text style={styles.nonClientInfo}>ID: {item.id}</Text>
)}
</Card>
);
};
if (loading) return <LoadingSpinner message="Chargement utilisateurs..." />;
return (
<View style={styles.container}>
{/* Role filter grid */}
<View style={styles.filterGrid}>
{ROLE_TABS.map((tab) => {
const active = filter === tab.key;
const count = getCountForFilter(tab.key);
return (
<TouchableOpacity
key={tab.key}
style={[
styles.filterCard,
active && {
backgroundColor: tab.color + "15",
borderColor: tab.color,
},
]}
onPress={() => setFilter(tab.key)}
activeOpacity={0.7}
>
<View
style={[
styles.filterIconCircle,
{
backgroundColor:
(active
? tab.color
: colors.textMuted) + "20",
},
]}
>
<Ionicons
name={tab.icon as any}
size={20}
color={
active ? tab.color : colors.textMuted
}
/>
</View>
<Text
style={[
styles.filterCount,
active && { color: tab.color },
]}
>
{count}
</Text>
<Text
style={[
styles.filterLabel,
active && { color: tab.color },
]}
>
{tab.label}
</Text>
</TouchableOpacity>
);
})}
</View>
{/* Add user button */}
<TouchableOpacity
style={styles.addButton}
onPress={openCreateModal}
activeOpacity={0.7}
>
<Ionicons
name="add-circle"
size={22}
color={colors.textWhite}
/>
<Text style={styles.addButtonText}>Créer un utilisateur</Text>
</TouchableOpacity>
{/* List */}
<FlatList
data={mergedUsers}
keyExtractor={(item) => `${item.role}-${item.id}`}
renderItem={renderUser}
refreshControl={
<RefreshControl
refreshing={refreshing}
onRefresh={onRefresh}
tintColor={colors.accent}
/>
}
contentContainerStyle={{
padding: spacing.l,
paddingBottom: 100,
}}
ListEmptyComponent={
<Text style={styles.empty}>Aucun utilisateur</Text>
}
/>
{/* Edit client modal */}
<Modal
visible={editClientModal.visible}
onClose={() =>
setEditClientModal({ visible: false, client: null })
}
title="Modifier client"
icon="person-outline"
>
<TextInput
label="Nom"
value={editNom}
onChangeText={setEditNom}
/>
<TextInput
label="Prénom"
value={editPrenom}
onChangeText={setEditPrenom}
/>
<TextInput
label="Téléphone"
value={editTel}
onChangeText={setEditTel}
keyboardType="phone-pad"
/>
<Button
title="Enregistrer"
onPress={handleSaveClient}
fullWidth
/>
</Modal>
{/* Edit user modal (livreur/cabine) */}
<Modal
visible={editUserModal.visible}
onClose={() => setEditUserModal({ visible: false, user: null })}
title={`Modifier ${editUserModal.user ? getRoleLabel(editUserModal.user.role) : ""}`}
icon="create-outline"
>
<TextInput
label="Nom d'utilisateur"
value={editUsername}
onChangeText={setEditUsername}
autoCapitalize="none"
/>
<Text style={styles.roleEditLabel}>Rôle</Text>
<View style={styles.roleEditRow}>
{["livreur", "cabine", "admin"].map((r) => (
<TouchableOpacity
key={r}
style={[
styles.roleEditBtn,
editRole === r && {
backgroundColor:
getRoleBadgeColor(r) + "20",
borderColor: getRoleBadgeColor(r),
},
]}
onPress={() => setEditRole(r)}
>
<Text
style={[
styles.roleEditBtnText,
editRole === r && {
color: getRoleBadgeColor(r),
},
]}
>
{getRoleLabel(r)}
</Text>
</TouchableOpacity>
))}
</View>
<Button
title="Enregistrer"
onPress={handleSaveUser}
fullWidth
/>
</Modal>
{/* Create user modal */}
<Modal
visible={createModal}
onClose={() => setCreateModal(false)}
title="Créer un utilisateur"
icon="person-add-outline"
>
<Text style={styles.roleEditLabel}>Type d'utilisateur</Text>
<View style={styles.roleEditRow}>
{(["client", "cabine", "livreur"] as const).map((r) => (
<TouchableOpacity
key={r}
style={[
styles.roleEditBtn,
createType === r && {
backgroundColor:
getRoleBadgeColor(r) + "20",
borderColor: getRoleBadgeColor(r),
},
]}
onPress={() => setCreateType(r)}
>
<Text
style={[
styles.roleEditBtnText,
createType === r && {
color: getRoleBadgeColor(r),
},
]}
>
{getRoleLabel(r)}
</Text>
</TouchableOpacity>
))}
</View>
<TextInput
label="Nom d'utilisateur"
value={createUsername}
onChangeText={setCreateUsername}
autoCapitalize="none"
/>
<TextInput
label="Mot de passe"
value={createPassword}
onChangeText={setCreatePassword}
secureTextEntry
/>
{createType === "client" && (
<>
<TextInput
label="Nom"
value={createNom}
onChangeText={setCreateNom}
/>
<TextInput
label="Prénom"
value={createPrenom}
onChangeText={setCreatePrenom}
/>
<TextInput
label="Téléphone"
value={createTel}
onChangeText={setCreateTel}
keyboardType="phone-pad"
/>
</>
)}
<Button
title={creating ? "Création..." : "Créer"}
onPress={handleCreate}
fullWidth
disabled={creating}
/>
</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,302 @@
import React, { useState, useRef, useMemo } from "react";
import {
View,
Text,
StyleSheet,
KeyboardAvoidingView,
Platform,
TouchableOpacity,
TextInput as RNTextInput,
Animated,
Dimensions,
} from "react-native";
import { Ionicons } from "@expo/vector-icons";
import { useNavigation } from "@react-navigation/native";
import { spacing, fontSize, borderRadius } from "../../theme";
import { useTheme } from "../../context/ThemeContext";
import { useAuth } from "../../auth/AuthContext";
import { loginAdmin } from "../../api/api_admin";
import AlertModal from "../../components/ui/AlertModal";
import { useAlert } from "../../hooks/useAlert";
const { width } = Dimensions.get("window");
export default function AdminLoginScreen() {
const { colors } = useTheme();
const ACCENT = colors.accent;
const [username, setUsername] = useState("");
const [password, setPassword] = useState("");
const [loading, setLoading] = useState(false);
const [showPassword, setShowPassword] = useState(false);
const { loginAdmin: authLogin } = useAuth();
const navigation = useNavigation();
const buttonScale = useRef(new Animated.Value(1)).current;
const { alert, showError, hideAlert } = useAlert();
const styles = useMemo(
() =>
StyleSheet.create({
container: { flex: 1, backgroundColor: colors.bgPrimary },
accentBar: { height: 4, width: "100%" },
heroSection: {
alignItems: "center",
paddingTop: 60,
paddingBottom: spacing.xl,
},
heroBg: {
width: 120,
height: 120,
borderRadius: 60,
justifyContent: "center",
alignItems: "center",
},
heroInner: {
width: 88,
height: 88,
borderRadius: 44,
justifyContent: "center",
alignItems: "center",
},
heroTitle: {
fontSize: fontSize.xxl,
fontWeight: "800",
color: colors.textWhite,
marginTop: spacing.l,
letterSpacing: 0.5,
},
heroSubtitle: {
fontSize: fontSize.md,
color: colors.textMuted,
marginTop: spacing.xs,
},
formSection: { paddingHorizontal: spacing.xl, flex: 1 },
inputContainer: {
flexDirection: "row",
alignItems: "center",
backgroundColor: colors.bgSecondary,
borderRadius: borderRadius.md,
borderWidth: 1,
borderColor: colors.border,
marginBottom: spacing.m,
overflow: "hidden",
},
inputIconBox: {
width: 52,
height: 52,
justifyContent: "center",
alignItems: "center",
},
input: {
flex: 1,
color: colors.textPrimary,
fontSize: fontSize.md,
paddingVertical: 15,
paddingHorizontal: spacing.m,
},
eyeBtn: { padding: spacing.m },
loginBtn: {
flexDirection: "row",
alignItems: "center",
justifyContent: "center",
height: 52,
borderRadius: borderRadius.md,
marginTop: spacing.m,
},
loginBtnText: {
color: colors.white,
fontSize: fontSize.lg,
fontWeight: "700",
},
backLink: {
flexDirection: "row",
alignItems: "center",
justifyContent: "center",
marginTop: spacing.xl,
paddingVertical: spacing.m,
},
backText: {
color: colors.textMuted,
fontSize: fontSize.sm,
marginLeft: spacing.xs,
},
}),
[colors],
);
const handleLogin = async () => {
if (!username.trim() || !password.trim()) {
showError("Erreur", "Veuillez remplir tous les champs");
return;
}
setLoading(true);
try {
const result = await loginAdmin(username.trim(), password);
if (result.success && result.access_token) {
await authLogin(result.access_token, "admin");
} else {
showError("Erreur", result.message || "Connexion échouée");
}
} catch {
showError("Erreur", "Erreur de connexion");
} finally {
setLoading(false);
}
};
const onPressIn = () =>
Animated.spring(buttonScale, {
toValue: 0.96,
useNativeDriver: true,
}).start();
const onPressOut = () =>
Animated.spring(buttonScale, {
toValue: 1,
useNativeDriver: true,
}).start();
return (
<KeyboardAvoidingView
style={styles.container}
behavior={Platform.OS === "ios" ? "padding" : undefined}
>
{/* Decorative top accent */}
<View style={[styles.accentBar, { backgroundColor: ACCENT }]} />
<View style={styles.heroSection}>
<View
style={[styles.heroBg, { backgroundColor: ACCENT + "12" }]}
>
<View
style={[
styles.heroInner,
{ backgroundColor: ACCENT + "20" },
]}
>
<Ionicons
name="shield-checkmark"
size={48}
color={ACCENT}
/>
</View>
</View>
<Text style={styles.heroTitle}>Administration</Text>
<Text style={styles.heroSubtitle}>
Accès au panneau de contrôle
</Text>
</View>
<View style={styles.formSection}>
{/* Username */}
<View style={styles.inputContainer}>
<View
style={[
styles.inputIconBox,
{ backgroundColor: ACCENT + "15" },
]}
>
<Ionicons
name="person-outline"
size={20}
color={ACCENT}
/>
</View>
<RNTextInput
style={styles.input}
placeholder="Nom d'utilisateur"
placeholderTextColor={colors.textMuted}
value={username}
onChangeText={setUsername}
autoCapitalize="none"
autoCorrect={false}
/>
</View>
{/* Password */}
<View style={styles.inputContainer}>
<View
style={[
styles.inputIconBox,
{ backgroundColor: ACCENT + "15" },
]}
>
<Ionicons
name="lock-closed-outline"
size={20}
color={ACCENT}
/>
</View>
<RNTextInput
style={styles.input}
placeholder="Mot de passe"
placeholderTextColor={colors.textMuted}
value={password}
onChangeText={setPassword}
secureTextEntry={!showPassword}
/>
<TouchableOpacity
onPress={() => setShowPassword(!showPassword)}
style={styles.eyeBtn}
>
<Ionicons
name={
showPassword ? "eye-off-outline" : "eye-outline"
}
size={20}
color={colors.textMuted}
/>
</TouchableOpacity>
</View>
{/* Login button */}
<Animated.View style={{ transform: [{ scale: buttonScale }] }}>
<TouchableOpacity
style={[styles.loginBtn, { backgroundColor: ACCENT }]}
onPress={handleLogin}
onPressIn={onPressIn}
onPressOut={onPressOut}
disabled={loading}
activeOpacity={0.9}
>
{loading ? (
<Text style={styles.loginBtnText}>
Connexion...
</Text>
) : (
<>
<Text style={styles.loginBtnText}>
Se connecter
</Text>
<Ionicons
name="arrow-forward"
size={20}
color={colors.white}
style={{ marginLeft: spacing.s }}
/>
</>
)}
</TouchableOpacity>
</Animated.View>
{/* Back link */}
<TouchableOpacity
onPress={() => navigation.goBack()}
style={styles.backLink}
>
<Ionicons
name="chevron-back"
size={18}
color={colors.textMuted}
/>
<Text style={styles.backText}>Choisir un autre rôle</Text>
</TouchableOpacity>
</View>
<AlertModal
visible={alert.visible}
type={alert.type}
title={alert.title}
message={alert.message}
onClose={hideAlert}
/>
</KeyboardAvoidingView>
);
}
@@ -0,0 +1,293 @@
import React, { useState, useRef, useMemo } from "react";
import {
View,
Text,
StyleSheet,
KeyboardAvoidingView,
Platform,
TouchableOpacity,
TextInput as RNTextInput,
Animated,
Dimensions,
} from "react-native";
import { Ionicons } from "@expo/vector-icons";
import { useNavigation } from "@react-navigation/native";
import { spacing, fontSize, borderRadius } from "../../theme";
import { useTheme } from "../../context/ThemeContext";
import { useAuth } from "../../auth/AuthContext";
import { loginAdmin } from "../../api/api_admin";
import AlertModal from "../../components/ui/AlertModal";
import { useAlert } from "../../hooks/useAlert";
const { width } = Dimensions.get("window");
export default function CabineLoginScreen() {
const { colors } = useTheme();
const ACCENT = colors.info;
const [username, setUsername] = useState("");
const [password, setPassword] = useState("");
const [loading, setLoading] = useState(false);
const [showPassword, setShowPassword] = useState(false);
const { loginAdmin: authLogin } = useAuth();
const navigation = useNavigation();
const buttonScale = useRef(new Animated.Value(1)).current;
const { alert, showError, hideAlert } = useAlert();
const styles = useMemo(
() =>
StyleSheet.create({
container: { flex: 1, backgroundColor: colors.bgPrimary },
accentBar: { height: 4, width: "100%" },
heroSection: {
alignItems: "center",
paddingTop: 60,
paddingBottom: spacing.xl,
},
heroBg: {
width: 120,
height: 120,
borderRadius: 60,
justifyContent: "center",
alignItems: "center",
},
heroInner: {
width: 88,
height: 88,
borderRadius: 44,
justifyContent: "center",
alignItems: "center",
},
heroTitle: {
fontSize: fontSize.xxl,
fontWeight: "800",
color: colors.textWhite,
marginTop: spacing.l,
letterSpacing: 0.5,
},
heroSubtitle: {
fontSize: fontSize.md,
color: colors.textMuted,
marginTop: spacing.xs,
},
formSection: { paddingHorizontal: spacing.xl, flex: 1 },
inputContainer: {
flexDirection: "row",
alignItems: "center",
backgroundColor: colors.bgSecondary,
borderRadius: borderRadius.md,
borderWidth: 1,
borderColor: colors.border,
marginBottom: spacing.m,
overflow: "hidden",
},
inputIconBox: {
width: 52,
height: 52,
justifyContent: "center",
alignItems: "center",
},
input: {
flex: 1,
color: colors.textPrimary,
fontSize: fontSize.md,
paddingVertical: 15,
paddingHorizontal: spacing.m,
},
eyeBtn: { padding: spacing.m },
loginBtn: {
flexDirection: "row",
alignItems: "center",
justifyContent: "center",
height: 52,
borderRadius: borderRadius.md,
marginTop: spacing.m,
},
loginBtnText: {
color: colors.white,
fontSize: fontSize.lg,
fontWeight: "700",
},
backLink: {
flexDirection: "row",
alignItems: "center",
justifyContent: "center",
marginTop: spacing.xl,
paddingVertical: spacing.m,
},
backText: {
color: colors.textMuted,
fontSize: fontSize.sm,
marginLeft: spacing.xs,
},
}),
[colors],
);
const handleLogin = async () => {
if (!username.trim() || !password.trim()) {
showError("Erreur", "Veuillez remplir tous les champs");
return;
}
setLoading(true);
try {
const result = await loginAdmin(username.trim(), password);
if (result.success && result.access_token) {
await authLogin(result.access_token, "cabine");
} else {
showError("Erreur", result.message || "Connexion échouée");
}
} catch {
showError("Erreur", "Erreur de connexion");
} finally {
setLoading(false);
}
};
const onPressIn = () =>
Animated.spring(buttonScale, {
toValue: 0.96,
useNativeDriver: true,
}).start();
const onPressOut = () =>
Animated.spring(buttonScale, {
toValue: 1,
useNativeDriver: true,
}).start();
return (
<KeyboardAvoidingView
style={styles.container}
behavior={Platform.OS === "ios" ? "padding" : undefined}
>
<View style={[styles.accentBar, { backgroundColor: ACCENT }]} />
<View style={styles.heroSection}>
<View
style={[styles.heroBg, { backgroundColor: ACCENT + "12" }]}
>
<View
style={[
styles.heroInner,
{ backgroundColor: ACCENT + "20" },
]}
>
<Ionicons name="desktop" size={48} color={ACCENT} />
</View>
</View>
<Text style={styles.heroTitle}>Cabine</Text>
<Text style={styles.heroSubtitle}>
Suivi des commandes et livreurs
</Text>
</View>
<View style={styles.formSection}>
<View style={styles.inputContainer}>
<View
style={[
styles.inputIconBox,
{ backgroundColor: ACCENT + "15" },
]}
>
<Ionicons
name="person-outline"
size={20}
color={ACCENT}
/>
</View>
<RNTextInput
style={styles.input}
placeholder="Nom d'utilisateur"
placeholderTextColor={colors.textMuted}
value={username}
onChangeText={setUsername}
autoCapitalize="none"
autoCorrect={false}
/>
</View>
<View style={styles.inputContainer}>
<View
style={[
styles.inputIconBox,
{ backgroundColor: ACCENT + "15" },
]}
>
<Ionicons
name="lock-closed-outline"
size={20}
color={ACCENT}
/>
</View>
<RNTextInput
style={styles.input}
placeholder="Mot de passe"
placeholderTextColor={colors.textMuted}
value={password}
onChangeText={setPassword}
secureTextEntry={!showPassword}
/>
<TouchableOpacity
onPress={() => setShowPassword(!showPassword)}
style={styles.eyeBtn}
>
<Ionicons
name={
showPassword ? "eye-off-outline" : "eye-outline"
}
size={20}
color={colors.textMuted}
/>
</TouchableOpacity>
</View>
<Animated.View style={{ transform: [{ scale: buttonScale }] }}>
<TouchableOpacity
style={[styles.loginBtn, { backgroundColor: ACCENT }]}
onPress={handleLogin}
onPressIn={onPressIn}
onPressOut={onPressOut}
disabled={loading}
activeOpacity={0.9}
>
{loading ? (
<Text style={styles.loginBtnText}>
Connexion...
</Text>
) : (
<>
<Text style={styles.loginBtnText}>
Se connecter
</Text>
<Ionicons
name="arrow-forward"
size={20}
color={colors.white}
style={{ marginLeft: spacing.s }}
/>
</>
)}
</TouchableOpacity>
</Animated.View>
<TouchableOpacity
onPress={() => navigation.goBack()}
style={styles.backLink}
>
<Ionicons
name="chevron-back"
size={18}
color={colors.textMuted}
/>
<Text style={styles.backText}>Choisir un autre rôle</Text>
</TouchableOpacity>
</View>
<AlertModal
visible={alert.visible}
type={alert.type}
title={alert.title}
message={alert.message}
onClose={hideAlert}
/>
</KeyboardAvoidingView>
);
}
@@ -0,0 +1,293 @@
import React, { useState, useRef, useMemo } from "react";
import {
View,
Text,
StyleSheet,
KeyboardAvoidingView,
Platform,
TouchableOpacity,
TextInput as RNTextInput,
Animated,
Dimensions,
} from "react-native";
import { Ionicons } from "@expo/vector-icons";
import { useNavigation } from "@react-navigation/native";
import { spacing, fontSize, borderRadius } from "../../theme";
import { useTheme } from "../../context/ThemeContext";
import { useAuth } from "../../auth/AuthContext";
import { loginAdmin } from "../../api/api_admin";
import AlertModal from "../../components/ui/AlertModal";
import { useAlert } from "../../hooks/useAlert";
const { width } = Dimensions.get("window");
export default function DeliveryLoginScreen() {
const { colors } = useTheme();
const ACCENT = colors.success;
const [username, setUsername] = useState("");
const [password, setPassword] = useState("");
const [loading, setLoading] = useState(false);
const [showPassword, setShowPassword] = useState(false);
const { loginAdmin: authLogin } = useAuth();
const navigation = useNavigation();
const buttonScale = useRef(new Animated.Value(1)).current;
const { alert, showError, hideAlert } = useAlert();
const styles = useMemo(
() =>
StyleSheet.create({
container: { flex: 1, backgroundColor: colors.bgPrimary },
accentBar: { height: 4, width: "100%" },
heroSection: {
alignItems: "center",
paddingTop: 60,
paddingBottom: spacing.xl,
},
heroBg: {
width: 120,
height: 120,
borderRadius: 60,
justifyContent: "center",
alignItems: "center",
},
heroInner: {
width: 88,
height: 88,
borderRadius: 44,
justifyContent: "center",
alignItems: "center",
},
heroTitle: {
fontSize: fontSize.xxl,
fontWeight: "800",
color: colors.textWhite,
marginTop: spacing.l,
letterSpacing: 0.5,
},
heroSubtitle: {
fontSize: fontSize.md,
color: colors.textMuted,
marginTop: spacing.xs,
},
formSection: { paddingHorizontal: spacing.xl, flex: 1 },
inputContainer: {
flexDirection: "row",
alignItems: "center",
backgroundColor: colors.bgSecondary,
borderRadius: borderRadius.md,
borderWidth: 1,
borderColor: colors.border,
marginBottom: spacing.m,
overflow: "hidden",
},
inputIconBox: {
width: 52,
height: 52,
justifyContent: "center",
alignItems: "center",
},
input: {
flex: 1,
color: colors.textPrimary,
fontSize: fontSize.md,
paddingVertical: 15,
paddingHorizontal: spacing.m,
},
eyeBtn: { padding: spacing.m },
loginBtn: {
flexDirection: "row",
alignItems: "center",
justifyContent: "center",
height: 52,
borderRadius: borderRadius.md,
marginTop: spacing.m,
},
loginBtnText: {
color: colors.white,
fontSize: fontSize.lg,
fontWeight: "700",
},
backLink: {
flexDirection: "row",
alignItems: "center",
justifyContent: "center",
marginTop: spacing.xl,
paddingVertical: spacing.m,
},
backText: {
color: colors.textMuted,
fontSize: fontSize.sm,
marginLeft: spacing.xs,
},
}),
[colors],
);
const handleLogin = async () => {
if (!username.trim() || !password.trim()) {
showError("Erreur", "Veuillez remplir tous les champs");
return;
}
setLoading(true);
try {
const result = await loginAdmin(username.trim(), password);
if (result.success && result.access_token) {
await authLogin(result.access_token, "livreur");
} else {
showError("Erreur", result.message || "Connexion échouée");
}
} catch {
showError("Erreur", "Erreur de connexion");
} finally {
setLoading(false);
}
};
const onPressIn = () =>
Animated.spring(buttonScale, {
toValue: 0.96,
useNativeDriver: true,
}).start();
const onPressOut = () =>
Animated.spring(buttonScale, {
toValue: 1,
useNativeDriver: true,
}).start();
return (
<KeyboardAvoidingView
style={styles.container}
behavior={Platform.OS === "ios" ? "padding" : undefined}
>
<View style={[styles.accentBar, { backgroundColor: ACCENT }]} />
<View style={styles.heroSection}>
<View
style={[styles.heroBg, { backgroundColor: ACCENT + "12" }]}
>
<View
style={[
styles.heroInner,
{ backgroundColor: ACCENT + "20" },
]}
>
<Ionicons name="bicycle" size={48} color={ACCENT} />
</View>
</View>
<Text style={styles.heroTitle}>Livreur</Text>
<Text style={styles.heroSubtitle}>
Gestion de vos livraisons
</Text>
</View>
<View style={styles.formSection}>
<View style={styles.inputContainer}>
<View
style={[
styles.inputIconBox,
{ backgroundColor: ACCENT + "15" },
]}
>
<Ionicons
name="person-outline"
size={20}
color={ACCENT}
/>
</View>
<RNTextInput
style={styles.input}
placeholder="Nom d'utilisateur"
placeholderTextColor={colors.textMuted}
value={username}
onChangeText={setUsername}
autoCapitalize="none"
autoCorrect={false}
/>
</View>
<View style={styles.inputContainer}>
<View
style={[
styles.inputIconBox,
{ backgroundColor: ACCENT + "15" },
]}
>
<Ionicons
name="lock-closed-outline"
size={20}
color={ACCENT}
/>
</View>
<RNTextInput
style={styles.input}
placeholder="Mot de passe"
placeholderTextColor={colors.textMuted}
value={password}
onChangeText={setPassword}
secureTextEntry={!showPassword}
/>
<TouchableOpacity
onPress={() => setShowPassword(!showPassword)}
style={styles.eyeBtn}
>
<Ionicons
name={
showPassword ? "eye-off-outline" : "eye-outline"
}
size={20}
color={colors.textMuted}
/>
</TouchableOpacity>
</View>
<Animated.View style={{ transform: [{ scale: buttonScale }] }}>
<TouchableOpacity
style={[styles.loginBtn, { backgroundColor: ACCENT }]}
onPress={handleLogin}
onPressIn={onPressIn}
onPressOut={onPressOut}
disabled={loading}
activeOpacity={0.9}
>
{loading ? (
<Text style={styles.loginBtnText}>
Connexion...
</Text>
) : (
<>
<Text style={styles.loginBtnText}>
Se connecter
</Text>
<Ionicons
name="arrow-forward"
size={20}
color={colors.white}
style={{ marginLeft: spacing.s }}
/>
</>
)}
</TouchableOpacity>
</Animated.View>
<TouchableOpacity
onPress={() => navigation.goBack()}
style={styles.backLink}
>
<Ionicons
name="chevron-back"
size={18}
color={colors.textMuted}
/>
<Text style={styles.backText}>Choisir un autre rôle</Text>
</TouchableOpacity>
</View>
<AlertModal
visible={alert.visible}
type={alert.type}
title={alert.title}
message={alert.message}
onClose={hideAlert}
/>
</KeyboardAvoidingView>
);
}
@@ -0,0 +1,143 @@
import React, { useMemo } from "react";
import { View, Text, TouchableOpacity, StyleSheet } from "react-native";
import { Ionicons } from "@expo/vector-icons";
import { useNavigation } from "@react-navigation/native";
import type { NativeStackNavigationProp } from "@react-navigation/native-stack";
import type { AuthStackParamList } from "../../navigation/types";
import { spacing, fontSize, borderRadius } from "../../theme";
import { useTheme } from "../../context/ThemeContext";
type Nav = NativeStackNavigationProp<AuthStackParamList, "RoleSelect">;
export default function RoleSelectScreen() {
const { colors } = useTheme();
const navigation = useNavigation<Nav>();
const roles = [
{
key: "AdminLogin" as const,
label: "Admin",
desc: "Gestion complète du système",
icon: "shield-outline" as const,
color: colors.accent,
},
{
key: "CabineLogin" as const,
label: "Cabine",
desc: "Suivi des commandes et livreurs",
icon: "desktop-outline" as const,
color: colors.info,
},
{
key: "DeliveryLogin" as const,
label: "Livreur",
desc: "Gestion des livraisons",
icon: "bicycle-outline" as const,
color: colors.success,
},
];
const styles = useMemo(
() =>
StyleSheet.create({
container: {
flex: 1,
backgroundColor: colors.bgPrimary,
justifyContent: "center",
alignItems: "center",
padding: spacing.l,
},
card: {
width: "100%",
maxWidth: 400,
backgroundColor: colors.bgSecondary,
borderRadius: borderRadius.lg,
padding: spacing.xl,
alignItems: "center",
},
title: {
fontSize: fontSize.xxl,
fontWeight: "bold",
color: colors.textWhite,
marginTop: spacing.m,
},
subtitle: {
color: colors.textSecondary,
fontSize: fontSize.md,
marginTop: spacing.xs,
marginBottom: spacing.xl,
},
roleBtn: {
flexDirection: "row",
alignItems: "center",
backgroundColor: colors.bgCard,
borderRadius: borderRadius.md,
padding: spacing.l,
width: "100%",
borderWidth: 1,
borderColor: colors.border,
marginBottom: spacing.m,
},
iconCircle: {
width: 44,
height: 44,
borderRadius: 22,
justifyContent: "center",
alignItems: "center",
},
roleBtnText: { flex: 1, marginLeft: spacing.m },
roleTitle: {
color: colors.textWhite,
fontSize: fontSize.lg,
fontWeight: "600",
},
roleDesc: {
color: colors.textSecondary,
fontSize: fontSize.sm,
marginTop: 2,
},
}),
[colors],
);
return (
<View style={styles.container}>
<View style={styles.card}>
<Ionicons
name="people-outline"
size={48}
color={colors.accent}
/>
<Text style={styles.title}>Panel Administration</Text>
<Text style={styles.subtitle}>Sélectionnez votre rôle</Text>
{roles.map((r) => (
<TouchableOpacity
key={r.key}
style={styles.roleBtn}
onPress={() => navigation.navigate(r.key)}
activeOpacity={0.7}
>
<View
style={[
styles.iconCircle,
{ backgroundColor: r.color + "20" },
]}
>
<Ionicons name={r.icon} size={24} color={r.color} />
</View>
<View style={styles.roleBtnText}>
<Text style={styles.roleTitle}>{r.label}</Text>
<Text style={styles.roleDesc}>{r.desc}</Text>
</View>
<Ionicons
name="chevron-forward"
size={20}
color={colors.textMuted}
/>
</TouchableOpacity>
))}
</View>
</View>
);
}
@@ -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>
);
}
@@ -0,0 +1,451 @@
import React, { useState, useEffect, useCallback, useMemo } from "react";
import {
View,
Text,
StyleSheet,
FlatList,
RefreshControl,
Modal,
TouchableOpacity,
Animated,
} from "react-native";
import { Ionicons } from "@expo/vector-icons";
import { spacing, fontSize, borderRadius } from "../../theme";
import { useTheme } from "../../context/ThemeContext";
import {
getMyAlerts,
triggerPoliceAlert,
endAlert,
} from "../../api/api_delivery";
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";
import Button from "../../components/ui/Button";
import AlertModal from "../../components/ui/AlertModal";
import { useAlert } from "../../hooks/useAlert";
export default function AlertsScreen() {
const { colors } = useTheme();
const [alerts, setAlerts] = useState<AlertType[]>([]);
const [loading, setLoading] = useState(true);
const [refreshing, setRefreshing] = useState(false);
const [triggering, setTriggering] = useState(false);
const [showConfirmModal, setShowConfirmModal] = useState(false);
const [showSuccessModal, setShowSuccessModal] = useState(false);
const [successMessage, setSuccessMessage] = useState("");
const [pulseAnim] = useState(() => new Animated.Value(1));
const { alert: alertModal, showError, hideAlert } = useAlert();
const loadData = useCallback(async () => {
try {
const res = await getMyAlerts();
if (res.success && res.alerts) setAlerts(res.alerts);
} catch {
/* ignore */
}
setLoading(false);
}, []);
useEffect(() => {
loadData();
}, [loadData]);
// Pulse animation when modal is open
useEffect(() => {
if (!showConfirmModal) return;
const anim = Animated.loop(
Animated.sequence([
Animated.timing(pulseAnim, {
toValue: 1.15,
duration: 800,
useNativeDriver: true,
}),
Animated.timing(pulseAnim, {
toValue: 1,
duration: 800,
useNativeDriver: true,
}),
]),
);
anim.start();
return () => anim.stop();
}, [showConfirmModal, pulseAnim]);
const onRefresh = async () => {
setRefreshing(true);
await loadData();
setRefreshing(false);
};
const handleConfirmTrigger = async () => {
setShowConfirmModal(false);
setTriggering(true);
const res = await triggerPoliceAlert();
setTriggering(false);
if (res.success) {
setSuccessMessage(res.message || "Alerte déclenchée avec succès");
setShowSuccessModal(true);
loadData();
} else {
showError("Erreur", res.error || "Erreur");
}
};
const handleEnd = async (alertId: number) => {
const res = await endAlert(alertId);
if (res.success) {
loadData();
} else {
showError("Erreur", res.error || "Erreur");
}
};
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.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>
{item.status === "true" && (
<Button
title="Terminer l'alerte"
onPress={() => handleEnd(item.id)}
style={{
marginTop: spacing.s,
backgroundColor: colors.success,
}}
/>
)}
</Card>
);
const styles = useMemo(
() =>
StyleSheet.create({
container: { flex: 1, backgroundColor: colors.bgPrimary },
row: { flexDirection: "row", alignItems: "center" },
date: { color: colors.textMuted, fontSize: fontSize.sm },
empty: {
color: colors.textMuted,
textAlign: "center",
marginTop: spacing.xxl,
fontSize: fontSize.md,
},
triggerSection: {
padding: spacing.l,
borderBottomWidth: 1,
borderBottomColor: colors.border,
},
// Modal
modalOverlay: {
flex: 1,
backgroundColor: "rgba(0, 0, 0, 0.85)",
justifyContent: "center",
alignItems: "center",
padding: spacing.xl,
},
modalContent: {
width: "100%",
backgroundColor: colors.bgCard,
borderRadius: borderRadius.lg,
padding: spacing.xl,
alignItems: "center",
borderWidth: 1,
borderColor: colors.danger + "40",
},
modalIconCircle: {
width: 90,
height: 90,
borderRadius: 45,
backgroundColor: colors.danger + "20",
justifyContent: "center",
alignItems: "center",
marginBottom: spacing.l,
},
modalIconInner: {
width: 68,
height: 68,
borderRadius: 34,
backgroundColor: colors.danger,
justifyContent: "center",
alignItems: "center",
},
modalTitle: {
fontSize: fontSize.xl,
fontWeight: "700",
color: colors.danger,
marginBottom: spacing.s,
},
modalMessage: {
fontSize: fontSize.sm,
color: colors.textSecondary,
textAlign: "center",
lineHeight: 20,
marginBottom: spacing.m,
},
modalWarning: {
fontSize: fontSize.md,
fontWeight: "600",
color: colors.textWhite,
textAlign: "center",
marginBottom: spacing.xl,
},
modalButtons: {
flexDirection: "row",
gap: spacing.m,
width: "100%",
},
modalCancelBtn: {
flex: 1,
paddingVertical: 14,
borderRadius: borderRadius.sm,
backgroundColor: colors.bgInput,
alignItems: "center",
justifyContent: "center",
},
modalCancelText: {
color: colors.textSecondary,
fontSize: fontSize.md,
fontWeight: "600",
},
modalConfirmBtn: {
flex: 1,
flexDirection: "row",
paddingVertical: 14,
borderRadius: borderRadius.sm,
backgroundColor: colors.danger,
alignItems: "center",
justifyContent: "center",
gap: spacing.xs,
},
modalConfirmText: {
color: colors.white,
fontSize: fontSize.md,
fontWeight: "700",
},
// Success Modal
successModalContent: {
width: "100%",
backgroundColor: colors.bgCard,
borderRadius: borderRadius.lg,
padding: spacing.xl,
alignItems: "center",
borderWidth: 1,
borderColor: colors.success + "40",
},
successIconCircle: {
width: 90,
height: 90,
borderRadius: 45,
backgroundColor: colors.success + "20",
justifyContent: "center",
alignItems: "center",
marginBottom: spacing.l,
},
successIconInner: {
width: 68,
height: 68,
borderRadius: 34,
backgroundColor: colors.success,
justifyContent: "center",
alignItems: "center",
},
successTitle: {
fontSize: fontSize.xl,
fontWeight: "700",
color: colors.success,
marginBottom: spacing.s,
},
successMessage: {
fontSize: fontSize.sm,
color: colors.textSecondary,
textAlign: "center",
lineHeight: 20,
marginBottom: spacing.s,
},
successHint: {
fontSize: fontSize.xs,
color: colors.textMuted,
textAlign: "center",
lineHeight: 18,
marginBottom: spacing.xl,
},
successBtn: {
width: "100%",
paddingVertical: 14,
borderRadius: borderRadius.sm,
backgroundColor: colors.success,
alignItems: "center",
justifyContent: "center",
},
successBtnText: {
color: colors.white,
fontSize: fontSize.md,
fontWeight: "700",
},
}),
[colors],
);
if (loading) return <LoadingSpinner message="Chargement alertes..." />;
return (
<View style={styles.container}>
{/* Confirmation Modal */}
<Modal
visible={showConfirmModal}
transparent
animationType="fade"
onRequestClose={() => setShowConfirmModal(false)}
>
<View style={styles.modalOverlay}>
<View style={styles.modalContent}>
{/* Animated alert icon */}
<Animated.View
style={[
styles.modalIconCircle,
{ transform: [{ scale: pulseAnim }] },
]}
>
<View style={styles.modalIconInner}>
<Ionicons
name="warning"
size={40}
color={colors.white}
/>
</View>
</Animated.View>
<Text style={styles.modalTitle}>Alerte Police</Text>
<Text style={styles.modalMessage}>
Vous êtes sur le point de déclencher une alerte
police. Cette action notifiera immédiatement
l'administration.
</Text>
<Text style={styles.modalWarning}>
Confirmez-vous le déclenchement ?
</Text>
<View style={styles.modalButtons}>
<TouchableOpacity
style={styles.modalCancelBtn}
onPress={() => setShowConfirmModal(false)}
activeOpacity={0.7}
>
<Text style={styles.modalCancelText}>
Annuler
</Text>
</TouchableOpacity>
<TouchableOpacity
style={styles.modalConfirmBtn}
onPress={handleConfirmTrigger}
activeOpacity={0.7}
>
<Ionicons
name="alert-circle"
size={18}
color={colors.white}
/>
<Text style={styles.modalConfirmText}>
Déclencher
</Text>
</TouchableOpacity>
</View>
</View>
</View>
</Modal>
{/* Success Modal */}
<Modal
visible={showSuccessModal}
transparent
animationType="fade"
onRequestClose={() => setShowSuccessModal(false)}
>
<View style={styles.modalOverlay}>
<View style={styles.successModalContent}>
<View style={styles.successIconCircle}>
<View style={styles.successIconInner}>
<Ionicons
name="checkmark-sharp"
size={40}
color={colors.white}
/>
</View>
</View>
<Text style={styles.successTitle}>Alerte envoyée</Text>
<Text style={styles.successMessage}>
{successMessage}
</Text>
<Text style={styles.successHint}>
L'administration a é notifiée. Vous pourrez
terminer l'alerte quand la situation sera résolue.
</Text>
<TouchableOpacity
style={styles.successBtn}
onPress={() => setShowSuccessModal(false)}
activeOpacity={0.7}
>
<Text style={styles.successBtnText}>Compris</Text>
</TouchableOpacity>
</View>
</View>
</Modal>
<View style={styles.triggerSection}>
<Button
title={triggering ? "Envoi..." : "Déclencher alerte police"}
onPress={() => setShowConfirmModal(true)}
disabled={triggering}
style={{ backgroundColor: colors.danger }}
/>
</View>
<FlatList
data={alerts}
keyExtractor={(item) => item.id.toString()}
renderItem={renderAlert}
refreshControl={
<RefreshControl
refreshing={refreshing}
onRefresh={onRefresh}
tintColor={colors.success}
/>
}
contentContainerStyle={{ padding: spacing.l }}
ListEmptyComponent={
<Text style={styles.empty}>Aucune alerte</Text>
}
/>
<AlertModal
visible={alertModal.visible}
type={alertModal.type}
title={alertModal.title}
message={alertModal.message}
onClose={hideAlert}
/>
</View>
);
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,140 @@
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 } from "../../theme";
import { useTheme } from "../../context/ThemeContext";
import { getMyDeliveries, getMyStatus } from "../../api/api_delivery";
import type { DeliveryItem } from "../../api/types";
import LoadingSpinner from "../../components/ui/LoadingSpinner";
import Card from "../../components/ui/Card";
export default function StatsScreen() {
const { colors } = useTheme();
const [deliveries, setDeliveries] = useState<DeliveryItem[]>([]);
const [loading, setLoading] = useState(true);
const [refreshing, setRefreshing] = useState(false);
const loadData = useCallback(async () => {
try {
const res = await getMyDeliveries();
if (res.success && res.deliveries) setDeliveries(res.deliveries);
} catch {
/* ignore */
}
setLoading(false);
}, []);
useEffect(() => {
loadData();
}, [loadData]);
const onRefresh = async () => {
setRefreshing(true);
await loadData();
setRefreshing(false);
};
const total = deliveries.length;
const completed = deliveries.filter((d) => d.status === "livre").length;
const inProgress = deliveries.filter((d) => d.status === "en_route").length;
const pending = deliveries.filter((d) => d.status === "assigned").length;
const totalRevenue = deliveries
.filter((d) => d.status === "livre")
.reduce((s, d) => s + d.total_prix, 0);
const stats = [
{
label: "Total livraisons",
value: total.toString(),
icon: "cube-outline" as const,
color: colors.accent,
},
{
label: "Complétées",
value: completed.toString(),
icon: "checkmark-circle-outline" as const,
color: colors.success,
},
{
label: "En cours",
value: inProgress.toString(),
icon: "time-outline" as const,
color: colors.warning,
},
{
label: "En attente",
value: pending.toString(),
icon: "hourglass-outline" as const,
color: colors.info,
},
];
const styles = useMemo(
() =>
StyleSheet.create({
container: { flex: 1, backgroundColor: colors.bgPrimary },
title: {
color: colors.textWhite,
fontSize: fontSize.xl,
fontWeight: "700",
marginBottom: spacing.l,
},
grid: {
flexDirection: "row",
flexWrap: "wrap",
gap: spacing.m,
},
statCard: {
width: "47%",
alignItems: "center",
paddingVertical: spacing.l,
},
statValue: {
fontSize: fontSize.xxl,
fontWeight: "700",
marginTop: spacing.s,
},
statLabel: {
color: colors.textMuted,
fontSize: fontSize.xs,
marginTop: spacing.xs,
textAlign: "center",
},
}),
[colors],
);
if (loading) return <LoadingSpinner message="Chargement stats..." />;
return (
<ScrollView
style={styles.container}
contentContainerStyle={{ padding: spacing.l }}
refreshControl={
<RefreshControl
refreshing={refreshing}
onRefresh={onRefresh}
tintColor={colors.success}
/>
}
>
<Text style={styles.title}>Mes performances</Text>
<View style={styles.grid}>
{stats.map((s, i) => (
<Card key={i} style={styles.statCard}>
<Ionicons name={s.icon} size={28} color={s.color} />
<Text style={[styles.statValue, { color: s.color }]}>
{s.value}
</Text>
<Text style={styles.statLabel}>{s.label}</Text>
</Card>
))}
</View>
</ScrollView>
);
}