chore: v1.0.0
Omnex Plateform App - EAS Build / build (push) Failing after 8m4s

This commit is contained in:
Xor290
2026-08-04 18:47:47 +02:00
parent 736ec86747
commit 877291e684
69 changed files with 35272 additions and 0 deletions
@@ -0,0 +1,259 @@
import React, { useState, useEffect, useCallback, useMemo } from "react";
import {
View,
Text,
StyleSheet,
FlatList,
TouchableOpacity,
RefreshControl,
} from "react-native";
import { Ionicons } from "@expo/vector-icons";
import { spacing, fontSize } from "../../theme";
import { useTheme } from "../../context/ThemeContext";
import { addAddress, deleteAddress, getAllAddresses } from "../../api/api_admin";
import { useAlert } from "../../hooks/useAlert";
import Card from "../../components/ui/Card";
import Modal from "../../components/ui/Modal";
import TextInput from "../../components/ui/TextInput";
import Button from "../../components/ui/Button";
import AlertModal from "../../components/ui/AlertModal";
type AddressEntry = {
invalid_address: string;
correct_address: string;
};
export default function AddressScreen() {
const { colors } = useTheme();
const { alert, showError, showSuccess, showConfirm, hideAlert } = useAlert();
const [addresses, setAddresses] = useState<AddressEntry[]>([]);
const [loading, setLoading] = useState(true);
const [refreshing, setRefreshing] = useState(false);
const [addModal, setAddModal] = useState(false);
const [invalidInput, setInvalidInput] = useState("");
const [correctInput, setCorrectInput] = useState("");
const [saving, setSaving] = useState(false);
const loadAddresses = useCallback(async () => {
try {
const result = await getAllAddresses();
setAddresses(result);
} catch (e: any) {
showError("Erreur", e.response?.data?.error ?? e.message);
} finally {
setLoading(false);
}
}, [showError]);
useEffect(() => {
loadAddresses();
}, [loadAddresses]);
const onRefresh = async () => {
setRefreshing(true);
await loadAddresses();
setRefreshing(false);
};
const openAddModal = () => {
setInvalidInput("");
setCorrectInput("");
setAddModal(true);
};
const handleAdd = useCallback(async () => {
const inv = invalidInput.trim();
const cor = correctInput.trim();
if (!inv || !cor) {
showError("Erreur", "Les deux champs sont obligatoires.");
return;
}
setSaving(true);
try {
await addAddress(inv, cor);
setAddresses((prev) => [
...prev,
{ invalid_address: inv, correct_address: cor },
]);
setAddModal(false);
showSuccess("Succès", "Adresse ajoutée avec succès.");
} catch (e: any) {
showError("Erreur", e.response?.data?.error ?? e.message);
} finally {
setSaving(false);
}
}, [invalidInput, correctInput, showError, showSuccess]);
const handleDelete = useCallback(
(item: AddressEntry) => {
showConfirm(
"Supprimer",
`Supprimer la correction :\n"${item.invalid_address}" → "${item.correct_address}" ?`,
async () => {
try {
await deleteAddress(item.invalid_address, item.correct_address);
setAddresses((prev) =>
prev.filter(
(a) =>
a.invalid_address !== item.invalid_address ||
a.correct_address !== item.correct_address,
),
);
showSuccess("Supprimé", "Adresse supprimée.");
} catch (e: any) {
showError("Erreur", e.response?.data?.error ?? e.message);
}
},
"Supprimer",
);
},
[showConfirm, showError, showSuccess],
);
const styles = useMemo(
() =>
StyleSheet.create({
container: { flex: 1, backgroundColor: colors.bgPrimary },
header: {
flexDirection: "row",
justifyContent: "space-between",
alignItems: "center",
padding: spacing.l,
paddingBottom: spacing.s,
},
title: {
color: colors.textWhite,
fontSize: fontSize.lg,
fontWeight: "700",
},
addBtn: {
backgroundColor: colors.accent,
borderRadius: 20,
padding: spacing.s,
},
invalid: {
color: colors.danger,
fontSize: fontSize.md,
fontWeight: "600",
},
correct: {
color: colors.success,
fontSize: fontSize.md,
fontWeight: "600",
marginTop: 6,
},
label: {
fontSize: fontSize.xs,
fontWeight: "700",
letterSpacing: 0.5,
marginBottom: 2,
},
labelInvalid: {
color: colors.danger,
},
labelCorrect: {
color: colors.success,
},
arrow: {
color: colors.textMuted,
fontSize: fontSize.lg,
marginVertical: 4,
},
row: {
flexDirection: "row",
alignItems: "center",
justifyContent: "space-between",
},
empty: {
color: colors.textMuted,
textAlign: "center",
marginTop: spacing.xxl,
fontSize: fontSize.md,
},
}),
[colors],
);
const renderItem = ({ item }: { item: AddressEntry }) => (
<Card style={{ marginBottom: spacing.m }}>
<View style={styles.row}>
<View style={{ flex: 1, marginRight: spacing.m }}>
<Text style={styles.invalid}>{item.invalid_address}</Text>
<Text style={styles.arrow}></Text>
<Text style={styles.correct}>{item.correct_address}</Text>
</View>
<TouchableOpacity onPress={() => handleDelete(item)}>
<Ionicons name="trash-outline" size={22} color={colors.danger} />
</TouchableOpacity>
</View>
</Card>
);
return (
<View style={styles.container}>
<View style={styles.header}>
<Text style={styles.title}>Corrections d'adresses</Text>
<TouchableOpacity style={styles.addBtn} onPress={openAddModal}>
<Ionicons name="add" size={22} color={colors.textWhite} />
</TouchableOpacity>
</View>
<FlatList
data={addresses}
keyExtractor={(item, i) => `${item.invalid_address}-${i}`}
renderItem={renderItem}
contentContainerStyle={{ padding: spacing.l, paddingTop: spacing.s }}
refreshControl={
<RefreshControl refreshing={refreshing} onRefresh={onRefresh} />
}
ListEmptyComponent={
<Text style={styles.empty}>
{loading
? "Chargement..."
: "Aucune correction.\nAppuyez sur + pour en ajouter une."}
</Text>
}
/>
<Modal
visible={addModal}
onClose={() => setAddModal(false)}
title="Ajouter une correction"
icon="map-outline"
>
<TextInput
label="Adresse invalide"
value={invalidInput}
onChangeText={setInvalidInput}
placeholder="Ex: 10 rue de la paix"
autoCapitalize="none"
/>
<TextInput
label="Adresse correcte"
value={correctInput}
onChangeText={setCorrectInput}
placeholder="Ex: 10 Rue de la Paix, 75001 Paris"
autoCapitalize="none"
/>
<Button
title="Ajouter"
onPress={handleAdd}
loading={saving}
style={{ marginTop: spacing.m }}
/>
</Modal>
<AlertModal
visible={alert.visible}
type={alert.type}
title={alert.title}
message={alert.message}
onClose={hideAlert}
onConfirm={alert.onConfirm}
confirmText={alert.confirmText}
cancelText={alert.cancelText}
/>
</View>
);
}
@@ -0,0 +1,123 @@
import React, { useState, useEffect, useCallback, useMemo } from "react";
import { View, Text, StyleSheet, FlatList, RefreshControl } from "react-native";
import { Ionicons } from "@expo/vector-icons";
import { spacing, fontSize } from "../../theme";
import { useTheme } from "../../context/ThemeContext";
import { getAdminAlerts } from "../../api/api_admin";
import type { Alert as AlertType } from "../../api/types";
import LoadingSpinner from "../../components/ui/LoadingSpinner";
import Card from "../../components/ui/Card";
import Badge from "../../components/ui/Badge";
export default function AlertsScreen() {
const { colors } = useTheme();
const [alerts, setAlerts] = useState<AlertType[]>([]);
const [loading, setLoading] = useState(true);
const [refreshing, setRefreshing] = useState(false);
const loadData = useCallback(async () => {
try {
const result = await getAdminAlerts();
setAlerts(result.alerts);
} catch {
/* ignore */
}
setLoading(false);
}, []);
useEffect(() => {
loadData();
}, [loadData]);
const onRefresh = async () => {
setRefreshing(true);
await loadData();
setRefreshing(false);
};
const styles = useMemo(
() =>
StyleSheet.create({
container: { flex: 1, backgroundColor: colors.bgPrimary },
row: { flexDirection: "row", alignItems: "center" },
username: {
color: colors.textWhite,
fontSize: fontSize.md,
fontWeight: "600",
},
alertMessage: {
color: colors.danger,
fontSize: fontSize.sm,
fontWeight: "600",
marginTop: 2,
},
date: {
color: colors.textMuted,
fontSize: fontSize.xs,
marginTop: 2,
},
empty: {
color: colors.textMuted,
textAlign: "center",
marginTop: spacing.xxl,
fontSize: fontSize.md,
},
}),
[colors],
);
const renderAlert = ({ item }: { item: AlertType }) => (
<Card style={{ marginBottom: spacing.m }}>
<View style={styles.row}>
<Ionicons
name="alert-circle"
size={24}
color={
item.status === "true"
? colors.danger
: colors.textMuted
}
/>
<View style={{ flex: 1, marginLeft: spacing.m }}>
<Text style={styles.username}>
Livreur: {item.username}
</Text>
{item.message ? (
<Text style={styles.alertMessage}>{item.message}</Text>
) : null}
<Text style={styles.date}>
{new Date(item.created_at).toLocaleString("fr-FR")}
</Text>
</View>
<Badge
label={item.status === "true" ? "Active" : "Terminée"}
color={
item.status === "true" ? colors.danger : colors.success
}
/>
</View>
</Card>
);
if (loading) return <LoadingSpinner message="Chargement alertes..." />;
return (
<View style={styles.container}>
<FlatList
data={alerts}
keyExtractor={(item) => item.id.toString()}
renderItem={renderAlert}
refreshControl={
<RefreshControl
refreshing={refreshing}
onRefresh={onRefresh}
tintColor={colors.accent}
/>
}
contentContainerStyle={{ padding: spacing.l }}
ListEmptyComponent={
<Text style={styles.empty}>Aucune alerte</Text>
}
/>
</View>
);
}
@@ -0,0 +1,495 @@
import React, { useState, useEffect, useCallback } from "react";
import {
View,
Text,
StyleSheet,
FlatList,
TextInput,
TouchableOpacity,
Modal,
KeyboardAvoidingView,
Platform,
ScrollView,
Switch,
} from "react-native";
import { Ionicons } from "@expo/vector-icons";
import { spacing, fontSize, borderRadius } from "../../theme";
import { useTheme } from "../../context/ThemeContext";
import {
getCategories,
createCategoryAdmin,
updateCategoryAdmin,
deleteCategoryAdmin,
reorderCategoriesAdmin,
} from "../../api/api_admin";
import type { Category } from "../../api/api_admin";
import LoadingSpinner from "../../components/ui/LoadingSpinner";
import Card from "../../components/ui/Card";
import AlertModal from "../../components/ui/AlertModal";
import { useAlert } from "../../hooks/useAlert";
const PRESET_COLORS = [
"#7c3aed", "#9333ea", "#6366f1",
"#3b82f6", "#0ea5e9", "#06b6d4",
"#10b981", "#22c55e", "#84cc16",
"#f59e0b", "#f97316", "#ef4444",
"#ec4899", "#f472b6", "#ffffff",
"#a3a3a3", "#1a1a1a", "#000000",
];
export default function CategoriesScreen() {
const { colors } = useTheme();
const [categories, setCategories] = useState<Category[]>([]);
const [loading, setLoading] = useState(true);
const [modalVisible, setModalVisible] = useState(false);
const [editingCategory, setEditingCategory] = useState<Category | null>(null);
const [name, setName] = useState("");
const [color, setColor] = useState("#7c3aed");
const [hexInput, setHexInput] = useState("#7c3aed");
const [isComingSoon, setIsComingSoon] = useState(false);
const [saving, setSaving] = useState(false);
const [reordering, setReordering] = useState(false);
const { alert, showError, showSuccess, showConfirm, hideAlert } = useAlert();
const load = useCallback(async () => {
setLoading(true);
const data = await getCategories();
setCategories(data);
setLoading(false);
}, []);
useEffect(() => {
load();
}, [load]);
const selectColor = (c: string) => {
setColor(c);
setHexInput(c);
};
const handleHexInput = (val: string) => {
setHexInput(val);
if (/^#[0-9A-Fa-f]{6}$/.test(val)) {
setColor(val);
}
};
const openCreate = () => {
setEditingCategory(null);
setName("");
selectColor("#7c3aed");
setIsComingSoon(false);
setModalVisible(true);
};
const openEdit = (cat: Category) => {
setEditingCategory(cat);
setName(cat.name);
selectColor(cat.color || "#7c3aed");
setIsComingSoon(cat.is_coming_soon || false);
setModalVisible(true);
};
const closeModal = () => {
setModalVisible(false);
setName("");
setIsComingSoon(false);
setEditingCategory(null);
};
const handleSave = async () => {
const trimmed = name.trim();
if (!trimmed) {
showError("Erreur", "Le nom est requis");
return;
}
if (!/^#[0-9A-Fa-f]{6}$/.test(color)) {
showError("Erreur", "Couleur invalide (format: #RRGGBB)");
return;
}
setSaving(true);
if (editingCategory) {
const res = await updateCategoryAdmin(editingCategory.id, trimmed, color, isComingSoon);
if (res.success) {
showSuccess("Succès", "Catégorie modifiée");
closeModal();
load();
} else {
showError("Erreur", res.error || "Erreur");
}
} else {
const res = await createCategoryAdmin(trimmed, color, isComingSoon);
if (res.success) {
showSuccess("Succès", "Catégorie créée");
closeModal();
load();
} else {
showError("Erreur", res.error || "Erreur");
}
}
setSaving(false);
};
const handleDelete = (cat: Category) => {
showConfirm(
"Supprimer",
`Supprimer la catégorie "${cat.name}" ?`,
async () => {
const res = await deleteCategoryAdmin(cat.id);
if (res.success) {
showSuccess("Succès", "Catégorie supprimée");
load();
} else {
showError("Erreur", res.error || "Erreur");
}
},
);
};
const handleMove = async (index: number, direction: "up" | "down") => {
const newList = [...categories];
const swapIndex = direction === "up" ? index - 1 : index + 1;
if (swapIndex < 0 || swapIndex >= newList.length) return;
[newList[index], newList[swapIndex]] = [newList[swapIndex], newList[index]];
setCategories(newList);
setReordering(true);
await reorderCategoriesAdmin(newList.map((c) => c.id));
setReordering(false);
};
const styles = StyleSheet.create({
container: { flex: 1, backgroundColor: colors.bgPrimary },
header: {
flexDirection: "row",
alignItems: "center",
justifyContent: "space-between",
padding: spacing.m,
borderBottomWidth: 1,
borderBottomColor: colors.border,
},
title: { fontSize: fontSize.xl, fontWeight: "700", color: colors.textPrimary },
addBtn: {
flexDirection: "row",
alignItems: "center",
gap: 6,
backgroundColor: colors.accent,
paddingHorizontal: spacing.m,
paddingVertical: spacing.s,
borderRadius: borderRadius.md,
},
addBtnText: { color: colors.textWhite, fontWeight: "600", fontSize: fontSize.sm },
list: { padding: spacing.m, gap: spacing.s },
row: {
flexDirection: "row",
alignItems: "center",
justifyContent: "space-between",
},
catLeft: { flexDirection: "row", alignItems: "center", gap: spacing.s, flex: 1 },
colorDot: { width: 16, height: 16, borderRadius: 8 },
catName: {
fontSize: fontSize.md,
color: colors.textPrimary,
fontWeight: "600",
textTransform: "capitalize",
},
catDate: { fontSize: fontSize.xs, color: colors.textSecondary, marginTop: 2 },
comingSoonBadge: {
backgroundColor: colors.warning + "33",
borderWidth: 1,
borderColor: colors.warning,
borderRadius: borderRadius.sm,
paddingHorizontal: 6,
paddingVertical: 2,
},
comingSoonBadgeText: { fontSize: fontSize.xs, color: colors.warning, fontWeight: "600" },
orderBtns: { flexDirection: "column", alignItems: "center", marginRight: spacing.s },
orderBtn: { padding: 2 },
actions: { flexDirection: "row", gap: spacing.s },
actionBtn: { padding: 8 },
overlay: {
flex: 1,
backgroundColor: colors.overlay,
justifyContent: "center",
padding: spacing.xl,
},
modal: {
backgroundColor: colors.bgCard,
borderRadius: borderRadius.lg,
padding: spacing.l,
gap: spacing.m,
},
modalTitle: { fontSize: fontSize.lg, fontWeight: "700", color: colors.textPrimary },
label: { fontSize: fontSize.sm, color: colors.textSecondary, marginBottom: 4 },
input: {
borderWidth: 1,
borderColor: colors.border,
borderRadius: borderRadius.md,
padding: spacing.m,
color: colors.textPrimary,
fontSize: fontSize.md,
backgroundColor: colors.bgInput,
},
colorSection: { gap: spacing.s },
previewRow: { flexDirection: "row", alignItems: "center", gap: spacing.m },
previewDot: {
width: 36,
height: 36,
borderRadius: 18,
borderWidth: 2,
borderColor: colors.border,
},
hexInput: {
flex: 1,
borderWidth: 1,
borderColor: colors.border,
borderRadius: borderRadius.md,
padding: spacing.s,
color: colors.textPrimary,
fontSize: fontSize.md,
backgroundColor: colors.bgInput,
},
paletteGrid: {
flexDirection: "row",
flexWrap: "wrap",
gap: spacing.s,
},
colorSwatch: {
width: 32,
height: 32,
borderRadius: borderRadius.sm,
borderWidth: 2,
},
comingSoonRow: {
flexDirection: "row",
alignItems: "center",
justifyContent: "space-between",
paddingVertical: spacing.s,
borderTopWidth: 1,
borderTopColor: colors.border,
},
comingSoonHint: { fontSize: fontSize.xs, color: colors.textMuted, marginTop: 2 },
modalBtns: { flexDirection: "row", gap: spacing.s },
cancelBtn: {
flex: 1,
padding: spacing.m,
borderRadius: borderRadius.md,
borderWidth: 1,
borderColor: colors.border,
alignItems: "center",
},
cancelBtnText: { color: colors.textPrimary, fontWeight: "600" },
saveBtn: {
flex: 1,
padding: spacing.m,
borderRadius: borderRadius.md,
backgroundColor: colors.accent,
alignItems: "center",
},
saveBtnText: { color: colors.textWhite, fontWeight: "600" },
empty: {
textAlign: "center",
color: colors.textSecondary,
marginTop: spacing.xl,
},
});
if (loading) return <LoadingSpinner />;
return (
<View style={styles.container}>
<View style={styles.header}>
<Text style={styles.title}>Catégories</Text>
<TouchableOpacity style={styles.addBtn} onPress={openCreate}>
<Ionicons name="add" size={18} color={colors.textWhite} />
<Text style={styles.addBtnText}>Ajouter</Text>
</TouchableOpacity>
</View>
<FlatList
data={categories}
keyExtractor={(item) => String(item.id)}
contentContainerStyle={styles.list}
ListEmptyComponent={
<Text style={styles.empty}>Aucune catégorie. Créez-en une !</Text>
}
renderItem={({ item, index }) => (
<Card>
<View style={styles.row}>
<View style={styles.orderBtns}>
<TouchableOpacity
onPress={() => handleMove(index, "up")}
disabled={index === 0 || reordering}
style={styles.orderBtn}
>
<Ionicons
name="chevron-up"
size={18}
color={index === 0 ? colors.textMuted : colors.textSecondary}
/>
</TouchableOpacity>
<TouchableOpacity
onPress={() => handleMove(index, "down")}
disabled={index === categories.length - 1 || reordering}
style={styles.orderBtn}
>
<Ionicons
name="chevron-down"
size={18}
color={index === categories.length - 1 ? colors.textMuted : colors.textSecondary}
/>
</TouchableOpacity>
</View>
<View style={styles.catLeft}>
<View
style={[
styles.colorDot,
{ backgroundColor: item.color || "#7c3aed" },
]}
/>
<View>
<View style={{ flexDirection: "row", alignItems: "center", gap: 6 }}>
<Text style={styles.catName}>{item.name}</Text>
{item.is_coming_soon && (
<View style={styles.comingSoonBadge}>
<Text style={styles.comingSoonBadgeText}>Prochainement</Text>
</View>
)}
</View>
<Text style={styles.catDate}>
Créée le{" "}
{new Date(item.created_at).toLocaleDateString("fr-FR")}
</Text>
</View>
</View>
<View style={styles.actions}>
<TouchableOpacity
style={styles.actionBtn}
onPress={() => openEdit(item)}
>
<Ionicons
name="pencil-outline"
size={20}
color={colors.info}
/>
</TouchableOpacity>
<TouchableOpacity
style={styles.actionBtn}
onPress={() => handleDelete(item)}
>
<Ionicons
name="trash-outline"
size={20}
color={colors.danger}
/>
</TouchableOpacity>
</View>
</View>
</Card>
)}
/>
<Modal visible={modalVisible} transparent animationType="fade">
<KeyboardAvoidingView
behavior={Platform.OS === "ios" ? "padding" : undefined}
style={styles.overlay}
>
<ScrollView
contentContainerStyle={styles.modal}
keyboardShouldPersistTaps="handled"
>
<Text style={styles.modalTitle}>
{editingCategory ? "Modifier la catégorie" : "Nouvelle catégorie"}
</Text>
<Text style={styles.label}>Nom</Text>
<TextInput
style={styles.input}
placeholder="Nom de la catégorie"
placeholderTextColor={colors.textSecondary}
value={name}
onChangeText={setName}
autoCapitalize="none"
/>
<View style={styles.colorSection}>
<Text style={styles.label}>Couleur du filtre</Text>
<View style={styles.previewRow}>
<View
style={[styles.previewDot, { backgroundColor: color }]}
/>
<TextInput
style={styles.hexInput}
value={hexInput}
onChangeText={handleHexInput}
placeholder="#RRGGBB"
placeholderTextColor={colors.textSecondary}
autoCapitalize="none"
maxLength={7}
/>
</View>
<View style={styles.paletteGrid}>
{PRESET_COLORS.map((c) => (
<TouchableOpacity
key={c}
style={[
styles.colorSwatch,
{
backgroundColor: c,
borderColor:
color === c
? colors.textWhite
: "transparent",
},
]}
onPress={() => selectColor(c)}
/>
))}
</View>
</View>
<View style={styles.comingSoonRow}>
<View>
<Text style={styles.label}>Prochainement</Text>
<Text style={styles.comingSoonHint}>
Affiche un message d'attente aux clients
</Text>
</View>
<Switch
value={isComingSoon}
onValueChange={setIsComingSoon}
trackColor={{ false: colors.border, true: colors.accent }}
thumbColor={colors.textWhite}
/>
</View>
<View style={styles.modalBtns}>
<TouchableOpacity style={styles.cancelBtn} onPress={closeModal}>
<Text style={styles.cancelBtnText}>Annuler</Text>
</TouchableOpacity>
<TouchableOpacity
style={styles.saveBtn}
onPress={handleSave}
disabled={saving}
>
<Text style={styles.saveBtnText}>
{saving ? "..." : "Enregistrer"}
</Text>
</TouchableOpacity>
</View>
</ScrollView>
</KeyboardAvoidingView>
</Modal>
<AlertModal
visible={alert.visible}
type={alert.type}
title={alert.title}
message={alert.message}
onClose={hideAlert}
onConfirm={alert.onConfirm}
confirmText={alert.confirmText || (alert.type === "confirm" ? "Confirmer" : "OK")}
cancelText={alert.cancelText || "Annuler"}
/>
</View>
);
}
@@ -0,0 +1,252 @@
import React, { useState, useEffect, useCallback, useMemo } from "react";
import {
View,
Text,
StyleSheet,
ScrollView,
RefreshControl,
TouchableOpacity,
Linking,
Alert,
useWindowDimensions,
} from "react-native";
import { Ionicons } from "@expo/vector-icons";
import { spacing, fontSize, borderRadius } from "../../theme";
import { shadows } from "../../theme/shadows";
import { useTheme } from "../../context/ThemeContext";
import { useAuth } from "../../auth/AuthContext";
import {
getAllCommands,
getAllClients,
getAvailableDeliveryPersons,
getCommandCountByStatus,
getAdminTelegramStatus,
generateAdminLinkToken,
unlinkAdminTelegram,
} from "../../api/api_admin";
import LoadingSpinner from "../../components/ui/LoadingSpinner";
interface StatCard {
label: string;
value: number;
icon: keyof typeof Ionicons.glyphMap;
color: string;
}
export default function DashboardScreen() {
const { colors } = useTheme();
const { username } = useAuth();
const { width: screenWidth } = useWindowDimensions();
const [stats, setStats] = useState<StatCard[]>([]);
const [loading, setLoading] = useState(true);
const [refreshing, setRefreshing] = useState(false);
const [tgLinked, setTgLinked] = useState(false);
const [tgEnabled, setTgEnabled] = useState(false);
const [tgLoading, setTgLoading] = useState(false);
const loadStats = useCallback(async () => {
try {
const [allCmd, pending, enRoute, completed, clients, livreurs] =
await Promise.all([
getAllCommands().then((r) => r.count),
getCommandCountByStatus("pending"),
getCommandCountByStatus("en_route"),
getCommandCountByStatus("approved"),
getAllClients()
.then((c) => c.length)
.catch(() => 0),
getAvailableDeliveryPersons()
.then((r) => r.count)
.catch(() => 0),
]);
setStats([
{
label: "Total commandes",
value: allCmd,
icon: "receipt-outline",
color: colors.accent,
},
{
label: "En attente",
value: pending,
icon: "time-outline",
color: colors.warning,
},
{
label: "En route",
value: enRoute,
icon: "navigate-outline",
color: colors.info,
},
{
label: "Terminées",
value: completed,
icon: "checkmark-circle-outline",
color: colors.success,
},
{
label: "Clients",
value: clients,
icon: "people-outline",
color: colors.accentLight,
},
{
label: "Livreurs",
value: livreurs,
icon: "bicycle-outline",
color: colors.categoryGros,
},
]);
} catch {
/* ignore */
}
setLoading(false);
}, [colors]);
useEffect(() => {
loadStats();
getAdminTelegramStatus().then((s) => { setTgLinked(s.linked); setTgEnabled(s.enabled); });
}, [loadStats]);
const handleLinkTelegram = async () => {
setTgLoading(true);
const res = await generateAdminLinkToken();
setTgLoading(false);
if (res.error || !res.link_url) {
Alert.alert("Erreur", res.error || "Service Telegram non disponible");
return;
}
Linking.openURL(res.link_url);
};
const handleUnlinkTelegram = () => {
Alert.alert("Délier Telegram", "Vous ne recevrez plus de notifications Telegram.", [
{ text: "Annuler", style: "cancel" },
{ text: "Délier", style: "destructive", onPress: async () => { await unlinkAdminTelegram(); setTgLinked(false); } },
]);
};
const onRefresh = async () => {
setRefreshing(true);
await loadStats();
setRefreshing(false);
};
const styles = useMemo(
() =>
StyleSheet.create({
container: {
flex: 1,
backgroundColor: colors.bgPrimary,
padding: screenWidth < 380 ? spacing.m : spacing.l,
},
welcome: {
fontSize: screenWidth < 380 ? fontSize.lg : fontSize.xl,
fontWeight: "bold",
color: colors.textWhite,
marginBottom: spacing.xs,
},
subtitle: {
fontSize: screenWidth < 380 ? fontSize.sm : fontSize.md,
color: colors.textSecondary,
marginBottom: spacing.l,
},
grid: {
flexDirection: "row",
flexWrap: "wrap",
gap: screenWidth < 380 ? spacing.s : spacing.m,
},
card: {
width: screenWidth < 360 ? "100%" : "47%",
backgroundColor: colors.bgCard,
borderRadius: borderRadius.md,
padding: screenWidth < 380 ? spacing.m : spacing.l,
borderWidth: 1,
borderColor: colors.borderLight,
alignItems: "center",
},
iconCircle: {
width: screenWidth < 380 ? 40 : 48,
height: screenWidth < 380 ? 40 : 48,
borderRadius: screenWidth < 380 ? 20 : 24,
justifyContent: "center",
alignItems: "center",
marginBottom: spacing.s,
},
cardValue: {
fontSize: screenWidth < 380 ? fontSize.xl : fontSize.xxl,
fontWeight: "bold",
color: colors.textWhite,
},
cardLabel: {
fontSize: screenWidth < 380 ? fontSize.xs : fontSize.sm,
color: colors.textSecondary,
marginTop: spacing.xs,
textAlign: "center",
},
}),
[colors, screenWidth],
);
if (loading) return <LoadingSpinner message="Chargement..." />;
return (
<ScrollView
style={styles.container}
refreshControl={
<RefreshControl
refreshing={refreshing}
onRefresh={onRefresh}
tintColor={colors.accent}
/>
}
>
<Text style={styles.welcome}>Bonjour, {username}</Text>
<Text style={styles.subtitle}>Vue d'ensemble</Text>
<View style={styles.grid}>
{stats.map((s, i) => (
<View key={i} style={[styles.card, shadows.md]}>
<View
style={[
styles.iconCircle,
{ backgroundColor: s.color + "20" },
]}
>
<Ionicons name={s.icon} size={24} color={s.color} />
</View>
<Text style={styles.cardValue}>{s.value}</Text>
<Text style={styles.cardLabel}>{s.label}</Text>
</View>
))}
</View>
{tgEnabled && (
<View style={{ margin: spacing.l, backgroundColor: colors.bgCard, borderRadius: borderRadius.md, padding: spacing.l, borderWidth: 1, borderColor: colors.borderLight }}>
<View style={{ flexDirection: "row", alignItems: "center", gap: spacing.s, marginBottom: spacing.m }}>
<Ionicons name="paper-plane-outline" size={18} color="#2AABEE" />
<Text style={{ color: colors.textPrimary, fontSize: fontSize.md, fontWeight: "600" }}>Notifications Telegram</Text>
</View>
{tgLinked ? (
<View>
<View style={{ flexDirection: "row", alignItems: "center", gap: spacing.s, marginBottom: spacing.s }}>
<Ionicons name="checkmark-circle" size={14} color={colors.success} />
<Text style={{ color: colors.success, fontSize: fontSize.sm }}>Compte Telegram lié</Text>
</View>
<TouchableOpacity onPress={handleUnlinkTelegram} style={{ flexDirection: "row", alignItems: "center", gap: spacing.s, padding: spacing.s, borderRadius: borderRadius.sm, borderWidth: 1, borderColor: colors.danger + "66" }}>
<Ionicons name="unlink-outline" size={14} color={colors.danger} />
<Text style={{ color: colors.danger, fontSize: fontSize.sm }}>Délier Telegram</Text>
</TouchableOpacity>
</View>
) : (
<TouchableOpacity onPress={handleLinkTelegram} disabled={tgLoading} style={{ flexDirection: "row", alignItems: "center", justifyContent: "center", gap: spacing.s, padding: spacing.m, borderRadius: borderRadius.sm, backgroundColor: "#2AABEE" }}>
<Ionicons name="paper-plane-outline" size={14} color="#fff" />
<Text style={{ color: "#fff", fontSize: fontSize.sm, fontWeight: "600" }}>{tgLoading ? "Génération..." : "Lier Telegram"}</Text>
</TouchableOpacity>
)}
</View>
)}
</ScrollView>
);
}
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,307 @@
import React, { useState, useRef, useMemo } from "react";
import {
View,
Text,
StyleSheet,
KeyboardAvoidingView,
ScrollView,
TouchableOpacity,
TextInput as RNTextInput,
Animated,
useWindowDimensions,
} from "react-native";
import { Ionicons } from "@expo/vector-icons";
import { useNavigation } from "@react-navigation/native";
import { spacing, fontSize, borderRadius } from "../../theme";
import { useTheme } from "../../context/ThemeContext";
import { useAuth } from "../../auth/AuthContext";
import { loginAdmin } from "../../api/api_admin";
import AlertModal from "../../components/ui/AlertModal";
import { useAlert } from "../../hooks/useAlert";
export default function AdminLoginScreen() {
const { colors } = useTheme();
const { width: screenWidth } = useWindowDimensions();
const ACCENT = colors.accent;
const [username, setUsername] = useState("");
const [password, setPassword] = useState("");
const [loading, setLoading] = useState(false);
const [showPassword, setShowPassword] = useState(false);
const { loginAdmin: authLogin } = useAuth();
const navigation = useNavigation();
const buttonScale = useRef(new Animated.Value(1)).current;
const { alert, showError, hideAlert } = useAlert();
const styles = useMemo(
() =>
StyleSheet.create({
container: { flex: 1, backgroundColor: colors.bgPrimary },
accentBar: { height: 4, width: "100%" },
heroSection: {
alignItems: "center",
paddingTop: screenWidth < 380 ? 36 : 60,
paddingBottom: spacing.l,
},
heroBg: {
width: screenWidth < 380 ? 96 : 120,
height: screenWidth < 380 ? 96 : 120,
borderRadius: screenWidth < 380 ? 48 : 60,
justifyContent: "center",
alignItems: "center",
},
heroInner: {
width: screenWidth < 380 ? 68 : 88,
height: screenWidth < 380 ? 68 : 88,
borderRadius: screenWidth < 380 ? 34 : 44,
justifyContent: "center",
alignItems: "center",
},
heroTitle: {
fontSize: screenWidth < 380 ? fontSize.xl : fontSize.xxl,
fontWeight: "800",
color: colors.textPrimary,
marginTop: spacing.m,
letterSpacing: 0.5,
},
heroSubtitle: {
fontSize: screenWidth < 380 ? fontSize.sm : fontSize.md,
color: colors.textMuted,
marginTop: spacing.xs,
},
formSection: { paddingHorizontal: screenWidth < 380 ? spacing.l : spacing.xl, flex: 1 },
inputContainer: {
flexDirection: "row",
alignItems: "center",
backgroundColor: colors.bgSecondary,
borderRadius: borderRadius.md,
borderWidth: 1,
borderColor: colors.border,
marginBottom: spacing.m,
overflow: "hidden",
},
inputIconBox: {
width: 52,
height: 52,
justifyContent: "center",
alignItems: "center",
},
input: {
flex: 1,
color: colors.textPrimary,
fontSize: fontSize.md,
paddingVertical: 15,
paddingHorizontal: spacing.m,
},
eyeBtn: { padding: spacing.m },
loginBtn: {
flexDirection: "row",
alignItems: "center",
justifyContent: "center",
height: 52,
borderRadius: borderRadius.md,
marginTop: spacing.m,
},
loginBtnText: {
color: colors.white,
fontSize: fontSize.lg,
fontWeight: "700",
},
backLink: {
flexDirection: "row",
alignItems: "center",
justifyContent: "center",
marginTop: spacing.xl,
paddingVertical: spacing.m,
},
backText: {
color: colors.textMuted,
fontSize: fontSize.sm,
marginLeft: spacing.xs,
},
}),
[colors, screenWidth],
);
const handleLogin = async () => {
if (!username.trim() || !password.trim()) {
showError("Erreur", "Veuillez remplir tous les champs");
return;
}
setLoading(true);
try {
const result = await loginAdmin(username.trim(), password);
if (result.success && result.access_token) {
await authLogin(result.access_token, "admin");
} else {
showError("Erreur", result.message || "Connexion échouée");
}
} catch {
showError("Erreur", "Erreur de connexion");
} finally {
setLoading(false);
}
};
const onPressIn = () =>
Animated.spring(buttonScale, {
toValue: 0.96,
useNativeDriver: true,
}).start();
const onPressOut = () =>
Animated.spring(buttonScale, {
toValue: 1,
useNativeDriver: true,
}).start();
return (
<KeyboardAvoidingView
style={styles.container}
behavior="height"
>
<ScrollView
contentContainerStyle={{ flexGrow: 1 }}
keyboardShouldPersistTaps="handled"
showsVerticalScrollIndicator={false}
>
{/* Decorative top accent */}
<View style={[styles.accentBar, { backgroundColor: ACCENT }]} />
<View style={styles.heroSection}>
<View
style={[styles.heroBg, { backgroundColor: ACCENT + "12" }]}
>
<View
style={[
styles.heroInner,
{ backgroundColor: ACCENT + "20" },
]}
>
<Ionicons
name="shield-checkmark"
size={48}
color={ACCENT}
/>
</View>
</View>
<Text style={styles.heroTitle}>Administration</Text>
<Text style={styles.heroSubtitle}>
Accès au panneau de contrôle
</Text>
</View>
<View style={styles.formSection}>
{/* Username */}
<View style={styles.inputContainer}>
<View
style={[
styles.inputIconBox,
{ backgroundColor: ACCENT + "15" },
]}
>
<Ionicons
name="person-outline"
size={20}
color={ACCENT}
/>
</View>
<RNTextInput
style={styles.input}
placeholder="Nom d'utilisateur"
placeholderTextColor={colors.textMuted}
value={username}
onChangeText={setUsername}
autoCapitalize="none"
autoCorrect={false}
/>
</View>
{/* Password */}
<View style={styles.inputContainer}>
<View
style={[
styles.inputIconBox,
{ backgroundColor: ACCENT + "15" },
]}
>
<Ionicons
name="lock-closed-outline"
size={20}
color={ACCENT}
/>
</View>
<RNTextInput
style={styles.input}
placeholder="Mot de passe"
placeholderTextColor={colors.textMuted}
value={password}
onChangeText={setPassword}
secureTextEntry={!showPassword}
/>
<TouchableOpacity
onPress={() => setShowPassword(!showPassword)}
style={styles.eyeBtn}
>
<Ionicons
name={
showPassword ? "eye-off-outline" : "eye-outline"
}
size={20}
color={colors.textMuted}
/>
</TouchableOpacity>
</View>
{/* Login button */}
<Animated.View style={{ transform: [{ scale: buttonScale }] }}>
<TouchableOpacity
style={[styles.loginBtn, { backgroundColor: ACCENT }]}
onPress={handleLogin}
onPressIn={onPressIn}
onPressOut={onPressOut}
disabled={loading}
activeOpacity={0.9}
>
{loading ? (
<Text style={styles.loginBtnText}>
Connexion...
</Text>
) : (
<>
<Text style={styles.loginBtnText}>
Se connecter
</Text>
<Ionicons
name="arrow-forward"
size={20}
color={colors.white}
style={{ marginLeft: spacing.s }}
/>
</>
)}
</TouchableOpacity>
</Animated.View>
{/* Back link */}
<TouchableOpacity
onPress={() => navigation.goBack()}
style={styles.backLink}
>
<Ionicons
name="chevron-back"
size={18}
color={colors.textMuted}
/>
<Text style={styles.backText}>Choisir un autre rôle</Text>
</TouchableOpacity>
</View>
<AlertModal
visible={alert.visible}
type={alert.type}
title={alert.title}
message={alert.message}
onClose={hideAlert}
/>
</ScrollView>
</KeyboardAvoidingView>
);
}
@@ -0,0 +1,298 @@
import React, { useState, useRef, useMemo } from "react";
import {
View,
Text,
StyleSheet,
KeyboardAvoidingView,
ScrollView,
TouchableOpacity,
TextInput as RNTextInput,
Animated,
useWindowDimensions,
} from "react-native";
import { Ionicons } from "@expo/vector-icons";
import { useNavigation } from "@react-navigation/native";
import { spacing, fontSize, borderRadius } from "../../theme";
import { useTheme } from "../../context/ThemeContext";
import { useAuth } from "../../auth/AuthContext";
import { loginAdmin } from "../../api/api_admin";
import AlertModal from "../../components/ui/AlertModal";
import { useAlert } from "../../hooks/useAlert";
export default function CabineLoginScreen() {
const { colors } = useTheme();
const { width: screenWidth } = useWindowDimensions();
const ACCENT = colors.info;
const [username, setUsername] = useState("");
const [password, setPassword] = useState("");
const [loading, setLoading] = useState(false);
const [showPassword, setShowPassword] = useState(false);
const { loginAdmin: authLogin } = useAuth();
const navigation = useNavigation();
const buttonScale = useRef(new Animated.Value(1)).current;
const { alert, showError, hideAlert } = useAlert();
const styles = useMemo(
() =>
StyleSheet.create({
container: { flex: 1, backgroundColor: colors.bgPrimary },
accentBar: { height: 4, width: "100%" },
heroSection: {
alignItems: "center",
paddingTop: screenWidth < 380 ? 36 : 60,
paddingBottom: spacing.l,
},
heroBg: {
width: screenWidth < 380 ? 96 : 120,
height: screenWidth < 380 ? 96 : 120,
borderRadius: screenWidth < 380 ? 48 : 60,
justifyContent: "center",
alignItems: "center",
},
heroInner: {
width: screenWidth < 380 ? 68 : 88,
height: screenWidth < 380 ? 68 : 88,
borderRadius: screenWidth < 380 ? 34 : 44,
justifyContent: "center",
alignItems: "center",
},
heroTitle: {
fontSize: screenWidth < 380 ? fontSize.xl : fontSize.xxl,
fontWeight: "800",
color: colors.textPrimary,
marginTop: spacing.m,
letterSpacing: 0.5,
},
heroSubtitle: {
fontSize: screenWidth < 380 ? fontSize.sm : fontSize.md,
color: colors.textMuted,
marginTop: spacing.xs,
},
formSection: { paddingHorizontal: screenWidth < 380 ? spacing.l : spacing.xl, flex: 1 },
inputContainer: {
flexDirection: "row",
alignItems: "center",
backgroundColor: colors.bgSecondary,
borderRadius: borderRadius.md,
borderWidth: 1,
borderColor: colors.border,
marginBottom: spacing.m,
overflow: "hidden",
},
inputIconBox: {
width: 52,
height: 52,
justifyContent: "center",
alignItems: "center",
},
input: {
flex: 1,
color: colors.textPrimary,
fontSize: fontSize.md,
paddingVertical: 15,
paddingHorizontal: spacing.m,
},
eyeBtn: { padding: spacing.m },
loginBtn: {
flexDirection: "row",
alignItems: "center",
justifyContent: "center",
height: 52,
borderRadius: borderRadius.md,
marginTop: spacing.m,
},
loginBtnText: {
color: colors.white,
fontSize: fontSize.lg,
fontWeight: "700",
},
backLink: {
flexDirection: "row",
alignItems: "center",
justifyContent: "center",
marginTop: spacing.xl,
paddingVertical: spacing.m,
},
backText: {
color: colors.textMuted,
fontSize: fontSize.sm,
marginLeft: spacing.xs,
},
}),
[colors, screenWidth],
);
const handleLogin = async () => {
if (!username.trim() || !password.trim()) {
showError("Erreur", "Veuillez remplir tous les champs");
return;
}
setLoading(true);
try {
const result = await loginAdmin(username.trim(), password);
if (result.success && result.access_token) {
await authLogin(result.access_token, "cabine");
} else {
showError("Erreur", result.message || "Connexion échouée");
}
} catch {
showError("Erreur", "Erreur de connexion");
} finally {
setLoading(false);
}
};
const onPressIn = () =>
Animated.spring(buttonScale, {
toValue: 0.96,
useNativeDriver: true,
}).start();
const onPressOut = () =>
Animated.spring(buttonScale, {
toValue: 1,
useNativeDriver: true,
}).start();
return (
<KeyboardAvoidingView
style={styles.container}
behavior="height"
>
<ScrollView
contentContainerStyle={{ flexGrow: 1 }}
keyboardShouldPersistTaps="handled"
showsVerticalScrollIndicator={false}
>
<View style={[styles.accentBar, { backgroundColor: ACCENT }]} />
<View style={styles.heroSection}>
<View
style={[styles.heroBg, { backgroundColor: ACCENT + "12" }]}
>
<View
style={[
styles.heroInner,
{ backgroundColor: ACCENT + "20" },
]}
>
<Ionicons name="desktop" size={48} color={ACCENT} />
</View>
</View>
<Text style={styles.heroTitle}>Cabine</Text>
<Text style={styles.heroSubtitle}>
Suivi des commandes et livreurs
</Text>
</View>
<View style={styles.formSection}>
<View style={styles.inputContainer}>
<View
style={[
styles.inputIconBox,
{ backgroundColor: ACCENT + "15" },
]}
>
<Ionicons
name="person-outline"
size={20}
color={ACCENT}
/>
</View>
<RNTextInput
style={styles.input}
placeholder="Nom d'utilisateur"
placeholderTextColor={colors.textMuted}
value={username}
onChangeText={setUsername}
autoCapitalize="none"
autoCorrect={false}
/>
</View>
<View style={styles.inputContainer}>
<View
style={[
styles.inputIconBox,
{ backgroundColor: ACCENT + "15" },
]}
>
<Ionicons
name="lock-closed-outline"
size={20}
color={ACCENT}
/>
</View>
<RNTextInput
style={styles.input}
placeholder="Mot de passe"
placeholderTextColor={colors.textMuted}
value={password}
onChangeText={setPassword}
secureTextEntry={!showPassword}
/>
<TouchableOpacity
onPress={() => setShowPassword(!showPassword)}
style={styles.eyeBtn}
>
<Ionicons
name={
showPassword ? "eye-off-outline" : "eye-outline"
}
size={20}
color={colors.textMuted}
/>
</TouchableOpacity>
</View>
<Animated.View style={{ transform: [{ scale: buttonScale }] }}>
<TouchableOpacity
style={[styles.loginBtn, { backgroundColor: ACCENT }]}
onPress={handleLogin}
onPressIn={onPressIn}
onPressOut={onPressOut}
disabled={loading}
activeOpacity={0.9}
>
{loading ? (
<Text style={styles.loginBtnText}>
Connexion...
</Text>
) : (
<>
<Text style={styles.loginBtnText}>
Se connecter
</Text>
<Ionicons
name="arrow-forward"
size={20}
color={colors.white}
style={{ marginLeft: spacing.s }}
/>
</>
)}
</TouchableOpacity>
</Animated.View>
<TouchableOpacity
onPress={() => navigation.goBack()}
style={styles.backLink}
>
<Ionicons
name="chevron-back"
size={18}
color={colors.textMuted}
/>
<Text style={styles.backText}>Choisir un autre rôle</Text>
</TouchableOpacity>
</View>
<AlertModal
visible={alert.visible}
type={alert.type}
title={alert.title}
message={alert.message}
onClose={hideAlert}
/>
</ScrollView>
</KeyboardAvoidingView>
);
}
@@ -0,0 +1,298 @@
import React, { useState, useRef, useMemo } from "react";
import {
View,
Text,
StyleSheet,
KeyboardAvoidingView,
ScrollView,
TouchableOpacity,
TextInput as RNTextInput,
Animated,
useWindowDimensions,
} from "react-native";
import { Ionicons } from "@expo/vector-icons";
import { useNavigation } from "@react-navigation/native";
import { spacing, fontSize, borderRadius } from "../../theme";
import { useTheme } from "../../context/ThemeContext";
import { useAuth } from "../../auth/AuthContext";
import { loginAdmin } from "../../api/api_admin";
import AlertModal from "../../components/ui/AlertModal";
import { useAlert } from "../../hooks/useAlert";
export default function DeliveryLoginScreen() {
const { colors } = useTheme();
const { width: screenWidth } = useWindowDimensions();
const ACCENT = colors.success;
const [username, setUsername] = useState("");
const [password, setPassword] = useState("");
const [loading, setLoading] = useState(false);
const [showPassword, setShowPassword] = useState(false);
const { loginAdmin: authLogin } = useAuth();
const navigation = useNavigation();
const buttonScale = useRef(new Animated.Value(1)).current;
const { alert, showError, hideAlert } = useAlert();
const styles = useMemo(
() =>
StyleSheet.create({
container: { flex: 1, backgroundColor: colors.bgPrimary },
accentBar: { height: 4, width: "100%" },
heroSection: {
alignItems: "center",
paddingTop: screenWidth < 380 ? 36 : 60,
paddingBottom: spacing.l,
},
heroBg: {
width: screenWidth < 380 ? 96 : 120,
height: screenWidth < 380 ? 96 : 120,
borderRadius: screenWidth < 380 ? 48 : 60,
justifyContent: "center",
alignItems: "center",
},
heroInner: {
width: screenWidth < 380 ? 68 : 88,
height: screenWidth < 380 ? 68 : 88,
borderRadius: screenWidth < 380 ? 34 : 44,
justifyContent: "center",
alignItems: "center",
},
heroTitle: {
fontSize: screenWidth < 380 ? fontSize.xl : fontSize.xxl,
fontWeight: "800",
color: colors.textPrimary,
marginTop: spacing.m,
letterSpacing: 0.5,
},
heroSubtitle: {
fontSize: screenWidth < 380 ? fontSize.sm : fontSize.md,
color: colors.textMuted,
marginTop: spacing.xs,
},
formSection: { paddingHorizontal: screenWidth < 380 ? spacing.l : spacing.xl, flex: 1 },
inputContainer: {
flexDirection: "row",
alignItems: "center",
backgroundColor: colors.bgSecondary,
borderRadius: borderRadius.md,
borderWidth: 1,
borderColor: colors.border,
marginBottom: spacing.m,
overflow: "hidden",
},
inputIconBox: {
width: 52,
height: 52,
justifyContent: "center",
alignItems: "center",
},
input: {
flex: 1,
color: colors.textPrimary,
fontSize: fontSize.md,
paddingVertical: 15,
paddingHorizontal: spacing.m,
},
eyeBtn: { padding: spacing.m },
loginBtn: {
flexDirection: "row",
alignItems: "center",
justifyContent: "center",
height: 52,
borderRadius: borderRadius.md,
marginTop: spacing.m,
},
loginBtnText: {
color: colors.white,
fontSize: fontSize.lg,
fontWeight: "700",
},
backLink: {
flexDirection: "row",
alignItems: "center",
justifyContent: "center",
marginTop: spacing.xl,
paddingVertical: spacing.m,
},
backText: {
color: colors.textMuted,
fontSize: fontSize.sm,
marginLeft: spacing.xs,
},
}),
[colors, screenWidth],
);
const handleLogin = async () => {
if (!username.trim() || !password.trim()) {
showError("Erreur", "Veuillez remplir tous les champs");
return;
}
setLoading(true);
try {
const result = await loginAdmin(username.trim(), password);
if (result.success && result.access_token) {
await authLogin(result.access_token, "livreur");
} else {
showError("Erreur", result.message || "Connexion échouée");
}
} catch {
showError("Erreur", "Erreur de connexion");
} finally {
setLoading(false);
}
};
const onPressIn = () =>
Animated.spring(buttonScale, {
toValue: 0.96,
useNativeDriver: true,
}).start();
const onPressOut = () =>
Animated.spring(buttonScale, {
toValue: 1,
useNativeDriver: true,
}).start();
return (
<KeyboardAvoidingView
style={styles.container}
behavior="height"
>
<ScrollView
contentContainerStyle={{ flexGrow: 1 }}
keyboardShouldPersistTaps="handled"
showsVerticalScrollIndicator={false}
>
<View style={[styles.accentBar, { backgroundColor: ACCENT }]} />
<View style={styles.heroSection}>
<View
style={[styles.heroBg, { backgroundColor: ACCENT + "12" }]}
>
<View
style={[
styles.heroInner,
{ backgroundColor: ACCENT + "20" },
]}
>
<Ionicons name="bicycle" size={48} color={ACCENT} />
</View>
</View>
<Text style={styles.heroTitle}>Livreur</Text>
<Text style={styles.heroSubtitle}>
Gestion de vos livraisons
</Text>
</View>
<View style={styles.formSection}>
<View style={styles.inputContainer}>
<View
style={[
styles.inputIconBox,
{ backgroundColor: ACCENT + "15" },
]}
>
<Ionicons
name="person-outline"
size={20}
color={ACCENT}
/>
</View>
<RNTextInput
style={styles.input}
placeholder="Nom d'utilisateur"
placeholderTextColor={colors.textMuted}
value={username}
onChangeText={setUsername}
autoCapitalize="none"
autoCorrect={false}
/>
</View>
<View style={styles.inputContainer}>
<View
style={[
styles.inputIconBox,
{ backgroundColor: ACCENT + "15" },
]}
>
<Ionicons
name="lock-closed-outline"
size={20}
color={ACCENT}
/>
</View>
<RNTextInput
style={styles.input}
placeholder="Mot de passe"
placeholderTextColor={colors.textMuted}
value={password}
onChangeText={setPassword}
secureTextEntry={!showPassword}
/>
<TouchableOpacity
onPress={() => setShowPassword(!showPassword)}
style={styles.eyeBtn}
>
<Ionicons
name={
showPassword ? "eye-off-outline" : "eye-outline"
}
size={20}
color={colors.textMuted}
/>
</TouchableOpacity>
</View>
<Animated.View style={{ transform: [{ scale: buttonScale }] }}>
<TouchableOpacity
style={[styles.loginBtn, { backgroundColor: ACCENT }]}
onPress={handleLogin}
onPressIn={onPressIn}
onPressOut={onPressOut}
disabled={loading}
activeOpacity={0.9}
>
{loading ? (
<Text style={styles.loginBtnText}>
Connexion...
</Text>
) : (
<>
<Text style={styles.loginBtnText}>
Se connecter
</Text>
<Ionicons
name="arrow-forward"
size={20}
color={colors.white}
style={{ marginLeft: spacing.s }}
/>
</>
)}
</TouchableOpacity>
</Animated.View>
<TouchableOpacity
onPress={() => navigation.goBack()}
style={styles.backLink}
>
<Ionicons
name="chevron-back"
size={18}
color={colors.textMuted}
/>
<Text style={styles.backText}>Choisir un autre rôle</Text>
</TouchableOpacity>
</View>
<AlertModal
visible={alert.visible}
type={alert.type}
title={alert.title}
message={alert.message}
onClose={hideAlert}
/>
</ScrollView>
</KeyboardAvoidingView>
);
}
@@ -0,0 +1,143 @@
import React, { useMemo } from "react";
import { View, Text, TouchableOpacity, StyleSheet } from "react-native";
import { Ionicons } from "@expo/vector-icons";
import { useNavigation } from "@react-navigation/native";
import type { NativeStackNavigationProp } from "@react-navigation/native-stack";
import type { AuthStackParamList } from "../../navigation/types";
import { spacing, fontSize, borderRadius } from "../../theme";
import { useTheme } from "../../context/ThemeContext";
type Nav = NativeStackNavigationProp<AuthStackParamList, "RoleSelect">;
export default function RoleSelectScreen() {
const { colors } = useTheme();
const navigation = useNavigation<Nav>();
const roles = [
{
key: "AdminLogin" as const,
label: "Admin",
desc: "Gestion complète du système",
icon: "shield-outline" as const,
color: colors.accent,
},
{
key: "CabineLogin" as const,
label: "Cabine",
desc: "Suivi des commandes et livreurs",
icon: "desktop-outline" as const,
color: colors.info,
},
{
key: "DeliveryLogin" as const,
label: "Livreur",
desc: "Gestion des livraisons",
icon: "bicycle-outline" as const,
color: colors.success,
},
];
const styles = useMemo(
() =>
StyleSheet.create({
container: {
flex: 1,
backgroundColor: colors.bgPrimary,
justifyContent: "center",
alignItems: "center",
padding: spacing.l,
},
card: {
width: "100%",
maxWidth: 400,
backgroundColor: colors.bgSecondary,
borderRadius: borderRadius.lg,
padding: spacing.xl,
alignItems: "center",
},
title: {
fontSize: fontSize.xxl,
fontWeight: "bold",
color: colors.textWhite,
marginTop: spacing.m,
},
subtitle: {
color: colors.textSecondary,
fontSize: fontSize.md,
marginTop: spacing.xs,
marginBottom: spacing.xl,
},
roleBtn: {
flexDirection: "row",
alignItems: "center",
backgroundColor: colors.bgCard,
borderRadius: borderRadius.md,
padding: spacing.l,
width: "100%",
borderWidth: 1,
borderColor: colors.border,
marginBottom: spacing.m,
},
iconCircle: {
width: 44,
height: 44,
borderRadius: 22,
justifyContent: "center",
alignItems: "center",
},
roleBtnText: { flex: 1, marginLeft: spacing.m },
roleTitle: {
color: colors.textWhite,
fontSize: fontSize.lg,
fontWeight: "600",
},
roleDesc: {
color: colors.textSecondary,
fontSize: fontSize.sm,
marginTop: 2,
},
}),
[colors],
);
return (
<View style={styles.container}>
<View style={styles.card}>
<Ionicons
name="people-outline"
size={48}
color={colors.accent}
/>
<Text style={styles.title}>Panel Administration</Text>
<Text style={styles.subtitle}>Sélectionnez votre rôle</Text>
{roles.map((r) => (
<TouchableOpacity
key={r.key}
style={styles.roleBtn}
onPress={() => navigation.navigate(r.key)}
activeOpacity={0.7}
>
<View
style={[
styles.iconCircle,
{ backgroundColor: r.color + "20" },
]}
>
<Ionicons name={r.icon} size={24} color={r.color} />
</View>
<View style={styles.roleBtnText}>
<Text style={styles.roleTitle}>{r.label}</Text>
<Text style={styles.roleDesc}>{r.desc}</Text>
</View>
<Ionicons
name="chevron-forward"
size={20}
color={colors.textMuted}
/>
</TouchableOpacity>
))}
</View>
</View>
);
}
@@ -0,0 +1,270 @@
import React, { useState, useRef, useMemo, useEffect } from "react";
import {
View,
Text,
StyleSheet,
KeyboardAvoidingView,
ScrollView,
TouchableOpacity,
TextInput as RNTextInput,
Animated,
useWindowDimensions,
} from "react-native";
import { Ionicons } from "@expo/vector-icons";
import { useNavigation } from "@react-navigation/native";
import type { NativeStackNavigationProp } from "@react-navigation/native-stack";
import type { AuthStackParamList } from "../../navigation/types";
import { spacing, fontSize, borderRadius } from "../../theme";
import { useTheme } from "../../context/ThemeContext";
import { getStoredServerUrl, saveServerUrl } from "../../api/client";
import { normalizeServerUrl } from "../../utils/serverConfig";
import AlertModal from "../../components/ui/AlertModal";
import { useAlert } from "../../hooks/useAlert";
type Nav = NativeStackNavigationProp<AuthStackParamList, "ServerConfig">;
export default function ServerConfigScreen() {
const { colors } = useTheme();
const { width: screenWidth } = useWindowDimensions();
const ACCENT = colors.accent;
const navigation = useNavigation<Nav>();
const [address, setAddress] = useState("");
const [saving, setSaving] = useState(false);
const buttonScale = useRef(new Animated.Value(1)).current;
const { alert, showError, hideAlert } = useAlert();
useEffect(() => {
(async () => {
const stored = await getStoredServerUrl();
if (stored) setAddress(stored);
})();
}, []);
const styles = useMemo(
() =>
StyleSheet.create({
container: { flex: 1, backgroundColor: colors.bgPrimary },
accentBar: { height: 4, width: "100%" },
heroSection: {
alignItems: "center",
paddingTop: screenWidth < 380 ? 36 : 60,
paddingBottom: spacing.l,
},
heroBg: {
width: screenWidth < 380 ? 96 : 120,
height: screenWidth < 380 ? 96 : 120,
borderRadius: screenWidth < 380 ? 48 : 60,
justifyContent: "center",
alignItems: "center",
},
heroInner: {
width: screenWidth < 380 ? 68 : 88,
height: screenWidth < 380 ? 68 : 88,
borderRadius: screenWidth < 380 ? 34 : 44,
justifyContent: "center",
alignItems: "center",
},
heroTitle: {
fontSize: screenWidth < 380 ? fontSize.xl : fontSize.xxl,
fontWeight: "800",
color: colors.textPrimary,
marginTop: spacing.m,
letterSpacing: 0.5,
},
heroSubtitle: {
fontSize: screenWidth < 380 ? fontSize.sm : fontSize.md,
color: colors.textMuted,
marginTop: spacing.xs,
textAlign: "center",
paddingHorizontal: spacing.l,
},
formSection: {
paddingHorizontal:
screenWidth < 380 ? spacing.l : spacing.xl,
flex: 1,
},
inputContainer: {
flexDirection: "row",
alignItems: "center",
backgroundColor: colors.bgSecondary,
borderRadius: borderRadius.md,
borderWidth: 1,
borderColor: colors.border,
marginBottom: spacing.xs,
overflow: "hidden",
},
inputIconBox: {
width: 52,
height: 52,
justifyContent: "center",
alignItems: "center",
},
input: {
flex: 1,
color: colors.textPrimary,
fontSize: fontSize.md,
paddingVertical: 15,
paddingHorizontal: spacing.m,
},
hint: {
color: colors.textMuted,
fontSize: fontSize.sm,
marginBottom: spacing.m,
marginLeft: spacing.xs,
},
continueBtn: {
flexDirection: "row",
alignItems: "center",
justifyContent: "center",
height: 52,
borderRadius: borderRadius.md,
marginTop: spacing.m,
},
continueBtnText: {
color: colors.white,
fontSize: fontSize.lg,
fontWeight: "700",
},
}),
[colors, screenWidth, ACCENT],
);
const handleContinue = async () => {
const normalized = normalizeServerUrl(address);
if (!normalized) {
showError(
"Adresse invalide",
"Utilisez une URL (https://exemple.com) ou une adresse IP:Port (192.168.1.10:8000)",
);
return;
}
setSaving(true);
try {
await saveServerUrl(normalized);
navigation.replace("RoleSelect");
} finally {
setSaving(false);
}
};
const onPressIn = () =>
Animated.spring(buttonScale, {
toValue: 0.96,
useNativeDriver: true,
}).start();
const onPressOut = () =>
Animated.spring(buttonScale, {
toValue: 1,
useNativeDriver: true,
}).start();
return (
<KeyboardAvoidingView style={styles.container} behavior="height">
<ScrollView
contentContainerStyle={{ flexGrow: 1 }}
keyboardShouldPersistTaps="handled"
showsVerticalScrollIndicator={false}
>
<View style={[styles.accentBar, { backgroundColor: ACCENT }]} />
<View style={styles.heroSection}>
<View
style={[
styles.heroBg,
{ backgroundColor: ACCENT + "12" },
]}
>
<View
style={[
styles.heroInner,
{ backgroundColor: ACCENT + "20" },
]}
>
<Ionicons
name="server-outline"
size={48}
color={ACCENT}
/>
</View>
</View>
<Text style={styles.heroTitle}>Serveur API</Text>
<Text style={styles.heroSubtitle}>
Renseignez l'adresse du serveur avant de vous
connecter
</Text>
</View>
<View style={styles.formSection}>
<View style={styles.inputContainer}>
<View
style={[
styles.inputIconBox,
{ backgroundColor: ACCENT + "15" },
]}
>
<Ionicons
name="globe-outline"
size={20}
color={ACCENT}
/>
</View>
<RNTextInput
style={styles.input}
placeholder="192.168.1.10:8000 ou https://exemple.com"
placeholderTextColor={colors.textMuted}
value={address}
onChangeText={setAddress}
autoCapitalize="none"
autoCorrect={false}
keyboardType="url"
/>
</View>
<Text style={styles.hint}>
URL complète ou adresse IP suivie du port
</Text>
<Animated.View
style={{ transform: [{ scale: buttonScale }] }}
>
<TouchableOpacity
style={[
styles.continueBtn,
{ backgroundColor: ACCENT },
]}
onPress={handleContinue}
onPressIn={onPressIn}
onPressOut={onPressOut}
disabled={saving}
activeOpacity={0.9}
>
{saving ? (
<Text style={styles.continueBtnText}>
Enregistrement...
</Text>
) : (
<>
<Text style={styles.continueBtnText}>
Continuer
</Text>
<Ionicons
name="arrow-forward"
size={20}
color={colors.white}
style={{ marginLeft: spacing.s }}
/>
</>
)}
</TouchableOpacity>
</Animated.View>
</View>
<AlertModal
visible={alert.visible}
type={alert.type}
title={alert.title}
message={alert.message}
onClose={hideAlert}
/>
</ScrollView>
</KeyboardAvoidingView>
);
}
@@ -0,0 +1,281 @@
import React, { useState, useEffect, useCallback, useMemo } from "react";
import {
View,
Text,
StyleSheet,
FlatList,
TouchableOpacity,
RefreshControl,
} from "react-native";
import { Ionicons } from "@expo/vector-icons";
import { spacing, fontSize } from "../../theme";
import { useTheme } from "../../context/ThemeContext";
import {
addAddress,
deleteAddress,
getAllAddresses,
} from "../../api/api_cabine";
import { useAlert } from "../../hooks/useAlert";
import Card from "../../components/ui/Card";
import Modal from "../../components/ui/Modal";
import TextInput from "../../components/ui/TextInput";
import Button from "../../components/ui/Button";
import AlertModal from "../../components/ui/AlertModal";
type AddressEntry = {
invalid_address: string;
correct_address: string;
};
export default function AddressScreen() {
const { colors } = useTheme();
const { alert, showError, showSuccess, showConfirm, hideAlert } =
useAlert();
const [addresses, setAddresses] = useState<AddressEntry[]>([]);
const [loading, setLoading] = useState(true);
const [refreshing, setRefreshing] = useState(false);
const [addModal, setAddModal] = useState(false);
const [invalidInput, setInvalidInput] = useState("");
const [correctInput, setCorrectInput] = useState("");
const [saving, setSaving] = useState(false);
const loadAddresses = useCallback(async () => {
try {
const result = await getAllAddresses();
setAddresses(result);
} catch (e: any) {
showError("Erreur", e.response?.data?.error ?? e.message);
} finally {
setLoading(false);
}
}, [showError]);
useEffect(() => {
loadAddresses();
}, [loadAddresses]);
const onRefresh = async () => {
setRefreshing(true);
await loadAddresses();
setRefreshing(false);
};
const openAddModal = () => {
setInvalidInput("");
setCorrectInput("");
setAddModal(true);
};
const handleAdd = useCallback(async () => {
const inv = invalidInput.trim();
const cor = correctInput.trim();
if (!inv || !cor) {
showError("Erreur", "Les deux champs sont obligatoires.");
return;
}
setSaving(true);
try {
await addAddress(inv, cor);
setAddresses((prev) => [
...prev,
{ invalid_address: inv, correct_address: cor },
]);
setAddModal(false);
showSuccess("Succès", "Adresse ajoutée avec succès.");
} catch (e: any) {
showError("Erreur", e.response?.data?.error ?? e.message);
} finally {
setSaving(false);
}
}, [invalidInput, correctInput, showError, showSuccess]);
const handleDelete = useCallback(
(item: AddressEntry) => {
showConfirm(
"Supprimer",
`Supprimer la correction :\n"${item.invalid_address}" → "${item.correct_address}" ?`,
async () => {
try {
await deleteAddress(
item.invalid_address,
item.correct_address,
);
setAddresses((prev) =>
prev.filter(
(a) =>
a.invalid_address !==
item.invalid_address ||
a.correct_address !== item.correct_address,
),
);
showSuccess("Supprimé", "Adresse supprimée.");
} catch (e: any) {
showError(
"Erreur",
e.response?.data?.error ?? e.message,
);
}
},
"Supprimer",
);
},
[showConfirm, showError, showSuccess],
);
const styles = useMemo(
() =>
StyleSheet.create({
container: { flex: 1, backgroundColor: colors.bgPrimary },
header: {
flexDirection: "row",
justifyContent: "space-between",
alignItems: "center",
padding: spacing.l,
paddingBottom: spacing.s,
},
title: {
color: colors.textWhite,
fontSize: fontSize.lg,
fontWeight: "700",
},
addBtn: {
backgroundColor: colors.accent,
borderRadius: 20,
padding: spacing.s,
},
invalid: {
color: colors.danger,
fontSize: fontSize.md,
fontWeight: "600",
},
correct: {
color: colors.success,
fontSize: fontSize.md,
fontWeight: "600",
marginTop: 6,
},
label: {
fontSize: fontSize.xs,
fontWeight: "700",
letterSpacing: 0.5,
marginBottom: 2,
},
labelInvalid: {
color: colors.danger,
},
labelCorrect: {
color: colors.success,
},
arrow: {
color: colors.textMuted,
fontSize: fontSize.lg,
marginVertical: 4,
},
row: {
flexDirection: "row",
alignItems: "center",
justifyContent: "space-between",
},
empty: {
color: colors.textMuted,
textAlign: "center",
marginTop: spacing.xxl,
fontSize: fontSize.md,
},
}),
[colors],
);
const renderItem = ({ item }: { item: AddressEntry }) => (
<Card style={{ marginBottom: spacing.m }}>
<View style={styles.row}>
<View style={{ flex: 1, marginRight: spacing.m }}>
<Text style={styles.invalid}>{item.invalid_address}</Text>
<Text style={styles.arrow}></Text>
<Text style={styles.correct}>{item.correct_address}</Text>
</View>
<TouchableOpacity onPress={() => handleDelete(item)}>
<Ionicons
name="trash-outline"
size={22}
color={colors.danger}
/>
</TouchableOpacity>
</View>
</Card>
);
return (
<View style={styles.container}>
<View style={styles.header}>
<Text style={styles.title}>Corrections d'adresses</Text>
<TouchableOpacity style={styles.addBtn} onPress={openAddModal}>
<Ionicons name="add" size={22} color={colors.textWhite} />
</TouchableOpacity>
</View>
<FlatList
data={addresses}
keyExtractor={(item, i) => `${item.invalid_address}-${i}`}
renderItem={renderItem}
contentContainerStyle={{
padding: spacing.l,
paddingTop: spacing.s,
}}
refreshControl={
<RefreshControl
refreshing={refreshing}
onRefresh={onRefresh}
/>
}
ListEmptyComponent={
<Text style={styles.empty}>
{loading
? "Chargement..."
: "Aucune correction.\nAppuyez sur + pour en ajouter une."}
</Text>
}
/>
<Modal
visible={addModal}
onClose={() => setAddModal(false)}
title="Ajouter une correction"
icon="map-outline"
>
<TextInput
label="Adresse invalide"
value={invalidInput}
onChangeText={setInvalidInput}
placeholder="Ex: 10 rue de la paix"
autoCapitalize="none"
/>
<TextInput
label="Adresse correcte"
value={correctInput}
onChangeText={setCorrectInput}
placeholder="Ex: 10 Rue de la Paix, 75001 Paris"
autoCapitalize="none"
/>
<Button
title="Ajouter"
onPress={handleAdd}
loading={saving}
style={{ marginTop: spacing.m }}
/>
</Modal>
<AlertModal
visible={alert.visible}
type={alert.type}
title={alert.title}
message={alert.message}
onClose={hideAlert}
onConfirm={alert.onConfirm}
confirmText={alert.confirmText}
cancelText={alert.cancelText}
/>
</View>
);
}
@@ -0,0 +1,177 @@
import React, { useState, useEffect, useCallback, useMemo } from "react";
import {
View,
Text,
StyleSheet,
FlatList,
RefreshControl,
TouchableOpacity,
} from "react-native";
import { Ionicons } from "@expo/vector-icons";
import { spacing, fontSize } from "../../theme";
import { useTheme } from "../../context/ThemeContext";
import { getAllAlerts, getActiveAlerts } from "../../api/api_cabine";
import type { Alert as AlertType } from "../../api/types";
import LoadingSpinner from "../../components/ui/LoadingSpinner";
import Card from "../../components/ui/Card";
import Badge from "../../components/ui/Badge";
export default function AlertsScreen() {
const { colors } = useTheme();
const [alerts, setAlerts] = useState<AlertType[]>([]);
const [loading, setLoading] = useState(true);
const [refreshing, setRefreshing] = useState(false);
const [filter, setFilter] = useState<"all" | "active">("all");
const loadData = useCallback(async () => {
try {
const result =
filter === "active"
? await getActiveAlerts()
: await getAllAlerts();
setAlerts(result.alerts);
} catch {
/* ignore */
}
setLoading(false);
}, [filter]);
useEffect(() => {
setLoading(true);
loadData();
}, [loadData]);
const onRefresh = async () => {
setRefreshing(true);
await loadData();
setRefreshing(false);
};
const styles = useMemo(
() =>
StyleSheet.create({
container: { flex: 1, backgroundColor: colors.bgPrimary },
row: { flexDirection: "row", alignItems: "center" },
username: {
color: colors.textWhite,
fontSize: fontSize.md,
fontWeight: "600",
},
alertMessage: {
color: colors.danger,
fontSize: fontSize.sm,
fontWeight: "600",
marginTop: 2,
},
date: {
color: colors.textMuted,
fontSize: fontSize.xs,
marginTop: 2,
},
empty: {
color: colors.textMuted,
textAlign: "center",
marginTop: spacing.xxl,
fontSize: fontSize.md,
},
filterRow: {
flexDirection: "row",
paddingHorizontal: spacing.l,
paddingTop: spacing.m,
gap: spacing.s,
},
filterBtn: {
paddingVertical: spacing.xs,
paddingHorizontal: spacing.m,
borderRadius: 20,
backgroundColor: colors.bgSecondary,
},
filterActive: { backgroundColor: colors.info },
filterText: {
color: colors.textMuted,
fontSize: fontSize.sm,
},
filterTextActive: {
color: colors.textWhite,
fontWeight: "600",
},
}),
[colors],
);
const renderAlert = ({ item }: { item: AlertType }) => (
<Card style={{ marginBottom: spacing.m }}>
<View style={styles.row}>
<Ionicons
name="alert-circle"
size={24}
color={
item.status === "true"
? colors.danger
: colors.textMuted
}
/>
<View style={{ flex: 1, marginLeft: spacing.m }}>
<Text style={styles.username}>
Livreur: {item.username}
</Text>
{item.message ? (
<Text style={styles.alertMessage}>{item.message}</Text>
) : null}
<Text style={styles.date}>
{new Date(item.created_at).toLocaleString("fr-FR")}
</Text>
</View>
<Badge
label={item.status === "true" ? "Active" : "Terminée"}
color={
item.status === "true" ? colors.danger : colors.success
}
/>
</View>
</Card>
);
if (loading) return <LoadingSpinner message="Chargement alertes..." />;
return (
<View style={styles.container}>
<View style={styles.filterRow}>
{(["all", "active"] as const).map((f) => (
<TouchableOpacity
key={f}
onPress={() => setFilter(f)}
style={[
styles.filterBtn,
filter === f && styles.filterActive,
]}
>
<Text
style={[
styles.filterText,
filter === f && styles.filterTextActive,
]}
>
{f === "all" ? "Toutes" : "Actives"}
</Text>
</TouchableOpacity>
))}
</View>
<FlatList
data={alerts}
keyExtractor={(item) => item.id.toString()}
renderItem={renderAlert}
refreshControl={
<RefreshControl
refreshing={refreshing}
onRefresh={onRefresh}
tintColor={colors.info}
/>
}
contentContainerStyle={{ padding: spacing.l }}
ListEmptyComponent={
<Text style={styles.empty}>Aucune alerte</Text>
}
/>
</View>
);
}
@@ -0,0 +1,247 @@
import React, { useState, useEffect, useCallback, useMemo } from "react";
import {
View,
Text,
StyleSheet,
ScrollView,
RefreshControl,
TouchableOpacity,
Linking,
Alert,
useWindowDimensions,
} from "react-native";
import { Ionicons } from "@expo/vector-icons";
import { spacing, fontSize, borderRadius } from "../../theme";
import { useTheme } from "../../context/ThemeContext";
import { shadows } from "../../theme/shadows";
import { useAuth } from "../../auth/AuthContext";
import {
getCabineCommands,
getAllDeliveryPersonsWithDetails,
getCabineTelegramStatus,
generateCabineLinkToken,
unlinkCabineTelegram,
} from "../../api/api_cabine";
import LoadingSpinner from "../../components/ui/LoadingSpinner";
export default function DashboardScreen() {
const { colors } = useTheme();
const { username } = useAuth();
const { width: screenWidth } = useWindowDimensions();
const [tgLinked, setTgLinked] = useState(false);
const [tgEnabled, setTgEnabled] = useState(false);
const [tgLoading, setTgLoading] = useState(false);
const [stats, setStats] = useState<
Array<{
label: string;
value: number;
icon: keyof typeof Ionicons.glyphMap;
color: string;
}>
>([]);
const [loading, setLoading] = useState(true);
const [refreshing, setRefreshing] = useState(false);
const loadStats = useCallback(async () => {
try {
const [allCmd, livreursRes] = await Promise.all([
getCabineCommands(),
getAllDeliveryPersonsWithDetails(),
]);
const cmds = allCmd.commands;
setStats([
{
label: "Commandes actives",
value: cmds.filter(
(c: any) =>
!["approved", "cancelled"].includes(c.status),
).length,
icon: "receipt-outline",
color: colors.info,
},
{
label: "En route",
value: cmds.filter((c: any) => c.status === "en_route")
.length,
icon: "navigate-outline",
color: colors.warning,
},
{
label: "En attente",
value: cmds.filter((c: any) => c.status === "pending")
.length,
icon: "time-outline",
color: colors.accent,
},
{
label: "Livreurs dispo",
value: livreursRes.stats.available,
icon: "bicycle-outline",
color: colors.success,
},
{
label: "Livreurs occupés",
value: livreursRes.stats.busy,
icon: "bicycle",
color: colors.warning,
},
{
label: "Total terminées",
value: cmds.filter((c: any) => c.status === "approved")
.length,
icon: "checkmark-circle-outline",
color: colors.successDark,
},
]);
} catch {
/* ignore */
}
setLoading(false);
}, [colors]);
useEffect(() => {
loadStats();
getCabineTelegramStatus().then((s) => { setTgLinked(s.linked); setTgEnabled(s.enabled); });
}, [loadStats]);
const handleLinkTelegram = async () => {
setTgLoading(true);
const res = await generateCabineLinkToken();
setTgLoading(false);
if (res.error || !res.link_url) {
Alert.alert("Erreur", res.error || "Service Telegram non disponible");
return;
}
Linking.openURL(res.link_url);
};
const handleUnlinkTelegram = () => {
Alert.alert("Délier Telegram", "Vous ne recevrez plus de notifications Telegram.", [
{ text: "Annuler", style: "cancel" },
{ text: "Délier", style: "destructive", onPress: async () => { await unlinkCabineTelegram(); setTgLinked(false); } },
]);
};
const onRefresh = async () => {
setRefreshing(true);
await loadStats();
setRefreshing(false);
};
const styles = useMemo(
() =>
StyleSheet.create({
container: {
flex: 1,
backgroundColor: colors.bgPrimary,
padding: screenWidth < 380 ? spacing.m : spacing.l,
},
welcome: {
fontSize: screenWidth < 380 ? fontSize.lg : fontSize.xl,
fontWeight: "bold",
color: colors.textWhite,
marginBottom: spacing.xs,
},
subtitle: {
fontSize: screenWidth < 380 ? fontSize.sm : fontSize.md,
color: colors.textSecondary,
marginBottom: spacing.l,
},
grid: {
flexDirection: "row",
flexWrap: "wrap",
gap: screenWidth < 380 ? spacing.s : spacing.m,
},
card: {
width: screenWidth < 360 ? "100%" : "47%",
backgroundColor: colors.bgCard,
borderRadius: borderRadius.md,
padding: screenWidth < 380 ? spacing.m : spacing.l,
borderWidth: 1,
borderColor: colors.borderLight,
alignItems: "center",
},
iconCircle: {
width: screenWidth < 380 ? 40 : 48,
height: screenWidth < 380 ? 40 : 48,
borderRadius: screenWidth < 380 ? 20 : 24,
justifyContent: "center",
alignItems: "center",
marginBottom: spacing.s,
},
cardValue: {
fontSize: screenWidth < 380 ? fontSize.xl : fontSize.xxl,
fontWeight: "bold",
color: colors.textWhite,
},
cardLabel: {
fontSize: screenWidth < 380 ? fontSize.xs : fontSize.sm,
color: colors.textSecondary,
marginTop: spacing.xs,
textAlign: "center",
},
}),
[colors, screenWidth],
);
if (loading) return <LoadingSpinner message="Chargement..." />;
return (
<ScrollView
style={styles.container}
refreshControl={
<RefreshControl
refreshing={refreshing}
onRefresh={onRefresh}
tintColor={colors.info}
/>
}
>
<Text style={styles.welcome}>Cabine - {username}</Text>
<Text style={styles.subtitle}>Suivi des opérations</Text>
<View style={styles.grid}>
{stats.map((s, i) => (
<View key={i} style={[styles.card, shadows.md]}>
<View
style={[
styles.iconCircle,
{ backgroundColor: s.color + "20" },
]}
>
<Ionicons name={s.icon} size={24} color={s.color} />
</View>
<Text style={styles.cardValue}>{s.value}</Text>
<Text style={styles.cardLabel}>{s.label}</Text>
</View>
))}
</View>
{tgEnabled && (
<View style={{ margin: spacing.l, backgroundColor: colors.bgCard, borderRadius: borderRadius.md, padding: spacing.l, borderWidth: 1, borderColor: colors.borderLight }}>
<View style={{ flexDirection: "row", alignItems: "center", gap: spacing.s, marginBottom: spacing.m }}>
<Ionicons name="paper-plane-outline" size={18} color="#2AABEE" />
<Text style={{ color: colors.textPrimary, fontSize: fontSize.md, fontWeight: "600" }}>Notifications Telegram</Text>
</View>
{tgLinked ? (
<View>
<View style={{ flexDirection: "row", alignItems: "center", gap: spacing.s, marginBottom: spacing.s }}>
<Ionicons name="checkmark-circle" size={14} color={colors.success} />
<Text style={{ color: colors.success, fontSize: fontSize.sm }}>Compte Telegram lié</Text>
</View>
<TouchableOpacity onPress={handleUnlinkTelegram} style={{ flexDirection: "row", alignItems: "center", gap: spacing.s, padding: spacing.s, borderRadius: borderRadius.sm, borderWidth: 1, borderColor: colors.danger + "66" }}>
<Ionicons name="unlink-outline" size={14} color={colors.danger} />
<Text style={{ color: colors.danger, fontSize: fontSize.sm }}>Délier Telegram</Text>
</TouchableOpacity>
</View>
) : (
<TouchableOpacity onPress={handleLinkTelegram} disabled={tgLoading} style={{ flexDirection: "row", alignItems: "center", justifyContent: "center", gap: spacing.s, padding: spacing.m, borderRadius: borderRadius.sm, backgroundColor: "#2AABEE" }}>
<Ionicons name="paper-plane-outline" size={14} color="#fff" />
<Text style={{ color: "#fff", fontSize: fontSize.sm, fontWeight: "600" }}>{tgLoading ? "Génération..." : "Lier Telegram"}</Text>
</TouchableOpacity>
)}
</View>
)}
</ScrollView>
);
}
@@ -0,0 +1,803 @@
import React, {
useState,
useEffect,
useCallback,
useRef,
useMemo,
} from "react";
import {
View,
Text,
StyleSheet,
FlatList,
RefreshControl,
TouchableOpacity,
Modal,
StatusBar,
Dimensions,
} from "react-native";
import { Ionicons } from "@expo/vector-icons";
import { spacing, fontSize, borderRadius } from "../../theme";
import { useTheme } from "../../context/ThemeContext";
import {
getAllDeliveryPersonsWithDetails,
getDeliverymanLocationForCommand,
} from "../../api/api_cabine";
import { geocodeAddress, calculateRoute } from "../../api/tomtom";
import type { RouteInfo, LatLng } from "../../api/tomtom";
import type { DeliveryPerson } from "../../api/types";
import { STATUS_LABELS, getStatusColors } from "../../utils/constants";
import LoadingSpinner from "../../components/ui/LoadingSpinner";
import Card from "../../components/ui/Card";
import Badge from "../../components/ui/Badge";
import TomTomMap, {
TomTomMapRef,
TomTomMarker,
} from "../../components/TomTomMap";
const MAP_HEIGHT = 280;
export default function DeliveryScreen() {
const { colors } = useTheme();
const statusColors = getStatusColors(colors);
const [livreurs, setLivreurs] = useState<DeliveryPerson[]>([]);
const [stats, setStats] = useState({
total: 0,
available: 0,
busy: 0,
offline: 0,
});
const [loading, setLoading] = useState(true);
const [refreshing, setRefreshing] = useState(false);
// Map
const mapRef = useRef<TomTomMapRef | null>(null);
const fullscreenMapRef = useRef<TomTomMapRef | null>(null);
const [mapFullscreen, setMapFullscreen] = useState(false);
// Selected livreur route
const [selectedLivreur, setSelectedLivreur] =
useState<DeliveryPerson | null>(null);
const [routeInfo, setRouteInfo] = useState<RouteInfo | null>(null);
const [routeLoading, setRouteLoading] = useState(false);
const loadData = useCallback(async () => {
try {
const result = await getAllDeliveryPersonsWithDetails();
setLivreurs(result.deliveryPersons);
setStats(result.stats);
} catch {
/* ignore */
}
setLoading(false);
}, []);
useEffect(() => {
loadData();
const interval = setInterval(loadData, 15000);
return () => clearInterval(interval);
}, [loadData]);
const onRefresh = async () => {
setRefreshing(true);
await loadData();
setRefreshing(false);
};
const livreursWithGPS = livreurs.filter(
(l) => l.location.latitude !== 0 && l.location.longitude !== 0,
);
// Track livreur route using their current command
const trackLivreur = useCallback(
async (livreur: DeliveryPerson) => {
setSelectedLivreur(livreur);
setRouteInfo(null);
if (!livreur.stats.current_command) return;
setRouteLoading(true);
try {
const locRes = await getDeliverymanLocationForCommand(
livreur.stats.current_command,
);
const cmdAddress =
locRes.data?.delivery_address || locRes.data?.adresse;
if (!cmdAddress) {
setRouteLoading(false);
return;
}
const origin: LatLng = {
latitude: livreur.location.latitude,
longitude: livreur.location.longitude,
};
const dest = await geocodeAddress(cmdAddress);
if (!dest) {
setRouteLoading(false);
return;
}
const result = await calculateRoute(origin, dest);
if (result) {
setRouteInfo(result.route);
const ref = mapFullscreen ? fullscreenMapRef : mapRef;
ref.current?.fitToCoordinates([origin, dest]);
ref.current?.calcRoute(origin, dest);
} else {
const ref = mapFullscreen ? fullscreenMapRef : mapRef;
ref.current?.calcRoute(origin, dest);
}
} catch {
/* silent */
}
setRouteLoading(false);
},
[mapFullscreen],
);
const clearRoute = () => {
setSelectedLivreur(null);
setRouteInfo(null);
mapRef.current?.clearRoute();
fullscreenMapRef.current?.clearRoute();
};
const livreurMarkers = useMemo<TomTomMarker[]>(
() =>
livreursWithGPS.map((l) => ({
id: l.username,
latitude: l.location.latitude,
longitude: l.location.longitude,
color:
selectedLivreur?.username === l.username
? "#2196F3"
: statusColors[l.status] || "#888",
label: l.username,
description: `${STATUS_LABELS[l.status] || l.status}${l.stats.current_command ? ` · Cmd #${l.stats.current_command}` : ""}`,
isSelected: selectedLivreur?.username === l.username,
})),
[livreursWithGPS, selectedLivreur, statusColors],
);
const renderLivreur = ({ item }: { item: DeliveryPerson }) => {
const isSelected = selectedLivreur?.username === item.username;
const hasGPS = item.location.latitude !== 0;
return (
<Card
style={[
{ marginBottom: spacing.m },
isSelected && { borderWidth: 1, borderColor: colors.info },
]}
>
<View style={styles.row}>
<View
style={[
styles.statusDot,
{
backgroundColor:
statusColors[item.status] ||
colors.textMuted,
},
]}
/>
<Text style={styles.username}>{item.username}</Text>
<Badge
label={STATUS_LABELS[item.status] || item.status}
color={statusColors[item.status] || colors.textMuted}
/>
</View>
<View style={styles.statsRow}>
<Text style={styles.statText}>
Queue: {item.stats.queue_size}
</Text>
<Text style={styles.statText}>
Total: {item.stats.total_deliveries}
</Text>
</View>
{hasGPS && (
<Text style={styles.location}>
GPS: {item.location.latitude.toFixed(4)},{" "}
{item.location.longitude.toFixed(4)}
</Text>
)}
{item.stats.current_command && (
<Text style={styles.currentCmd}>
Commande: #{item.stats.current_command}
</Text>
)}
{/* Buttons */}
{hasGPS && (
<View style={styles.btnRow}>
<TouchableOpacity
style={[
styles.trackBtn,
isSelected && { backgroundColor: colors.info },
]}
onPress={() =>
isSelected ? clearRoute() : trackLivreur(item)
}
activeOpacity={0.7}
>
<Ionicons
name={
isSelected
? "close-circle-outline"
: "navigate-outline"
}
size={15}
color={isSelected ? colors.white : colors.info}
/>
<Text
style={[
styles.trackBtnText,
isSelected && { color: colors.white },
]}
>
{isSelected ? "Arrêter" : "Suivre"}
</Text>
</TouchableOpacity>
</View>
)}
</Card>
);
};
const renderHeader = () => (
<View>
{livreursWithGPS.length > 0 && (
<View style={styles.mapContainer}>
<TomTomMap
ref={mapRef}
style={styles.map}
markers={livreurMarkers}
initialCenter={{
latitude: livreursWithGPS[0].location.latitude,
longitude: livreursWithGPS[0].location.longitude,
}}
initialZoom={13}
onMarkerPress={(id) => {
const livreur = livreursWithGPS.find(
(l) => l.username === id,
);
if (livreur) {
if (selectedLivreur?.username === id) {
clearRoute();
} else {
trackLivreur(livreur);
}
}
}}
/>
{routeInfo && selectedLivreur && (
<View style={styles.routeOverlay}>
<Text style={styles.routeOverlayUser}>
{selectedLivreur.username}
</Text>
<View style={styles.routeChips}>
<View style={styles.routeChip}>
<Ionicons
name="speedometer-outline"
size={12}
color={colors.info}
/>
<Text style={styles.routeChipText}>
{routeInfo.distance}
</Text>
</View>
<View style={styles.routeChip}>
<Ionicons
name="time-outline"
size={12}
color={colors.info}
/>
<Text style={styles.routeChipText}>
{routeInfo.duration}
</Text>
</View>
</View>
</View>
)}
{routeLoading && (
<View style={styles.routeLoadingOverlay}>
<Text style={styles.routeLoadingText}>
Calcul itinéraire...
</Text>
</View>
)}
<View style={styles.mapBtns}>
<TouchableOpacity
style={styles.mapBtn}
onPress={() => mapRef.current?.fitAllMarkers()}
>
<Ionicons
name="locate-outline"
size={18}
color={colors.white}
/>
</TouchableOpacity>
<TouchableOpacity
style={styles.mapBtn}
onPress={() => setMapFullscreen(true)}
>
<Ionicons
name="expand-outline"
size={18}
color={colors.white}
/>
</TouchableOpacity>
</View>
</View>
)}
{livreursWithGPS.length === 0 && !loading && (
<View style={styles.noMapBox}>
<Ionicons
name="location-outline"
size={32}
color={colors.textMuted}
/>
<Text style={styles.noMapText}>
Aucun livreur avec GPS actif
</Text>
</View>
)}
<Text style={styles.sectionTitle}>
Livreurs ({livreurs.length})
</Text>
</View>
);
const styles = useMemo(
() =>
StyleSheet.create({
container: { flex: 1, backgroundColor: colors.bgPrimary },
summaryRow: {
flexDirection: "row",
padding: spacing.l,
gap: spacing.s,
},
summaryCard: {
flex: 1,
backgroundColor: colors.bgCard,
borderRadius: borderRadius.sm,
padding: spacing.m,
borderLeftWidth: 3,
alignItems: "center",
},
summaryValue: {
fontSize: fontSize.xl,
fontWeight: "bold",
color: colors.textWhite,
},
summaryLabel: {
fontSize: fontSize.xs,
color: colors.textMuted,
},
mapContainer: {
borderRadius: borderRadius.md,
overflow: "hidden",
marginBottom: spacing.m,
position: "relative",
},
map: { width: "100%", height: MAP_HEIGHT },
routeOverlay: {
position: "absolute",
top: spacing.s,
left: spacing.s,
backgroundColor: "rgba(0,0,0,0.75)",
borderRadius: borderRadius.sm,
padding: spacing.s,
paddingHorizontal: spacing.m,
},
routeOverlayUser: {
color: colors.white,
fontSize: fontSize.sm,
fontWeight: "700",
},
routeChips: {
flexDirection: "row",
gap: spacing.s,
marginTop: 4,
},
routeChip: {
flexDirection: "row",
alignItems: "center",
gap: 3,
},
routeChipText: {
color: colors.info,
fontSize: fontSize.xs,
fontWeight: "600",
},
routeLoadingOverlay: {
position: "absolute",
top: 0,
left: 0,
right: 0,
bottom: 0,
backgroundColor: "rgba(0,0,0,0.3)",
justifyContent: "center",
alignItems: "center",
},
routeLoadingText: {
color: colors.white,
fontSize: fontSize.sm,
fontWeight: "600",
},
mapBtns: {
position: "absolute",
top: spacing.s,
right: spacing.s,
gap: spacing.xs,
},
mapBtn: {
width: 36,
height: 36,
borderRadius: 18,
backgroundColor: "rgba(0,0,0,0.6)",
justifyContent: "center",
alignItems: "center",
},
noMapBox: {
alignItems: "center",
paddingVertical: spacing.xl,
marginBottom: spacing.m,
backgroundColor: colors.bgSecondary,
borderRadius: borderRadius.md,
},
noMapText: {
color: colors.textMuted,
fontSize: fontSize.sm,
marginTop: spacing.s,
},
sectionTitle: {
color: colors.textWhite,
fontSize: fontSize.lg,
fontWeight: "700",
marginBottom: spacing.m,
},
row: {
flexDirection: "row",
alignItems: "center",
gap: spacing.s,
marginBottom: spacing.s,
},
statusDot: { width: 10, height: 10, borderRadius: 5 },
username: {
flex: 1,
fontSize: fontSize.lg,
fontWeight: "600",
color: colors.textWhite,
},
statsRow: {
flexDirection: "row",
gap: spacing.l,
marginTop: spacing.xs,
},
statText: {
color: colors.textSecondary,
fontSize: fontSize.sm,
},
location: {
color: colors.textMuted,
fontSize: fontSize.xs,
marginTop: spacing.s,
},
currentCmd: {
color: colors.info,
fontSize: fontSize.sm,
marginTop: spacing.xs,
fontWeight: "500",
},
btnRow: {
flexDirection: "row",
gap: spacing.s,
marginTop: spacing.m,
},
trackBtn: {
flex: 1,
flexDirection: "row",
alignItems: "center",
justifyContent: "center",
gap: spacing.xs,
paddingVertical: spacing.s,
borderRadius: borderRadius.sm,
borderWidth: 1,
borderColor: colors.info,
},
trackBtnText: {
color: colors.info,
fontSize: fontSize.sm,
fontWeight: "600",
},
empty: {
color: colors.textMuted,
textAlign: "center",
marginTop: spacing.xxl,
},
fullscreenContainer: {
flex: 1,
backgroundColor: colors.bgPrimary,
},
fullscreenMap: { ...StyleSheet.absoluteFillObject },
fullscreenTopBar: {
position: "absolute",
top: 0,
left: 0,
right: 0,
flexDirection: "row",
alignItems: "center",
justifyContent: "space-between",
paddingTop: 50,
paddingHorizontal: spacing.l,
paddingBottom: spacing.m,
backgroundColor: "rgba(0,0,0,0.5)",
},
closeBtn: {
width: 40,
height: 40,
borderRadius: 20,
backgroundColor: "rgba(255,255,255,0.15)",
justifyContent: "center",
alignItems: "center",
},
fullscreenTitle: {
color: colors.white,
fontSize: fontSize.lg,
fontWeight: "700",
},
fullscreenRouteBar: {
position: "absolute",
top: 110,
left: spacing.m,
right: spacing.m,
flexDirection: "row",
alignItems: "center",
backgroundColor: "rgba(0,0,0,0.8)",
padding: spacing.m,
borderRadius: borderRadius.md,
},
fullscreenRouteUser: {
color: colors.white,
fontSize: fontSize.md,
fontWeight: "700",
},
fullscreenRouteInfo: {
color: colors.info,
fontSize: fontSize.sm,
marginTop: 2,
},
clearRouteBtn: { padding: spacing.xs },
fullscreenBottomBar: {
position: "absolute",
bottom: 0,
left: 0,
right: 0,
backgroundColor: "rgba(0,0,0,0.7)",
paddingHorizontal: spacing.l,
paddingTop: spacing.m,
paddingBottom: 40,
},
legendRow: {
flexDirection: "row",
justifyContent: "center",
gap: spacing.l,
},
legendItem: {
flexDirection: "row",
alignItems: "center",
gap: 6,
},
legendDot: { width: 10, height: 10, borderRadius: 5 },
legendText: { color: colors.white, fontSize: fontSize.sm },
}),
[colors],
);
if (loading) return <LoadingSpinner message="Chargement livreurs..." />;
return (
<View style={styles.container}>
{/* Fullscreen map */}
<Modal
visible={mapFullscreen}
animationType="fade"
onRequestClose={() => setMapFullscreen(false)}
statusBarTranslucent
>
<StatusBar hidden={mapFullscreen} />
<View style={styles.fullscreenContainer}>
{livreursWithGPS.length > 0 && (
<TomTomMap
ref={fullscreenMapRef}
style={styles.fullscreenMap}
markers={livreurMarkers}
initialCenter={{
latitude:
livreursWithGPS[0].location.latitude,
longitude:
livreursWithGPS[0].location.longitude,
}}
initialZoom={13}
onMarkerPress={(id) => {
const livreur = livreursWithGPS.find(
(l) => l.username === id,
);
if (livreur) {
if (selectedLivreur?.username === id) {
clearRoute();
} else {
trackLivreur(livreur);
}
}
}}
/>
)}
<View style={styles.fullscreenTopBar}>
<TouchableOpacity
style={styles.closeBtn}
onPress={() => setMapFullscreen(false)}
>
<Ionicons
name="close"
size={24}
color={colors.white}
/>
</TouchableOpacity>
<Text style={styles.fullscreenTitle}>
Suivi des livreurs
</Text>
<TouchableOpacity
style={styles.closeBtn}
onPress={() =>
fullscreenMapRef.current?.fitAllMarkers()
}
>
<Ionicons
name="locate-outline"
size={20}
color={colors.white}
/>
</TouchableOpacity>
</View>
{routeInfo && selectedLivreur && (
<View style={styles.fullscreenRouteBar}>
<View style={{ flex: 1 }}>
<Text style={styles.fullscreenRouteUser}>
{selectedLivreur.username}
</Text>
<Text style={styles.fullscreenRouteInfo}>
{routeInfo.distance} · {routeInfo.duration}
{selectedLivreur.stats.current_command
? ` · Cmd #${selectedLivreur.stats.current_command}`
: ""}
</Text>
</View>
<TouchableOpacity
style={styles.clearRouteBtn}
onPress={clearRoute}
>
<Ionicons
name="close-circle"
size={24}
color={colors.textMuted}
/>
</TouchableOpacity>
</View>
)}
<View style={styles.fullscreenBottomBar}>
<View style={styles.legendRow}>
<View style={styles.legendItem}>
<View
style={[
styles.legendDot,
{ backgroundColor: colors.success },
]}
/>
<Text style={styles.legendText}>
Dispo ({stats.available})
</Text>
</View>
<View style={styles.legendItem}>
<View
style={[
styles.legendDot,
{ backgroundColor: colors.warning },
]}
/>
<Text style={styles.legendText}>
Occupé ({stats.busy})
</Text>
</View>
<View style={styles.legendItem}>
<View
style={[
styles.legendDot,
{ backgroundColor: colors.textMuted },
]}
/>
<Text style={styles.legendText}>
Offline ({stats.offline})
</Text>
</View>
</View>
</View>
</View>
</Modal>
{/* Summary */}
<View style={styles.summaryRow}>
<View
style={[
styles.summaryCard,
{ borderLeftColor: colors.success },
]}
>
<Text style={styles.summaryValue}>{stats.available}</Text>
<Text style={styles.summaryLabel}>Dispo</Text>
</View>
<View
style={[
styles.summaryCard,
{ borderLeftColor: colors.warning },
]}
>
<Text style={styles.summaryValue}>{stats.busy}</Text>
<Text style={styles.summaryLabel}>Occupés</Text>
</View>
<View
style={[
styles.summaryCard,
{ borderLeftColor: colors.textMuted },
]}
>
<Text style={styles.summaryValue}>{stats.offline}</Text>
<Text style={styles.summaryLabel}>Offline</Text>
</View>
</View>
<FlatList
data={livreurs}
keyExtractor={(item) => item.username}
renderItem={renderLivreur}
refreshControl={
<RefreshControl
refreshing={refreshing}
onRefresh={onRefresh}
tintColor={colors.info}
/>
}
contentContainerStyle={{ padding: spacing.l, paddingTop: 0 }}
ListHeaderComponent={renderHeader()}
ListEmptyComponent={
<Text style={styles.empty}>Aucun livreur</Text>
}
/>
</View>
);
}
@@ -0,0 +1,794 @@
import React, { useState, useEffect, useCallback, useMemo } from "react";
import {
View,
Text,
StyleSheet,
FlatList,
RefreshControl,
ScrollView,
TouchableOpacity,
TextInput,
} from "react-native";
import { Ionicons } from "@expo/vector-icons";
import { spacing, fontSize, borderRadius } from "../../theme";
import { useTheme } from "../../context/ThemeContext";
import {
getCommandItems,
deleteCommand,
confirmReceptionCabine,
notifyClientToDescendCabine,
getCabineLivreursList,
assignDeliveryPersonByCabine,
proposeAddressChangeCabine,
getCabineCommands,
} from "../../api/api_cabine";
import type { CommandResponse } from "../../api/types";
import StatusBadge from "../../components/StatusBadge";
import LoadingSpinner from "../../components/ui/LoadingSpinner";
import Modal from "../../components/ui/Modal";
import Card from "../../components/ui/Card";
import AlertModal from "../../components/ui/AlertModal";
import { useAlert } from "../../hooks/useAlert";
const STATUS_COLORS: Record<string, string> = {
pending: "#F59E0B",
};
const STATUS_ICONS: Record<string, keyof typeof Ionicons.glyphMap> = {
pending: "time-outline",
};
const STATUS_LABELS: Record<string, string> = {
pending: "En attente",
};
export default function OrdersScreen() {
const { colors } = useTheme();
const [commands, setCommands] = useState<CommandResponse[]>([]);
const [loading, setLoading] = useState(true);
const [refreshing, setRefreshing] = useState(false);
const [openMenuId, setOpenMenuId] = useState<number | null>(null);
const { alert, showError, showSuccess, showConfirm, hideAlert } =
useAlert();
const [itemsModal, setItemsModal] = useState<{
visible: boolean;
commandId: number | null;
items: any[];
commandInfo: any;
clientInfo: any;
}>({
visible: false,
commandId: null,
items: [],
commandInfo: null,
clientInfo: null,
});
const [assignModal, setAssignModal] = useState<{
visible: boolean;
commandId: number | null;
}>({ visible: false, commandId: null });
const [addressModal, setAddressModal] = useState<{
visible: boolean;
commandId: number | null;
input: string;
}>({ visible: false, commandId: null, input: "" });
const [livreurs, setLivreurs] = useState<
{ id: number; username: string }[]
>([]);
const loadData = useCallback(async () => {
try {
const result = await getCabineCommands();
setCommands(
result.commands.filter(
(c: CommandResponse) =>
!["approved", "cancelled"].includes(c.status),
),
);
} catch {
/* ignore */
}
setLoading(false);
}, []);
useEffect(() => {
loadData();
}, [loadData]);
useEffect(() => {
const interval = setInterval(() => {
loadData();
}, 30000);
return () => clearInterval(interval);
}, [loadData]);
const onRefresh = async () => {
setRefreshing(true);
await loadData();
setRefreshing(false);
};
const openItems = async (commandId: number) => {
setOpenMenuId(null);
try {
const result = await getCommandItems(commandId);
setItemsModal({
visible: true,
commandId,
items: result.items,
commandInfo: result.command_info,
clientInfo: result.client_info,
});
} catch (e: any) {
showError("Erreur", e.message);
}
};
const handleConfirmReception = (commandId: number) => {
setOpenMenuId(null);
showConfirm(
"Confirmer la réception",
`Confirmer la réception de la commande #${commandId} au nom du client ?`,
async () => {
try {
await confirmReceptionCabine(commandId);
await loadData();
} catch (e: any) {
showError("Erreur", e.message);
}
},
"Confirmer",
);
};
const handleNotifyClient = async (commandId: number) => {
setOpenMenuId(null);
try {
await notifyClientToDescendCabine(commandId);
showSuccess(
"Notification envoyée",
"Le client a été prévenu de descendre",
);
} catch (e: any) {
showError("Erreur", e.message);
}
};
const handleProposeAddress = async () => {
if (!addressModal.commandId || !addressModal.input.trim()) return;
try {
await proposeAddressChangeCabine(
addressModal.commandId,
addressModal.input.trim(),
);
setAddressModal({ visible: false, commandId: null, input: "" });
showSuccess(
"Proposition envoyée",
"Le client a été notifié de la nouvelle adresse proposée",
);
} catch (e: any) {
showError("Erreur", e.message);
}
};
const handleDelete = (commandId: number) => {
setOpenMenuId(null);
showConfirm(
"Supprimer",
`Supprimer la commande #${commandId} ?`,
async () => {
try {
await deleteCommand(commandId);
await loadData();
} catch (e: any) {
showError("Erreur", e.message);
}
},
"Supprimer",
);
};
const closeModal = () =>
setItemsModal({
visible: false,
commandId: null,
items: [],
commandInfo: null,
clientInfo: null,
});
const openAssignModal = async (commandId: number) => {
setOpenMenuId(null);
try {
const list = await getCabineLivreursList();
setLivreurs(list);
setAssignModal({ visible: true, commandId });
} catch {
showError("Erreur", "Impossible de charger les livreurs");
}
};
const handleAssign = async (livreurUsername: string) => {
if (!assignModal.commandId) return;
try {
await assignDeliveryPersonByCabine(
assignModal.commandId,
livreurUsername,
);
setAssignModal({ visible: false, commandId: null });
await loadData();
} catch (e: any) {
showError("Erreur", e.message);
}
};
const styles = useMemo(
() =>
StyleSheet.create({
container: { flex: 1, backgroundColor: colors.bgPrimary },
refreshRow: {
paddingHorizontal: spacing.l,
paddingTop: spacing.m,
paddingBottom: spacing.s,
alignItems: "flex-start",
},
refreshBtn: {
flexDirection: "row",
alignItems: "center",
gap: spacing.xs,
paddingHorizontal: spacing.m,
paddingVertical: spacing.s,
backgroundColor: colors.bgCard,
borderRadius: borderRadius.sm,
borderWidth: 1,
borderColor: colors.border,
},
refreshBtnText: {
color: colors.textSecondary,
fontSize: fontSize.sm,
},
row: {
flexDirection: "row",
justifyContent: "space-between",
alignItems: "center",
marginBottom: spacing.s,
},
orderId: {
fontSize: fontSize.lg,
fontWeight: "bold",
color: colors.textWhite,
},
info: {
color: colors.textSecondary,
fontSize: fontSize.sm,
marginTop: 2,
},
empty: {
color: colors.textMuted,
textAlign: "center",
marginTop: spacing.xl,
},
// Select actions
selectBtn: {
flexDirection: "row",
alignItems: "center",
justifyContent: "space-between",
marginTop: spacing.m,
paddingHorizontal: spacing.m,
paddingVertical: spacing.s,
backgroundColor: colors.bgPrimary,
borderRadius: borderRadius.sm,
borderWidth: 1,
borderColor: colors.border,
},
selectBtnText: {
color: colors.textSecondary,
fontSize: fontSize.sm,
},
dropdown: {
marginTop: 2,
backgroundColor: colors.bgCard,
borderRadius: borderRadius.sm,
borderWidth: 1,
borderColor: colors.border,
overflow: "hidden",
},
dropdownItem: {
flexDirection: "row",
alignItems: "center",
gap: spacing.s,
paddingHorizontal: spacing.m,
paddingVertical: spacing.m,
borderBottomWidth: 1,
borderBottomColor: colors.border,
},
dropdownItemLast: {
borderBottomWidth: 0,
},
dropdownText: {
color: colors.textWhite,
fontSize: fontSize.sm,
},
dropdownTextDanger: {
color: colors.danger,
fontSize: fontSize.sm,
},
addressInput: {
backgroundColor: colors.bgPrimary,
borderWidth: 1,
borderColor: colors.border,
borderRadius: borderRadius.sm,
color: colors.textWhite,
paddingHorizontal: spacing.m,
paddingVertical: spacing.m,
fontSize: fontSize.md,
marginBottom: spacing.m,
},
addressConfirmBtn: {
backgroundColor: colors.accent,
borderRadius: borderRadius.sm,
paddingVertical: spacing.m,
alignItems: "center",
},
addressConfirmText: {
color: colors.textWhite,
fontSize: fontSize.md,
fontWeight: "700",
},
// Modal summary
modalSummary: {
backgroundColor: colors.bgCard,
borderRadius: borderRadius.sm,
padding: spacing.m,
marginBottom: spacing.m,
gap: spacing.xs,
},
modalSummaryRow: {
flexDirection: "row",
alignItems: "center",
gap: spacing.s,
},
modalSummaryText: {
color: colors.textSecondary,
fontSize: fontSize.sm,
flex: 1,
},
modalTotal: {
color: colors.accent,
fontSize: fontSize.md,
fontWeight: "700",
marginTop: spacing.xs,
},
progressLabel: {
color: colors.textMuted,
fontSize: fontSize.xs,
marginBottom: spacing.xs,
},
itemCard: {
backgroundColor: colors.bgCard,
borderRadius: borderRadius.sm,
marginBottom: spacing.s,
overflow: "hidden",
},
itemCardAccent: { height: 3 },
itemCardBody: {
padding: spacing.m,
flexDirection: "row",
alignItems: "flex-start",
gap: spacing.m,
},
itemIndex: {
width: 28,
height: 28,
borderRadius: 14,
backgroundColor: colors.bgPrimary,
justifyContent: "center",
alignItems: "center",
marginTop: 2,
},
itemIndexText: {
color: colors.textMuted,
fontSize: fontSize.xs,
fontWeight: "700",
},
itemInfo: { flex: 1 },
itemName: {
color: colors.textWhite,
fontSize: fontSize.md,
fontWeight: "600",
marginBottom: 2,
},
itemMeta: {
color: colors.textMuted,
fontSize: fontSize.xs,
marginBottom: spacing.s,
},
itemStatusRow: {
flexDirection: "row",
alignItems: "center",
gap: 5,
},
itemStatusText: { fontSize: fontSize.xs, fontWeight: "600" },
livreurItem: {
flexDirection: "row",
alignItems: "center",
padding: spacing.m,
backgroundColor: colors.bgCard,
borderRadius: borderRadius.sm,
marginBottom: spacing.s,
gap: spacing.m,
},
livreurName: { color: colors.textWhite, fontSize: fontSize.md },
}),
[colors],
);
const renderOrder = ({ item }: { item: CommandResponse }) => {
const isOpen = openMenuId === item.id;
type Action = {
label: string;
icon: keyof typeof Ionicons.glyphMap;
onPress: () => void;
danger?: boolean;
condition?: boolean;
};
const actions: Action[] = (
[
{
label: "Voir items",
icon: "receipt-outline" as keyof typeof Ionicons.glyphMap,
onPress: () => openItems(item.id),
},
{
label: "Proposer adresse",
icon: "location-outline" as keyof typeof Ionicons.glyphMap,
onPress: () => {
setOpenMenuId(null);
setAddressModal({
visible: true,
commandId: item.id,
input: "",
});
},
},
{
label: "Le livreur est là",
icon: "notifications-outline" as keyof typeof Ionicons.glyphMap,
onPress: () => handleNotifyClient(item.id),
},
{
label: "Assigner livreur",
icon: "bicycle-outline" as keyof typeof Ionicons.glyphMap,
onPress: () => openAssignModal(item.id),
},
{
label: "Confirmer réception",
icon: "checkmark-circle-outline" as keyof typeof Ionicons.glyphMap,
onPress: () => handleConfirmReception(item.id),
condition: item.status === "livre",
},
{
label: "Supprimer",
icon: "trash-outline" as keyof typeof Ionicons.glyphMap,
onPress: () => handleDelete(item.id),
danger: true,
},
] as Action[]
).filter((a) => a.condition !== false);
return (
<Card style={{ marginBottom: spacing.m }}>
<View style={styles.row}>
<Text style={styles.orderId}>#{item.id}</Text>
<StatusBadge status={item.status} />
</View>
<Text style={styles.info}>Client: {item.username}</Text>
<Text style={styles.info}>Adresse: {item.adresse}</Text>
<Text style={styles.info}>
Total{(item.referral_used ?? 0) > 0 ? " brut" : ""}: {item.total_prix.toFixed(2)}
</Text>
{(item.referral_used ?? 0) > 0 && (
<>
<Text style={[styles.info, { color: colors.success }]}>
Parrainage: -{(item.referral_used ?? 0).toFixed(2)}
</Text>
<Text style={[styles.info, { fontWeight: "700" }]}>
Net: {(item.total_prix - (item.referral_used ?? 0)).toFixed(2)}
</Text>
</>
)}
<TouchableOpacity
style={styles.selectBtn}
onPress={() => setOpenMenuId(isOpen ? null : item.id)}
>
<Text style={styles.selectBtnText}>Actions</Text>
<Ionicons
name={isOpen ? "chevron-up" : "chevron-down"}
size={14}
color={colors.textSecondary}
/>
</TouchableOpacity>
{isOpen && (
<View style={styles.dropdown}>
{actions.map((action, index) => (
<TouchableOpacity
key={action.label}
style={[
styles.dropdownItem,
index === actions.length - 1 &&
styles.dropdownItemLast,
]}
onPress={action.onPress}
>
<Ionicons
name={action.icon}
size={16}
color={
action.danger
? colors.danger
: colors.accent
}
/>
<Text
style={
action.danger
? styles.dropdownTextDanger
: styles.dropdownText
}
>
{action.label}
</Text>
</TouchableOpacity>
))}
</View>
)}
</Card>
);
};
const totalCount = itemsModal.items.length;
if (loading) return <LoadingSpinner message="Chargement..." />;
return (
<View style={styles.container}>
<View style={styles.refreshRow}>
<TouchableOpacity
style={styles.refreshBtn}
onPress={onRefresh}
disabled={refreshing}
>
<Ionicons
name="refresh-outline"
size={16}
color={refreshing ? colors.textMuted : colors.accent}
/>
<Text style={styles.refreshBtnText}>Actualiser</Text>
</TouchableOpacity>
</View>
<FlatList
data={commands}
keyExtractor={(item) => item.id.toString()}
renderItem={renderOrder}
refreshControl={
<RefreshControl
refreshing={refreshing}
onRefresh={onRefresh}
tintColor={colors.info}
/>
}
contentContainerStyle={{ padding: spacing.l }}
ListEmptyComponent={
<Text style={styles.empty}>Aucune commande active</Text>
}
/>
<Modal
visible={itemsModal.visible}
onClose={closeModal}
title={`Commande #${itemsModal.commandId}`}
icon="receipt-outline"
>
<ScrollView
showsVerticalScrollIndicator={false}
bounces={false}
>
{(itemsModal.commandInfo || itemsModal.clientInfo) && (
<View style={styles.modalSummary}>
{itemsModal.clientInfo?.username && (
<View style={styles.modalSummaryRow}>
<Ionicons
name="person-outline"
size={14}
color={colors.textMuted}
/>
<Text style={styles.modalSummaryText}>
{itemsModal.clientInfo.prenom}{" "}
{itemsModal.clientInfo.nom} ·{" "}
{itemsModal.clientInfo.username}
</Text>
</View>
)}
{(itemsModal.commandInfo?.address ||
itemsModal.commandInfo?.adresse) && (
<View style={styles.modalSummaryRow}>
<Ionicons
name="location-outline"
size={14}
color={colors.textMuted}
/>
<Text style={styles.modalSummaryText}>
{itemsModal.commandInfo.address ||
itemsModal.commandInfo.adresse}
</Text>
</View>
)}
{itemsModal.commandInfo?.total_prix != null && (
<Text style={styles.modalTotal}>
{Number(
itemsModal.commandInfo.total_prix,
).toFixed(2)}{" "}
</Text>
)}
{(itemsModal.commandInfo?.referral_used ?? 0) >
0 && (
<Text
style={[
styles.modalTotal,
{ color: colors.success },
]}
>
Parrainage: -
{Number(
itemsModal.commandInfo.referral_used,
).toFixed(2)}{" "}
</Text>
)}
</View>
)}
{totalCount > 0 && (
<Text style={styles.progressLabel}>
{totalCount} article{totalCount > 1 ? "s" : ""}
</Text>
)}
{itemsModal.items.map((item: any, index: number) => {
const statusColor =
STATUS_COLORS[item.status] || colors.textMuted;
return (
<View key={item.id} style={styles.itemCard}>
<View
style={[
styles.itemCardAccent,
{ backgroundColor: statusColor },
]}
/>
<View style={styles.itemCardBody}>
<View style={styles.itemIndex}>
<Text style={styles.itemIndexText}>
{index + 1}
</Text>
</View>
<View style={styles.itemInfo}>
<Text style={styles.itemName}>
{item.produit ?? item.product_name}
</Text>
<Text style={styles.itemMeta}>
Qté:{" "}
{item.quantite ?? item.quantity}{item.unit || ""} ·{" "}
{(item.prix ?? item.price)?.toFixed(
2,
)}{" "}
</Text>
<View style={styles.itemStatusRow}>
<Ionicons
name={
STATUS_ICONS[item.status] ||
"ellipse-outline"
}
size={13}
color={statusColor}
/>
<Text
style={[
styles.itemStatusText,
{ color: statusColor },
]}
>
{STATUS_LABELS[item.status] ||
item.status}
</Text>
</View>
</View>
</View>
</View>
);
})}
{itemsModal.items.length === 0 && (
<Text style={styles.empty}>Aucun item</Text>
)}
</ScrollView>
</Modal>
<Modal
visible={assignModal.visible}
onClose={() =>
setAssignModal({ visible: false, commandId: null })
}
title={`Assigner commande #${assignModal.commandId}`}
icon="bicycle-outline"
>
{livreurs.map((l) => (
<TouchableOpacity
key={l.username}
style={styles.livreurItem}
onPress={() => handleAssign(l.username)}
>
<Ionicons
name="person-outline"
size={20}
color={colors.accent}
/>
<Text style={styles.livreurName}>{l.username}</Text>
</TouchableOpacity>
))}
{livreurs.length === 0 && (
<Text style={styles.empty}>Aucun livreur disponible</Text>
)}
</Modal>
{/* Modal proposition adresse */}
<Modal
visible={addressModal.visible}
onClose={() =>
setAddressModal({
visible: false,
commandId: null,
input: "",
})
}
title={`Proposer adresse — commande #${addressModal.commandId}`}
icon="location-outline"
>
<TextInput
style={styles.addressInput}
placeholder="Nouvelle adresse..."
placeholderTextColor={colors.textMuted}
value={addressModal.input}
onChangeText={(t) =>
setAddressModal((prev) => ({ ...prev, input: t }))
}
multiline
/>
<TouchableOpacity
style={styles.addressConfirmBtn}
onPress={handleProposeAddress}
>
<Text style={styles.addressConfirmText}>
Envoyer la proposition
</Text>
</TouchableOpacity>
</Modal>
<AlertModal
visible={alert.visible}
type={alert.type}
title={alert.title}
message={alert.message}
onClose={hideAlert}
onConfirm={alert.onConfirm}
confirmText={alert.confirmText}
cancelText={alert.cancelText}
/>
</View>
);
}
@@ -0,0 +1,219 @@
import React, { useState, useEffect, useCallback, useMemo } from "react";
import { View, Text, StyleSheet, FlatList, RefreshControl } from "react-native";
import { spacing, fontSize } from "../../theme";
import { useTheme } from "../../context/ThemeContext";
import { applyClientPenalty, resetClientPenalties, resetClientPoints, getPublicSettings, getCabineAllClients } from "../../api/api_cabine";
import type { PublicSettings } from "../../api/api_cabine";
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 AlertModal from "../../components/ui/AlertModal";
import { useAlert } from "../../hooks/useAlert";
export default function UsersScreen() {
const { colors } = useTheme();
const [clients, setClients] = useState<ClientResponse[]>([]);
const [loading, setLoading] = useState(true);
const [refreshing, setRefreshing] = useState(false);
const [appSettings, setAppSettings] = useState<PublicSettings>({
penalties_enabled: true,
show_amende_score: true,
points_enabled: true,
points_separated: true,
pool_names: [],
pool_keys: [],
});
const [penaltyModal, setPenaltyModal] = useState<{
visible: boolean;
username: string;
}>({ visible: false, username: "" });
const { alert, showError, showSuccess, showConfirm, hideAlert } =
useAlert();
const loadData = useCallback(async () => {
try {
const [clientsData, settings] = await Promise.all([
getCabineAllClients(),
getPublicSettings(),
]);
setClients(clientsData);
setAppSettings(settings);
} catch {
/* ignore */
}
setLoading(false);
}, []);
useEffect(() => {
loadData();
}, [loadData]);
const onRefresh = async () => {
setRefreshing(true);
await loadData();
setRefreshing(false);
};
const handleReset = (username: string) => {
showConfirm(
"Reset pénalités",
`Réinitialiser les pénalités de ${username} ?`,
async () => {
try {
await resetClientPenalties(username);
await loadData();
} catch (e: any) {
showError("Erreur", e.message);
}
},
"Reset",
);
};
const handleResetPool = (username: string, poolIdx: number) => {
const poolNames = appSettings.pool_names;
const poolName = poolIdx >= 0 && poolIdx < poolNames.length
? poolNames[poolIdx]
: "tous les points";
showConfirm(
`Reset ${poolName}`,
`Réinitialiser les points "${poolName}" de ${username} ?`,
async () => {
try {
await resetClientPoints(username, poolIdx);
await loadData();
} catch (e: any) {
showError("Erreur", e.message);
}
},
"Reset",
);
};
const styles = useMemo(
() =>
StyleSheet.create({
container: { flex: 1, backgroundColor: colors.bgPrimary },
username: {
fontSize: fontSize.lg,
fontWeight: "bold",
color: colors.textWhite,
},
info: {
color: colors.textSecondary,
fontSize: fontSize.sm,
marginTop: 2,
},
statsRow: {
flexDirection: "row",
marginTop: spacing.m,
gap: spacing.l,
},
stat: { alignItems: "center" },
statValue: { fontSize: fontSize.lg, fontWeight: "bold" },
statLabel: { fontSize: fontSize.xs, color: colors.textMuted },
actions: {
flexDirection: "row",
gap: spacing.s,
marginTop: spacing.m,
flexWrap: "wrap",
},
empty: {
color: colors.textMuted,
textAlign: "center",
marginTop: spacing.xxl,
},
}),
[colors],
);
const renderClient = ({ item }: { item: ClientResponse }) => (
<Card style={{ marginBottom: spacing.m }}>
<Text style={styles.username}>{item.username}</Text>
<Text style={styles.info}>
{item.prenom} {item.nom} - {item.telephone}
</Text>
<View style={styles.statsRow}>
{appSettings.points_enabled && appSettings.pool_names.map((name, i) => {
const key = appSettings.pool_keys[i] ?? "";
const value = key ? (item.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>
);
})}
{appSettings.show_amende_score && (
<View style={styles.stat}>
<Text style={[styles.statValue, { color: colors.warning }]}>
{item.amende}
</Text>
<Text style={styles.statLabel}>Amende</Text>
</View>
)}
<View style={styles.stat}>
<Text style={[styles.statValue, { color: colors.danger }]}>
{item.cancellations_count}
</Text>
<Text style={styles.statLabel}>Annul.</Text>
</View>
</View>
<View style={styles.actions}>
{appSettings.penalties_enabled && (
<Button
title="Reset pénalités"
onPress={() => handleReset(item.username)}
size="sm"
variant="outline"
/>
)}
{appSettings.points_enabled && appSettings.pool_names.map((name, i) => (
<Button
key={i}
title={`Reset ${name}`}
onPress={() => handleResetPool(item.username, i)}
size="sm"
variant="outline"
/>
))}
</View>
</Card>
);
if (loading) return <LoadingSpinner message="Chargement..." />;
return (
<View style={styles.container}>
<FlatList
data={clients}
keyExtractor={(item) => item.id.toString()}
renderItem={renderClient}
refreshControl={
<RefreshControl
refreshing={refreshing}
onRefresh={onRefresh}
tintColor={colors.info}
/>
}
contentContainerStyle={{ padding: spacing.l }}
ListEmptyComponent={
<Text style={styles.empty}>Aucun client</Text>
}
/>
<AlertModal
visible={alert.visible}
type={alert.type}
title={alert.title}
message={alert.message}
onClose={hideAlert}
onConfirm={alert.onConfirm}
confirmText={alert.confirmText}
cancelText={alert.cancelText}
/>
</View>
);
}
@@ -0,0 +1,538 @@
import React, { useState, useEffect, useCallback, useMemo } from "react";
import {
View,
Text,
StyleSheet,
FlatList,
RefreshControl,
Modal,
TouchableOpacity,
Animated,
} from "react-native";
import { Ionicons } from "@expo/vector-icons";
import { spacing, fontSize, borderRadius } from "../../theme";
import { useTheme } from "../../context/ThemeContext";
import {
getMyAlerts,
triggerPoliceAlert,
endAlert,
} from "../../api/api_delivery";
import type { Alert as AlertType } from "../../api/types";
import LoadingSpinner from "../../components/ui/LoadingSpinner";
import Card from "../../components/ui/Card";
import Badge from "../../components/ui/Badge";
import Button from "../../components/ui/Button";
import AlertModal from "../../components/ui/AlertModal";
import { useAlert } from "../../hooks/useAlert";
const ALERT_PHRASES = [
{ label: "Contrôle de police", icon: "shield-outline" as const },
{ label: "Guet-apens", icon: "warning-outline" as const },
];
const ALERT_CONFIG: Record<string, { title: string; message: string; successHint: string }> = {
"Contrôle de police": {
title: "Alerte — Contrôle de police",
message: "Vous signalez un contrôle de police. Restez calme, soyez coopératif et ne résistez pas. L'administration sera immédiatement notifiée.",
successHint: "L'administration a été alertée. Restez calme, coopérez avec les forces de l'ordre et attendez les instructions.",
},
"Guet-apens": {
title: "Alerte — Guet-apens",
message: "Vous signalez un guet-apens. Si possible, éloignez-vous de la zone immédiatement. L'administration sera immédiatement notifiée.",
successHint: "L'administration a été alertée. Éloignez-vous du danger si possible et attendez les instructions de l'équipe.",
},
};
export default function AlertsScreen() {
const { colors } = useTheme();
const [alerts, setAlerts] = useState<AlertType[]>([]);
const [loading, setLoading] = useState(true);
const [refreshing, setRefreshing] = useState(false);
const [triggering, setTriggering] = useState(false);
const [showPhraseModal, setShowPhraseModal] = useState(false);
const [showConfirmModal, setShowConfirmModal] = useState(false);
const [showSuccessModal, setShowSuccessModal] = useState(false);
const [selectedPhrase, setSelectedPhrase] = useState("");
const [successMessage, setSuccessMessage] = useState("");
const [pulseAnim] = useState(() => new Animated.Value(1));
const { alert: alertModal, showError, hideAlert } = useAlert();
const loadData = useCallback(async () => {
try {
const res = await getMyAlerts();
if (res.success && res.alerts) setAlerts(res.alerts);
} catch {
/* ignore */
}
setLoading(false);
}, []);
useEffect(() => {
loadData();
}, [loadData]);
useEffect(() => {
if (!showConfirmModal) return;
const anim = Animated.loop(
Animated.sequence([
Animated.timing(pulseAnim, {
toValue: 1.15,
duration: 800,
useNativeDriver: true,
}),
Animated.timing(pulseAnim, {
toValue: 1,
duration: 800,
useNativeDriver: true,
}),
]),
);
anim.start();
return () => anim.stop();
}, [showConfirmModal, pulseAnim]);
const onRefresh = async () => {
setRefreshing(true);
await loadData();
setRefreshing(false);
};
const handleSelectPhrase = (phrase: string) => {
setSelectedPhrase(phrase);
setShowPhraseModal(false);
setShowConfirmModal(true);
};
const handleConfirmTrigger = async () => {
setShowConfirmModal(false);
setTriggering(true);
const res = await triggerPoliceAlert(selectedPhrase);
setTriggering(false);
if (res.success) {
setSuccessMessage(res.message || "Alerte déclenchée avec succès");
setShowSuccessModal(true);
loadData();
} else {
showError("Erreur", res.error || "Erreur");
}
};
const handleEnd = async (alertId: number) => {
const res = await endAlert(alertId);
if (res.success) {
loadData();
} else {
showError("Erreur", res.error || "Erreur");
}
};
const renderAlert = ({ item }: { item: AlertType }) => (
<Card style={{ marginBottom: spacing.m }}>
<View style={styles.row}>
<Ionicons
name="alert-circle"
size={24}
color={
item.status === "true"
? colors.danger
: colors.textMuted
}
/>
<View style={{ flex: 1, marginLeft: spacing.m }}>
{item.message ? (
<Text style={styles.alertMessage}>{item.message}</Text>
) : null}
<Text style={styles.date}>
{new Date(item.created_at).toLocaleString("fr-FR")}
</Text>
</View>
<Badge
label={item.status === "true" ? "Active" : "Terminée"}
color={
item.status === "true" ? colors.danger : colors.success
}
/>
</View>
{item.status === "true" && (
<Button
title="Terminer l'alerte"
onPress={() => handleEnd(item.id)}
style={{
marginTop: spacing.s,
backgroundColor: colors.success,
}}
/>
)}
</Card>
);
const styles = useMemo(
() =>
StyleSheet.create({
container: { flex: 1, backgroundColor: colors.bgPrimary },
row: { flexDirection: "row", alignItems: "center" },
alertMessage: { color: colors.textPrimary, fontSize: fontSize.md, fontWeight: "600", marginBottom: 2 },
date: { color: colors.textMuted, fontSize: fontSize.sm },
empty: {
color: colors.textMuted,
textAlign: "center",
marginTop: spacing.xxl,
fontSize: fontSize.md,
},
triggerSection: {
padding: spacing.l,
borderBottomWidth: 1,
borderBottomColor: colors.border,
},
// Modal
modalOverlay: {
flex: 1,
backgroundColor: "rgba(0, 0, 0, 0.85)",
justifyContent: "center",
alignItems: "center",
padding: spacing.xl,
},
modalContent: {
width: "100%",
backgroundColor: colors.bgCard,
borderRadius: borderRadius.lg,
padding: spacing.xl,
alignItems: "center",
borderWidth: 1,
borderColor: colors.danger + "40",
},
modalIconCircle: {
width: 90,
height: 90,
borderRadius: 45,
backgroundColor: colors.danger + "20",
justifyContent: "center",
alignItems: "center",
marginBottom: spacing.l,
},
modalIconInner: {
width: 68,
height: 68,
borderRadius: 34,
backgroundColor: colors.danger,
justifyContent: "center",
alignItems: "center",
},
modalTitle: {
fontSize: fontSize.xl,
fontWeight: "700",
color: colors.danger,
marginBottom: spacing.s,
},
modalMessage: {
fontSize: fontSize.sm,
color: colors.textSecondary,
textAlign: "center",
lineHeight: 20,
marginBottom: spacing.m,
},
modalWarning: {
fontSize: fontSize.md,
fontWeight: "600",
color: colors.textWhite,
textAlign: "center",
marginBottom: spacing.xl,
},
modalButtons: {
flexDirection: "row",
gap: spacing.m,
width: "100%",
},
modalCancelBtn: {
flex: 1,
paddingVertical: 14,
borderRadius: borderRadius.sm,
backgroundColor: colors.bgInput,
alignItems: "center",
justifyContent: "center",
},
modalCancelText: {
color: colors.textSecondary,
fontSize: fontSize.md,
fontWeight: "600",
},
modalConfirmBtn: {
flex: 1,
flexDirection: "row",
paddingVertical: 14,
borderRadius: borderRadius.sm,
backgroundColor: colors.danger,
alignItems: "center",
justifyContent: "center",
gap: spacing.xs,
},
modalConfirmText: {
color: colors.white,
fontSize: fontSize.md,
fontWeight: "700",
},
// Success Modal
successModalContent: {
width: "100%",
backgroundColor: colors.bgCard,
borderRadius: borderRadius.lg,
padding: spacing.xl,
alignItems: "center",
borderWidth: 1,
borderColor: colors.success + "40",
},
successIconCircle: {
width: 90,
height: 90,
borderRadius: 45,
backgroundColor: colors.success + "20",
justifyContent: "center",
alignItems: "center",
marginBottom: spacing.l,
},
successIconInner: {
width: 68,
height: 68,
borderRadius: 34,
backgroundColor: colors.success,
justifyContent: "center",
alignItems: "center",
},
successTitle: {
fontSize: fontSize.xl,
fontWeight: "700",
color: colors.success,
marginBottom: spacing.s,
},
successMessage: {
fontSize: fontSize.sm,
color: colors.textSecondary,
textAlign: "center",
lineHeight: 20,
marginBottom: spacing.s,
},
successHint: {
fontSize: fontSize.xs,
color: colors.textMuted,
textAlign: "center",
lineHeight: 18,
marginBottom: spacing.xl,
},
successBtn: {
width: "100%",
paddingVertical: 14,
borderRadius: borderRadius.sm,
backgroundColor: colors.success,
alignItems: "center",
justifyContent: "center",
},
successBtnText: {
color: colors.white,
fontSize: fontSize.md,
fontWeight: "700",
},
phraseBtn: {
flexDirection: "row",
alignItems: "center",
gap: spacing.m,
paddingVertical: 14,
paddingHorizontal: spacing.l,
borderRadius: borderRadius.sm,
borderWidth: 1,
backgroundColor: colors.bgInput,
},
phraseBtnText: {
fontSize: fontSize.md,
fontWeight: "600",
flex: 1,
},
}),
[colors],
);
if (loading) return <LoadingSpinner message="Chargement alertes..." />;
return (
<View style={styles.container}>
{/* Phrase selection Modal */}
<Modal
visible={showPhraseModal}
transparent
animationType="fade"
onRequestClose={() => setShowPhraseModal(false)}
>
<View style={styles.modalOverlay}>
<View style={[styles.modalContent, { borderColor: colors.danger + "40" }]}>
<View style={styles.modalIconCircle}>
<View style={styles.modalIconInner}>
<Ionicons name="warning" size={40} color={colors.white} />
</View>
</View>
<Text style={styles.modalTitle}>Type d'alerte</Text>
<Text style={styles.modalMessage}>
Sélectionnez la raison de l'alerte.
</Text>
<View style={{ width: "100%", gap: spacing.m, marginBottom: spacing.l }}>
{ALERT_PHRASES.map((p) => (
<TouchableOpacity
key={p.label}
style={[styles.phraseBtn, { borderColor: colors.danger }]}
onPress={() => handleSelectPhrase(p.label)}
activeOpacity={0.7}
>
<Ionicons name={p.icon} size={20} color={colors.danger} />
<Text style={[styles.phraseBtnText, { color: colors.textWhite }]}>
{p.label}
</Text>
</TouchableOpacity>
))}
</View>
<TouchableOpacity
style={styles.modalCancelBtn}
onPress={() => setShowPhraseModal(false)}
activeOpacity={0.7}
>
<Text style={styles.modalCancelText}>Annuler</Text>
</TouchableOpacity>
</View>
</View>
</Modal>
{/* Confirmation Modal */}
<Modal
visible={showConfirmModal}
transparent
animationType="fade"
onRequestClose={() => setShowConfirmModal(false)}
>
<View style={styles.modalOverlay}>
<View style={styles.modalContent}>
{/* Animated alert icon */}
<Animated.View
style={[
styles.modalIconCircle,
{ transform: [{ scale: pulseAnim }] },
]}
>
<View style={styles.modalIconInner}>
<Ionicons
name="warning"
size={40}
color={colors.white}
/>
</View>
</Animated.View>
<Text style={styles.modalTitle}>
{ALERT_CONFIG[selectedPhrase]?.title ?? "Alerte"}
</Text>
<Text style={styles.modalMessage}>
{ALERT_CONFIG[selectedPhrase]?.message ?? "Vous êtes sur le point de déclencher une alerte. L'administration sera immédiatement notifiée."}
</Text>
<Text style={styles.modalWarning}>
Confirmez-vous le déclenchement ?
</Text>
<View style={styles.modalButtons}>
<TouchableOpacity
style={styles.modalCancelBtn}
onPress={() => setShowConfirmModal(false)}
activeOpacity={0.7}
>
<Text style={styles.modalCancelText}>
Annuler
</Text>
</TouchableOpacity>
<TouchableOpacity
style={styles.modalConfirmBtn}
onPress={handleConfirmTrigger}
activeOpacity={0.7}
>
<Ionicons
name="alert-circle"
size={18}
color={colors.white}
/>
<Text style={styles.modalConfirmText}>
Déclencher
</Text>
</TouchableOpacity>
</View>
</View>
</View>
</Modal>
{/* Success Modal */}
<Modal
visible={showSuccessModal}
transparent
animationType="fade"
onRequestClose={() => setShowSuccessModal(false)}
>
<View style={styles.modalOverlay}>
<View style={styles.successModalContent}>
<View style={styles.successIconCircle}>
<View style={styles.successIconInner}>
<Ionicons
name="checkmark-sharp"
size={40}
color={colors.white}
/>
</View>
</View>
<Text style={styles.successTitle}>Alerte envoyée</Text>
<Text style={styles.successMessage}>
{successMessage}
</Text>
<Text style={styles.successHint}>
{ALERT_CONFIG[selectedPhrase]?.successHint ?? "L'administration a été notifiée. Vous pourrez terminer l'alerte quand la situation sera résolue."}
</Text>
<TouchableOpacity
style={styles.successBtn}
onPress={() => setShowSuccessModal(false)}
activeOpacity={0.7}
>
<Text style={styles.successBtnText}>Compris</Text>
</TouchableOpacity>
</View>
</View>
</Modal>
<View style={styles.triggerSection}>
<Button
title={triggering ? "Envoi..." : "Déclencher alerte police"}
onPress={() => setShowPhraseModal(true)}
disabled={triggering}
style={{ backgroundColor: colors.danger }}
/>
</View>
<FlatList
data={alerts}
keyExtractor={(item) => item.id.toString()}
renderItem={renderAlert}
refreshControl={
<RefreshControl
refreshing={refreshing}
onRefresh={onRefresh}
tintColor={colors.success}
/>
}
contentContainerStyle={{ padding: spacing.l }}
ListEmptyComponent={
<Text style={styles.empty}>Aucune alerte</Text>
}
/>
<AlertModal
visible={alertModal.visible}
type={alertModal.type}
title={alertModal.title}
message={alertModal.message}
onClose={hideAlert}
/>
</View>
);
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,152 @@
import React, { useState, useCallback } from "react";
import {
View,
Text,
StyleSheet,
ScrollView,
RefreshControl,
} from "react-native";
import { Ionicons } from "@expo/vector-icons";
import { useFocusEffect } from "@react-navigation/native";
import { useTheme } from "../../context/ThemeContext";
import { spacing, fontSize } from "../../theme";
import { getMyRatings } from "../../api/api_delivery";
import type { LivreurRating } from "../../api/api_delivery";
import LoadingSpinner from "../../components/ui/LoadingSpinner";
const STAR_COLOR = "#f59e0b";
const STAR_EMPTY = "#374151";
function Stars({ value }: { value: number }) {
return (
<View style={{ flexDirection: "row", gap: 2 }}>
{[1, 2, 3, 4, 5].map((i) => (
<Ionicons
key={i}
name={i <= value ? "star" : "star-outline"}
size={14}
color={i <= value ? STAR_COLOR : STAR_EMPTY}
/>
))}
</View>
);
}
function formatDate(iso: string): string {
try {
return new Date(iso).toLocaleDateString("fr-FR", {
day: "2-digit",
month: "2-digit",
year: "numeric",
});
} catch {
return "";
}
}
export default function RatingsScreen() {
const { colors } = useTheme();
const [ratings, setRatings] = useState<LivreurRating[]>([]);
const [average, setAverage] = useState(0);
const [loading, setLoading] = useState(true);
const [refreshing, setRefreshing] = useState(false);
const load = useCallback(async (silent = false) => {
if (!silent) setLoading(true);
const res = await getMyRatings();
if (res.success) {
setRatings(res.ratings);
setAverage(res.average);
}
setLoading(false);
setRefreshing(false);
}, []);
useFocusEffect(useCallback(() => { load(); }, [load]));
const onRefresh = () => { setRefreshing(true); load(true); };
const styles = StyleSheet.create({
container: { flex: 1, backgroundColor: colors.bgPrimary },
content: { padding: spacing.l, paddingBottom: spacing.xxxl },
headerCard: {
backgroundColor: colors.bgCard,
borderRadius: 14,
padding: spacing.l,
marginBottom: spacing.l,
alignItems: "center",
borderWidth: 1,
borderColor: colors.borderLight,
},
avgNumber: { fontSize: 48, fontWeight: "800", color: STAR_COLOR, lineHeight: 56 },
avgLabel: { fontSize: fontSize.sm, color: colors.textMuted, marginTop: spacing.xs },
countLabel: { fontSize: fontSize.xs, color: colors.textMuted, marginTop: 4 },
starsRow: { flexDirection: "row", gap: 4, marginTop: spacing.s },
card: {
backgroundColor: colors.bgCard,
borderRadius: 12,
padding: spacing.l,
marginBottom: spacing.m,
borderWidth: 1,
borderColor: colors.borderLight,
},
cardHeader: { flexDirection: "row", justifyContent: "space-between", alignItems: "center", marginBottom: spacing.s },
client: { fontSize: fontSize.sm, fontWeight: "600", color: colors.textPrimary },
date: { fontSize: fontSize.xs, color: colors.textMuted },
orderRef: { fontSize: fontSize.xs, color: colors.textMuted, marginBottom: spacing.s },
comment: { fontSize: fontSize.sm, color: colors.textSecondary, fontStyle: "italic", marginTop: spacing.s, lineHeight: 20 },
emptyWrap: { alignItems: "center", paddingVertical: spacing.xxxl },
emptyText: { color: colors.textMuted, fontSize: fontSize.md, marginTop: spacing.m, textAlign: "center" },
});
if (loading) return <LoadingSpinner message="Chargement des avis..." />;
return (
<ScrollView
style={styles.container}
contentContainerStyle={styles.content}
refreshControl={<RefreshControl refreshing={refreshing} onRefresh={onRefresh} tintColor={STAR_COLOR} />}
>
{/* Résumé */}
<View style={styles.headerCard}>
<Text style={styles.avgNumber}>
{average > 0 ? average.toFixed(1) : "—"}
</Text>
<View style={styles.starsRow}>
{[1, 2, 3, 4, 5].map((i) => (
<Ionicons
key={i}
name={i <= Math.round(average) ? "star" : "star-outline"}
size={22}
color={i <= Math.round(average) ? STAR_COLOR : STAR_EMPTY}
/>
))}
</View>
<Text style={styles.avgLabel}>Note moyenne</Text>
<Text style={styles.countLabel}>{ratings.length} avis client{ratings.length > 1 ? "s" : ""}</Text>
</View>
{/* Liste */}
{ratings.length === 0 ? (
<View style={styles.emptyWrap}>
<Ionicons name="chatbubble-ellipses-outline" size={48} color={colors.textMuted} />
<Text style={styles.emptyText}>Aucun avis reçu pour l'instant</Text>
</View>
) : (
ratings.map((r) => (
<View key={r.id} style={styles.card}>
<View style={styles.cardHeader}>
<Text style={styles.client}>{r.client_username}</Text>
<Text style={styles.date}>{formatDate(r.created_at)}</Text>
</View>
<Text style={styles.orderRef}>Commande #{r.order_id}</Text>
<Stars value={r.rating} />
{r.comment ? (
<Text style={styles.comment}>"{r.comment}"</Text>
) : null}
</View>
))
)}
</ScrollView>
);
}
@@ -0,0 +1,272 @@
import React, { useState, useEffect, useCallback, useMemo } from "react";
import {
View,
Text,
StyleSheet,
ScrollView,
RefreshControl,
TouchableOpacity,
} from "react-native";
import { Ionicons } from "@expo/vector-icons";
import { spacing, fontSize } from "../../theme";
import { useTheme } from "../../context/ThemeContext";
import { getMyDeliveries, getMyStats } from "../../api/api_delivery";
import type { DeliveryItem, } from "../../api/types";
import type { StatPoint } from "../../api/api_delivery";
import LoadingSpinner from "../../components/ui/LoadingSpinner";
import Card from "../../components/ui/Card";
type Period = "day" | "week" | "month";
const BAR_MAX_HEIGHT = 110;
const BAR_WIDTH = 36;
const BAR_GAP = 8;
function BarChart({ data, colors }: { data: StatPoint[]; colors: any }) {
const maxVal = Math.max(...data.map((d) => d.count), 1);
if (data.length === 0) {
return (
<View style={{ alignItems: "center", paddingVertical: spacing.xl }}>
<Ionicons name="bar-chart-outline" size={36} color={colors.textMuted} />
<Text style={{ color: colors.textMuted, fontSize: fontSize.sm, marginTop: spacing.s }}>
Aucune donnée sur cette période
</Text>
</View>
);
}
return (
<ScrollView horizontal showsHorizontalScrollIndicator={false} style={{ marginTop: spacing.m }}>
<View style={{ flexDirection: "row", alignItems: "flex-end", paddingBottom: spacing.s, paddingHorizontal: 4 }}>
{data.map((point, i) => {
const val = point.count;
const barH = Math.max(4, (val / maxVal) * BAR_MAX_HEIGHT);
const isLast = i === data.length - 1;
return (
<View
key={i}
style={{
alignItems: "center",
marginRight: isLast ? 0 : BAR_GAP,
width: BAR_WIDTH,
}}
>
<Text style={{ color: colors.textMuted, fontSize: 9, marginBottom: 3 }}>
{val > 0 ? String(val) : ""}
</Text>
<View
style={{
width: BAR_WIDTH - 6,
height: barH,
backgroundColor: val > 0 ? colors.accent : colors.border,
borderRadius: 5,
opacity: val > 0 ? 1 : 0.3,
}}
/>
<Text
style={{
color: colors.textMuted,
fontSize: 9,
marginTop: 4,
textAlign: "center",
}}
numberOfLines={1}
>
{point.label}
</Text>
</View>
);
})}
</View>
</ScrollView>
);
}
export default function StatsScreen() {
const { colors } = useTheme();
const [deliveries, setDeliveries] = useState<DeliveryItem[]>([]);
const [byDay, setByDay] = useState<StatPoint[]>([]);
const [byWeek, setByWeek] = useState<StatPoint[]>([]);
const [byMonth, setByMonth] = useState<StatPoint[]>([]);
const [todayCount, setTodayCount] = useState(0);
const [loading, setLoading] = useState(true);
const [refreshing, setRefreshing] = useState(false);
const [period, setPeriod] = useState<Period>("week");
const loadData = useCallback(async () => {
try {
const [delivRes, statsRes] = await Promise.all([
getMyDeliveries(),
getMyStats(),
]);
if (delivRes.success && delivRes.deliveries) setDeliveries(delivRes.deliveries);
if (statsRes.success) {
setByDay(statsRes.by_day ?? []);
setByWeek(statsRes.by_week ?? []);
setByMonth(statsRes.by_month ?? []);
setTodayCount(statsRes.today_count ?? 0);
}
} catch {
/* ignore */
}
setLoading(false);
}, []);
useEffect(() => { loadData(); }, [loadData]);
const onRefresh = async () => {
setRefreshing(true);
await loadData();
setRefreshing(false);
};
const total = deliveries.length;
const completed = deliveries.filter((d) => d.status === "livre" || d.status === "approved").length;
const inProgress = deliveries.filter((d) => d.status === "en_route").length;
const pending = deliveries.filter((d) => d.status === "assigned").length;
const chartData = period === "day" ? byDay : period === "week" ? byWeek : byMonth;
const periodTotal = useMemo(
() => chartData.reduce((acc, p) => acc + p.count, 0),
[chartData],
);
const periodLabels: Record<Period, string> = {
day: "30 derniers jours",
week: "12 dernières semaines",
month: "12 derniers mois",
};
const styles = useMemo(
() =>
StyleSheet.create({
container: { flex: 1, backgroundColor: colors.bgPrimary },
title: {
color: colors.textWhite,
fontSize: fontSize.xl,
fontWeight: "700",
marginBottom: spacing.l,
},
grid: { flexDirection: "row", flexWrap: "wrap", gap: spacing.m },
statCard: { width: "47%", alignItems: "center", paddingVertical: spacing.l },
statValue: { fontSize: fontSize.xxl, fontWeight: "700", marginTop: spacing.s },
statLabel: {
color: colors.textMuted,
fontSize: fontSize.xs,
marginTop: spacing.xs,
textAlign: "center",
},
sectionTitle: {
color: colors.textWhite,
fontSize: fontSize.lg,
fontWeight: "700",
marginBottom: spacing.m,
marginTop: spacing.xl,
},
periodRow: {
flexDirection: "row",
gap: spacing.s,
marginBottom: spacing.m,
},
periodBtn: {
flex: 1,
paddingVertical: spacing.s,
borderRadius: 8,
alignItems: "center",
backgroundColor: colors.bgCard,
borderWidth: 1,
borderColor: colors.border,
},
periodBtnActive: {
backgroundColor: colors.accent + "22",
borderColor: colors.accent,
},
periodBtnText: {
fontSize: fontSize.sm,
fontWeight: "600",
color: colors.textMuted,
},
periodBtnTextActive: { color: colors.accent },
chartCard: { paddingBottom: spacing.s },
summaryRow: {
flexDirection: "row",
justifyContent: "space-between",
marginTop: spacing.s,
paddingTop: spacing.s,
borderTopWidth: 1,
borderTopColor: colors.border,
},
summaryItem: { alignItems: "center", flex: 1 },
summaryValue: { color: colors.textWhite, fontSize: fontSize.lg, fontWeight: "700" },
summaryLabel: { color: colors.textMuted, fontSize: fontSize.xs, marginTop: 2 },
periodHint: { color: colors.textMuted, fontSize: fontSize.xs, marginBottom: spacing.s },
}),
[colors],
);
const summaryCards = [
{ label: "Livraisons du jour", value: todayCount.toString(), icon: "today-outline" as const, color: colors.accent },
{ label: "Total livraisons", value: total.toString(), icon: "cube-outline" as const, color: colors.accent },
{ label: "Complétées", value: completed.toString(), icon: "checkmark-circle-outline" as const, color: colors.success },
{ label: "En cours", value: inProgress.toString(), icon: "time-outline" as const, color: colors.warning },
{ label: "En attente", value: pending.toString(), icon: "hourglass-outline" as const, color: colors.info },
];
if (loading) return <LoadingSpinner message="Chargement stats..." />;
return (
<ScrollView
style={styles.container}
contentContainerStyle={{ padding: spacing.l }}
refreshControl={<RefreshControl refreshing={refreshing} onRefresh={onRefresh} tintColor={colors.success} />}
>
<Text style={styles.title}>Mes performances</Text>
{/* Cartes résumé */}
<View style={styles.grid}>
{summaryCards.map((s, i) => (
<Card key={i} style={styles.statCard}>
<Ionicons name={s.icon} size={28} color={s.color} />
<Text style={[styles.statValue, { color: s.color }]}>{s.value}</Text>
<Text style={styles.statLabel}>{s.label}</Text>
</Card>
))}
</View>
{/* Section graphiques */}
<Text style={styles.sectionTitle}>Évolution</Text>
{/* Sélecteur période */}
<View style={styles.periodRow}>
{(["day", "week", "month"] as Period[]).map((p) => (
<TouchableOpacity
key={p}
style={[styles.periodBtn, period === p && styles.periodBtnActive]}
onPress={() => setPeriod(p)}
>
<Text style={[styles.periodBtnText, period === p && styles.periodBtnTextActive]}>
{p === "day" ? "Jour" : p === "week" ? "Semaine" : "Mois"}
</Text>
</TouchableOpacity>
))}
</View>
<Card style={styles.chartCard}>
<Text style={styles.periodHint}>{periodLabels[period]}</Text>
<BarChart data={chartData} colors={colors} />
{chartData.length > 0 && (
<View style={styles.summaryRow}>
<View style={styles.summaryItem}>
<Text style={styles.summaryValue}>{periodTotal}</Text>
<Text style={styles.summaryLabel}>livraisons</Text>
</View>
</View>
)}
</Card>
</ScrollView>
);
}