1046 lines
42 KiB
TypeScript
1046 lines
42 KiB
TypeScript
import React, { useState, useEffect, useRef, useMemo } from "react";
|
|
import {
|
|
View,
|
|
Text,
|
|
StyleSheet,
|
|
ScrollView,
|
|
TouchableOpacity,
|
|
Modal,
|
|
Alert,
|
|
useWindowDimensions,
|
|
} from "react-native";
|
|
import {
|
|
useRoute,
|
|
useNavigation,
|
|
type RouteProp,
|
|
} from "@react-navigation/native";
|
|
import { Ionicons } from "@expo/vector-icons";
|
|
import TomTomMap, { type TomTomMapRef, type TomTomMarker } from "../../components/TomTomMap";
|
|
import { spacing, fontSize, borderRadius } from "../../theme";
|
|
import { useTheme } from "../../context/ThemeContext";
|
|
import {
|
|
getCommandByID,
|
|
getCommandItems,
|
|
updateCommandStatus,
|
|
validateCommand,
|
|
notifyClientToDescend,
|
|
getDeliveryPersonDetails,
|
|
deleteCommandItem,
|
|
deleteCommand,
|
|
} from "../../api/api_admin";
|
|
import { calculateRoute, geocodeAddress } 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">;
|
|
|
|
export default function OrderDetailScreen() {
|
|
const { colors } = useTheme();
|
|
const { height: screenHeight } = useWindowDimensions();
|
|
const MAP_HEIGHT = screenHeight < 700 ? 180 : 240;
|
|
const route = useRoute<Route>();
|
|
const navigation = useNavigation();
|
|
const { orderId } = route.params;
|
|
const [command, setCommand] = useState<any>(null);
|
|
const [items, setItems] = useState<any[]>([]);
|
|
const [loading, setLoading] = useState(true);
|
|
const [showItemsModal, setShowItemsModal] = useState(false);
|
|
|
|
// Map / tracking
|
|
const mapRef = useRef<TomTomMapRef | null>(null);
|
|
const fullscreenMapRef = useRef<TomTomMapRef | null>(null);
|
|
const [mapFullscreen, setMapFullscreen] = useState(false);
|
|
const [livreurCoords, setLivreurCoords] = useState<LatLng | null>(null);
|
|
const [destCoords, setDestCoords] = useState<LatLng | null>(null);
|
|
const [livreurMarkers, setLivreurMarkers] = useState<TomTomMarker[]>([]);
|
|
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]);
|
|
|
|
useEffect(() => {
|
|
const interval = setInterval(async () => {
|
|
try {
|
|
const res = await getCommandByID(orderId);
|
|
setCommand(res.command);
|
|
} catch {
|
|
/* ignore */
|
|
}
|
|
}, 20000);
|
|
return () => clearInterval(interval);
|
|
}, [orderId]);
|
|
|
|
// Rejoue la route sur la carte fullscreen quand elle s'ouvre
|
|
useEffect(() => {
|
|
if (mapFullscreen && livreurCoords && destCoords) {
|
|
setTimeout(() => {
|
|
fullscreenMapRef.current?.calcRoute(livreurCoords, destCoords);
|
|
}, 600);
|
|
}
|
|
}, [mapFullscreen]);
|
|
|
|
const loadLivreurRoute = async (
|
|
livreurUsername: string,
|
|
deliveryAddress: string,
|
|
) => {
|
|
setMapLoading(true);
|
|
try {
|
|
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);
|
|
setLivreurMarkers([{
|
|
id: livreurUsername,
|
|
latitude: loc.latitude,
|
|
longitude: loc.longitude,
|
|
color: "#22c55e",
|
|
label: livreurUsername,
|
|
description: "Livreur",
|
|
}]);
|
|
|
|
// Dessine la route sur la carte et récupère les infos (distance/durée)
|
|
const dest = await geocodeAddress(deliveryAddress);
|
|
if (dest) {
|
|
setDestCoords(dest);
|
|
const result = await calculateRoute(origin, dest);
|
|
if (result) {
|
|
setRouteInfo(result.route);
|
|
mapRef.current?.calcRoute(origin, dest);
|
|
}
|
|
}
|
|
} catch {
|
|
/* silent */
|
|
}
|
|
setMapLoading(false);
|
|
};
|
|
|
|
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 handleNotifyClient = async () => {
|
|
try {
|
|
await notifyClientToDescend(orderId);
|
|
showSuccess(
|
|
"Notification envoyée",
|
|
"Le client a été prévenu de descendre",
|
|
);
|
|
} catch (e: any) {
|
|
showError("Erreur", e.message);
|
|
}
|
|
};
|
|
|
|
const handleDeleteItem = (itemId: number, itemName: string) => {
|
|
Alert.alert(
|
|
"Supprimer l'article",
|
|
`Supprimer "${itemName}" de la commande ?`,
|
|
[
|
|
{ text: "Annuler", style: "cancel" },
|
|
{
|
|
text: "Supprimer",
|
|
style: "destructive",
|
|
onPress: async () => {
|
|
try {
|
|
await deleteCommandItem(orderId, itemId);
|
|
const itemsRes = await getCommandItems(orderId);
|
|
setItems(itemsRes.items ?? []);
|
|
const cmdRes = await getCommandByID(orderId);
|
|
setCommand(cmdRes.command);
|
|
} catch (e: any) {
|
|
showError("Erreur", e.message);
|
|
}
|
|
},
|
|
},
|
|
],
|
|
);
|
|
};
|
|
|
|
const handleDeleteCommand = () => {
|
|
Alert.alert(
|
|
"Supprimer la commande",
|
|
`Supprimer définitivement la commande #${orderId} ?`,
|
|
[
|
|
{ text: "Annuler", style: "cancel" },
|
|
{
|
|
text: "Supprimer",
|
|
style: "destructive",
|
|
onPress: async () => {
|
|
try {
|
|
await deleteCommand(orderId);
|
|
navigation.goBack();
|
|
} 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",
|
|
alignItems: "center",
|
|
},
|
|
itemDeleteBtn: {
|
|
padding: spacing.xs,
|
|
marginLeft: spacing.s,
|
|
},
|
|
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 },
|
|
|
|
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,
|
|
},
|
|
|
|
// Grouped items
|
|
subItemRow: {
|
|
flexDirection: "row",
|
|
justifyContent: "space-between",
|
|
alignItems: "center",
|
|
paddingVertical: spacing.xs,
|
|
paddingLeft: spacing.m,
|
|
borderLeftWidth: 2,
|
|
borderLeftColor: colors.border,
|
|
marginLeft: spacing.xs,
|
|
marginBottom: 2,
|
|
},
|
|
subItemText: {
|
|
color: colors.textSecondary,
|
|
fontSize: fontSize.sm,
|
|
flex: 1,
|
|
},
|
|
groupTotalRow: {
|
|
flexDirection: "row",
|
|
justifyContent: "space-between",
|
|
alignItems: "center",
|
|
marginTop: spacing.s,
|
|
paddingTop: spacing.s,
|
|
borderTopWidth: 1,
|
|
borderTopColor: colors.border,
|
|
},
|
|
groupTotalQty: {
|
|
color: colors.textWhite,
|
|
fontSize: fontSize.sm,
|
|
fontWeight: "700",
|
|
},
|
|
groupTotalPrice: {
|
|
color: colors.accent,
|
|
fontSize: fontSize.md,
|
|
fontWeight: "700",
|
|
},
|
|
categoryBadge: {
|
|
fontSize: fontSize.xs,
|
|
color: colors.accent,
|
|
fontWeight: "600",
|
|
marginRight: spacing.s,
|
|
textTransform: "uppercase",
|
|
},
|
|
// Category summary
|
|
categorySummaryCard: {
|
|
marginTop: spacing.s,
|
|
},
|
|
categoryRow: {
|
|
flexDirection: "row",
|
|
justifyContent: "space-between",
|
|
alignItems: "center",
|
|
paddingVertical: spacing.s,
|
|
borderBottomWidth: 1,
|
|
borderBottomColor: colors.border,
|
|
},
|
|
categoryName: {
|
|
color: colors.textSecondary,
|
|
fontSize: fontSize.sm,
|
|
fontWeight: "600",
|
|
flex: 1,
|
|
},
|
|
categoryQty: {
|
|
color: colors.textWhite,
|
|
fontSize: fontSize.sm,
|
|
fontWeight: "700",
|
|
marginRight: spacing.l,
|
|
},
|
|
categoryTotal: {
|
|
color: colors.accent,
|
|
fontSize: fontSize.sm,
|
|
fontWeight: "700",
|
|
minWidth: 70,
|
|
textAlign: "right",
|
|
},
|
|
|
|
// Items modal
|
|
itemsModalOverlay: {
|
|
flex: 1,
|
|
backgroundColor: "rgba(0,0,0,0.6)",
|
|
justifyContent: "flex-end",
|
|
},
|
|
itemsModalContainer: {
|
|
backgroundColor: colors.bgPrimary,
|
|
borderTopLeftRadius: borderRadius.lg,
|
|
borderTopRightRadius: borderRadius.lg,
|
|
maxHeight: "75%",
|
|
padding: spacing.l,
|
|
},
|
|
itemsModalHeader: {
|
|
flexDirection: "row",
|
|
justifyContent: "space-between",
|
|
alignItems: "center",
|
|
marginBottom: spacing.m,
|
|
},
|
|
itemsModalTitle: {
|
|
fontSize: fontSize.lg,
|
|
fontWeight: "700",
|
|
color: colors.textWhite,
|
|
},
|
|
itemsModalCard: {
|
|
backgroundColor: colors.bgCard,
|
|
borderRadius: borderRadius.sm,
|
|
padding: spacing.m,
|
|
marginBottom: spacing.s,
|
|
},
|
|
|
|
// 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, MAP_HEIGHT],
|
|
);
|
|
|
|
// Groupement des items par product_id
|
|
const productGroups = (items ?? []).reduce<Record<string, any[]>>(
|
|
(acc, item) => {
|
|
const key = String(item.product_id || item.produit);
|
|
if (!acc[key]) acc[key] = [];
|
|
acc[key].push(item);
|
|
return acc;
|
|
},
|
|
{},
|
|
);
|
|
const groupedList = Object.values(productGroups);
|
|
|
|
// Récapitulatif par catégorie
|
|
const categoryTotals = (items ?? []).reduce<
|
|
Record<string, { qty: number; total: number }>
|
|
>((acc, item) => {
|
|
const cat = item.category || "Autre";
|
|
if (!acc[cat]) acc[cat] = { qty: 0, total: 0 };
|
|
acc[cat].qty += item.quantite ?? 0;
|
|
acc[cat].total += item.prix ?? 0;
|
|
return acc;
|
|
}, {});
|
|
const categoryEntries = Object.entries(categoryTotals);
|
|
|
|
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)}
|
|
>
|
|
<View style={styles.fullscreenContainer}>
|
|
<TomTomMap
|
|
ref={fullscreenMapRef}
|
|
style={styles.fullscreenMap}
|
|
markers={livreurMarkers}
|
|
initialCenter={livreurCoords ?? undefined}
|
|
initialZoom={14}
|
|
/>
|
|
<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 brut: {command.total_prix?.toFixed(2)} €
|
|
</Text>
|
|
{command.referral_used > 0 && (
|
|
<>
|
|
<Text style={[styles.info, { color: colors.success }]}>
|
|
Parrainage utilisé: -
|
|
{command.referral_used?.toFixed(2)} €
|
|
</Text>
|
|
<Text
|
|
style={[
|
|
styles.info,
|
|
{
|
|
fontWeight: "700",
|
|
color: colors.textPrimary,
|
|
},
|
|
]}
|
|
>
|
|
Net:{" "}
|
|
{(
|
|
command.total_prix - command.referral_used
|
|
).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>
|
|
{command.status === "cancelled" && command.cancel_reason ? (
|
|
<View
|
|
style={{
|
|
marginTop: spacing.m,
|
|
padding: spacing.m,
|
|
backgroundColor: colors.danger + "18",
|
|
borderRadius: borderRadius.sm,
|
|
borderLeftWidth: 3,
|
|
borderLeftColor: colors.danger,
|
|
}}
|
|
>
|
|
<Text
|
|
style={{
|
|
color: colors.danger,
|
|
fontSize: fontSize.xs,
|
|
fontWeight: "700",
|
|
marginBottom: 4,
|
|
textTransform: "uppercase",
|
|
letterSpacing: 0.5,
|
|
}}
|
|
>
|
|
Motif d'annulation
|
|
</Text>
|
|
<Text
|
|
style={{
|
|
color: colors.textSecondary,
|
|
fontSize: fontSize.sm,
|
|
}}
|
|
>
|
|
{command.cancel_reason}
|
|
</Text>
|
|
</View>
|
|
) : command.status === "cancelled" ? (
|
|
<View
|
|
style={{
|
|
marginTop: spacing.m,
|
|
padding: spacing.m,
|
|
backgroundColor: colors.bgSecondary,
|
|
borderRadius: borderRadius.sm,
|
|
}}
|
|
>
|
|
<Text
|
|
style={{
|
|
color: colors.textMuted,
|
|
fontSize: fontSize.sm,
|
|
fontStyle: "italic",
|
|
}}
|
|
>
|
|
Aucun motif fourni
|
|
</Text>
|
|
</View>
|
|
) : null}
|
|
</Card>
|
|
|
|
{/* Livreur tracking map */}
|
|
{command.livreur_assign && (
|
|
<>
|
|
<Text style={styles.sectionTitle}>Suivi du livreur</Text>
|
|
{hasMap ? (
|
|
<View style={styles.mapContainer}>
|
|
<TomTomMap
|
|
ref={mapRef}
|
|
style={styles.map}
|
|
markers={livreurMarkers}
|
|
initialCenter={livreurCoords ?? undefined}
|
|
initialZoom={14}
|
|
/>
|
|
|
|
{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 groupés par produit */}
|
|
<Text style={styles.sectionTitle}>Articles ({items.length})</Text>
|
|
{groupedList.map((group, gi) => {
|
|
const rep = group[0];
|
|
const name = rep.produit ?? rep.product_name;
|
|
const unit = rep.unit || "";
|
|
const totalQty = group.reduce(
|
|
(s: number, it: any) => s + (it.quantite ?? 0),
|
|
0,
|
|
);
|
|
const totalPrice = group.reduce(
|
|
(s: number, it: any) => s + (it.prix ?? 0),
|
|
0,
|
|
);
|
|
const isMultiple = group.length > 1;
|
|
return (
|
|
<Card
|
|
key={`${rep.product_id}-${gi}`}
|
|
style={{ marginBottom: spacing.s }}
|
|
>
|
|
<View style={styles.row}>
|
|
<Text style={[styles.itemName, { flex: 1 }]}>
|
|
{name}
|
|
</Text>
|
|
{rep.category ? (
|
|
<Text style={styles.categoryBadge}>
|
|
{rep.category}
|
|
</Text>
|
|
) : null}
|
|
<TouchableOpacity
|
|
style={styles.itemDeleteBtn}
|
|
onPress={() => handleDeleteItem(rep.id, name)}
|
|
>
|
|
<Ionicons
|
|
name="trash-outline"
|
|
size={18}
|
|
color={colors.danger}
|
|
/>
|
|
</TouchableOpacity>
|
|
</View>
|
|
{isMultiple &&
|
|
group.map((item: any, i: number) => (
|
|
<View key={item.id} style={styles.subItemRow}>
|
|
<Text style={styles.subItemText}>
|
|
{item.quantite}
|
|
{unit} — {item.prix?.toFixed(2)} €
|
|
</Text>
|
|
<TouchableOpacity
|
|
onPress={() =>
|
|
handleDeleteItem(item.id, name)
|
|
}
|
|
>
|
|
<Ionicons
|
|
name="remove-circle-outline"
|
|
size={16}
|
|
color={colors.danger}
|
|
/>
|
|
</TouchableOpacity>
|
|
</View>
|
|
))}
|
|
<View style={styles.groupTotalRow}>
|
|
<Text style={styles.groupTotalQty}>
|
|
{isMultiple
|
|
? `Total: ${totalQty}${unit}`
|
|
: `${totalQty}${unit}`}
|
|
</Text>
|
|
<Text style={styles.groupTotalPrice}>
|
|
{totalPrice.toFixed(2)} €
|
|
</Text>
|
|
</View>
|
|
</Card>
|
|
);
|
|
})}
|
|
|
|
{/* Récapitulatif par catégorie */}
|
|
{categoryEntries.length > 0 && (
|
|
<>
|
|
<Text style={styles.sectionTitle}>Par catégorie</Text>
|
|
<Card style={styles.categorySummaryCard}>
|
|
{categoryEntries.map(([cat, data]) => (
|
|
<View key={cat} style={styles.categoryRow}>
|
|
<Text style={styles.categoryName}>{cat}</Text>
|
|
<Text style={styles.categoryQty}>
|
|
{data.qty.toFixed(
|
|
data.qty % 1 === 0 ? 0 : 2,
|
|
)}
|
|
</Text>
|
|
<Text style={styles.categoryTotal}>
|
|
{data.total.toFixed(2)} €
|
|
</Text>
|
|
</View>
|
|
))}
|
|
</Card>
|
|
</>
|
|
)}
|
|
|
|
{/* Actions */}
|
|
<Text style={styles.sectionTitle}>Actions</Text>
|
|
<View style={styles.actions}>
|
|
<Button
|
|
title="Voir les items"
|
|
onPress={() => setShowItemsModal(true)}
|
|
variant="secondary"
|
|
fullWidth
|
|
/>
|
|
{command.status !== "approved" &&
|
|
command.status !== "cancelled" && (
|
|
<Button
|
|
title="Valider commande"
|
|
onPress={handleValidate}
|
|
variant="success"
|
|
fullWidth
|
|
style={{ marginTop: spacing.s }}
|
|
/>
|
|
)}
|
|
{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 }}
|
|
/>
|
|
)}
|
|
{!["approved", "cancelled"].includes(command.status) && (
|
|
<Button
|
|
title="Le livreur est là"
|
|
onPress={handleNotifyClient}
|
|
variant="warning"
|
|
fullWidth
|
|
style={{ marginTop: spacing.s }}
|
|
/>
|
|
)}
|
|
|
|
<Button
|
|
title="Supprimer la commande"
|
|
onPress={handleDeleteCommand}
|
|
variant="danger"
|
|
fullWidth
|
|
style={{ marginTop: spacing.s }}
|
|
/>
|
|
</View>
|
|
{/* Items modal */}
|
|
<Modal
|
|
visible={showItemsModal}
|
|
animationType="slide"
|
|
onRequestClose={() => setShowItemsModal(false)}
|
|
transparent
|
|
>
|
|
<View style={styles.itemsModalOverlay}>
|
|
<View style={styles.itemsModalContainer}>
|
|
<View style={styles.itemsModalHeader}>
|
|
<Text style={styles.itemsModalTitle}>
|
|
Articles ({items.length})
|
|
</Text>
|
|
<TouchableOpacity
|
|
style={styles.closeBtn}
|
|
onPress={() => setShowItemsModal(false)}
|
|
>
|
|
<Ionicons
|
|
name="close"
|
|
size={22}
|
|
color={colors.textWhite}
|
|
/>
|
|
</TouchableOpacity>
|
|
</View>
|
|
<ScrollView showsVerticalScrollIndicator={false}>
|
|
{groupedList.map((group, gi) => {
|
|
const rep = group[0];
|
|
const name = rep.produit ?? rep.product_name;
|
|
const unit = rep.unit || "";
|
|
const totalQty = group.reduce(
|
|
(s: number, it: any) =>
|
|
s + (it.quantite ?? 0),
|
|
0,
|
|
);
|
|
const totalPrice = group.reduce(
|
|
(s: number, it: any) => s + (it.prix ?? 0),
|
|
0,
|
|
);
|
|
const isMultiple = group.length > 1;
|
|
return (
|
|
<View
|
|
key={`modal-${rep.product_id}-${gi}`}
|
|
style={styles.itemsModalCard}
|
|
>
|
|
<View style={styles.row}>
|
|
<Text
|
|
style={[
|
|
styles.itemName,
|
|
{ flex: 1 },
|
|
]}
|
|
>
|
|
{name}
|
|
</Text>
|
|
{rep.category ? (
|
|
<Text
|
|
style={styles.categoryBadge}
|
|
>
|
|
{rep.category}
|
|
</Text>
|
|
) : null}
|
|
<TouchableOpacity
|
|
style={styles.itemDeleteBtn}
|
|
onPress={() => {
|
|
setShowItemsModal(false);
|
|
handleDeleteItem(
|
|
rep.id,
|
|
name,
|
|
);
|
|
}}
|
|
>
|
|
<Ionicons
|
|
name="trash-outline"
|
|
size={18}
|
|
color={colors.danger}
|
|
/>
|
|
</TouchableOpacity>
|
|
</View>
|
|
{isMultiple &&
|
|
group.map((item: any) => (
|
|
<View
|
|
key={item.id}
|
|
style={styles.subItemRow}
|
|
>
|
|
<Text
|
|
style={
|
|
styles.subItemText
|
|
}
|
|
>
|
|
{item.quantite}
|
|
{unit} —{" "}
|
|
{item.prix?.toFixed(2)}{" "}
|
|
€
|
|
</Text>
|
|
</View>
|
|
))}
|
|
<View style={styles.groupTotalRow}>
|
|
<Text style={styles.groupTotalQty}>
|
|
{isMultiple
|
|
? `Total: ${totalQty}${unit}`
|
|
: `${totalQty}${unit}`}
|
|
</Text>
|
|
<Text
|
|
style={styles.groupTotalPrice}
|
|
>
|
|
{totalPrice.toFixed(2)} €
|
|
</Text>
|
|
</View>
|
|
</View>
|
|
);
|
|
})}
|
|
{/* Récapitulatif catégories dans le modal */}
|
|
{categoryEntries.length > 0 && (
|
|
<View
|
|
style={{
|
|
marginTop: spacing.m,
|
|
borderTopWidth: 1,
|
|
borderTopColor: colors.border,
|
|
paddingTop: spacing.m,
|
|
}}
|
|
>
|
|
<Text
|
|
style={[
|
|
styles.itemsModalTitle,
|
|
{
|
|
fontSize: fontSize.md,
|
|
marginBottom: spacing.s,
|
|
},
|
|
]}
|
|
>
|
|
Par catégorie
|
|
</Text>
|
|
{categoryEntries.map(([cat, data]) => (
|
|
<View
|
|
key={cat}
|
|
style={styles.categoryRow}
|
|
>
|
|
<Text style={styles.categoryName}>
|
|
{cat}
|
|
</Text>
|
|
<Text style={styles.categoryQty}>
|
|
{data.qty.toFixed(
|
|
data.qty % 1 === 0 ? 0 : 2,
|
|
)}
|
|
</Text>
|
|
<Text style={styles.categoryTotal}>
|
|
{data.total.toFixed(2)} €
|
|
</Text>
|
|
</View>
|
|
))}
|
|
</View>
|
|
)}
|
|
{items.length === 0 && (
|
|
<Text style={styles.empty}>Aucun article</Text>
|
|
)}
|
|
</ScrollView>
|
|
</View>
|
|
</View>
|
|
</Modal>
|
|
|
|
<AlertModal
|
|
visible={alert.visible}
|
|
type={alert.type}
|
|
title={alert.title}
|
|
message={alert.message}
|
|
onClose={hideAlert}
|
|
/>
|
|
</ScrollView>
|
|
);
|
|
}
|