Files
projet_gestion_commande/frontend-admin/src/screens/cabine/OrdersScreen.tsx
T

750 lines
28 KiB
TypeScript

import React, { useState, useEffect, useCallback, useMemo } from "react";
import {
View,
Text,
StyleSheet,
FlatList,
RefreshControl,
ScrollView,
TouchableOpacity,
TextInput,
} from "react-native";
import { Ionicons } from "@expo/vector-icons";
import { spacing, fontSize, borderRadius } from "../../theme";
import { useTheme } from "../../context/ThemeContext";
import { getAllCommands } from "../../api/api_admin";
import {
getCommandItems,
deleteCommand,
confirmReceptionCabine,
notifyClientToDescendCabine,
getCabineLivreursList,
assignDeliveryPersonByCabine,
proposeAddressChangeCabine,
} 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 Card from "../../components/ui/Card";
import AlertModal from "../../components/ui/AlertModal";
import { useAlert } from "../../hooks/useAlert";
const STATUS_COLORS: Record<string, string> = {
pending: "#F59E0B",
};
const STATUS_ICONS: Record<string, keyof typeof Ionicons.glyphMap> = {
pending: "time-outline",
};
const STATUS_LABELS: Record<string, string> = {
pending: "En attente",
};
export default function OrdersScreen() {
const { colors } = useTheme();
const [commands, setCommands] = useState<CommandResponse[]>([]);
const [loading, setLoading] = useState(true);
const [refreshing, setRefreshing] = useState(false);
const [openMenuId, setOpenMenuId] = useState<number | null>(null);
const { alert, showError, showSuccess, showConfirm, hideAlert } =
useAlert();
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 [assignModal, setAssignModal] = useState<{
visible: boolean;
commandId: number | null;
}>({ visible: false, commandId: null });
const [addressModal, setAddressModal] = useState<{
visible: boolean;
commandId: number | null;
input: string;
}>({ visible: false, commandId: null, input: "" });
const [livreurs, setLivreurs] = useState<
{ id: number; username: string }[]
>([]);
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]);
useEffect(() => {
const interval = setInterval(() => {
loadData();
}, 30000);
return () => clearInterval(interval);
}, [loadData]);
const onRefresh = async () => {
setRefreshing(true);
await loadData();
setRefreshing(false);
};
const openItems = async (commandId: number) => {
setOpenMenuId(null);
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 handleConfirmReception = (commandId: number) => {
setOpenMenuId(null);
showConfirm(
"Confirmer la réception",
`Confirmer la réception de la commande #${commandId} au nom du client ?`,
async () => {
try {
await confirmReceptionCabine(commandId);
await loadData();
} catch (e: any) {
showError("Erreur", e.message);
}
},
"Confirmer",
);
};
const handleNotifyClient = async (commandId: number) => {
setOpenMenuId(null);
try {
await notifyClientToDescendCabine(commandId);
showSuccess(
"Notification envoyée",
"Le client a été prévenu de descendre",
);
} catch (e: any) {
showError("Erreur", e.message);
}
};
const handleProposeAddress = async () => {
if (!addressModal.commandId || !addressModal.input.trim()) return;
try {
await proposeAddressChangeCabine(
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 handleDelete = (commandId: number) => {
setOpenMenuId(null);
showConfirm(
"Supprimer",
`Supprimer la commande #${commandId} ?`,
async () => {
try {
await deleteCommand(commandId);
await loadData();
} catch (e: any) {
showError("Erreur", e.message);
}
},
"Supprimer",
);
};
const closeModal = () =>
setItemsModal({
visible: false,
commandId: null,
items: [],
commandInfo: null,
clientInfo: null,
});
const openAssignModal = async (commandId: number) => {
setOpenMenuId(null);
try {
const list = await getCabineLivreursList();
setLivreurs(list);
setAssignModal({ visible: true, commandId });
} catch {
showError("Erreur", "Impossible de charger les livreurs");
}
};
const handleAssign = async (livreurUsername: string) => {
if (!assignModal.commandId) return;
try {
await assignDeliveryPersonByCabine(
assignModal.commandId,
livreurUsername,
);
setAssignModal({ visible: false, commandId: null });
await loadData();
} 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,
},
orderId: {
fontSize: fontSize.lg,
fontWeight: "bold",
color: colors.textWhite,
},
info: {
color: colors.textSecondary,
fontSize: fontSize.sm,
marginTop: 2,
},
empty: {
color: colors.textMuted,
textAlign: "center",
marginTop: spacing.xl,
},
// Select actions
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,
},
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",
},
// Modal summary
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,
},
progressLabel: {
color: colors.textMuted,
fontSize: fontSize.xs,
marginBottom: spacing.xs,
},
itemCard: {
backgroundColor: colors.bgCard,
borderRadius: borderRadius.sm,
marginBottom: spacing.s,
overflow: "hidden",
},
itemCardAccent: { height: 3 },
itemCardBody: {
padding: spacing.m,
flexDirection: "row",
alignItems: "flex-start",
gap: spacing.m,
},
itemIndex: {
width: 28,
height: 28,
borderRadius: 14,
backgroundColor: colors.bgPrimary,
justifyContent: "center",
alignItems: "center",
marginTop: 2,
},
itemIndexText: {
color: colors.textMuted,
fontSize: fontSize.xs,
fontWeight: "700",
},
itemInfo: { flex: 1 },
itemName: {
color: colors.textWhite,
fontSize: fontSize.md,
fontWeight: "600",
marginBottom: 2,
},
itemMeta: {
color: colors.textMuted,
fontSize: fontSize.xs,
marginBottom: spacing.s,
},
itemStatusRow: {
flexDirection: "row",
alignItems: "center",
gap: 5,
},
itemStatusText: { fontSize: fontSize.xs, fontWeight: "600" },
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 }) => {
const isOpen = openMenuId === item.id;
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: () => openItems(item.id),
},
{
label: "Proposer adresse",
icon: "location-outline" as keyof typeof Ionicons.glyphMap,
onPress: () => {
setOpenMenuId(null);
setAddressModal({
visible: true,
commandId: item.id,
input: "",
});
},
},
{
label: "Le livreur est là",
icon: "notifications-outline" as keyof typeof Ionicons.glyphMap,
onPress: () => handleNotifyClient(item.id),
},
{
label: "Assigner livreur",
icon: "bicycle-outline" as keyof typeof Ionicons.glyphMap,
onPress: () => openAssignModal(item.id),
},
{
label: "Confirmer réception",
icon: "checkmark-circle-outline" as keyof typeof Ionicons.glyphMap,
onPress: () => handleConfirmReception(item.id),
condition: item.status === "livre",
},
{
label: "Supprimer",
icon: "trash-outline" as keyof typeof Ionicons.glyphMap,
onPress: () => handleDelete(item.id),
danger: true,
},
] as Action[]
).filter((a) => a.condition !== false);
return (
<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>
<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>
);
};
const totalCount = itemsModal.items.length;
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={closeModal}
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>
)}
{(itemsModal.commandInfo?.referral_used ?? 0) >
0 && (
<Text
style={[
styles.modalTotal,
{ color: colors.success },
]}
>
Parrainage: -
{Number(
itemsModal.commandInfo.referral_used,
).toFixed(2)}{" "}
</Text>
)}
</View>
)}
{totalCount > 0 && (
<Text style={styles.progressLabel}>
{totalCount} article{totalCount > 1 ? "s" : ""}
</Text>
)}
{itemsModal.items.map((item: any, index: number) => {
const statusColor =
STATUS_COLORS[item.status] || colors.textMuted;
return (
<View key={item.id} style={styles.itemCard}>
<View
style={[
styles.itemCardAccent,
{ backgroundColor: statusColor },
]}
/>
<View style={styles.itemCardBody}>
<View style={styles.itemIndex}>
<Text style={styles.itemIndexText}>
{index + 1}
</Text>
</View>
<View style={styles.itemInfo}>
<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 style={styles.itemStatusRow}>
<Ionicons
name={
STATUS_ICONS[item.status] ||
"ellipse-outline"
}
size={13}
color={statusColor}
/>
<Text
style={[
styles.itemStatusText,
{ color: statusColor },
]}
>
{STATUS_LABELS[item.status] ||
item.status}
</Text>
</View>
</View>
</View>
</View>
);
})}
{itemsModal.items.length === 0 && (
<Text style={styles.empty}>Aucun item</Text>
)}
</ScrollView>
</Modal>
<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>
);
}