chore: fix

This commit is contained in:
2026-05-03 19:38:29 +02:00
parent 4075ae2568
commit f8473b9a54
27 changed files with 608 additions and 115 deletions
+16
View File
@@ -531,6 +531,22 @@ export const getClientReferralAdmin = async (
}
};
export const resetClientReferral = async (
username: string,
): Promise<{ success: boolean; message?: string }> => {
try {
const { data } = await apiClient.delete(
`${V2}/admin/protected/client/${username}/referral/reset`,
);
return { success: true, message: data.message };
} catch (error: any) {
return {
success: false,
message: error.response?.data?.error || "Erreur reset parrainage",
};
}
};
export interface CancelledOrder {
id: number;
username: string;
+5
View File
@@ -35,11 +35,16 @@ export interface ClientResponse {
export interface CommandResponse {
id: number;
client_order_number?: number;
username: string;
status: string;
adresse: string;
total_prix: number;
livreur_assign?: string | null;
proposed_address?: string | null;
address_proposal_status?: string;
referral_used?: number;
cancel_reason?: string;
created_at: string;
updated_at: string;
}
+445 -35
View File
@@ -1,4 +1,4 @@
import React, { useState, useEffect, useCallback, useMemo } from "react";
import React, { useState, useEffect, useCallback, useMemo, useRef } from "react";
import {
View,
Text,
@@ -9,6 +9,7 @@ import {
ScrollView,
TextInput,
Share,
ActivityIndicator,
} from "react-native";
import { Ionicons } from "@expo/vector-icons";
import { useNavigation } from "@react-navigation/native";
@@ -38,6 +39,7 @@ import AlertModal from "../../components/ui/AlertModal";
import { useAlert } from "../../hooks/useAlert";
type Nav = NativeStackNavigationProp<AdminStackParamList>;
type ArchiveTab = "approved" | "cancelled";
export default function OrdersScreen() {
const { colors } = useTheme();
@@ -77,6 +79,48 @@ export default function OrdersScreen() {
const [exporting, setExporting] = useState(false);
// --- Archive (approved / cancelled) ---
const [archiveTab, setArchiveTab] = useState<ArchiveTab | null>(null);
const [archiveSearch, setArchiveSearch] = useState("");
const [archiveCommands, setArchiveCommands] = useState<CommandResponse[]>([]);
const [archiveLoading, setArchiveLoading] = useState(false);
const archiveDebounce = useRef<ReturnType<typeof setTimeout> | null>(null);
const loadArchive = useCallback(
async (tab: ArchiveTab, search: string) => {
setArchiveLoading(true);
try {
const result = await getAllCommands(tab, search.trim() || undefined);
setArchiveCommands(result.commands);
} catch {
setArchiveCommands([]);
}
setArchiveLoading(false);
},
[],
);
const handleArchiveTab = (tab: ArchiveTab) => {
if (archiveTab === tab) {
setArchiveTab(null);
setArchiveCommands([]);
setArchiveSearch("");
return;
}
setArchiveTab(tab);
setArchiveSearch("");
loadArchive(tab, "");
};
const handleArchiveSearch = (text: string) => {
setArchiveSearch(text);
if (archiveDebounce.current) clearTimeout(archiveDebounce.current);
archiveDebounce.current = setTimeout(() => {
if (archiveTab) loadArchive(archiveTab, text);
}, 400);
};
// --- Active orders ---
const loadData = useCallback(async () => {
try {
const result = await getAllCommands(undefined);
@@ -106,6 +150,7 @@ export default function OrdersScreen() {
const onRefresh = async () => {
setRefreshing(true);
await loadData();
if (archiveTab) loadArchive(archiveTab, archiveSearch);
setRefreshing(false);
};
@@ -258,6 +303,7 @@ export default function OrdersScreen() {
gap: spacing.s,
paddingBottom: spacing.m,
alignItems: "center",
flexWrap: "wrap",
},
refreshBtn: {
flexDirection: "row",
@@ -274,11 +320,79 @@ export default function OrdersScreen() {
color: colors.textSecondary,
fontSize: fontSize.sm,
},
archiveRow: {
flexDirection: "row",
gap: spacing.s,
marginBottom: spacing.m,
flexWrap: "wrap",
},
archiveTabBtn: {
flexDirection: "row",
alignItems: "center",
gap: spacing.xs,
paddingHorizontal: spacing.m,
paddingVertical: spacing.s,
borderRadius: borderRadius.sm,
borderWidth: 1,
},
archiveTabBtnText: {
fontSize: fontSize.sm,
fontWeight: "600",
},
searchBar: {
flexDirection: "row",
alignItems: "center",
gap: spacing.s,
backgroundColor: colors.bgCard,
borderRadius: borderRadius.sm,
borderWidth: 1,
borderColor: colors.border,
paddingHorizontal: spacing.m,
paddingVertical: spacing.s,
marginBottom: spacing.m,
},
searchInput: {
flex: 1,
color: colors.textWhite,
fontSize: fontSize.sm,
padding: 0,
},
archiveHeader: {
flexDirection: "row",
alignItems: "center",
justifyContent: "space-between",
marginBottom: spacing.s,
},
archiveHeaderText: {
color: colors.textMuted,
fontSize: fontSize.xs,
fontWeight: "600",
textTransform: "uppercase",
letterSpacing: 0.8,
},
divider: {
height: 1,
backgroundColor: colors.border,
marginVertical: spacing.m,
},
activeSectionTitle: {
color: colors.textMuted,
fontSize: fontSize.xs,
fontWeight: "600",
textTransform: "uppercase",
letterSpacing: 0.8,
marginBottom: spacing.m,
},
cardText: {
color: colors.textSecondary,
fontSize: fontSize.sm,
marginTop: 2,
},
cardTextMuted: {
color: colors.textMuted,
fontSize: fontSize.xs,
marginTop: 2,
},
cardDate: {
color: colors.textMuted,
fontSize: fontSize.xs,
@@ -289,12 +403,54 @@ export default function OrdersScreen() {
fontWeight: "bold",
color: colors.textWhite,
},
orderIdSub: {
fontSize: fontSize.xs,
color: colors.textMuted,
marginLeft: spacing.xs,
},
row: {
flexDirection: "row",
justifyContent: "space-between",
alignItems: "center",
marginBottom: spacing.s,
},
idRow: {
flexDirection: "row",
alignItems: "baseline",
gap: spacing.xs,
marginBottom: spacing.s,
},
chip: {
paddingHorizontal: spacing.s,
paddingVertical: 2,
borderRadius: borderRadius.full ?? 99,
alignSelf: "flex-start",
marginTop: spacing.xs,
},
chipText: {
fontSize: fontSize.xs,
fontWeight: "600",
},
cancelReasonBox: {
backgroundColor: colors.bgPrimary,
borderRadius: borderRadius.sm,
padding: spacing.s,
marginTop: spacing.s,
borderLeftWidth: 3,
borderLeftColor: colors.danger,
},
cancelReasonText: {
color: colors.textSecondary,
fontSize: fontSize.xs,
},
proposedAddressBox: {
backgroundColor: colors.bgPrimary,
borderRadius: borderRadius.sm,
padding: spacing.s,
marginTop: spacing.s,
borderLeftWidth: 3,
borderLeftColor: colors.warning,
},
selectBtn: {
flexDirection: "row",
alignItems: "center",
@@ -343,6 +499,12 @@ export default function OrdersScreen() {
marginTop: spacing.xxl,
fontSize: fontSize.md,
},
archiveEmpty: {
color: colors.textMuted,
textAlign: "center",
marginVertical: spacing.l,
fontSize: fontSize.sm,
},
livreurItem: {
flexDirection: "row",
alignItems: "center",
@@ -353,8 +515,6 @@ export default function OrdersScreen() {
gap: spacing.m,
},
livreurName: { color: colors.textWhite, fontSize: fontSize.md },
// Modal items
modalSummary: {
backgroundColor: colors.bgCard,
borderRadius: borderRadius.sm,
@@ -432,6 +592,103 @@ export default function OrdersScreen() {
[colors],
);
const renderArchiveCard = (item: CommandResponse) => {
const isCancelled = item.status === "cancelled";
const accentColor = isCancelled ? colors.danger : colors.success;
return (
<Card
key={item.id}
style={{ marginBottom: spacing.s, borderLeftWidth: 3, borderLeftColor: accentColor }}
>
<TouchableOpacity
onPress={() => navigation.navigate("OrderDetail", { orderId: item.id })}
activeOpacity={0.7}
>
{/* ID row */}
<View style={styles.idRow}>
<Text style={styles.orderId}>#{item.id}</Text>
{item.client_order_number != null && item.client_order_number > 0 && (
<Text style={styles.orderIdSub}>
(commande #{item.client_order_number} du client)
</Text>
)}
<StatusBadge status={item.status} />
</View>
{/* Client */}
<Text style={styles.cardText}>
<Text style={{ color: colors.textMuted }}>Client : </Text>
{item.username}
</Text>
{/* Adresse */}
{!!item.adresse && (
<Text style={styles.cardText}>
<Text style={{ color: colors.textMuted }}>Adresse : </Text>
{item.adresse}
</Text>
)}
{/* Adresse proposée */}
{!!item.proposed_address && (
<View style={styles.proposedAddressBox}>
<Text style={[styles.cancelReasonText, { color: colors.warning }]}>
Adresse proposée ({item.address_proposal_status ?? "—"}) :{" "}
{item.proposed_address}
</Text>
</View>
)}
{/* Total + parrainage */}
<Text style={styles.cardText}>
<Text style={{ color: colors.textMuted }}>Total : </Text>
{item.total_prix.toFixed(2)}
{(item.referral_used ?? 0) > 0 && (
<Text style={{ color: colors.accent }}>
{" "}(parrainage -{item.referral_used!.toFixed(2)} )
</Text>
)}
</Text>
{/* Livreur */}
{!!item.livreur_assign && (
<Text style={styles.cardText}>
<Text style={{ color: colors.textMuted }}>Livreur : </Text>
{item.livreur_assign}
</Text>
)}
{/* Raison annulation */}
{isCancelled && !!item.cancel_reason && (
<View style={styles.cancelReasonBox}>
<Text style={styles.cancelReasonText}>
Raison : {item.cancel_reason}
</Text>
</View>
)}
{/* Dates */}
<Text style={styles.cardDate}>
Créée : {new Date(item.created_at).toLocaleString("fr-FR")}
</Text>
<Text style={styles.cardDate}>
Modifiée : {new Date(item.updated_at).toLocaleString("fr-FR")}
</Text>
</TouchableOpacity>
{/* Action rapide items */}
<TouchableOpacity
style={[styles.selectBtn, { marginTop: spacing.s }]}
onPress={() => openItems(item.id)}
>
<Text style={styles.selectBtnText}>Voir les articles</Text>
<Ionicons name="receipt-outline" size={14} color={colors.accent} />
</TouchableOpacity>
</Card>
);
};
const renderOrder = ({ item }: { item: CommandResponse }) => {
const isOpen = openMenuId === item.id;
const isDone = ["approved", "cancelled"].includes(item.status);
@@ -592,6 +849,189 @@ export default function OrdersScreen() {
);
};
const ListHeader = (
<View>
{/* Boutons actions */}
<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>
<TouchableOpacity
style={styles.refreshBtn}
onPress={handleExportCSV}
disabled={exporting}
>
<Ionicons
name="download-outline"
size={16}
color={exporting ? colors.textMuted : colors.accent}
/>
<Text style={styles.refreshBtnText}>
{exporting ? "Export..." : "Export CSV"}
</Text>
</TouchableOpacity>
</View>
{/* Boutons archives */}
<View style={styles.archiveRow}>
<TouchableOpacity
style={[
styles.archiveTabBtn,
{
backgroundColor:
archiveTab === "approved"
? colors.success + "22"
: colors.bgCard,
borderColor:
archiveTab === "approved"
? colors.success
: colors.border,
},
]}
onPress={() => handleArchiveTab("approved")}
>
<Ionicons
name="checkmark-circle-outline"
size={16}
color={
archiveTab === "approved"
? colors.success
: colors.textMuted
}
/>
<Text
style={[
styles.archiveTabBtnText,
{
color:
archiveTab === "approved"
? colors.success
: colors.textMuted,
},
]}
>
Approuvées
</Text>
</TouchableOpacity>
<TouchableOpacity
style={[
styles.archiveTabBtn,
{
backgroundColor:
archiveTab === "cancelled"
? colors.danger + "22"
: colors.bgCard,
borderColor:
archiveTab === "cancelled"
? colors.danger
: colors.border,
},
]}
onPress={() => handleArchiveTab("cancelled")}
>
<Ionicons
name="close-circle-outline"
size={16}
color={
archiveTab === "cancelled"
? colors.danger
: colors.textMuted
}
/>
<Text
style={[
styles.archiveTabBtnText,
{
color:
archiveTab === "cancelled"
? colors.danger
: colors.textMuted,
},
]}
>
Annulées
</Text>
</TouchableOpacity>
</View>
{/* Section archive */}
{archiveTab !== null && (
<View>
{/* Barre de recherche */}
<View style={styles.searchBar}>
<Ionicons
name="search-outline"
size={16}
color={colors.textMuted}
/>
<TextInput
style={styles.searchInput}
placeholder="Rechercher par username..."
placeholderTextColor={colors.textMuted}
value={archiveSearch}
onChangeText={handleArchiveSearch}
autoCapitalize="none"
autoCorrect={false}
/>
{archiveSearch.length > 0 && (
<TouchableOpacity
onPress={() => {
setArchiveSearch("");
if (archiveTab) loadArchive(archiveTab, "");
}}
>
<Ionicons
name="close-outline"
size={18}
color={colors.textMuted}
/>
</TouchableOpacity>
)}
</View>
{/* Header résultats */}
<View style={styles.archiveHeader}>
<Text style={styles.archiveHeaderText}>
{archiveTab === "approved" ? "Commandes approuvées" : "Commandes annulées"}
</Text>
{!archiveLoading && (
<Text style={styles.archiveHeaderText}>
{archiveCommands.length} résultat{archiveCommands.length !== 1 ? "s" : ""}
</Text>
)}
</View>
{archiveLoading ? (
<View style={{ paddingVertical: spacing.l, alignItems: "center" }}>
<ActivityIndicator size="small" color={colors.accent} />
</View>
) : archiveCommands.length === 0 ? (
<Text style={styles.archiveEmpty}>Aucune commande trouvée</Text>
) : (
archiveCommands.map((item) => renderArchiveCard(item))
)}
<View style={styles.divider} />
</View>
)}
{/* Titre section commandes actives */}
<Text style={styles.activeSectionTitle}>
Commandes actives ({commands.length})
</Text>
</View>
);
if (loading) return <LoadingSpinner message="Chargement commandes..." />;
return (
@@ -608,42 +1048,12 @@ export default function OrdersScreen() {
/>
}
contentContainerStyle={{ padding: spacing.l }}
ListHeaderComponent={
<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>
<TouchableOpacity
style={styles.refreshBtn}
onPress={handleExportCSV}
disabled={exporting}
>
<Ionicons
name="download-outline"
size={16}
color={exporting ? colors.textMuted : colors.accent}
/>
<Text style={styles.refreshBtnText}>
{exporting ? "Export..." : "Export CSV"}
</Text>
</TouchableOpacity>
</View>
}
ListHeaderComponent={ListHeader}
ListEmptyComponent={
<Text style={styles.empty}>Aucune commande</Text>
<Text style={styles.empty}>Aucune commande active</Text>
}
/>
{/* Modal items */}
<Modal
visible={itemsModal.visible}
@@ -25,6 +25,7 @@ import {
createUserByAdmin,
setClientParrain,
creditClientReferral,
resetClientReferral,
getClientCancelledOrders,
getSettings,
applyClientPenalty,
@@ -452,6 +453,20 @@ export default function UsersScreen() {
}
};
const handleResetReferral = async (username: string) => {
try {
const res = await resetClientReferral(username);
if (res.success) {
showSuccess("Parrainage", "Solde remis à zéro");
await loadData();
} else {
showError("Erreur", res.message || "Echec du reset");
}
} catch (e: any) {
showError("Erreur", e.message);
}
};
// --------------------------------------------------
// Points & amende
// --------------------------------------------------
@@ -934,6 +949,18 @@ export default function UsersScreen() {
/>
</TouchableOpacity>
)}
{referralEnabled && (item.clientData!.referral_balance ?? 0) > 0 && (
<TouchableOpacity
style={styles.editIconBtn}
onPress={() => handleResetReferral(item.clientData!.username)}
>
<Ionicons
name="refresh-outline"
size={20}
color={colors.danger}
/>
</TouchableOpacity>
)}
<TouchableOpacity
style={styles.editIconBtn}
onPress={() => openCancelledOrders(item.clientData!)}
@@ -58,7 +58,7 @@ import TomTomMap, {
} from "../../components/TomTomMap";
import DetailsModal from "../../components/ui/Modal";
const LOCATION_INTERVAL_MS = 15000;
const LOCATION_INTERVAL_MS = 5000;
const STATUS_LABELS: Record<string, string> = {
available: "Disponible",