Files
projet_gestion_commande/frontend-admin/src/screens/admin/OrdersScreen.tsx
T
2026-03-14 00:23:02 +01:00

844 lines
31 KiB
TypeScript

import React, { useState, useEffect, useCallback, useMemo } from "react";
import {
View,
Text,
StyleSheet,
FlatList,
TouchableOpacity,
RefreshControl,
ScrollView,
TextInput,
} 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,
getCommandItems,
notifyClientToDescend,
confirmReceptionAdmin,
deleteCommand,
deleteCommandItem,
proposeAddressChangeAdmin,
} 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 Card from "../../components/ui/Card";
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 { alert, showError, showSuccess, showConfirm, hideAlert } =
useAlert();
const [openMenuId, setOpenMenuId] = useState<number | null>(null);
const [assignModal, setAssignModal] = useState<{
visible: boolean;
commandId: number | null;
}>({ visible: false, commandId: null });
const [livreurs, setLivreurs] = useState<any[]>([]);
const [addressModal, setAddressModal] = useState<{
visible: boolean;
commandId: number | null;
input: string;
}>({ visible: false, commandId: null, input: "" });
const [itemsModal, setItemsModal] = useState<{
visible: boolean;
commandId: number | null;
items: any[];
commandInfo: any;
clientInfo: any;
}>({
visible: false,
commandId: null,
items: [],
commandInfo: null,
clientInfo: null,
});
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 openItems = async (commandId: number) => {
try {
const result = await getCommandItems(commandId);
setItemsModal({
visible: true,
commandId,
items: result.items,
commandInfo: result.command_info,
clientInfo: result.client_info,
});
} catch (e: any) {
showError("Erreur", e.message);
}
};
const closeItemsModal = () =>
setItemsModal({
visible: false,
commandId: null,
items: [],
commandInfo: null,
clientInfo: null,
});
const handleNotifyClient = async (commandId: number) => {
try {
await notifyClientToDescend(commandId);
showSuccess(
"Notification envoyée",
"Le client a été prévenu de descendre",
);
} catch (e: any) {
showError("Erreur", e.message);
}
};
const handleConfirmReception = (commandId: number) => {
showConfirm(
"Confirmer la commande",
`Confirmer la réception de la commande #${commandId} ?`,
async () => {
try {
const res = await confirmReceptionAdmin(commandId);
showSuccess(
"Réception confirmée",
`${res.points_earned} point(s) attribués au client ${res.client_username}`,
);
await loadData();
} catch (e: any) {
showError("Erreur", e.message);
}
},
"Confirmer",
);
};
const handleProposeAddress = async () => {
if (!addressModal.commandId || !addressModal.input.trim()) return;
try {
await proposeAddressChangeAdmin(
addressModal.commandId,
addressModal.input.trim(),
);
setAddressModal({ visible: false, commandId: null, input: "" });
showSuccess(
"Proposition envoyée",
"Le client a été notifié de la nouvelle adresse proposée",
);
} catch (e: any) {
showError("Erreur", e.message);
}
};
const handleDeleteItem = (
commandId: number,
itemId: number,
itemName: string,
) => {
showConfirm(
"Supprimer l'article",
`Supprimer "${itemName}" de la commande #${commandId} ?`,
async () => {
try {
await deleteCommandItem(commandId, itemId);
const result = await getCommandItems(commandId);
setItemsModal((prev) => ({ ...prev, items: result.items }));
} catch (e: any) {
showError("Erreur", e.message);
}
},
"Supprimer",
);
};
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 },
filterList: {
maxHeight: 50,
paddingHorizontal: spacing.l,
paddingVertical: spacing.s,
},
refreshRow: {
paddingHorizontal: spacing.l,
paddingBottom: spacing.s,
alignItems: "flex-start",
},
refreshBtn: {
flexDirection: "row",
alignItems: "center",
gap: spacing.xs,
paddingHorizontal: spacing.m,
paddingVertical: spacing.s,
backgroundColor: colors.bgCard,
borderRadius: borderRadius.sm,
borderWidth: 1,
borderColor: colors.border,
},
refreshBtnText: {
color: colors.textSecondary,
fontSize: fontSize.sm,
},
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 },
cardText: {
color: colors.textSecondary,
fontSize: fontSize.sm,
marginTop: 2,
},
cardDate: {
color: colors.textMuted,
fontSize: fontSize.xs,
marginTop: spacing.s,
},
orderId: {
fontSize: fontSize.lg,
fontWeight: "bold",
color: colors.textWhite,
},
row: {
flexDirection: "row",
justifyContent: "space-between",
alignItems: "center",
marginBottom: spacing.s,
},
selectBtn: {
flexDirection: "row",
alignItems: "center",
justifyContent: "space-between",
marginTop: spacing.m,
paddingHorizontal: spacing.m,
paddingVertical: spacing.s,
backgroundColor: colors.bgPrimary,
borderRadius: borderRadius.sm,
borderWidth: 1,
borderColor: colors.border,
},
selectBtnText: {
color: colors.textSecondary,
fontSize: fontSize.sm,
},
dropdown: {
marginTop: 2,
backgroundColor: colors.bgCard,
borderRadius: borderRadius.sm,
borderWidth: 1,
borderColor: colors.border,
overflow: "hidden",
},
dropdownItem: {
flexDirection: "row",
alignItems: "center",
gap: spacing.s,
paddingHorizontal: spacing.m,
paddingVertical: spacing.m,
borderBottomWidth: 1,
borderBottomColor: colors.border,
},
dropdownItemLast: { borderBottomWidth: 0 },
dropdownText: {
color: colors.textWhite,
fontSize: fontSize.sm,
},
dropdownTextDanger: {
color: colors.danger,
fontSize: fontSize.sm,
},
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 },
// Modal items
modalSummary: {
backgroundColor: colors.bgCard,
borderRadius: borderRadius.sm,
padding: spacing.m,
marginBottom: spacing.m,
gap: spacing.xs,
},
modalSummaryRow: {
flexDirection: "row",
alignItems: "center",
gap: spacing.s,
},
modalSummaryText: {
color: colors.textSecondary,
fontSize: fontSize.sm,
flex: 1,
},
modalTotal: {
color: colors.accent,
fontSize: fontSize.md,
fontWeight: "700",
marginTop: spacing.xs,
},
itemCard: {
backgroundColor: colors.bgCard,
borderRadius: borderRadius.sm,
marginBottom: spacing.s,
overflow: "hidden",
},
itemCardBody: {
padding: spacing.m,
},
itemName: {
color: colors.textWhite,
fontSize: fontSize.md,
fontWeight: "600",
marginBottom: 2,
},
itemMeta: {
color: colors.textMuted,
fontSize: fontSize.xs,
},
itemRow: {
flexDirection: "row",
alignItems: "center",
justifyContent: "space-between",
},
itemDeleteBtn: {
padding: spacing.xs,
marginLeft: spacing.s,
},
addressInput: {
backgroundColor: colors.bgPrimary,
borderWidth: 1,
borderColor: colors.border,
borderRadius: borderRadius.sm,
color: colors.textWhite,
paddingHorizontal: spacing.m,
paddingVertical: spacing.m,
fontSize: fontSize.md,
marginBottom: spacing.m,
},
addressConfirmBtn: {
backgroundColor: colors.accent,
borderRadius: borderRadius.sm,
paddingVertical: spacing.m,
alignItems: "center",
},
addressConfirmText: {
color: colors.textWhite,
fontSize: fontSize.md,
fontWeight: "700",
},
}),
[colors],
);
const renderOrder = ({ item }: { item: CommandResponse }) => {
const isOpen = openMenuId === item.id;
const isDone = ["approved", "cancelled"].includes(item.status);
type Action = {
label: string;
icon: keyof typeof Ionicons.glyphMap;
onPress: () => void;
danger?: boolean;
condition?: boolean;
};
const actions: Action[] = (
[
{
label: "Voir items",
icon: "receipt-outline" as keyof typeof Ionicons.glyphMap,
onPress: () => {
setOpenMenuId(null);
openItems(item.id);
},
},
{
label: "Proposer adresse",
icon: "location-outline" as keyof typeof Ionicons.glyphMap,
onPress: () => {
setOpenMenuId(null);
setAddressModal({
visible: true,
commandId: item.id,
input: "",
});
},
condition: !isDone,
},
{
label: "Le livreur est là",
icon: "notifications-outline" as keyof typeof Ionicons.glyphMap,
onPress: () => {
setOpenMenuId(null);
handleNotifyClient(item.id);
},
condition: !isDone,
},
{
label: "Assigner livreur",
icon: "bicycle-outline" as keyof typeof Ionicons.glyphMap,
onPress: () => {
setOpenMenuId(null);
openAssignModal(item.id);
},
condition: !isDone,
},
{
label: "Passer en route",
icon: "car-outline" as keyof typeof Ionicons.glyphMap,
onPress: () => {
setOpenMenuId(null);
handleStatusUpdate(item.id, "en_route");
},
condition: item.status === "assigned",
},
{
label: "Confirmer réception",
icon: "checkmark-circle-outline" as keyof typeof Ionicons.glyphMap,
onPress: () => {
setOpenMenuId(null);
handleConfirmReception(item.id);
},
condition: item.status === "livre",
},
{
label: "Supprimer",
icon: "trash-outline" as keyof typeof Ionicons.glyphMap,
onPress: () => {
setOpenMenuId(null);
handleDelete(item.id);
},
danger: true,
},
] as Action[]
).filter((a) => a.condition !== false);
return (
<Card style={{ marginBottom: spacing.m }}>
<TouchableOpacity
onPress={() =>
navigation.navigate("OrderDetail", { orderId: item.id })
}
activeOpacity={0.7}
>
<View style={styles.row}>
<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>
</TouchableOpacity>
<TouchableOpacity
style={styles.selectBtn}
onPress={() => setOpenMenuId(isOpen ? null : item.id)}
>
<Text style={styles.selectBtnText}>Actions</Text>
<Ionicons
name={isOpen ? "chevron-up" : "chevron-down"}
size={14}
color={colors.textSecondary}
/>
</TouchableOpacity>
{isOpen && (
<View style={styles.dropdown}>
{actions.map((action, index) => (
<TouchableOpacity
key={action.label}
style={[
styles.dropdownItem,
index === actions.length - 1 &&
styles.dropdownItemLast,
]}
onPress={action.onPress}
>
<Ionicons
name={action.icon}
size={16}
color={
action.danger
? colors.danger
: colors.accent
}
/>
<Text
style={
action.danger
? styles.dropdownTextDanger
: styles.dropdownText
}
>
{action.label}
</Text>
</TouchableOpacity>
))}
</View>
)}
</Card>
);
};
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}
/>
<View style={styles.refreshRow}>
<TouchableOpacity
style={styles.refreshBtn}
onPress={onRefresh}
disabled={refreshing}
>
<Ionicons
name="refresh-outline"
size={16}
color={refreshing ? colors.textMuted : colors.accent}
/>
<Text style={styles.refreshBtnText}>Actualiser</Text>
</TouchableOpacity>
</View>
<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 items */}
<Modal
visible={itemsModal.visible}
onClose={closeItemsModal}
title={`Commande #${itemsModal.commandId}`}
icon="receipt-outline"
>
<ScrollView
showsVerticalScrollIndicator={false}
bounces={false}
>
{(itemsModal.commandInfo || itemsModal.clientInfo) && (
<View style={styles.modalSummary}>
{itemsModal.clientInfo?.username && (
<View style={styles.modalSummaryRow}>
<Ionicons
name="person-outline"
size={14}
color={colors.textMuted}
/>
<Text style={styles.modalSummaryText}>
{itemsModal.clientInfo.prenom}{" "}
{itemsModal.clientInfo.nom} ·{" "}
{itemsModal.clientInfo.username}
</Text>
</View>
)}
{(itemsModal.commandInfo?.address ||
itemsModal.commandInfo?.adresse) && (
<View style={styles.modalSummaryRow}>
<Ionicons
name="location-outline"
size={14}
color={colors.textMuted}
/>
<Text style={styles.modalSummaryText}>
{itemsModal.commandInfo.address ||
itemsModal.commandInfo.adresse}
</Text>
</View>
)}
{itemsModal.commandInfo?.total_prix != null && (
<Text style={styles.modalTotal}>
{Number(
itemsModal.commandInfo.total_prix,
).toFixed(2)}{" "}
</Text>
)}
</View>
)}
{itemsModal.items.map((item: any) => (
<View key={item.id} style={styles.itemCard}>
<View style={styles.itemCardBody}>
<View style={styles.itemRow}>
<View style={{ flex: 1 }}>
<Text style={styles.itemName}>
{item.produit ?? item.product_name}
</Text>
<Text style={styles.itemMeta}>
Qté:{" "}
{item.quantite ?? item.quantity} ·{" "}
{(item.prix ?? item.price)?.toFixed(
2,
)}{" "}
</Text>
</View>
<TouchableOpacity
style={styles.itemDeleteBtn}
onPress={() =>
handleDeleteItem(
itemsModal.commandId!,
item.id,
item.produit ??
item.product_name,
)
}
>
<Ionicons
name="trash-outline"
size={18}
color={colors.danger}
/>
</TouchableOpacity>
</View>
</View>
</View>
))}
{itemsModal.items.length === 0 && (
<Text style={styles.empty}>Aucun item</Text>
)}
</ScrollView>
</Modal>
{/* Modal assignation */}
<Modal
visible={assignModal.visible}
onClose={() =>
setAssignModal({ visible: false, commandId: null })
}
title={`Assigner commande #${assignModal.commandId}`}
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>
{/* Modal proposition adresse */}
<Modal
visible={addressModal.visible}
onClose={() =>
setAddressModal({
visible: false,
commandId: null,
input: "",
})
}
title={`Proposer adresse — commande #${addressModal.commandId}`}
icon="location-outline"
>
<TextInput
style={styles.addressInput}
placeholder="Nouvelle adresse..."
placeholderTextColor={colors.textMuted}
value={addressModal.input}
onChangeText={(t) =>
setAddressModal((prev) => ({ ...prev, input: t }))
}
multiline
/>
<TouchableOpacity
style={styles.addressConfirmBtn}
onPress={handleProposeAddress}
>
<Text style={styles.addressConfirmText}>
Envoyer la proposition
</Text>
</TouchableOpacity>
</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>
);
}