2260 lines
93 KiB
TypeScript
2260 lines
93 KiB
TypeScript
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>
|
||
);
|
||
}
|