1167 lines
44 KiB
TypeScript
1167 lines
44 KiB
TypeScript
import React, { useState, useEffect, useCallback, useMemo } from "react";
|
|
import {
|
|
View,
|
|
Text,
|
|
StyleSheet,
|
|
FlatList,
|
|
RefreshControl,
|
|
TouchableOpacity,
|
|
TextInput as RNTextInput,
|
|
} 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,
|
|
creditClientReferral,
|
|
getSettings,
|
|
} 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 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 [filter, setFilter] = useState<RoleFilter>("all");
|
|
const [search, setSearch] = useState("");
|
|
|
|
// 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("");
|
|
|
|
// 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("");
|
|
|
|
// 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 [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);
|
|
|
|
const { alert, showError, 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);
|
|
}
|
|
} 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);
|
|
setEditClientModal({ visible: true, client });
|
|
};
|
|
|
|
const handleSaveClient = async () => {
|
|
if (!editClientModal.client) return;
|
|
try {
|
|
await updateClientByAdmin(editClientModal.client.id, {
|
|
nom: editNom,
|
|
prenom: editPrenom,
|
|
telephone: editTel,
|
|
});
|
|
setEditClientModal({ visible: false, client: null });
|
|
await loadData();
|
|
} catch (e: any) {
|
|
showError("Erreur", e.message);
|
|
}
|
|
};
|
|
|
|
// --------------------------------------------------
|
|
// Edit user (livreur/cabine)
|
|
// --------------------------------------------------
|
|
const openEditUser = (user: UserItem) => {
|
|
setEditUsername(user.username);
|
|
setEditRole(user.role);
|
|
setEditUserModal({ visible: true, user });
|
|
};
|
|
|
|
const handleSaveUser = async () => {
|
|
if (!editUserModal.user) 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 (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);
|
|
}
|
|
};
|
|
|
|
// --------------------------------------------------
|
|
// Create user
|
|
// --------------------------------------------------
|
|
const openCreateModal = () => {
|
|
setCreateType("client");
|
|
setCreateUsername("");
|
|
setCreatePassword("");
|
|
setCreateNom("");
|
|
setCreatePrenom("");
|
|
setCreateTel("");
|
|
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(),
|
|
});
|
|
} 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: spacing.m,
|
|
paddingVertical: spacing.m,
|
|
gap: spacing.s,
|
|
},
|
|
filterCard: {
|
|
flex: 1,
|
|
alignItems: "center",
|
|
paddingVertical: spacing.m,
|
|
borderRadius: borderRadius.md,
|
|
borderWidth: 1,
|
|
borderColor: colors.border,
|
|
backgroundColor: colors.bgSecondary,
|
|
},
|
|
filterIconCircle: {
|
|
width: 36,
|
|
height: 36,
|
|
borderRadius: 18,
|
|
justifyContent: "center",
|
|
alignItems: "center",
|
|
marginBottom: 4,
|
|
},
|
|
filterCount: {
|
|
color: colors.textWhite,
|
|
fontSize: fontSize.lg,
|
|
fontWeight: "700",
|
|
},
|
|
filterLabel: {
|
|
color: colors.textMuted,
|
|
fontSize: fontSize.sm,
|
|
fontWeight: "600",
|
|
},
|
|
|
|
// 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,
|
|
},
|
|
statsRow: {
|
|
flexDirection: "row",
|
|
marginTop: spacing.m,
|
|
gap: spacing.l,
|
|
marginLeft: 52,
|
|
},
|
|
stat: { alignItems: "center" },
|
|
statValue: {
|
|
fontSize: fontSize.md,
|
|
fontWeight: "bold",
|
|
color: colors.textWhite,
|
|
},
|
|
statLabel: { fontSize: fontSize.xs, color: colors.textMuted },
|
|
|
|
// 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],
|
|
);
|
|
|
|
// --------------------------------------------------
|
|
// 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 && (
|
|
<>
|
|
<TouchableOpacity
|
|
style={styles.editIconBtn}
|
|
onPress={() => openReferralModal(item.clientData!)}
|
|
>
|
|
<Ionicons
|
|
name="gift-outline"
|
|
size={20}
|
|
color={colors.success}
|
|
/>
|
|
</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>
|
|
</View>
|
|
<View style={styles.statsRow}>
|
|
<View style={styles.stat}>
|
|
<Text style={styles.statValue}>
|
|
{item.clientData.command}
|
|
</Text>
|
|
<Text style={styles.statLabel}>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 }]}>{value}</Text>
|
|
<Text style={styles.statLabel}>{name}</Text>
|
|
</View>
|
|
);
|
|
})}
|
|
{amendeEnabled && (
|
|
<View style={styles.stat}>
|
|
<Text
|
|
style={[
|
|
styles.statValue,
|
|
{ color: colors.warning },
|
|
]}
|
|
>
|
|
{item.clientData.amende}
|
|
</Text>
|
|
<Text style={styles.statLabel}>Amendes</Text>
|
|
</View>
|
|
)}
|
|
<View style={styles.stat}>
|
|
<Text
|
|
style={[
|
|
styles.statValue,
|
|
{ color: colors.danger },
|
|
]}
|
|
>
|
|
{item.clientData.cancellations_count}
|
|
</Text>
|
|
<Text style={styles.statLabel}>Annul.</Text>
|
|
</View>
|
|
{(item.clientData.referral_balance ?? 0) > 0 && (
|
|
<View style={styles.stat}>
|
|
<Text
|
|
style={[
|
|
styles.statValue,
|
|
{ color: colors.success },
|
|
]}
|
|
>
|
|
{(item.clientData.referral_balance ?? 0).toFixed(0)}€
|
|
</Text>
|
|
<Text style={styles.statLabel}>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"
|
|
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"
|
|
/>
|
|
<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"
|
|
/>
|
|
</>
|
|
)}
|
|
<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 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>
|
|
);
|
|
}
|