Files
projet_gestion_commande/frontend-admin/src/screens/admin/UsersScreen.tsx
T
Xor290 75161a1ae0
Frontend Admin - EAS Build / build (push) Has been cancelled
chore: build
2026-06-28 12:28:46 +02:00

2260 lines
93 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import React, { useState, useEffect, useCallback, useMemo } from "react";
import {
View,
Text,
StyleSheet,
FlatList,
ScrollView,
RefreshControl,
TouchableOpacity,
TextInput as RNTextInput,
ActivityIndicator,
useWindowDimensions,
} from "react-native";
import { Ionicons } from "@expo/vector-icons";
import { spacing, fontSize, borderRadius } from "../../theme";
import { useTheme } from "../../context/ThemeContext";
import {
getAllClients,
getAllUsers,
updateClientByAdmin,
updateUserByAdmin,
deleteUserAdmin,
deleteClientAdmin,
createClientByAdmin,
createUserByAdmin,
setClientParrain,
getClientParrain,
creditClientReferral,
resetClientReferral,
getClientCancelledOrders,
getSettings,
applyClientPenalty,
resetClientPenalties,
resetClientPoints,
addClientPoints,
subtractClientPoints,
} from "../../api/api_admin";
import type { CancelledOrder } from "../../api/api_admin";
import type { ClientResponse } from "../../api/types";
import LoadingSpinner from "../../components/ui/LoadingSpinner";
import Card from "../../components/ui/Card";
import Button from "../../components/ui/Button";
import Modal from "../../components/ui/Modal";
import TextInput from "../../components/ui/TextInput";
import AlertModal from "../../components/ui/AlertModal";
import { useAlert } from "../../hooks/useAlert";
// --------------------------------------------------
// Types
// --------------------------------------------------
interface UserItem {
id: number;
username: string;
role: string;
}
type RoleFilter = "all" | "client" | "livreur" | "cabine" | "admin";
// ==================================================
// SCREEN
// ==================================================
export default function UsersScreen() {
const { colors } = useTheme();
const { width: screenWidth } = useWindowDimensions();
const ROLE_TABS: {
key: RoleFilter;
label: string;
icon: keyof typeof Ionicons.glyphMap;
color: string;
}[] = useMemo(
() => [
{
key: "all",
label: "Tous",
icon: "people-outline",
color: colors.accent,
},
{
key: "client",
label: "Clients",
icon: "person-outline",
color: colors.info,
},
{
key: "livreur",
label: "Livreurs",
icon: "bicycle-outline",
color: colors.success,
},
{
key: "cabine",
label: "Cabines",
icon: "desktop-outline",
color: colors.warning,
},
{
key: "admin",
label: "Admins",
icon: "shield-outline",
color: colors.accent,
},
],
[colors],
);
const getRoleBadgeColor = useCallback(
(role: string) => {
switch (role) {
case "client":
return colors.info;
case "livreur":
return colors.success;
case "cabine":
return colors.warning;
case "admin":
return colors.accent;
default:
return colors.textMuted;
}
},
[colors],
);
const getRoleLabel = useCallback((role: string) => {
switch (role) {
case "client":
return "Client";
case "livreur":
return "Livreur";
case "cabine":
return "Cabine";
case "admin":
return "Admin";
default:
return role;
}
}, []);
const [users, setUsers] = useState<UserItem[]>([]);
const [clients, setClients] = useState<ClientResponse[]>([]);
const [loading, setLoading] = useState(true);
const [refreshing, setRefreshing] = useState(false);
const [poolNames, setPoolNames] = useState<string[]>([]);
const [poolKeys, setPoolKeys] = useState<string[]>([]);
const [pointsEnabled, setPointsEnabled] = useState(true);
const [amendeEnabled, setAmendeEnabled] = useState(true);
const [referralEnabled, setReferralEnabled] = useState(true);
const [filter, setFilter] = useState<RoleFilter>("all");
const [search, setSearch] = useState("");
const [parrainMap, setParrainMap] = useState<Record<string, string | null>>(
{},
);
// Edit client modal
const [editClientModal, setEditClientModal] = useState<{
visible: boolean;
client: ClientResponse | null;
}>({ visible: false, client: null });
const [editNom, setEditNom] = useState("");
const [editPrenom, setEditPrenom] = useState("");
const [editTel, setEditTel] = useState("");
const [editClientUsername, setEditClientUsername] = useState("");
const [editClientPassword, setEditClientPassword] = useState("");
// Edit user modal (livreur/cabine)
const [editUserModal, setEditUserModal] = useState<{
visible: boolean;
user: UserItem | null;
}>({ visible: false, user: null });
const [editUsername, setEditUsername] = useState("");
const [editRole, setEditRole] = useState("");
const [editUserPassword, setEditUserPassword] = useState("");
// Create user modal
const [createModal, setCreateModal] = useState(false);
const [createType, setCreateType] = useState<
"client" | "cabine" | "livreur"
>("client");
const [createUsername, setCreateUsername] = useState("");
const [createPassword, setCreatePassword] = useState("");
const [createNom, setCreateNom] = useState("");
const [createPrenom, setCreatePrenom] = useState("");
const [createTel, setCreateTel] = useState("");
const [createParrain, setCreateParrain] = useState("");
const [creating, setCreating] = useState(false);
// Referral credit modal
const [referralModal, setReferralModal] = useState<{
visible: boolean;
username: string;
currentBalance: number;
}>({ visible: false, username: "", currentBalance: 0 });
const [referralAmount, setReferralAmount] = useState("");
const [referralLoading, setReferralLoading] = useState(false);
// Points & amende modal
const [sanctionModal, setSanctionModal] = useState<{
visible: boolean;
client: ClientResponse | null;
}>({ visible: false, client: null });
const [sanctionTab, setSanctionTab] = useState<"amende" | "points">(
"amende",
);
const [amendeAmount, setAmendeAmount] = useState("");
const [amendeReason, setAmendeReason] = useState("");
const [amendeLoading, setAmendeLoading] = useState(false);
const [pointsInputs, setPointsInputs] = useState<Record<string, string>>(
{},
);
const [pointsLoading, setPointsLoading] = useState<Record<string, boolean>>(
{},
);
const [subtractInputs, setSubtractInputs] = useState<
Record<string, string>
>({});
const [subtractLoading, setSubtractLoading] = useState<
Record<string, boolean>
>({});
// Cancelled orders modal
const [cancelledModal, setCancelledModal] = useState<{
visible: boolean;
username: string;
orders: CancelledOrder[];
loading: boolean;
}>({ visible: false, username: "", orders: [], loading: false });
const { alert, showError, showSuccess, showConfirm, hideAlert } =
useAlert();
// --------------------------------------------------
// Data
// --------------------------------------------------
const loadData = useCallback(async () => {
try {
const [usersData, clientsData, settingsRes] = await Promise.all([
getAllUsers(),
getAllClients(),
getSettings(),
]);
setUsers(usersData);
setClients(clientsData);
if (settingsRes.success && settingsRes.settings) {
const pools = settingsRes.settings.points_pools ?? [];
setPoolNames(pools.map((p) => p.name));
setPoolKeys(pools.map((p) => p.key));
setPointsEnabled(settingsRes.settings.points_enabled ?? true);
setAmendeEnabled(
settingsRes.settings.show_amende_score ?? true,
);
setReferralEnabled(
settingsRes.settings.referral_enabled ?? true,
);
}
// Charger le parrain de chaque client en parallèle (best-effort,
// une erreur sur un client n'empêche pas les autres de s'afficher).
const clientUsernames = (clientsData ?? []).map((c) => c.username);
if (clientUsernames.length > 0) {
const results = await Promise.all(
clientUsernames.map(async (username) => {
const res = await getClientParrain(username);
return [
username,
res.success ? (res.parrain ?? null) : null,
] as const;
}),
);
const map: Record<string, string | null> = {};
for (const [username, parrain] of results) {
map[username] = parrain;
}
setParrainMap(map);
} else {
setParrainMap({});
}
} catch {
/* ignore */
}
setLoading(false);
}, []);
useEffect(() => {
loadData();
}, [loadData]);
const onRefresh = async () => {
setRefreshing(true);
await loadData();
setRefreshing(false);
};
// --------------------------------------------------
// Merged & filtered list
// --------------------------------------------------
const getMergedUsers = () => {
// Build a map of client details by username
const clientMap = new Map<string, ClientResponse>();
clients.forEach((c) => clientMap.set(c.username, c));
// Merge: for each user from getAllUsers, attach client details if role=client
let merged = users.map((u) => ({
...u,
clientData:
u.role === "client" ? clientMap.get(u.username) || null : null,
}));
// Also add clients that might not be in users list
clients.forEach((c) => {
if (!merged.find((m) => m.username === c.username)) {
merged.push({
id: c.id,
username: c.username,
role: "client",
clientData: c,
});
}
});
if (filter !== "all") {
merged = merged.filter((u) => u.role === filter);
}
if (search.trim()) {
const q = search.trim().toLowerCase();
merged = merged.filter((u) => {
if (u.username.toLowerCase().includes(q)) return true;
if (u.clientData) {
const c = u.clientData;
return (
c.nom.toLowerCase().includes(q) ||
c.prenom.toLowerCase().includes(q) ||
c.telephone.toLowerCase().includes(q)
);
}
return false;
});
}
return merged;
};
const mergedUsers = getMergedUsers();
// Stats
const totalClients =
users.filter((u) => u.role === "client").length || clients.length;
const totalLivreurs = users.filter((u) => u.role === "livreur").length;
const totalCabines = users.filter((u) => u.role === "cabine").length;
const totalAdmins = users.filter((u) => u.role === "admin").length;
const getCountForFilter = (f: RoleFilter) => {
if (f === "all") return mergedUsers.length;
if (f === "client") return totalClients;
if (f === "livreur") return totalLivreurs;
if (f === "cabine") return totalCabines;
if (f === "admin") return totalAdmins;
return 0;
};
// --------------------------------------------------
// Edit client
// --------------------------------------------------
const openEditClient = (client: ClientResponse) => {
setEditNom(client.nom);
setEditPrenom(client.prenom);
setEditTel(client.telephone);
setEditClientUsername(client.username);
setEditClientPassword("");
setEditClientModal({ visible: true, client });
};
const handleSaveClient = async () => {
if (!editClientModal.client) return;
if (editClientPassword.trim() && editClientPassword.trim().length < 8) {
showError(
"Erreur",
"Le mot de passe doit contenir au moins 8 caractères",
);
return;
}
try {
const updates: Record<string, any> = {
nom: editNom,
prenom: editPrenom,
telephone: editTel,
username: editClientUsername,
};
if (editClientPassword.trim()) {
updates.password = editClientPassword.trim();
}
await updateClientByAdmin(editClientModal.client.id, updates);
setEditClientModal({ visible: false, client: null });
await loadData();
} catch (e: any) {
showError("Erreur", e.response?.data?.error || e.message);
}
};
// --------------------------------------------------
// Edit user (livreur/cabine)
// --------------------------------------------------
const openEditUser = (user: UserItem) => {
setEditUsername(user.username);
setEditRole(user.role);
setEditUserPassword("");
setEditUserModal({ visible: true, user });
};
const handleSaveUser = async () => {
if (!editUserModal.user) return;
if (editUserPassword.trim() && editUserPassword.trim().length < 8) {
showError(
"Erreur",
"Le mot de passe doit faire au moins 8 caractères",
);
return;
}
try {
const updates: Record<string, any> = {};
if (editUsername.trim() !== editUserModal.user.username) {
updates.username = editUsername.trim();
}
if (editRole !== editUserModal.user.role) {
updates.role = editRole;
}
if (editUserPassword.trim()) {
updates.password = editUserPassword.trim();
}
if (Object.keys(updates).length === 0) {
setEditUserModal({ visible: false, user: null });
return;
}
await updateUserByAdmin(editUserModal.user.id, updates);
setEditUserModal({ visible: false, user: null });
await loadData();
} catch (e: any) {
showError("Erreur", e.response?.data?.error || e.message);
}
};
// --------------------------------------------------
// Delete user/client
// --------------------------------------------------
const handleDelete = (item: ReturnType<typeof getMergedUsers>[0]) => {
const label =
item.role === "client"
? "client"
: getRoleLabel(item.role).toLowerCase();
showConfirm(
"Supprimer",
`Supprimer le ${label} "${item.username}" ?`,
async () => {
try {
if (item.role === "client" && item.clientData) {
await deleteClientAdmin(item.clientData.id);
} else {
await deleteUserAdmin(item.id);
}
await loadData();
} catch (e: any) {
showError("Erreur", e.message);
}
},
"Supprimer",
);
};
// --------------------------------------------------
// Referral credit
// --------------------------------------------------
const openReferralModal = (client: ClientResponse) => {
setReferralAmount("");
setReferralModal({
visible: true,
username: client.username,
currentBalance: client.referral_balance ?? 0,
});
};
const handleCreditReferral = async () => {
const amount = parseFloat(referralAmount);
if (isNaN(amount) || amount <= 0) {
showError("Erreur", "Entrez un montant valide");
return;
}
setReferralLoading(true);
try {
const res = await creditClientReferral(
referralModal.username,
amount,
);
if (res.success) {
setReferralModal((prev) => ({ ...prev, visible: false }));
await loadData();
} else {
showError("Erreur", res.message || "Echec du crédit");
}
} catch (e: any) {
showError("Erreur", e.message);
} finally {
setReferralLoading(false);
}
};
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
// --------------------------------------------------
const openSanctionModal = (client: ClientResponse) => {
setAmendeAmount("");
setAmendeReason("");
setPointsInputs({});
setPointsLoading({});
setSubtractInputs({});
setSubtractLoading({});
setSanctionTab("amende");
setSanctionModal({ visible: true, client });
};
const handleAddAmende = () => {
const pts = parseInt(amendeAmount, 10);
if (isNaN(pts) || pts <= 0) {
showError("Erreur", "Entrez un montant valide");
return;
}
if (!amendeReason.trim()) {
showError("Erreur", "La raison est obligatoire");
return;
}
if (!sanctionModal.client) return;
showConfirm(
"Confirmer l'amende",
`Ajouter ${pts} € d'amende à "${sanctionModal.client.username}" ?\nRaison : ${amendeReason.trim()}`,
async () => {
setAmendeLoading(true);
const res = await applyClientPenalty(
sanctionModal.client!.username,
pts,
amendeReason.trim(),
);
setAmendeLoading(false);
if (!res.success) {
showError("Erreur", res.error || "Echec");
return;
}
setAmendeAmount("");
setAmendeReason("");
if (res.compensated) {
showSuccess(
"Compensation",
"Amende annulée — solde parrainage débité en compensation",
);
} else {
showSuccess(
"Validé",
`Amende de ${pts} € appliquée à "${sanctionModal.client!.username}"`,
);
}
await loadData();
},
);
};
const handleResetAmende = () => {
if (!sanctionModal.client) return;
showConfirm(
"Remettre à zéro",
`Remettre l'amende de "${sanctionModal.client.username}" à 0 ?`,
async () => {
const res = await resetClientPenalties(
sanctionModal.client!.username,
false,
);
if (!res.success) {
showError("Erreur", res.error || "Echec");
return;
}
showSuccess(
"Validé",
`Amende de "${sanctionModal.client!.username}" remise à zéro`,
);
await loadData();
},
"Remettre à zéro",
);
};
const handleAddPoints = (poolKey: string, poolName: string) => {
const pts = parseInt(pointsInputs[poolKey] || "", 10);
if (isNaN(pts) || pts <= 0) {
showError("Erreur", "Entrez un nombre de points valide");
return;
}
if (!sanctionModal.client) return;
showConfirm(
"Confirmer l'ajout",
`Ajouter ${pts} points "${poolName}" à "${sanctionModal.client.username}" ?`,
async () => {
setPointsLoading((prev) => ({ ...prev, [poolKey]: true }));
const res = await addClientPoints(
sanctionModal.client!.username,
poolKey,
pts,
);
setPointsLoading((prev) => ({ ...prev, [poolKey]: false }));
if (!res.success) {
showError("Erreur", res.error || "Echec");
return;
}
setPointsInputs((prev) => ({ ...prev, [poolKey]: "" }));
showSuccess(
"Validé",
`${pts} points "${poolName}" ajoutés à "${sanctionModal.client!.username}"`,
);
await loadData();
},
);
};
const handleSubtractPoints = (poolKey: string, poolName: string) => {
const pts = parseInt(subtractInputs[poolKey] || "", 10);
if (isNaN(pts) || pts <= 0) {
showError("Erreur", "Entrez un nombre de points valide");
return;
}
if (!sanctionModal.client) return;
showConfirm(
"Confirmer le retrait",
`Retirer ${pts} points "${poolName}" à "${sanctionModal.client.username}" ?`,
async () => {
setSubtractLoading((prev) => ({ ...prev, [poolKey]: true }));
const res = await subtractClientPoints(
sanctionModal.client!.username,
poolKey,
pts,
);
setSubtractLoading((prev) => ({ ...prev, [poolKey]: false }));
if (!res.success) {
showError("Erreur", res.error || "Echec");
return;
}
setSubtractInputs((prev) => ({ ...prev, [poolKey]: "" }));
showSuccess(
"Validé",
`${pts} points "${poolName}" retirés à "${sanctionModal.client!.username}"`,
);
await loadData();
},
);
};
const handleResetPoints = (
poolKey: string,
poolName: string,
poolIdx: number,
) => {
if (!sanctionModal.client) return;
showConfirm(
"Remettre à zéro",
`Remettre les points "${poolName}" de "${sanctionModal.client.username}" à 0 ?`,
async () => {
const res = await resetClientPoints(
sanctionModal.client!.username,
poolIdx,
);
if (!res.success) {
showError("Erreur", res.error || "Echec");
return;
}
showSuccess(
"Validé",
`Points "${poolName}" de "${sanctionModal.client!.username}" remis à zéro`,
);
await loadData();
},
"Remettre à zéro",
);
};
// --------------------------------------------------
// Cancelled orders
// --------------------------------------------------
const openCancelledOrders = async (client: ClientResponse) => {
setCancelledModal({
visible: true,
username: client.username,
orders: [],
loading: true,
});
const res = await getClientCancelledOrders(client.username);
setCancelledModal((prev) => ({
...prev,
orders: res.orders,
loading: false,
}));
};
// --------------------------------------------------
// Create user
// --------------------------------------------------
const openCreateModal = () => {
setCreateType("client");
setCreateUsername("");
setCreatePassword("");
setCreateNom("");
setCreatePrenom("");
setCreateTel("");
setCreateParrain("");
setCreateModal(true);
};
const handleCreate = async () => {
if (!createUsername.trim() || !createPassword.trim()) {
showError("Erreur", "Nom d'utilisateur et mot de passe requis");
return;
}
if (createPassword.trim().length < 8) {
showError(
"Erreur",
"Le mot de passe doit contenir au moins 8 caractères",
);
return;
}
if (createType === "client") {
if (
!createNom.trim() ||
!createPrenom.trim() ||
!createTel.trim()
) {
showError(
"Erreur",
"Nom, prénom et téléphone requis pour un client",
);
return;
}
}
setCreating(true);
const usernameToCreate = createUsername.trim();
try {
if (createType === "client") {
await createClientByAdmin({
username: usernameToCreate,
password: createPassword.trim(),
nom: createNom.trim(),
prenom: createPrenom.trim(),
telephone: createTel.trim(),
});
if (createParrain.trim()) {
await setClientParrain(
usernameToCreate,
createParrain.trim(),
);
}
} else {
await createUserByAdmin({
username: usernameToCreate,
password: createPassword.trim(),
role: createType,
});
}
setCreateModal(false);
await loadData();
} catch (e: any) {
// Erreur réseau : pas de réponse HTTP reçue — le backend a peut-être
// quand même créé l'utilisateur. On recharge et on vérifie.
if (!e.response) {
try {
if (createType === "client") {
const freshClients = await getAllClients();
setClients(freshClients);
if (
freshClients.find(
(c) => c.username === usernameToCreate,
)
) {
setCreateModal(false);
return;
}
} else {
const freshUsers = await getAllUsers();
setUsers(freshUsers);
if (
freshUsers.find(
(u: any) => u.username === usernameToCreate,
)
) {
setCreateModal(false);
return;
}
}
} catch {
/* ignore, on affiche l'erreur originale */
}
}
showError("Erreur", e.response?.data?.error || e.message);
} finally {
setCreating(false);
}
};
// --------------------------------------------------
// Styles
// --------------------------------------------------
const styles = useMemo(
() =>
StyleSheet.create({
container: { flex: 1, backgroundColor: colors.bgPrimary },
// Filter grid
filterGrid: {
flexDirection: "row",
paddingHorizontal:
screenWidth < 380 ? spacing.s : spacing.m,
paddingVertical: spacing.s,
gap: screenWidth < 380 ? spacing.xs : spacing.s,
},
filterCard: {
flex: 1,
alignItems: "center",
paddingVertical: spacing.s,
paddingHorizontal: 2,
borderRadius: borderRadius.md,
borderWidth: 1,
borderColor: colors.border,
backgroundColor: colors.bgSecondary,
},
filterIconCircle: {
width: screenWidth < 380 ? 28 : 36,
height: screenWidth < 380 ? 28 : 36,
borderRadius: screenWidth < 380 ? 14 : 18,
justifyContent: "center",
alignItems: "center",
marginBottom: 2,
},
filterCount: {
color: colors.textWhite,
fontSize: screenWidth < 380 ? fontSize.sm : fontSize.md,
fontWeight: "700",
},
filterLabel: {
color: colors.textMuted,
fontSize: screenWidth < 380 ? 10 : fontSize.xs,
fontWeight: "600",
textAlign: "center",
},
// Card
cardHeader: {
flexDirection: "row",
justifyContent: "space-between",
alignItems: "flex-start",
},
userInfo: {
flexDirection: "row",
alignItems: "center",
flex: 1,
gap: spacing.m,
},
roleIcon: {
width: 40,
height: 40,
borderRadius: 20,
justifyContent: "center",
alignItems: "center",
},
username: {
fontSize: fontSize.md,
fontWeight: "700",
color: colors.textWhite,
},
roleBadge: {
alignSelf: "flex-start",
paddingHorizontal: spacing.s,
paddingVertical: 2,
borderRadius: borderRadius.sm,
marginTop: 4,
},
roleBadgeText: { fontSize: fontSize.xs, fontWeight: "600" },
editIconBtn: { padding: spacing.s },
// Client details
clientDetails: { marginTop: spacing.s, marginLeft: 52 },
clientName: {
color: colors.textSecondary,
fontSize: fontSize.sm,
},
clientTel: {
color: colors.textMuted,
fontSize: fontSize.sm,
marginTop: 2,
},
parrainRow: {
flexDirection: "row",
alignItems: "center",
gap: 4,
marginTop: 4,
},
parrainText: {
color: colors.textMuted,
fontSize: fontSize.xs,
},
parrainName: {
color: colors.success,
fontWeight: "600",
},
statsRow: {
flexDirection: "row",
flexWrap: "wrap",
marginTop: spacing.s,
gap: spacing.s,
marginLeft: 52,
},
stat: {
alignItems: "center",
minWidth: 52,
paddingVertical: spacing.xs,
},
statValue: {
fontSize: fontSize.sm,
fontWeight: "bold",
color: colors.textWhite,
textAlign: "center",
},
statLabel: {
fontSize: fontSize.xs,
color: colors.textMuted,
textAlign: "center",
},
// Non-client
nonClientInfo: {
color: colors.textMuted,
fontSize: fontSize.sm,
marginTop: spacing.xs,
marginLeft: 52,
},
empty: {
color: colors.textMuted,
textAlign: "center",
marginTop: spacing.xxl,
},
// Search bar
searchContainer: {
flexDirection: "row",
alignItems: "center",
marginHorizontal: spacing.m,
marginBottom: spacing.s,
paddingHorizontal: spacing.m,
borderRadius: borderRadius.md,
backgroundColor: colors.bgSecondary,
borderWidth: 1,
borderColor: colors.border,
height: 44,
gap: spacing.s,
},
searchInput: {
flex: 1,
color: colors.textWhite,
fontSize: fontSize.sm,
},
// Add button
addButton: {
flexDirection: "row",
alignItems: "center",
justifyContent: "center",
gap: spacing.s,
marginHorizontal: spacing.m,
marginBottom: spacing.s,
paddingVertical: spacing.m,
borderRadius: borderRadius.md,
backgroundColor: colors.accent,
},
addButtonText: {
color: colors.textWhite,
fontSize: fontSize.md,
fontWeight: "700",
},
// Edit user modal
roleEditLabel: {
color: colors.textSecondary,
fontSize: fontSize.sm,
fontWeight: "600",
marginBottom: spacing.s,
marginTop: spacing.s,
},
roleEditRow: {
flexDirection: "row",
gap: spacing.s,
marginBottom: spacing.l,
},
roleEditBtn: {
flex: 1,
alignItems: "center",
paddingVertical: spacing.s,
borderRadius: borderRadius.md,
borderWidth: 1,
borderColor: colors.border,
backgroundColor: colors.bgInput,
},
roleEditBtnText: {
color: colors.textMuted,
fontSize: fontSize.sm,
fontWeight: "600",
},
}),
[colors, screenWidth],
);
// --------------------------------------------------
// Render
// --------------------------------------------------
const renderUser = ({
item,
}: {
item: ReturnType<typeof getMergedUsers>[0];
}) => {
const isClient = item.role === "client" && item.clientData;
const roleColor = getRoleBadgeColor(item.role);
return (
<Card style={{ marginBottom: spacing.m }}>
<View style={styles.cardHeader}>
<View style={styles.userInfo}>
<View
style={[
styles.roleIcon,
{ backgroundColor: roleColor + "20" },
]}
>
<Ionicons
name={
item.role === "client"
? "person"
: item.role === "livreur"
? "bicycle"
: item.role === "cabine"
? "desktop"
: "shield"
}
size={18}
color={roleColor}
/>
</View>
<View style={{ flex: 1 }}>
<Text style={styles.username}>{item.username}</Text>
<View
style={[
styles.roleBadge,
{ backgroundColor: roleColor + "20" },
]}
>
<Text
style={[
styles.roleBadgeText,
{ color: roleColor },
]}
>
{getRoleLabel(item.role)}
</Text>
</View>
</View>
</View>
<View style={{ flexDirection: "row", gap: spacing.xs }}>
{isClient && (
<>
{referralEnabled && (
<TouchableOpacity
style={styles.editIconBtn}
onPress={() =>
openReferralModal(item.clientData!)
}
>
<Ionicons
name="gift-outline"
size={20}
color={colors.success}
/>
</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!)
}
>
<Ionicons
name="ban-outline"
size={20}
color={colors.danger}
/>
</TouchableOpacity>
<TouchableOpacity
style={styles.editIconBtn}
onPress={() =>
openSanctionModal(item.clientData!)
}
>
<Ionicons
name="wallet-outline"
size={20}
color={colors.warning}
/>
</TouchableOpacity>
<TouchableOpacity
style={styles.editIconBtn}
onPress={() =>
openEditClient(item.clientData!)
}
>
<Ionicons
name="create-outline"
size={20}
color={colors.info}
/>
</TouchableOpacity>
</>
)}
{(item.role === "livreur" ||
item.role === "cabine") && (
<TouchableOpacity
style={styles.editIconBtn}
onPress={() => openEditUser(item)}
>
<Ionicons
name="create-outline"
size={20}
color={colors.info}
/>
</TouchableOpacity>
)}
{item.role !== "admin" && (
<TouchableOpacity
style={styles.editIconBtn}
onPress={() => handleDelete(item)}
>
<Ionicons
name="trash-outline"
size={20}
color={colors.danger}
/>
</TouchableOpacity>
)}
</View>
</View>
{/* Client details */}
{isClient && item.clientData && (
<>
<View style={styles.clientDetails}>
<Text style={styles.clientName}>
{item.clientData.prenom} {item.clientData.nom}
</Text>
<Text style={styles.clientTel}>
{item.clientData.telephone}
</Text>
{parrainMap[item.clientData.username] ? (
<View style={styles.parrainRow}>
<Ionicons
name="gift-outline"
size={12}
color={colors.success}
/>
<Text style={styles.parrainText}>
Parrainé par{" "}
<Text style={styles.parrainName}>
{
parrainMap[
item.clientData.username
]
}
</Text>
</Text>
</View>
) : null}
</View>
<View style={styles.statsRow}>
<View style={styles.stat}>
<Text
style={styles.statValue}
numberOfLines={1}
>
{item.clientData.command}
</Text>
<Text
style={styles.statLabel}
numberOfLines={1}
>
Cmd
</Text>
</View>
{pointsEnabled &&
poolNames.map((name, i) => {
const key = poolKeys[i] ?? "";
const value = key
? (item.clientData!.points_extra?.[
key
] ?? 0)
: 0;
const color =
i === 0
? colors.success
: i === 1
? colors.info
: colors.warning;
return (
<View key={i} style={styles.stat}>
<Text
style={[
styles.statValue,
{ color },
]}
numberOfLines={1}
>
{value}
</Text>
<Text
style={styles.statLabel}
numberOfLines={1}
>
{name}
</Text>
</View>
);
})}
{amendeEnabled && (
<View style={styles.stat}>
<Text
style={[
styles.statValue,
{ color: colors.warning },
]}
numberOfLines={1}
>
{item.clientData.amende}
</Text>
<Text
style={styles.statLabel}
numberOfLines={1}
>
Amende
</Text>
</View>
)}
<View style={styles.stat}>
<Text
style={[
styles.statValue,
{ color: colors.danger },
]}
numberOfLines={1}
>
{item.clientData.cancellations_count}
</Text>
<Text
style={styles.statLabel}
numberOfLines={1}
>
Annul.
</Text>
</View>
{referralEnabled &&
(item.clientData.referral_balance ?? 0) > 0 && (
<View style={styles.stat}>
<Text
style={[
styles.statValue,
{ color: colors.success },
]}
numberOfLines={1}
>
{(
item.clientData
.referral_balance ?? 0
).toFixed(0)}
</Text>
<Text
style={styles.statLabel}
numberOfLines={1}
>
Parrain
</Text>
</View>
)}
</View>
</>
)}
{/* Non-client: just show role + ID */}
{!isClient && (
<Text style={styles.nonClientInfo}>ID: {item.id}</Text>
)}
</Card>
);
};
if (loading) return <LoadingSpinner message="Chargement utilisateurs..." />;
return (
<View style={styles.container}>
{/* Role filter grid */}
<View style={styles.filterGrid}>
{ROLE_TABS.map((tab) => {
const active = filter === tab.key;
const count = getCountForFilter(tab.key);
return (
<TouchableOpacity
key={tab.key}
style={[
styles.filterCard,
active && {
backgroundColor: tab.color + "15",
borderColor: tab.color,
},
]}
onPress={() => setFilter(tab.key)}
activeOpacity={0.7}
>
<View
style={[
styles.filterIconCircle,
{
backgroundColor:
(active
? tab.color
: colors.textMuted) + "20",
},
]}
>
<Ionicons
name={tab.icon as any}
size={20}
color={
active ? tab.color : colors.textMuted
}
/>
</View>
<Text
style={[
styles.filterCount,
active && { color: tab.color },
]}
>
{count}
</Text>
<Text
style={[
styles.filterLabel,
active && { color: tab.color },
]}
>
{tab.label}
</Text>
</TouchableOpacity>
);
})}
</View>
{/* Search bar */}
<View style={styles.searchContainer}>
<Ionicons
name="search-outline"
size={18}
color={colors.textMuted}
/>
<RNTextInput
style={styles.searchInput}
placeholder="Rechercher par nom, username, téléphone..."
placeholderTextColor={colors.textMuted}
value={search}
onChangeText={setSearch}
autoCapitalize="none"
autoCorrect={false}
clearButtonMode="while-editing"
/>
{search.length > 0 && (
<TouchableOpacity onPress={() => setSearch("")}>
<Ionicons
name="close-circle"
size={18}
color={colors.textMuted}
/>
</TouchableOpacity>
)}
</View>
{/* Add user button */}
<TouchableOpacity
style={styles.addButton}
onPress={openCreateModal}
activeOpacity={0.7}
>
<Ionicons
name="add-circle"
size={22}
color={colors.textWhite}
/>
<Text style={styles.addButtonText}>Créer un utilisateur</Text>
</TouchableOpacity>
{/* List */}
<FlatList
data={mergedUsers}
keyExtractor={(item) => `${item.role}-${item.id}`}
renderItem={renderUser}
refreshControl={
<RefreshControl
refreshing={refreshing}
onRefresh={onRefresh}
tintColor={colors.accent}
/>
}
contentContainerStyle={{
padding: spacing.l,
paddingBottom: 100,
}}
ListEmptyComponent={
<Text style={styles.empty}>Aucun utilisateur</Text>
}
/>
{/* Edit client modal */}
<Modal
visible={editClientModal.visible}
onClose={() =>
setEditClientModal({ visible: false, client: null })
}
title="Modifier client"
icon="person-outline"
>
<TextInput
label="Nom d'utilisateur"
value={editClientUsername}
onChangeText={setEditClientUsername}
autoCapitalize="none"
/>
<TextInput
label="Mot de passe (laisser vide pour ne pas changer)"
value={editClientPassword}
onChangeText={setEditClientPassword}
secureTextEntry
/>
<TextInput
label="Nom"
value={editNom}
onChangeText={setEditNom}
/>
<TextInput
label="Prénom"
value={editPrenom}
onChangeText={setEditPrenom}
/>
<TextInput
label="Téléphone"
value={editTel}
onChangeText={setEditTel}
keyboardType="phone-pad"
/>
<Button
title="Enregistrer"
onPress={handleSaveClient}
fullWidth
/>
</Modal>
{/* Edit user modal (livreur/cabine) */}
<Modal
visible={editUserModal.visible}
onClose={() => setEditUserModal({ visible: false, user: null })}
title={`Modifier ${editUserModal.user ? getRoleLabel(editUserModal.user.role) : ""}`}
icon="create-outline"
>
<TextInput
label="Nom d'utilisateur"
value={editUsername}
onChangeText={setEditUsername}
autoCapitalize="none"
/>
<TextInput
label="Nouveau mot de passe (laisser vide pour ne pas changer)"
value={editUserPassword}
onChangeText={setEditUserPassword}
secureTextEntry
autoCapitalize="none"
/>
<Text style={styles.roleEditLabel}>Rôle</Text>
<View style={styles.roleEditRow}>
{["livreur", "cabine", "admin"].map((r) => (
<TouchableOpacity
key={r}
style={[
styles.roleEditBtn,
editRole === r && {
backgroundColor:
getRoleBadgeColor(r) + "20",
borderColor: getRoleBadgeColor(r),
},
]}
onPress={() => setEditRole(r)}
>
<Text
style={[
styles.roleEditBtnText,
editRole === r && {
color: getRoleBadgeColor(r),
},
]}
>
{getRoleLabel(r)}
</Text>
</TouchableOpacity>
))}
</View>
<Button
title="Enregistrer"
onPress={handleSaveUser}
fullWidth
/>
</Modal>
{/* Create user modal */}
<Modal
visible={createModal}
onClose={() => setCreateModal(false)}
title="Créer un utilisateur"
icon="person-add-outline"
>
<Text style={styles.roleEditLabel}>Type d'utilisateur</Text>
<View style={styles.roleEditRow}>
{(["client", "cabine", "livreur"] as const).map((r) => (
<TouchableOpacity
key={r}
style={[
styles.roleEditBtn,
createType === r && {
backgroundColor:
getRoleBadgeColor(r) + "20",
borderColor: getRoleBadgeColor(r),
},
]}
onPress={() => setCreateType(r)}
>
<Text
style={[
styles.roleEditBtnText,
createType === r && {
color: getRoleBadgeColor(r),
},
]}
>
{getRoleLabel(r)}
</Text>
</TouchableOpacity>
))}
</View>
<TextInput
label="Nom d'utilisateur"
value={createUsername}
onChangeText={setCreateUsername}
autoCapitalize="none"
/>
<TextInput
label="Mot de passe"
value={createPassword}
onChangeText={setCreatePassword}
secureTextEntry
/>
{createType === "client" && (
<>
<TextInput
label="Nom"
value={createNom}
onChangeText={setCreateNom}
/>
<TextInput
label="Prénom"
value={createPrenom}
onChangeText={setCreatePrenom}
/>
<TextInput
label="Téléphone"
value={createTel}
onChangeText={setCreateTel}
keyboardType="phone-pad"
/>
{referralEnabled && (
<TextInput
label="Parrain (optionnel)"
value={createParrain}
onChangeText={setCreateParrain}
autoCapitalize="none"
icon={
<Ionicons
name="gift-outline"
size={18}
color={colors.textMuted}
/>
}
/>
)}
</>
)}
<Button
title={creating ? "Création..." : "Créer"}
onPress={handleCreate}
fullWidth
disabled={creating}
/>
</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}
/>
{/* Modal commandes annulées */}
<Modal
visible={cancelledModal.visible}
onClose={() =>
setCancelledModal((prev) => ({ ...prev, visible: false }))
}
title={`Annulations — ${cancelledModal.username}`}
icon="ban-outline"
iconColor={colors.danger}
>
{cancelledModal.loading ? (
<ActivityIndicator
color={colors.danger}
style={{ marginVertical: spacing.xl }}
/>
) : cancelledModal.orders.length === 0 ? (
<Text
style={{
color: colors.textMuted,
textAlign: "center",
paddingVertical: spacing.xl,
}}
>
Aucune commande annulée
</Text>
) : (
<ScrollView
style={{ maxHeight: 400 }}
showsVerticalScrollIndicator={false}
>
{cancelledModal.orders.map((order, idx) => {
const date = order.cancellation?.cancelled_at
? new Date(
order.cancellation.cancelled_at,
).toLocaleDateString("fr-FR", {
day: "2-digit",
month: "2-digit",
year: "numeric",
})
: new Date(order.updated_at).toLocaleDateString(
"fr-FR",
{
day: "2-digit",
month: "2-digit",
year: "numeric",
},
);
const reason =
order.cancel_reason ||
order.cancellation?.reason ||
"";
return (
<View
key={order.id}
style={{
borderBottomWidth:
idx <
cancelledModal.orders.length - 1
? 1
: 0,
borderBottomColor: colors.border,
paddingVertical: spacing.m,
gap: spacing.xs,
}}
>
<View
style={{
flexDirection: "row",
justifyContent: "space-between",
alignItems: "center",
}}
>
<Text
style={{
color: colors.textWhite,
fontWeight: "700",
fontSize: fontSize.sm,
}}
>
Commande #{order.id}
</Text>
<Text
style={{
color: colors.textMuted,
fontSize: fontSize.xs,
}}
>
{date}
</Text>
</View>
<View
style={{
flexDirection: "row",
justifyContent: "space-between",
}}
>
<Text
style={{
color: colors.textMuted,
fontSize: fontSize.xs,
}}
>
{order.items_count} article
{order.items_count > 1 ? "s" : ""}
</Text>
<Text
style={{
color: colors.danger,
fontSize: fontSize.sm,
fontWeight: "600",
}}
>
{Number(order.total_prix).toFixed(
2,
)}{" "}
</Text>
</View>
{reason ? (
<Text
style={{
color: colors.warning,
fontSize: fontSize.xs,
fontStyle: "italic",
}}
>
Raison : {reason}
</Text>
) : (
<Text
style={{
color: colors.textMuted,
fontSize: fontSize.xs,
fontStyle: "italic",
}}
>
Aucune raison fournie
</Text>
)}
</View>
);
})}
</ScrollView>
)}
</Modal>
{/* Modal points & amendes */}
<Modal
visible={sanctionModal.visible}
onClose={() =>
setSanctionModal({ visible: false, client: null })
}
title={`Points & amendes — ${sanctionModal.client?.username ?? ""}`}
icon="wallet-outline"
iconColor={colors.warning}
>
{/* Onglets */}
<View
style={{
flexDirection: "row",
gap: spacing.s,
marginBottom: spacing.l,
}}
>
{(["amende", "points"] as const).map((tab) => (
<TouchableOpacity
key={tab}
onPress={() => setSanctionTab(tab)}
style={{
flex: 1,
alignItems: "center",
paddingVertical: spacing.s,
borderRadius: borderRadius.md,
backgroundColor:
sanctionTab === tab
? (tab === "amende"
? colors.danger
: colors.success) + "20"
: colors.bgInput,
borderWidth: 1,
borderColor:
sanctionTab === tab
? tab === "amende"
? colors.danger
: colors.success
: colors.border,
}}
>
<Ionicons
name={
tab === "amende"
? "warning-outline"
: "star-outline"
}
size={16}
color={
sanctionTab === tab
? tab === "amende"
? colors.danger
: colors.success
: colors.textMuted
}
/>
<Text
style={{
marginTop: 2,
fontSize: fontSize.xs,
fontWeight: "600",
color:
sanctionTab === tab
? tab === "amende"
? colors.danger
: colors.success
: colors.textMuted,
}}
>
{tab === "amende" ? "Amende" : "Points"}
</Text>
</TouchableOpacity>
))}
</View>
{sanctionTab === "amende" && (
<>
{/* Valeur actuelle */}
<View
style={{
flexDirection: "row",
justifyContent: "space-between",
alignItems: "center",
backgroundColor: colors.bgInput,
borderRadius: borderRadius.md,
padding: spacing.m,
marginBottom: spacing.m,
}}
>
<Text
style={{
color: colors.textSecondary,
fontSize: fontSize.sm,
}}
>
Amende actuelle
</Text>
<Text
style={{
color: colors.warning,
fontSize: fontSize.lg,
fontWeight: "700",
}}
>
{sanctionModal.client?.amende ?? 0} €
</Text>
</View>
{/* Ajouter amende */}
<TextInput
label="Montant à ajouter ()"
value={amendeAmount}
onChangeText={setAmendeAmount}
keyboardType="decimal-pad"
icon={
<Ionicons
name="add-circle-outline"
size={18}
color={colors.textMuted}
/>
}
/>
<TextInput
label="Raison (obligatoire)"
value={amendeReason}
onChangeText={setAmendeReason}
icon={
<Ionicons
name="document-text-outline"
size={18}
color={colors.textMuted}
/>
}
/>
<Button
title={
amendeLoading ? "Ajout..." : "Ajouter l'amende"
}
onPress={handleAddAmende}
disabled={amendeLoading}
variant="danger"
fullWidth
style={{ marginBottom: spacing.s }}
/>
{/* Remettre à zéro */}
<Button
title="Remettre l'amende à zéro"
onPress={handleResetAmende}
variant="outline"
fullWidth
/>
</>
)}
{sanctionTab === "points" && (
<ScrollView
showsVerticalScrollIndicator={false}
style={{ maxHeight: 380 }}
>
{poolKeys.length === 0 ? (
<Text
style={{
color: colors.textMuted,
textAlign: "center",
paddingVertical: spacing.xl,
}}
>
Aucun pool de points configuré
</Text>
) : (
poolKeys.map((key, idx) => {
const name = poolNames[idx] ?? key;
const current =
sanctionModal.client?.points_extra?.[key] ??
0;
const color =
idx === 0
? colors.success
: idx === 1
? colors.info
: colors.warning;
return (
<View
key={key}
style={{
borderWidth: 1,
borderColor: colors.border,
borderRadius: borderRadius.md,
padding: spacing.m,
marginBottom: spacing.m,
gap: spacing.s,
}}
>
{/* Header pool */}
<View
style={{
flexDirection: "row",
justifyContent: "space-between",
alignItems: "center",
}}
>
<Text
style={{
color: colors.textWhite,
fontWeight: "700",
fontSize: fontSize.sm,
}}
>
{name}
</Text>
<View
style={{
flexDirection: "row",
alignItems: "center",
gap: spacing.xs,
}}
>
<Ionicons
name="star"
size={14}
color={color}
/>
<Text
style={{
color,
fontWeight: "700",
fontSize: fontSize.md,
}}
>
{current}
</Text>
</View>
</View>
{/* Input + bouton ajouter */}
<View
style={{
flexDirection: "row",
gap: spacing.s,
alignItems: "center",
}}
>
<View style={{ flex: 1 }}>
<TextInput
placeholder="Points à ajouter"
value={
pointsInputs[key] ?? ""
}
onChangeText={(v) =>
setPointsInputs(
(prev) => ({
...prev,
[key]: v,
}),
)
}
keyboardType="numeric"
/>
</View>
<TouchableOpacity
onPress={() =>
handleAddPoints(key, name)
}
disabled={pointsLoading[key]}
style={{
backgroundColor:
color + "20",
borderWidth: 1,
borderColor: color,
borderRadius:
borderRadius.md,
paddingHorizontal:
spacing.m,
paddingVertical: spacing.m,
alignItems: "center",
justifyContent: "center",
minWidth: 72,
}}
>
{pointsLoading[key] ? (
<ActivityIndicator
size="small"
color={color}
/>
) : (
<Text
style={{
color,
fontWeight: "700",
fontSize:
fontSize.sm,
}}
>
+ Ajouter
</Text>
)}
</TouchableOpacity>
</View>
{/* Input + bouton enlever */}
<View
style={{
flexDirection: "row",
gap: spacing.s,
alignItems: "center",
}}
>
<View style={{ flex: 1 }}>
<TextInput
placeholder="Points à enlever"
value={
subtractInputs[key] ??
""
}
onChangeText={(v) =>
setSubtractInputs(
(prev) => ({
...prev,
[key]: v,
}),
)
}
keyboardType="numeric"
/>
</View>
<TouchableOpacity
onPress={() =>
handleSubtractPoints(
key,
name,
)
}
disabled={subtractLoading[key]}
style={{
backgroundColor:
colors.danger + "15",
borderWidth: 1,
borderColor: colors.danger,
borderRadius:
borderRadius.md,
paddingHorizontal:
spacing.m,
paddingVertical: spacing.m,
alignItems: "center",
justifyContent: "center",
minWidth: 72,
}}
>
{subtractLoading[key] ? (
<ActivityIndicator
size="small"
color={colors.danger}
/>
) : (
<Text
style={{
color: colors.danger,
fontWeight: "700",
fontSize:
fontSize.sm,
}}
>
Enlever
</Text>
)}
</TouchableOpacity>
</View>
{/* Bouton reset */}
<TouchableOpacity
onPress={() =>
handleResetPoints(
key,
name,
idx,
)
}
style={{
flexDirection: "row",
alignItems: "center",
justifyContent: "center",
gap: spacing.xs,
paddingVertical: spacing.s,
borderRadius: borderRadius.sm,
borderWidth: 1,
borderColor: colors.border,
}}
>
<Ionicons
name="refresh-outline"
size={14}
color={colors.textMuted}
/>
<Text
style={{
color: colors.textMuted,
fontSize: fontSize.xs,
fontWeight: "600",
}}
>
Remettre à zéro
</Text>
</TouchableOpacity>
</View>
);
})
)}
</ScrollView>
)}
</Modal>
{/* Modal crédit parrainage */}
<Modal
visible={referralModal.visible}
onClose={() =>
setReferralModal((prev) => ({ ...prev, visible: false }))
}
title={`Crédit parrainage — ${referralModal.username}`}
icon="gift-outline"
iconColor={colors.success}
>
<Text
style={{
color: colors.textSecondary,
fontSize: fontSize.sm,
marginBottom: spacing.s,
}}
>
Solde actuel : {referralModal.currentBalance.toFixed(2)} €
</Text>
<TextInput
placeholder="Montant à créditer (ex: 10)"
value={referralAmount}
onChangeText={setReferralAmount}
keyboardType="decimal-pad"
icon={
<Ionicons
name="cash-outline"
size={18}
color={colors.textMuted}
/>
}
/>
<Button
title={referralLoading ? "Chargement..." : "Créditer"}
onPress={handleCreditReferral}
disabled={referralLoading}
variant="success"
size="md"
fullWidth
style={{ marginTop: spacing.m }}
/>
</Modal>
</View>
);
}