This commit is contained in:
@@ -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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user