Files
projet_gestion_commande/mobile/src/screens/client/OrderTrackingScreen.tsx
T
2026-03-14 15:28:40 +01:00

767 lines
33 KiB
TypeScript

import React, { useState, useEffect, useCallback, useMemo } from "react";
import {
View,
Text,
FlatList,
TouchableOpacity,
StyleSheet,
TextInput as RNTextInput,
} from "react-native";
import { useNavigation, useFocusEffect } from "@react-navigation/native";
import type { NativeStackNavigationProp } from "@react-navigation/native-stack";
import { Ionicons } from "@expo/vector-icons";
import {
getMyOrders,
getOrderTracking,
getOrderETA,
confirmReception,
cancelCommand,
respondToAddressProposal,
formatOrderDate,
formatPrice,
calculateOrderTotal,
} from "../../api/api";
import type {
OrderDetail,
TrackingResponse,
ETAResponse,
CancelCommandResponse,
} from "../../api/api_types";
import type { ClientStackParamList } from "../../navigation/types";
import StatusBadge from "../../components/StatusBadge";
import LoadingSpinner from "../../components/ui/LoadingSpinner";
import Button from "../../components/ui/Button";
import Modal from "../../components/ui/Modal";
import Toast from "../../components/ui/Toast";
import { useTheme } from "../../context/ThemeContext";
import {
spacing,
borderRadius,
fontSize,
fontWeight,
shadows,
} from "../../theme";
type Nav = NativeStackNavigationProp<ClientStackParamList>;
const STATUS_PROGRESS: Record<string, number> = {
pending: 15,
assigned: 25,
en_route: 60,
arrived: 85,
livre: 95,
approved: 100,
cancelled: 0,
};
export default function OrderTrackingScreen() {
const navigation = useNavigation<Nav>();
const { colors } = useTheme();
const [orders, setOrders] = useState<OrderDetail[]>([]);
const [loading, setLoading] = useState(true);
const [expandedId, setExpandedId] = useState<number | null>(null);
const [tracking, setTracking] = useState<Record<number, TrackingResponse>>(
{},
);
const [etas, setEtas] = useState<Record<number, ETAResponse>>({});
const [confirmingId, setConfirmingId] = useState<number | null>(null);
const [confirmLoading, setConfirmLoading] = useState(false);
const [cancellingId, setCancellingId] = useState<number | null>(null);
const [cancelReason, setCancelReason] = useState("");
const [cancelLoading, setCancelLoading] = useState(false);
const [penaltyWarning, setPenaltyWarning] =
useState<CancelCommandResponse | null>(null);
const [penaltyOrderId, setPenaltyOrderId] = useState<number | null>(null);
const [toastMsg, setToastMsg] = useState("");
const [toastType, setToastType] = useState<
"success" | "error" | "warning" | "info"
>("success");
const [toastVisible, setToastVisible] = useState(false);
const showToast = (
msg: string,
type: "success" | "error" | "warning" | "info" = "success",
) => {
setToastMsg(msg);
setToastType(type);
setToastVisible(true);
};
const fetchOrders = useCallback(async () => {
try {
const res = await getMyOrders();
if (res.success) setOrders(res.commands || []);
} catch {
/* ignore */
} finally {
setLoading(false);
}
}, []);
useFocusEffect(
useCallback(() => {
setLoading(true);
fetchOrders();
}, [fetchOrders]),
);
useEffect(() => {
const interval = setInterval(fetchOrders, 10000);
return () => clearInterval(interval);
}, [fetchOrders]);
useEffect(() => {
if (expandedId === null) return;
(async () => {
const [trackRes, etaRes] = await Promise.all([
getOrderTracking(expandedId),
getOrderETA(expandedId),
]);
if (trackRes.success)
setTracking((prev) => ({ ...prev, [expandedId]: trackRes }));
if (etaRes.success)
setEtas((prev) => ({ ...prev, [expandedId]: etaRes }));
})();
}, [expandedId]);
const handleConfirm = async (orderId: number) => {
setConfirmLoading(true);
try {
const res = await confirmReception(orderId);
if (res.success) {
const cat = res.category || "";
let catLabel = "";
if (cat === "total") catLabel = " (Total)";
else if (cat.includes("zipette")) catLabel = " (Zipette&Co)";
else if (cat.includes("weed") || cat.includes("hash"))
catLabel = " (Weed&Hash)";
showToast(
`Livraison confirmee ! +${res.points_earned || 0} points${catLabel}`,
"success",
);
fetchOrders();
} else {
showToast(res.message || "Erreur", "error");
}
} catch {
showToast("Erreur de confirmation", "error");
} finally {
setConfirmLoading(false);
setConfirmingId(null);
}
};
const handleCancel = async (orderId: number, force = false) => {
setCancelLoading(true);
try {
const res = await cancelCommand(
orderId,
cancelReason || "Annulation client",
force,
);
if (res.success) {
showToast(res.message || "Commande annulee", "success");
setCancellingId(null);
setCancelReason("");
setPenaltyWarning(null);
fetchOrders();
} else if (res.warning) {
setPenaltyOrderId(orderId);
setPenaltyWarning(res);
setCancellingId(null);
} else {
showToast(res.message || "Erreur", "error");
}
} catch {
showToast("Erreur annulation", "error");
} finally {
setCancelLoading(false);
}
};
const styles = useMemo(
() =>
StyleSheet.create({
container: { flex: 1, backgroundColor: colors.bgPrimary },
emptyContainer: {
flex: 1,
justifyContent: "center",
alignItems: "center",
padding: spacing.xl,
},
emptyTitle: {
color: colors.textMuted,
fontSize: fontSize.lg,
marginTop: spacing.l,
},
list: { padding: spacing.l, paddingBottom: spacing.xxxl },
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.m,
},
orderId: {
color: colors.textWhite,
fontSize: fontSize.md,
fontWeight: fontWeight.semibold,
},
progressBarBg: {
height: 4,
backgroundColor: colors.bgInput,
borderRadius: 2,
marginBottom: spacing.m,
overflow: "hidden",
},
progressBarFill: {
height: "100%",
backgroundColor: colors.accent,
borderRadius: 2,
},
cardRow: {
flexDirection: "row",
alignItems: "center",
marginBottom: spacing.xs,
},
cardDetail: {
color: colors.textSecondary,
fontSize: fontSize.sm,
marginLeft: spacing.s,
flex: 1,
},
cardFooter: {
flexDirection: "row",
justifyContent: "space-between",
alignItems: "center",
marginTop: spacing.m,
paddingTop: spacing.m,
borderTopWidth: 1,
borderTopColor: colors.borderLight,
},
cardTotal: {
color: colors.success,
fontSize: fontSize.lg,
fontWeight: fontWeight.bold,
},
expandedSection: {
marginTop: spacing.l,
paddingTop: spacing.l,
borderTopWidth: 1,
borderTopColor: colors.borderLight,
},
trackRow: {
flexDirection: "row",
alignItems: "center",
gap: spacing.s,
marginBottom: spacing.s,
},
trackText: {
color: colors.textSecondary,
fontSize: fontSize.sm,
},
expandedActions: {
flexDirection: "row",
flexWrap: "wrap",
gap: spacing.s,
marginTop: spacing.l,
},
proposalBox: {
backgroundColor: colors.warning + "18",
borderWidth: 1,
borderColor: colors.warning,
borderRadius: borderRadius.md,
padding: spacing.m,
marginTop: spacing.m,
},
proposalTitle: {
color: colors.warning,
fontSize: fontSize.sm,
fontWeight: fontWeight.semibold,
marginBottom: spacing.xs,
},
proposalAddress: {
color: colors.textWhite,
fontSize: fontSize.md,
marginBottom: spacing.m,
},
proposalActions: {
flexDirection: "row",
gap: spacing.s,
},
modalBody: { gap: spacing.m },
modalText: {
color: colors.textPrimary,
fontSize: fontSize.md,
lineHeight: 22,
},
modalActions: {
flexDirection: "row",
justifyContent: "flex-end",
gap: spacing.m,
marginTop: spacing.m,
},
cancelInput: {
backgroundColor: colors.bgInput,
color: colors.textPrimary,
borderRadius: 12,
padding: spacing.m,
fontSize: fontSize.md,
borderWidth: 1,
borderColor: colors.borderLight,
minHeight: 80,
textAlignVertical: "top",
},
penaltyContent: { gap: spacing.l },
penaltyIconContainer: { alignItems: "center" },
penaltyIconCircle: {
width: 60,
height: 60,
borderRadius: 30,
backgroundColor: "rgba(245,158,11,0.12)",
justifyContent: "center",
alignItems: "center",
},
penaltyText: {
color: colors.textPrimary,
fontSize: fontSize.md,
textAlign: "center",
lineHeight: 22,
},
penaltyBadge: {
flexDirection: "row",
alignItems: "center",
justifyContent: "center",
gap: spacing.s,
backgroundColor: "rgba(245,158,11,0.1)",
paddingVertical: spacing.s,
paddingHorizontal: spacing.m,
borderRadius: 10,
alignSelf: "center",
},
penaltyDetail: {
color: colors.warning,
fontSize: fontSize.sm,
fontWeight: fontWeight.semibold,
},
}),
[colors],
);
if (loading)
return <LoadingSpinner message="Chargement des commandes..." />;
return (
<View style={styles.container}>
<Toast
message={toastMsg}
type={toastType}
visible={toastVisible}
onHide={() => setToastVisible(false)}
/>
{orders.length === 0 ? (
<View style={styles.emptyContainer}>
<Ionicons
name="receipt-outline"
size={80}
color={colors.textMuted}
/>
<Text style={styles.emptyTitle}>
Aucune commande active
</Text>
</View>
) : (
<FlatList
data={orders}
keyExtractor={(item) => String(item.id)}
contentContainerStyle={styles.list}
renderItem={({ item: order }) => {
const expanded = expandedId === order.id;
const total = calculateOrderTotal(order);
const progress = STATUS_PROGRESS[order.status] || 0;
const track = tracking[order.id];
const eta = etas[order.id];
const canConfirm = order.status === "livre";
const canCancel = [
"pending",
"assigned",
"en_route",
].includes(order.status);
return (
<TouchableOpacity
activeOpacity={0.8}
onPress={() =>
setExpandedId(expanded ? null : order.id)
}
style={[styles.card, shadows.sm]}
>
<View style={styles.cardHeader}>
<Text style={styles.orderId}>
Commande #{order.id}
</Text>
<StatusBadge status={order.status} />
</View>
<View style={styles.progressBarBg}>
<View
style={[
styles.progressBarFill,
{ width: `${progress}%` },
]}
/>
</View>
<View style={styles.cardRow}>
<Ionicons
name="location-outline"
size={14}
color={colors.textMuted}
/>
<Text
style={styles.cardDetail}
numberOfLines={1}
>
{order.delivery_address ||
order.adresse ||
"N/A"}
</Text>
</View>
<View style={styles.cardRow}>
<Ionicons
name="time-outline"
size={14}
color={colors.textMuted}
/>
<Text style={styles.cardDetail}>
{formatOrderDate(order.created_at)}
</Text>
</View>
<View style={styles.cardFooter}>
<Text style={styles.cardTotal}>
{formatPrice(total)}
</Text>
<Ionicons
name={
expanded
? "chevron-up"
: "chevron-down"
}
size={18}
color={colors.textMuted}
/>
</View>
{order.address_proposal_status === "pending" &&
order.proposed_address && (
<View style={styles.proposalBox}>
<Text style={styles.proposalTitle}>
📍 Nouvelle adresse proposée
</Text>
<Text
style={styles.proposalAddress}
>
{order.proposed_address}
</Text>
<View
style={styles.proposalActions}
>
<Button
title="Accepter"
variant="success"
size="sm"
onPress={async () => {
const res =
await respondToAddressProposal(
order.id,
true,
);
if (res.success) {
showToast(
"Adresse acceptée",
"success",
);
fetchOrders();
} else {
showToast(
res.message,
"error",
);
}
}}
/>
<Button
title="Refuser"
variant="danger"
size="sm"
onPress={async () => {
const res =
await respondToAddressProposal(
order.id,
false,
);
if (res.success) {
showToast(
"Adresse refusée",
"info",
);
fetchOrders();
} else {
showToast(
res.message,
"error",
);
}
}}
/>
</View>
</View>
)}
{expanded && (
<View style={styles.expandedSection}>
{track?.livreur_username && (
<View style={styles.trackRow}>
<Ionicons
name="bicycle-outline"
size={16}
color={colors.info}
/>
<Text style={styles.trackText}>
Livreur:{" "}
{track.livreur_username}
</Text>
</View>
)}
{order.status === "en_route" &&
eta?.eta_minutes != null &&
eta.eta_minutes > 0 && (
<View style={styles.trackRow}>
<Ionicons
name="timer-outline"
size={16}
color={colors.warning}
/>
<Text style={styles.trackText}>
Temps de livraison estimé : ~{eta.eta_minutes} min
</Text>
</View>
)}
{order.status === "arrived" && (
<View style={styles.trackRow}>
<Ionicons
name="timer-outline"
size={16}
color={colors.warning}
/>
<Text style={styles.trackText}>
Temps de livraison estimé : ~
{eta?.eta_minutes != null &&
eta.eta_minutes > 0 &&
eta.eta_minutes < 5
? eta.eta_minutes
: 5}{" "}
min
</Text>
</View>
)}
{track?.current_step && (
<View style={styles.trackRow}>
<Ionicons
name="footsteps-outline"
size={16}
color={colors.accent}
/>
<Text style={styles.trackText}>
{track.current_step}
</Text>
</View>
)}
<View style={styles.expandedActions}>
<Button
title="Details"
onPress={() =>
navigation.navigate(
"OrderDetails",
{ orderId: order.id },
)
}
variant="outline"
size="sm"
/>
{canConfirm && (
<Button
title="Confirmer reception"
onPress={() =>
setConfirmingId(
order.id,
)
}
variant="success"
size="sm"
/>
)}
{canCancel && (
<Button
title="Annuler"
onPress={() =>
setCancellingId(
order.id,
)
}
variant="danger"
size="sm"
/>
)}
</View>
</View>
)}
</TouchableOpacity>
);
}}
/>
)}
<Modal
visible={confirmingId !== null}
onClose={() => setConfirmingId(null)}
title="Confirmer la reception"
icon="checkmark-circle-outline"
iconColor={colors.success}
>
<View style={styles.modalBody}>
<Text style={styles.modalText}>
Confirmez-vous avoir recu votre commande #{confirmingId}{" "}
?
</Text>
<View style={styles.modalActions}>
<Button
title="Annuler"
onPress={() => setConfirmingId(null)}
variant="outline"
size="md"
/>
<Button
title="Confirmer"
onPress={() =>
confirmingId && handleConfirm(confirmingId)
}
loading={confirmLoading}
variant="success"
size="md"
/>
</View>
</View>
</Modal>
<Modal
visible={cancellingId !== null}
onClose={() => {
setCancellingId(null);
setCancelReason("");
}}
title="Annuler la commande"
icon="close-circle-outline"
iconColor={colors.danger}
>
<View style={styles.modalBody}>
<Text style={styles.modalText}>
Raison de l'annulation :
</Text>
<RNTextInput
style={styles.cancelInput}
placeholder="Raison (optionnel)"
placeholderTextColor={colors.textMuted}
value={cancelReason}
onChangeText={setCancelReason}
multiline
/>
<View style={styles.modalActions}>
<Button
title="Retour"
onPress={() => {
setCancellingId(null);
setCancelReason("");
}}
variant="outline"
size="md"
/>
<Button
title="Annuler la commande"
onPress={() =>
cancellingId && handleCancel(cancellingId)
}
loading={cancelLoading}
variant="danger"
size="md"
/>
</View>
</View>
</Modal>
<Modal
visible={penaltyWarning !== null}
onClose={() => {
setPenaltyWarning(null);
setPenaltyOrderId(null);
}}
title="Attention - Penalite"
icon="warning-outline"
iconColor={colors.warning}
>
<View style={styles.penaltyContent}>
<View style={styles.penaltyIconContainer}>
<View style={styles.penaltyIconCircle}>
<Ionicons
name="warning"
size={32}
color={colors.warning}
/>
</View>
</View>
<Text style={styles.penaltyText}>
{penaltyWarning?.message}
</Text>
{penaltyWarning?.penalty_warning && (
<View style={styles.penaltyBadge}>
<Ionicons
name="remove-circle-outline"
size={16}
color={colors.warning}
/>
<Text style={styles.penaltyDetail}>
Penalite:{" "}
{penaltyWarning.penalty_warning.penalty_amount}{" "}
points
</Text>
</View>
)}
<View style={styles.modalActions}>
<Button
title="Retour"
onPress={() => setPenaltyWarning(null)}
variant="outline"
size="md"
/>
<Button
title="Confirmer l'annulation"
onPress={() => {
if (penaltyOrderId)
handleCancel(penaltyOrderId, true);
setPenaltyWarning(null);
setPenaltyOrderId(null);
}}
variant="danger"
size="md"
/>
</View>
</View>
</Modal>
</View>
);
}