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

This commit is contained in:
Xor290
2026-08-04 18:47:47 +02:00
parent 736ec86747
commit 877291e684
69 changed files with 35272 additions and 0 deletions
+9
View File
@@ -0,0 +1,9 @@
node_modules
.expo/
dist
build
*.log
# code signing
*.pem
!certs/certificate.pem
+163
View File
@@ -0,0 +1,163 @@
import React, { useMemo, useEffect, useState } from "react";
import { StatusBar } from "expo-status-bar";
import { ActivityIndicator, View, StyleSheet } from "react-native";
import * as Updates from "expo-updates";
import { NavigationContainer } from "@react-navigation/native";
import { createNativeStackNavigator } from "@react-navigation/native-stack";
import { AuthProvider, useAuth } from "./src/auth/AuthContext";
import { ThemeProvider, useTheme } from "./src/context/ThemeContext";
import type { Colors } from "./src/theme";
import { spacing } from "./src/theme";
import { initServerBaseUrl } from "./src/api/client";
import ServerConfigScreen from "./src/screens/auth/ServerConfigScreen";
import RoleSelectScreen from "./src/screens/auth/RoleSelectScreen";
import AdminLoginScreen from "./src/screens/auth/AdminLoginScreen";
import CabineLoginScreen from "./src/screens/auth/CabineLoginScreen";
import DeliveryLoginScreen from "./src/screens/auth/DeliveryLoginScreen";
import AdminNavigator from "./src/navigation/AdminNavigator";
import CabineNavigator from "./src/navigation/CabineNavigator";
import DeliveryNavigator from "./src/navigation/DeliveryNavigator";
import type { AuthStackParamList } from "./src/navigation/types";
const Stack = createNativeStackNavigator<AuthStackParamList>();
function AuthStack() {
const { colors } = useTheme();
return (
<Stack.Navigator
initialRouteName="ServerConfig"
screenOptions={{
headerStyle: { backgroundColor: colors.bgSecondary },
headerTintColor: colors.textWhite,
}}
>
<Stack.Screen
name="ServerConfig"
component={ServerConfigScreen}
options={{ headerShown: false }}
/>
<Stack.Screen
name="RoleSelect"
component={RoleSelectScreen}
options={{ title: "Admin Panel" }}
/>
<Stack.Screen
name="AdminLogin"
component={AdminLoginScreen}
options={{ title: "Connexion Admin" }}
/>
<Stack.Screen
name="CabineLogin"
component={CabineLoginScreen}
options={{ title: "Connexion Cabine" }}
/>
<Stack.Screen
name="DeliveryLogin"
component={DeliveryLoginScreen}
options={{ title: "Connexion Livreur" }}
/>
</Stack.Navigator>
);
}
function RootNavigator() {
const { isAuthenticated, isLoading, role } = useAuth();
const { colors } = useTheme();
if (isLoading) {
return (
<View
style={{
flex: 1,
justifyContent: "center",
alignItems: "center",
backgroundColor: colors.bgPrimary,
}}
>
<ActivityIndicator size="large" color={colors.accent} />
</View>
);
}
if (isAuthenticated) {
switch (role) {
case "admin":
return <AdminNavigator />;
case "cabine":
return <CabineNavigator />;
case "livreur":
return <DeliveryNavigator />;
}
}
return <AuthStack />;
}
export default function App() {
useEffect(() => {
if (__DEV__) return;
const checkForUpdate = async () => {
try {
const update = await Updates.checkForUpdateAsync();
if (update.isAvailable) {
await Updates.fetchUpdateAsync();
await Updates.reloadAsync();
}
} catch {
// Silently ignore update errors
}
};
checkForUpdate();
}, []);
return (
<ThemeProvider>
<ServerGate>
<AuthProvider>
<NavigationContainer>
<RootNavigator />
<DynamicStatusBar />
</NavigationContainer>
</AuthProvider>
</ServerGate>
</ThemeProvider>
);
}
// Restores a previously saved backend server URL before any screen
// (including the auth restore flow) can issue an API call.
function ServerGate({ children }: { children: React.ReactNode }) {
const [ready, setReady] = useState(false);
const { colors } = useTheme();
useEffect(() => {
initServerBaseUrl().finally(() => setReady(true));
}, []);
if (!ready) {
return (
<View
style={{
flex: 1,
justifyContent: "center",
alignItems: "center",
backgroundColor: colors.bgPrimary,
}}
>
<ActivityIndicator size="large" color={colors.accent} />
</View>
);
}
return <>{children}</>;
}
function DynamicStatusBar() {
const { isDark } = useTheme();
return <StatusBar style={isDark ? "light" : "dark"} />;
}
+11
View File
@@ -0,0 +1,11 @@
module.exports = ({ config }) => {
const updateUrl = process.env.EXPO_PUBLIC_UPDATE_URL;
return {
...config,
updates: {
...config.updates,
...(updateUrl ? { url: updateUrl } : {}),
},
};
};
+66
View File
@@ -0,0 +1,66 @@
{
"expo": {
"name": "Admin Panel",
"slug": "omnex-plateform-app",
"version": "1.0.0",
"orientation": "portrait",
"icon": "./assets/icon.png",
"userInterfaceStyle": "dark",
"newArchEnabled": true,
"splash": {
"image": "./assets/icon.png",
"resizeMode": "contain",
"backgroundColor": "#000000"
},
"ios": {
"supportsTablet": true,
"bundleIdentifier": "com.uberstup.omnexpanel"
},
"android": {
"package": "com.uberstup.omnexpanel",
"abiFilters": ["arm64-v8a"],
"adaptiveIcon": {
"foregroundImage": "./assets/icon.png",
"backgroundColor": "#000000"
},
"edgeToEdgeEnabled": true,
"permissions": [
"android.permission.ACCESS_BACKGROUND_LOCATION",
"android.permission.ACCESS_COARSE_LOCATION",
"android.permission.ACCESS_FINE_LOCATION",
"android.permission.VIBRATE"
]
},
"web": {
"favicon": "./assets/icon.png"
},
"plugins": [
"expo-font",
"expo-location",
"expo-updates",
[
"expo-build-properties",
{
"android": {
"usesCleartextTraffic": true
}
}
]
],
"extra": {
"eas": {
"projectId": "d5554dc7-e186-4ff4-8b48-8c6225af089b"
}
},
"owner": "xor290",
"runtimeVersion": "omnex-admin-1.0.0",
"updates": {
"url": "https://u.expo.dev/d5554dc7-e186-4ff4-8b48-8c6225af089b",
"codeSigningCertificate": "./certs/certificate.pem",
"codeSigningMetadata": {
"keyid": "main",
"alg": "rsa-v1_5-sha256"
}
}
}
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 1.2 MiB

+18
View File
@@ -0,0 +1,18 @@
-----BEGIN CERTIFICATE-----
MIIC2TCCAcGgAwIBAgIJKnaMDb29+t01MA0GCSqGSIb3DQEBCwUAMBYxFDASBgNV
BAMTC0FkbWluIFBhbmVsMB4XDTI2MDgwNDE2MDkyNFoXDTM2MDgwNDE2MDkyNFow
FjEUMBIGA1UEAxMLQWRtaW4gUGFuZWwwggEiMA0GCSqGSIb3DQEBAQUAA4IBDwAw
ggEKAoIBAQCtGpRB5rb4htj1hEr/EwLJ7VzcbdAhHi/Gxthgq5cqkhe7ubksJJ1U
OTCnxvndVzDIKAvXuICz0SmVmEO3/Z0EtP7xQKEgnPlygmjq7EfCgMlfJ++AwLI3
bU6hFbdWauzWTYr8O8KiLv4WrWyQv231CnKfUGo43r6yG9Mh53goU67s0YiUoK2c
fWemnxrF72kgSae3/cGLdznY1NzJ1jO/bPukRU2DFHesAqAn9szo6+NLOm79/XCC
HSnIC6+Z9bIM0RvcRyQvxJlZCre4/The+tGBDXkVkDkifZ7ViTy9UquaRimesrcN
8pi3kbAgsJfAnOvxKkC2aWUpj70SGnOlAgMBAAGjKjAoMA4GA1UdDwEB/wQEAwIH
gDAWBgNVHSUBAf8EDDAKBggrBgEFBQcDAzANBgkqhkiG9w0BAQsFAAOCAQEAKmL5
HisWe4/WfAW2/luevt1pOYlOG5xuLglcXBYKpcbKbXMlzny2APfzGrkNV9MeQ42U
N2ry1ymzmm9X3kc9tq7lzsWzH7EB3spGYvuc1vfo51k20IyiWvYn4xLZgoZBVof7
IOP2LaQUUTTATsaCQaf/mbHHQK6sDHl4vk9wNFfjb0oisE9XYSCy7m8UILuo1Ua9
NxDwpLh2uYABqcad0NrKAZn8Dq7p6r37d/7kYSf0vjf5nCJVnOXtydsRCVKStWBY
K4dzEQUk5ZHUh+9L6IcLgG0hMs2mIsUyGkziZi4+2uzBP/Tamr2OwLIDSV6sHLbF
GDnfBkXxqFVJOS24Fg==
-----END CERTIFICATE-----
+19
View File
@@ -0,0 +1,19 @@
{
"cli": {
"version": ">= 16.0.0",
"appVersionSource": "local"
},
"build": {
"production": {
"distribution": "internal",
"autoIncrement": true,
"android": {
"buildType": "apk"
},
"env": {
"EXPO_PUBLIC_UPDATE_URL": "https://ota.uber-stup.club/api/manifest"
},
"channel": "production-omnex-admin"
}
}
}
+4
View File
@@ -0,0 +1,4 @@
import { registerRootComponent } from 'expo';
import App from './App';
registerRootComponent(App);
File diff suppressed because it is too large Load Diff
+45
View File
@@ -0,0 +1,45 @@
{
"name": "omnex-plateform-app",
"version": "1.0.0",
"main": "index.ts",
"scripts": {
"start": "expo start",
"android": "expo start --android",
"ios": "expo start --ios",
"web": "expo start --web"
},
"dependencies": {
"@expo/vector-icons": "^15.0.3",
"@react-native-async-storage/async-storage": "2.2.0",
"@react-native-community/geolocation": "^3.4.0",
"@react-navigation/bottom-tabs": "^7.10.1",
"@react-navigation/native": "^7.1.28",
"@react-navigation/native-stack": "^7.11.0",
"axios": "^1.13.4",
"expo": "~54.0.34",
"expo-build-properties": "~1.0.10",
"expo-font": "~14.0.11",
"expo-image-picker": "~17.0.11",
"expo-location": "~19.0.8",
"expo-status-bar": "~3.0.9",
"expo-updates": "~29.0.17",
"jwt-decode": "^4.0.0",
"react": "19.1.0",
"react-dom": "19.1.0",
"react-native": "0.81.5",
"react-native-gesture-handler": "~2.28.0",
"react-native-maps": "1.20.1",
"react-native-reanimated": "~4.1.1",
"react-native-safe-area-context": "~5.6.0",
"react-native-screens": "~4.16.0",
"react-native-web": "^0.21.0",
"react-native-webview": "13.15.0",
"react-native-worklets": "0.5.1"
},
"devDependencies": {
"@expo/ngrok": "^4.1.3",
"@types/react": "~19.1.0",
"typescript": "~5.9.2"
},
"private": true
}
File diff suppressed because it is too large Load Diff
+495
View File
@@ -0,0 +1,495 @@
import apiClient from "./client";
//@ts
import type {
OrderItem,
DeliveryPerson,
DeliveryPersonsStats,
Alert,
} from "./types";
const API = "/api/v1/cabine";
const V2 = "/api/v2";
export const getCommandItems = async (commandId: number) => {
const { data } = await apiClient.get(`${API}/commands/${commandId}/items`);
return {
success: true,
items: (data.items || []) as OrderItem[],
count: data.count || 0,
command_info: data.command_info,
client_info: data.client_info,
};
};
export const updateItemStatus = async (itemId: number, status: string) => {
const { data } = await apiClient.put(`${API}/items/${itemId}/status`, {
status,
});
return { success: true, message: data.message };
};
export const confirmReceptionCabine = async (commandId: number) => {
const { data } = await apiClient.post(
`${API}/commands/${commandId}/confirm-reception`,
);
return {
success: true,
message: data.message,
points_earned: data.points_earned,
client_username: data.client_username,
};
};
export const applyClientPenalty = async (
clientUsername: string,
reason: string,
amount?: number,
) => {
const { data } = await apiClient.post(`${API}/penalty`, {
client_username: clientUsername,
reason,
amount,
});
return { success: true, message: data.message, penalty: data.penalty };
};
export const getClientPenalties = async (clientUsername: string) => {
const { data } = await apiClient.get(
`${API}/client/${clientUsername}/penalties`,
);
return { success: true, penalties: data.penalties };
};
export const resetClientPenalties = async (clientUsername: string) => {
const { data } = await apiClient.post(
`${API}/client/${clientUsername}/penalties/reset`,
);
return { success: true, message: data.message };
};
export const resetClientPoints = async (
clientUsername: string,
pool: number = -1,
) => {
const { data } = await apiClient.post(
`${API}/client/${clientUsername}/point/reset`,
{ pool },
);
return { success: true, message: data.message };
};
export const getAllClientsWithPenalties = async () => {
const { data } = await apiClient.get(`${API}/penalties/all`);
return {
success: true,
clients: data.clients || [],
count: data.count || 0,
};
};
export const getPenaltiesStats = async () => {
const { data } = await apiClient.get(`${API}/penalties/stats`);
return { success: true, data: data.data };
};
export const getCancelledOrders = async () => {
const { data } = await apiClient.get(`${API}/commands/cancelled`);
return {
success: true,
commands: data.commands || [],
count: data.count || 0,
};
};
export const deleteCommand = async (commandId: number) => {
const { data } = await apiClient.delete(`${API}/commands/${commandId}`);
return { success: true, message: data.message };
};
export const proposeAddressChangeCabine = async (
commandId: number,
proposedAddress: string,
) => {
const { data } = await apiClient.post(
`${API}/commands/${commandId}/propose-address`,
{ proposed_address: proposedAddress },
);
return { success: true, message: data.message };
};
export const notifyClientToDescendCabine = async (commandId: number) => {
const { data } = await apiClient.post(
`${API}/commands/${commandId}/notify-client`,
);
return {
success: true,
message: data.message,
client_username: data.client_username,
};
};
export const getCabineLivreursList = async (): Promise<
{ id: number; username: string }[]
> => {
const { data } = await apiClient.get(`${API}/all/deliveryman`);
return data.users || [];
};
export const assignDeliveryPersonByCabine = async (
commandId: number,
livreurUsername: string,
) => {
const { data } = await apiClient.post(
`${API}/commands/${commandId}/assign`,
{ livreur_username: livreurUsername },
);
return { success: true, message: data.message };
};
export const getDeliverymanLocationForCommand = async (commandId: number) => {
try {
const { data } = await apiClient.get(
`${API}/commands/${commandId}/deliveryman/location`,
);
return { success: true, data: data.data };
} catch (error: any) {
return {
success: false,
error: error.response?.data?.error || "Erreur",
};
}
};
// ============================================
// LIVREURS
// ============================================
const parseStatus = (status: any): "available" | "busy" | "offline" => {
if (!status) return "offline";
if (status === "available" || status === "busy" || status === "offline")
return status;
if (typeof status === "string" && status.startsWith("{")) {
try {
return JSON.parse(status).status || "offline";
} catch {
/* ignore */
}
}
return "offline";
};
export const getAllDeliveryPersonsWithDetails = async (): Promise<{
success: boolean;
deliveryPersons: DeliveryPerson[];
count: number;
stats: DeliveryPersonsStats;
}> => {
try {
const { data } = await apiClient.get(`${API}/all/deliveryman`);
const users = data.users || [];
const enriched = await Promise.all(
users.map(async (u: any): Promise<DeliveryPerson> => {
try {
const { data: details } = await apiClient.get(
`${API}/delivery-persons/${u.username}`,
);
const d = details.deliveryman || details;
const parsedStatus = parseStatus(d.status);
const hasLoc =
d.location?.latitude && d.location?.longitude;
return {
id: u.id,
username: u.username,
status: parsedStatus,
location: {
latitude: hasLoc ? d.location.latitude : 0,
longitude: hasLoc ? d.location.longitude : 0,
last_update: d.location?.last_update
? new Date(
d.location.last_update * 1000,
).toISOString()
: new Date().toISOString(),
is_recent: d.location?.is_recent || false,
},
stats: {
total_deliveries: d.total_deliveries || 0,
completed_today: d.completed_deliveries || 0,
queue_size: d.queue_size || 0,
current_command: d.current_command || null,
},
};
} catch {
return {
id: u.id,
username: u.username,
status: "offline",
location: {
latitude: 0,
longitude: 0,
last_update: new Date().toISOString(),
is_recent: false,
},
stats: {
total_deliveries: 0,
completed_today: 0,
queue_size: 0,
current_command: null,
},
};
}
}),
);
const stats: DeliveryPersonsStats = {
total: enriched.length,
available: enriched.filter((d) => d.status === "available").length,
busy: enriched.filter((d) => d.status === "busy").length,
offline: enriched.filter((d) => d.status === "offline").length,
active_deliveries: enriched.filter(
(d) => d.stats.current_command !== null,
).length,
};
return {
success: true,
deliveryPersons: enriched,
count: enriched.length,
stats,
};
} catch {
return {
success: false,
deliveryPersons: [],
count: 0,
stats: {
total: 0,
available: 0,
busy: 0,
offline: 0,
active_deliveries: 0,
},
};
}
};
export const getDeliveryPersonMapLinks = async (username: string) => {
try {
const { data } = await apiClient.get(
`${API}/deliveryman/${username}/location`,
);
return {
success: true,
location: data.location,
map_links: data.map_links,
};
} catch (error: any) {
return {
success: false,
error: error.response?.data?.error || "Erreur",
};
}
};
// ============================================
// ALERTES
// ============================================
export const getActiveAlerts = async (): Promise<{
success: boolean;
alerts: Alert[];
count: number;
}> => {
try {
const { data } = await apiClient.get(`${API}/alerts`);
return {
success: true,
alerts: data.alerts || [],
count: data.count || 0,
};
} catch {
return { success: false, alerts: [], count: 0 };
}
};
export const getAllAlerts = async (): Promise<{
success: boolean;
alerts: Alert[];
count: number;
}> => {
try {
const { data } = await apiClient.get(`${API}/all/alerts`);
return {
success: true,
alerts: data.alerts || [],
count: data.count || 0,
};
} catch {
return { success: false, alerts: [], count: 0 };
}
};
// ============================================
// PARAMÈTRES PUBLICS
// ============================================
export interface PublicSettings {
penalties_enabled: boolean;
show_amende_score: boolean;
points_enabled: boolean;
points_separated: boolean;
pool_names: string[];
pool_keys: string[];
}
export interface AppNotification {
command_id: number;
type: string;
message: string;
created_at: string;
read: boolean;
}
export const getCabineNotifications = async (): Promise<{
notifications: AppNotification[];
unread_count: number;
}> => {
const { data } = await apiClient.get(`${API}/notifications`);
return data;
};
export const markCabineNotificationsRead = async (): Promise<void> => {
await apiClient.post(`${API}/notifications/read`);
};
export const getPublicSettings = async (): Promise<PublicSettings> => {
try {
const { data } = await apiClient.get("/api/v1/app-settings");
return {
penalties_enabled: data.penalties_enabled ?? true,
show_amende_score: data.show_amende_score ?? true,
points_enabled: data.points_enabled ?? true,
points_separated: data.points_separated ?? true,
pool_names: data.pool_names ?? [],
pool_keys: data.pool_keys ?? [],
};
} catch {
return {
penalties_enabled: true,
show_amende_score: true,
points_enabled: true,
points_separated: true,
pool_names: [],
pool_keys: [],
};
}
};
// ============================================
// ADDRESSES
// ============================================
export const addAddress = async (
invalidAddress: string,
correctAddress: string,
): Promise<{ success: boolean; message: string }> => {
const { data } = await apiClient.post(`${API}/add/address`, {
invalid_address: invalidAddress,
correct_address: correctAddress,
});
return { success: true, message: data.message };
};
export const deleteAddress = async (
invalidAddress: string,
correctAddress: string,
): Promise<{ success: boolean; message: string }> => {
const { data } = await apiClient.delete(`${API}/delete/address`, {
data: {
invalid_address: invalidAddress,
correct_address: correctAddress,
},
});
return { success: true, message: data.message };
};
export const getAllAddresses = async (): Promise<
{ invalid_address: string; correct_address: string }[]
> => {
const { data } = await apiClient.get(`${API}/addresses`);
return (data.addresses ?? []).map((a: any) => ({
invalid_address: a.invalid_address ?? a.InvalidAddress ?? "",
correct_address: a.correct_address ?? a.CorrectAddress ?? "",
}));
};
// ============================================
// COMMANDES — CABINE
// ============================================
export const getCabineCommands = async (): Promise<{
success: boolean;
commands: any[];
count: number;
}> => {
try {
const { data } = await apiClient.get(`${API}/commands`);
return { success: true, commands: data.commands || [], count: data.count || 0 };
} catch {
return { success: false, commands: [], count: 0 };
}
};
// ============================================
// CLIENTS — CABINE
// ============================================
export const getCabineAllClients = async (): Promise<any[]> => {
try {
const { data } = await apiClient.get(`${API}/all/clients`);
return data.clients || [];
} catch {
return [];
}
};
// ============================================
// TELEGRAM — CABINE
// ============================================
const CABINE_API = "/api/v1/cabine";
export const getCabineTelegramStatus = async (): Promise<{
linked: boolean;
enabled: boolean;
}> => {
try {
const { data } = await apiClient.get(`${CABINE_API}/telegram/status`);
return data;
} catch {
return { linked: false, enabled: false };
}
};
export const generateCabineLinkToken = async (): Promise<{
link_url?: string;
error?: string;
}> => {
try {
const { data } = await apiClient.post(
`${CABINE_API}/telegram/link-token`,
);
return data;
} catch (e: any) {
return { error: e?.response?.data?.error || "Erreur" };
}
};
export const unlinkCabineTelegram = async (): Promise<void> => {
try {
await apiClient.delete(`${CABINE_API}/telegram/unlink`);
} catch {
/* ignore */
}
};
+439
View File
@@ -0,0 +1,439 @@
import apiClient from "./client";
import type {
DeliveryStatus,
QueueInfo,
DeliveryItem,
DeliveryDetails,
Alert,
} from "./types";
const API = "/api/v1/livreur";
export const getMyStatus = async (): Promise<{
success: boolean;
status?: DeliveryStatus;
error?: string;
}> => {
try {
const { data } = await apiClient.get(`${API}/status`);
return { success: true, status: data.status };
} catch (error: any) {
return {
success: false,
error: error.response?.data?.error || "Erreur réseau",
};
}
};
export const updateMyStatus = async (
status: "available" | "busy" | "offline",
): Promise<{ success: boolean; message?: string; error?: string }> => {
try {
const { data } = await apiClient.post(`${API}/update/status`, {
status,
});
return { success: true, message: data.message };
} catch (error: any) {
return {
success: false,
error: error.response?.data?.error || "Erreur réseau",
};
}
};
export const getMyQueue = async (): Promise<{
success: boolean;
queue_info?: QueueInfo;
error?: string;
}> => {
try {
const { data } = await apiClient.get(`${API}/queue`);
return { success: true, queue_info: data.queue_info };
} catch (error: any) {
return {
success: false,
error: error.response?.data?.error || "Erreur réseau",
};
}
};
export const getMyDeliveries = async (): Promise<{
success: boolean;
deliveries?: DeliveryItem[];
error?: string;
}> => {
try {
const { data } = await apiClient.get(`${API}/deliveries`);
const deliveries: DeliveryItem[] = (data.deliveries || []).map(
(d: any) => ({ ...d, items: d.items || [] }),
);
return { success: true, deliveries };
} catch (error: any) {
return {
success: false,
error: error.response?.data?.error || "Erreur réseau",
};
}
};
export const getDeliveryDetails = async (
deliveryId: number,
): Promise<{
success: boolean;
delivery?: DeliveryDetails;
error?: string;
}> => {
try {
const { data } = await apiClient.get(`${API}/deliveries/${deliveryId}`);
// Le backend retourne { delivery: { id, status, adresse, items, ... }, success: true }
// Les items sont dans data.delivery.items, pas dans data.client_info
const deliveryObj = data.delivery || {};
return {
success: true,
delivery: {
delivery: {
...deliveryObj,
items: deliveryObj.items || [],
},
client_info: deliveryObj.client_info || data.client_info || {},
},
};
} catch (error: any) {
return {
success: false,
error: error.response?.data?.error || "Erreur réseau",
};
}
};
export const startDelivery = async (
deliveryId: number,
latitude: number,
longitude: number,
): Promise<{ success: boolean; message?: string; error?: string }> => {
try {
const { data } = await apiClient.post(
`${API}/deliveries/${deliveryId}/start`,
{ latitude, longitude },
);
return { success: true, message: data.message };
} catch (error: any) {
return {
success: false,
error: error.response?.data?.error || "Erreur réseau",
};
}
};
export const updateDeliveryStatus = async (
deliveryId: number,
status: string,
latitude: number,
longitude: number,
notes?: string,
): Promise<{ success: boolean; message?: string; error?: string }> => {
try {
const { data } = await apiClient.put(
`${API}/deliveries/${deliveryId}/status`,
{ status, latitude, longitude, ...(notes ? { notes } : {}) },
);
return { success: true, message: data.message };
} catch (error: any) {
return {
success: false,
error: error.response?.data?.error || "Erreur réseau",
};
}
};
// ============================================
// GPS
// ============================================
export const updateMyLocation = async (
latitude: number,
longitude: number,
): Promise<{ success: boolean; message?: string; error?: string }> => {
try {
const { data } = await apiClient.post(`${API}/location/update`, {
latitude,
longitude,
});
return { success: true, message: data.message };
} catch (error: any) {
return {
success: false,
error: error.response?.data?.error || "Erreur réseau",
};
}
};
export const getDeliveryNavLink = async (
deliveryId: number,
): Promise<{ success: boolean; waze_app?: string; error?: string }> => {
try {
const { data } = await apiClient.get(
`${API}/deliveries/${deliveryId}/nav-link`,
);
return { success: true, waze_app: data.waze_app };
} catch (error: any) {
return {
success: false,
error: error.response?.data?.error || "Erreur réseau",
};
}
};
export const getMyLocation = async (): Promise<{
success: boolean;
latitude?: number;
longitude?: number;
error?: string;
}> => {
try {
const { data } = await apiClient.get(`${API}/location`);
return {
success: true,
latitude: data.latitude,
longitude: data.longitude,
};
} catch (error: any) {
return {
success: false,
error: error.response?.data?.error || "Erreur réseau",
};
}
};
// ============================================
// ALERTES
// ============================================
export const triggerPoliceAlert = async (
alertMessage: string,
): Promise<{
success: boolean;
alert_id?: number;
message?: string;
error?: string;
}> => {
try {
const { data } = await apiClient.post(`${API}/alert`, {
message: alertMessage,
});
return {
success: true,
alert_id: data.alert_id,
message: data.message,
};
} catch (error: any) {
return {
success: false,
error: error.response?.data?.error || "Erreur réseau",
};
}
};
export const endAlert = async (
alertId: number,
): Promise<{ success: boolean; message?: string; error?: string }> => {
try {
const { data } = await apiClient.delete(`${API}/alert/${alertId}`);
return { success: true, message: data.message };
} catch (error: any) {
return {
success: false,
error: error.response?.data?.error || "Erreur réseau",
};
}
};
export const getMyAlerts = async (): Promise<{
success: boolean;
alerts?: Alert[];
count?: number;
error?: string;
}> => {
try {
const { data } = await apiClient.get(`${API}/alerts`);
return {
success: true,
alerts: data.alerts || [],
count: data.count || 0,
};
} catch (error: any) {
return {
success: false,
error: error.response?.data?.error || "Erreur réseau",
};
}
};
// ============================================
// NOTIFICATIONS
// ============================================
export type LivreurNotification = {
command_id: number;
type: string;
message: string;
created_at: string;
read: boolean;
};
export const getLivreurNotifications = async (): Promise<{
success: boolean;
notifications?: LivreurNotification[];
unread_count?: number;
total?: number;
error?: string;
}> => {
try {
const { data } = await apiClient.get(`${API}/notifications`);
return {
success: true,
notifications: data.notifications || [],
unread_count: data.unread_count || 0,
total: data.total || 0,
};
} catch (error: any) {
return {
success: false,
error: error.response?.data?.error || "Erreur réseau",
};
}
};
export const markLivreurNotificationsRead = async (): Promise<{
success: boolean;
error?: string;
}> => {
try {
await apiClient.post(`${API}/notifications/read`);
return { success: true };
} catch (error: any) {
return {
success: false,
error: error.response?.data?.error || "Erreur réseau",
};
}
};
// ============================================
// TELEGRAM — LIVREUR
// ============================================
export const getLivreurTelegramStatus = async (): Promise<{
linked: boolean;
enabled: boolean;
}> => {
try {
const { data } = await apiClient.get(`${API}/telegram/status`);
return data;
} catch {
return { linked: false, enabled: false };
}
};
export const generateLivreurLinkToken = async (): Promise<{
link_url?: string;
expires_in?: number;
error?: string;
}> => {
try {
const { data } = await apiClient.post(`${API}/telegram/link-token`);
return data;
} catch (e: any) {
return { error: e?.response?.data?.error || "Erreur" };
}
};
export const unlinkLivreurTelegram = async (): Promise<void> => {
try {
await apiClient.delete(`${API}/telegram/unlink`);
} catch {
/* ignore */
}
};
export type IssueType =
| "client_absent"
| "wrong_address"
| "refused_delivery"
| "no_access"
| "other";
export const ISSUE_LABELS: Record<IssueType, string> = {
client_absent: "Client absent",
wrong_address: "Adresse incorrecte",
refused_delivery: "Livraison refusée",
no_access: "Accès impossible",
other: "Autre",
};
export type StatPoint = { label: string; count: number; revenue: number };
export const getMyStats = async (): Promise<{
success: boolean;
by_day?: StatPoint[];
by_week?: StatPoint[];
by_month?: StatPoint[];
today_count?: number;
today_revenue?: number;
error?: string;
}> => {
try {
const { data } = await apiClient.get(`${API}/stats`);
return {
success: true,
by_day: data.by_day || [],
by_week: data.by_week || [],
by_month: data.by_month || [],
today_count: data.today_count || 0,
today_revenue: data.today_revenue || 0,
};
} catch (error: any) {
return { success: false, error: error.response?.data?.error || "Erreur réseau" };
}
};
export interface LivreurRating {
id: number;
order_id: number;
livreur_username: string;
client_username: string;
rating: number;
comment: string;
created_at: string;
}
export const getMyRatings = async (): Promise<{
success: boolean;
ratings: LivreurRating[];
average: number;
count: number;
error?: string;
}> => {
try {
const { data } = await apiClient.get(`${API}/ratings`);
return { success: true, ratings: data.ratings || [], average: data.average || 0, count: data.count || 0 };
} catch (e: any) {
return { success: false, ratings: [], average: 0, count: 0, error: e?.response?.data?.error || "Erreur" };
}
};
export const reportDeliveryIssue = async (
deliveryId: number,
issueType: IssueType,
description: string,
): Promise<{ success: boolean; error?: string }> => {
try {
await apiClient.post(`${API}/deliveries/${deliveryId}/issue`, {
issue_type: issueType,
description,
});
return { success: true };
} catch (e: any) {
return { success: false, error: e?.response?.data?.error || "Erreur" };
}
};
+70
View File
@@ -0,0 +1,70 @@
import axios from "axios";
import AsyncStorage from "@react-native-async-storage/async-storage";
import { getToken, getAdminToken } from "../auth/tokenStorage";
const SERVER_URL_KEY = "server_base_url";
export let API_BASE_URL = process.env.EXPO_PUBLIC_API_URL ?? "";
const apiClient = axios.create({
baseURL: API_BASE_URL,
timeout: 15000,
headers: {
"Content-Type": "application/json",
},
});
// Updates the active backend URL for every subsequent request made
// through apiClient (used by the pre-login server config screen).
export const setApiBaseUrl = (url: string) => {
API_BASE_URL = url;
apiClient.defaults.baseURL = url;
};
export const getStoredServerUrl = () => AsyncStorage.getItem(SERVER_URL_KEY);
export const saveServerUrl = async (url: string) => {
await AsyncStorage.setItem(SERVER_URL_KEY, url);
setApiBaseUrl(url);
};
export const clearServerUrl = async () => {
await AsyncStorage.removeItem(SERVER_URL_KEY);
setApiBaseUrl("");
};
// Restores the previously saved server URL (if any) into apiClient.
// Must resolve before any screen can issue API calls.
export const initServerBaseUrl = async (): Promise<string | null> => {
const stored = await getStoredServerUrl();
if (stored) {
setApiBaseUrl(stored);
}
return stored;
};
apiClient.interceptors.request.use(async (config) => {
const isAdminRoute =
config.url?.includes("/api/v2/") ||
config.url?.includes("/api/v1/cabine/") ||
config.url?.includes("/api/v1/livreur/");
const token = isAdminRoute ? await getAdminToken() : await getToken();
if (token) {
config.headers.Authorization = `Bearer ${token}`;
}
return config;
});
apiClient.interceptors.response.use(
(response) => response,
(error) => {
if (error.response?.status === 401) {
console.log("Session expirée");
}
return Promise.reject(error);
},
);
export default apiClient;
+194
View File
@@ -0,0 +1,194 @@
import axios from "axios";
const TOMTOM_API_KEY = "MERY8I7LMeYVSLKO5WuV73W9rKJpBLoB";
export interface LatLng {
latitude: number;
longitude: number;
}
export interface RouteInfo {
distance: string;
duration: string;
distanceMeters: number;
durationSeconds: number;
coordinates: LatLng[];
}
export interface NavigationInstruction {
instruction: string;
distance: string;
maneuver: string;
streetName?: string;
isActive: boolean;
}
export const maneuverTranslations: Record<string, string> = {
TURN_LEFT: "Tournez à gauche",
TURN_RIGHT: "Tournez à droite",
TURN_SLIGHT_LEFT: "Tournez légèrement à gauche",
TURN_SLIGHT_RIGHT: "Tournez légèrement à droite",
TURN_SHARP_LEFT: "Tournez fortement à gauche",
TURN_SHARP_RIGHT: "Tournez fortement à droite",
KEEP_LEFT: "Restez à gauche",
KEEP_RIGHT: "Restez à droite",
STRAIGHT: "Continuez tout droit",
ENTER_ROUNDABOUT: "Entrez dans le rond-point",
EXIT_ROUNDABOUT: "Sortez du rond-point",
MOTORWAY_ENTER: "Entrez sur l'autoroute",
MOTORWAY_EXIT: "Sortez de l'autoroute",
ARRIVE: "Vous êtes arrivé",
ARRIVE_LEFT: "Destination à gauche",
ARRIVE_RIGHT: "Destination à droite",
DEPART: "Départ",
U_TURN: "Faites demi-tour",
FOLLOW: "Suivez",
WAYPOINT_REACHED: "Point de passage atteint",
};
export const maneuverIcons: Record<string, string> = {
TURN_LEFT: "arrow-back",
TURN_RIGHT: "arrow-forward",
TURN_SLIGHT_LEFT: "arrow-up",
TURN_SLIGHT_RIGHT: "arrow-up",
TURN_SHARP_LEFT: "return-down-back",
TURN_SHARP_RIGHT: "return-down-forward",
KEEP_LEFT: "arrow-back",
KEEP_RIGHT: "arrow-forward",
STRAIGHT: "arrow-up",
ENTER_ROUNDABOUT: "sync",
EXIT_ROUNDABOUT: "arrow-forward",
MOTORWAY_ENTER: "speedometer",
MOTORWAY_EXIT: "exit-outline",
ARRIVE: "flag",
ARRIVE_LEFT: "flag",
ARRIVE_RIGHT: "flag",
DEPART: "car",
U_TURN: "return-up-back",
FOLLOW: "arrow-up",
WAYPOINT_REACHED: "location",
DEFAULT: "arrow-up",
};
// ---- Helpers ----
function formatDistance(meters: number): string {
if (meters < 1000) return `${Math.round(meters)} m`;
return `${(meters / 1000).toFixed(1)} km`;
}
export async function geocodeAddress(address: string): Promise<LatLng | null> {
try {
const url = `https://api.tomtom.com/search/2/geocode/${encodeURIComponent(address)}.json?key=${TOMTOM_API_KEY}&limit=1`;
const { data } = await axios.get(url);
if (data.results && data.results.length > 0) {
const pos = data.results[0].position;
if (pos?.lat != null && pos?.lon != null) {
return { latitude: pos.lat, longitude: pos.lon };
}
}
} catch {
/* silent */
}
return null;
}
export async function calculateRoute(
origin: LatLng,
destination: LatLng,
): Promise<{ route: RouteInfo; instructions: NavigationInstruction[] } | null> {
try {
const start = `${origin.latitude},${origin.longitude}`;
const end = `${destination.latitude},${destination.longitude}`;
const url = `https://api.tomtom.com/routing/1/calculateRoute/${start}:${end}/json?key=${TOMTOM_API_KEY}&instructionsType=text&language=fr-FR&traffic=true&travelMode=car`;
const { data } = await axios.get(url);
if (!data.routes || data.routes.length === 0) return null;
const route = data.routes[0];
const summary = route.summary;
// Extract polyline coordinates
const coordinates: LatLng[] = [];
if (route.legs) {
for (const leg of route.legs) {
if (leg.points) {
for (const pt of leg.points) {
coordinates.push({
latitude: pt.latitude,
longitude: pt.longitude,
});
}
}
}
}
// Fallback: straight line
if (coordinates.length === 0) {
coordinates.push(origin, destination);
}
const distanceKm = (summary.lengthInMeters / 1000).toFixed(1);
const durationMin = Math.round(summary.travelTimeInSeconds / 60);
const routeInfo: RouteInfo = {
distance: `${distanceKm} km`,
duration: `${durationMin} min`,
distanceMeters: summary.lengthInMeters,
durationSeconds: summary.travelTimeInSeconds,
coordinates,
};
// Extract instructions
const instructions: NavigationInstruction[] = [];
if (route.guidance?.instructions) {
for (let i = 0; i < route.guidance.instructions.length; i++) {
const inst = route.guidance.instructions[i];
const maneuver = inst.maneuver || "STRAIGHT";
const text =
inst.message ||
maneuverTranslations[maneuver] ||
"Continuez";
instructions.push({
instruction: text,
distance: formatDistance(inst.routeOffsetInMeters || 0),
maneuver,
streetName: inst.street,
isActive: i === 0,
});
}
}
if (instructions.length === 0) {
instructions.push({
instruction: "Dirigez-vous vers votre destination",
distance: formatDistance(summary.lengthInMeters || 0),
maneuver: "DEPART",
isActive: true,
});
instructions.push({
instruction: "Vous êtes arrivé à destination",
distance: "0 m",
maneuver: "ARRIVE",
isActive: false,
});
}
return { route: routeInfo, instructions };
} catch {
/* silent */
}
return null;
}
// ---- Bearing calculation ----
export function calculateBearing(from: LatLng, to: LatLng): number {
const φ1 = (from.latitude * Math.PI) / 180;
const φ2 = (to.latitude * Math.PI) / 180;
const Δλ = ((to.longitude - from.longitude) * Math.PI) / 180;
const y = Math.sin(Δλ) * Math.cos(φ2);
const x =
Math.cos(φ1) * Math.sin(φ2) -
Math.sin(φ1) * Math.cos(φ2) * Math.cos(Δλ);
return ((Math.atan2(y, x) * 180) / Math.PI + 360) % 360;
}
+176
View File
@@ -0,0 +1,176 @@
// Shared types for admin panel
export interface AdminUser {
id: number;
username: string;
role: string;
}
export interface AuthResponse {
success: boolean;
message?: string;
access_token?: string;
token_type?: string;
expires_in?: number;
user?: AdminUser;
}
export interface ClientResponse {
id: number;
username: string;
nom: string;
prenom: string;
telephone: string;
adresse?: string;
role?: string;
created_at?: string;
updated_at?: string;
command: number;
points_extra: Record<string, number>;
amende: number;
cancellations_count: number;
last_penalty_reason?: string;
referral_balance?: number;
}
export interface CommandResponse {
id: number;
client_order_number?: number;
username: string;
status: string;
adresse: string;
total_prix: number;
livreur_assign?: string | null;
proposed_address?: string | null;
address_proposal_status?: string;
referral_used?: number;
cancel_reason?: string;
created_at: string;
updated_at: string;
}
export interface OrderItem {
id: number;
command_id: number;
product_id: number;
product_name: string;
quantity: number;
price: number;
status: string;
created_at: string;
}
export interface Alert {
id: number;
username: string;
status: string;
message: string;
created_at: string;
updated_at: string;
}
export interface DeliveryPerson {
id: number;
username: string;
status: "available" | "busy" | "offline";
location: {
latitude: number;
longitude: number;
last_update: string;
is_recent: boolean;
};
stats: {
total_deliveries: number;
completed_today: number;
queue_size: number;
current_command: number | null;
};
}
export interface DeliveryPersonsStats {
total: number;
available: number;
busy: number;
offline: number;
active_deliveries: number;
}
export interface Product {
id: number;
name: string;
description?: string;
category: string;
stock: number;
unit: string;
prices?: Array<{
id?: number;
quantity: number;
price: number;
active_price?: boolean;
}>;
media?: Array<{
url: string;
type: string;
id?: number;
created_at?: string;
}>;
coming_soon?: boolean;
created_at?: string;
updated_at?: string;
}
export interface DeliveryStatus {
status: "available" | "busy" | "offline";
current_command?: number;
last_update?: number;
}
export interface QueueInfo {
queue_size: number;
commands: any[];
}
export interface DeliveryItemProduct {
produit: string;
quantite: number;
prix: number;
}
export interface DeliveryItem {
id: number;
status: string;
adresse: string;
total_prix: number;
referral_used?: number;
created_at: string;
updated_at: string;
eta?: string;
items?: DeliveryItemProduct[];
items_count?: number;
}
export interface ClientInfo {
username: string;
nom?: string;
prenom?: string;
telephone?: string;
}
export interface DeliveryDetails {
delivery: DeliveryItem;
client_info: ClientInfo;
}
export interface PenaltyInfo {
client_username: string;
current_amende: number;
cancellations_count: number;
next_penalty: number;
}
export interface MapLinks {
google_maps: string;
waze: string;
apple_maps: string;
openstreetmap: string;
}
@@ -0,0 +1,117 @@
import React, { createContext, useContext, useState, useEffect, type ReactNode } from 'react';
import {
getAdminToken, setAdminToken as storeAdminToken,
setAdminUsername, removeAdminUsername,
setRole as storeRole, getRole,
clearAllAuth,
} from './tokenStorage';
import { extractUsernameFromToken, extractRoleFromToken, isTokenExpired } from './jwtUtils';
export type UserRole = 'admin' | 'cabine' | 'livreur' | null;
interface AuthState {
token: string | null;
username: string | null;
role: UserRole;
isLoading: boolean;
isAuthenticated: boolean;
}
interface AuthContextType extends AuthState {
loginAdmin: (token: string, role: 'admin' | 'cabine' | 'livreur') => Promise<void>;
logout: () => Promise<void>;
}
const AuthContext = createContext<AuthContextType | undefined>(undefined);
export function AuthProvider({ children }: { children: ReactNode }) {
const [state, setState] = useState<AuthState>({
token: null,
username: null,
role: null,
isLoading: true,
isAuthenticated: false,
});
useEffect(() => {
const restore = async () => {
try {
const savedRole = await getRole();
if (savedRole === 'admin' || savedRole === 'cabine' || savedRole === 'livreur') {
const token = await getAdminToken();
if (token && !isTokenExpired(token)) {
const uname = extractUsernameFromToken(token);
setState({
token,
username: uname,
role: savedRole as UserRole,
isLoading: false,
isAuthenticated: true,
});
return;
}
}
await clearAllAuth();
setState({
token: null,
username: null,
role: null,
isLoading: false,
isAuthenticated: false,
});
} catch {
await clearAllAuth();
setState({
token: null,
username: null,
role: null,
isLoading: false,
isAuthenticated: false,
});
}
};
restore();
}, []);
const loginAdmin = async (token: string, role: 'admin' | 'cabine' | 'livreur') => {
const uname = extractUsernameFromToken(token);
await storeAdminToken(token);
if (uname) await setAdminUsername(uname);
await storeRole(role);
setState({
token,
username: uname,
role,
isLoading: false,
isAuthenticated: true,
});
};
const logout = async () => {
await clearAllAuth();
setState({
token: null,
username: null,
role: null,
isLoading: false,
isAuthenticated: false,
});
};
return (
<AuthContext.Provider value={{ ...state, loginAdmin, logout }}>
{children}
</AuthContext.Provider>
);
}
export function useAuth() {
const context = useContext(AuthContext);
if (!context) {
throw new Error('useAuth must be used within an AuthProvider');
}
return context;
}
+36
View File
@@ -0,0 +1,36 @@
import { jwtDecode } from 'jwt-decode';
export interface JWTPayload {
client_id?: number;
user_id?: number;
username: string;
role: string;
session_id: string;
iat: number;
exp: number;
iss: string;
}
export const decodeToken = (token: string): JWTPayload | null => {
try {
return jwtDecode<JWTPayload>(token);
} catch {
return null;
}
};
export const extractUsernameFromToken = (token: string): string | null => {
const payload = decodeToken(token);
return payload?.username ?? null;
};
export const extractRoleFromToken = (token: string): string | null => {
const payload = decodeToken(token);
return payload?.role ?? null;
};
export const isTokenExpired = (token: string): boolean => {
const payload = decodeToken(token);
if (!payload) return true;
return Date.now() >= payload.exp * 1000;
};
@@ -0,0 +1,43 @@
import AsyncStorage from '@react-native-async-storage/async-storage';
const TOKEN_KEY = 'token';
const ADMIN_TOKEN_KEY = 'admin_token';
const USERNAME_KEY = 'username';
const ADMIN_USERNAME_KEY = 'admin_username';
const ROLE_KEY = 'user_role';
// Client token
export const getToken = () => AsyncStorage.getItem(TOKEN_KEY);
export const setToken = (token: string) => AsyncStorage.setItem(TOKEN_KEY, token);
export const removeToken = () => AsyncStorage.removeItem(TOKEN_KEY);
// Admin/Cabine/Livreur token
export const getAdminToken = () => AsyncStorage.getItem(ADMIN_TOKEN_KEY);
export const setAdminToken = (token: string) => AsyncStorage.setItem(ADMIN_TOKEN_KEY, token);
export const removeAdminToken = () => AsyncStorage.removeItem(ADMIN_TOKEN_KEY);
// Username
export const getUsername = () => AsyncStorage.getItem(USERNAME_KEY);
export const setUsername = (username: string) => AsyncStorage.setItem(USERNAME_KEY, username);
export const removeUsername = () => AsyncStorage.removeItem(USERNAME_KEY);
// Admin username
export const getAdminUsername = () => AsyncStorage.getItem(ADMIN_USERNAME_KEY);
export const setAdminUsername = (username: string) => AsyncStorage.setItem(ADMIN_USERNAME_KEY, username);
export const removeAdminUsername = () => AsyncStorage.removeItem(ADMIN_USERNAME_KEY);
// Role
export const getRole = () => AsyncStorage.getItem(ROLE_KEY);
export const setRole = (role: string) => AsyncStorage.setItem(ROLE_KEY, role);
export const removeRole = () => AsyncStorage.removeItem(ROLE_KEY);
// Clear all auth data
export const clearAllAuth = async () => {
await AsyncStorage.multiRemove([
TOKEN_KEY,
ADMIN_TOKEN_KEY,
USERNAME_KEY,
ADMIN_USERNAME_KEY,
ROLE_KEY,
]);
};
@@ -0,0 +1,16 @@
import React from "react";
import Badge from "./ui/Badge";
import { STATUS_LABELS, getStatusColors } from "../utils/constants";
import { useTheme } from "../context/ThemeContext";
interface StatusBadgeProps {
status: string;
}
export default function StatusBadge({ status }: StatusBadgeProps) {
const { colors } = useTheme();
const label = STATUS_LABELS[status] || status;
const statusColors = getStatusColors(colors);
const color = statusColors[status] || colors.textMuted;
return <Badge label={label} color={color} />;
}
@@ -0,0 +1,369 @@
import React, {
useRef,
useEffect,
useCallback,
forwardRef,
useImperativeHandle,
} from "react";
import { StyleSheet } from "react-native";
import { WebView } from "react-native-webview";
import { geocodeAddress, calculateRoute } from "../api/tomtom";
const TOMTOM_API_KEY = "MERY8I7LMeYVSLKO5WuV73W9rKJpBLoB";
export interface TomTomMarker {
id: string;
latitude: number;
longitude: number;
color: string;
label?: string;
description?: string;
isSelected?: boolean;
}
export interface TomTomMapRef {
fitAllMarkers: () => void;
fitToCoordinates: (
coords: { latitude: number; longitude: number }[],
) => void;
calcRoute: (
origin: { latitude: number; longitude: number },
destination: { latitude: number; longitude: number },
) => void;
calcRouteFromAddress: (
origin: { latitude: number; longitude: number },
address: string,
) => void;
clearRoute: () => void;
}
interface TomTomMapProps {
style?: any;
markers?: TomTomMarker[];
initialCenter?: { latitude: number; longitude: number };
initialZoom?: number;
onMarkerPress?: (markerId: string) => void;
}
const TomTomMap = forwardRef<TomTomMapRef, TomTomMapProps>(
(
{
style,
markers = [],
initialCenter,
initialZoom = 14,
onMarkerPress,
},
ref,
) => {
const webViewRef = useRef<WebView>(null);
const isReady = useRef(false);
const pendingMessages = useRef<string[]>([]);
const sendMessage = useCallback((msg: object) => {
const json = JSON.stringify(msg);
if (isReady.current) {
webViewRef.current?.injectJavaScript(
`(function(){ try { handleMessage(${json}); } catch(e) {} })(); true;`,
);
} else {
pendingMessages.current.push(json);
}
}, []);
useImperativeHandle(
ref,
() => ({
fitAllMarkers: () => sendMessage({ type: "fitAll" }),
fitToCoordinates: (coords) =>
sendMessage({ type: "fitCoords", coords }),
clearRoute: () => sendMessage({ type: "clearRoute" }),
calcRoute: (origin, dest) => {
calculateRoute(origin, dest).then((result) => {
if (result) {
sendMessage({
type: "drawRoute",
coordinates: result.route.coordinates,
destination: dest,
});
} else {
// Fallback: ligne droite
sendMessage({
type: "drawRoute",
coordinates: [origin, dest],
destination: dest,
});
}
}).catch(() => {
sendMessage({
type: "drawRoute",
coordinates: [origin, dest],
destination: dest,
});
});
},
calcRouteFromAddress: async (origin, address) => {
try {
const dest = await geocodeAddress(address);
if (!dest) return;
const result = await calculateRoute(origin, dest);
if (result) {
sendMessage({
type: "drawRoute",
coordinates: result.route.coordinates,
destination: dest,
});
} else {
sendMessage({
type: "drawRoute",
coordinates: [origin, dest],
destination: dest,
});
}
} catch {
/* silent */
}
},
}),
[sendMessage],
);
useEffect(() => {
sendMessage({ type: "updateMarkers", markers });
}, [markers, sendMessage]);
const onMessage = useCallback(
(event: any) => {
try {
const data = JSON.parse(event.nativeEvent.data);
if (data.type === "ready") {
isReady.current = true;
// Rejouer les messages en attente
const pending = pendingMessages.current.slice();
pendingMessages.current = [];
for (const msg of pending) {
webViewRef.current?.injectJavaScript(
`(function(){ try { handleMessage(${msg}); } catch(e) {} })(); true;`,
);
}
// Fit sur tous les markers après un court délai
setTimeout(() => {
webViewRef.current?.injectJavaScript(
`(function(){ try { handleMessage(${JSON.stringify({ type: "fitAll" })}); } catch(e) {} })(); true;`,
);
}, 500);
} else if (data.type === "markerPress" && onMarkerPress) {
onMarkerPress(data.id);
}
} catch {
/* ignore */
}
},
[onMarkerPress],
);
const center =
initialCenter ||
(markers.length > 0
? {
latitude: markers[0].latitude,
longitude: markers[0].longitude,
}
: { latitude: 48.8566, longitude: 2.3522 });
const html = buildHtml(center, initialZoom, TOMTOM_API_KEY);
return (
<WebView
ref={webViewRef}
style={[styles.map, style]}
source={{ html, baseUrl: "https://api.tomtom.com" }}
onMessage={onMessage}
javaScriptEnabled
domStorageEnabled
scrollEnabled={false}
bounces={false}
originWhitelist={["*"]}
mixedContentMode="always"
allowFileAccessFromFileURLs
allowUniversalAccessFromFileURLs
androidLayerType="hardware"
/>
);
},
);
function buildHtml(
center: { latitude: number; longitude: number },
zoom: number,
apiKey: string,
) {
return `<!DOCTYPE html>
<html>
<head>
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no">
<link rel="stylesheet" href="https://api.tomtom.com/maps-sdk-for-web/cdn/6.x/6.25.0/maps/maps.css">
<script src="https://api.tomtom.com/maps-sdk-for-web/cdn/6.x/6.25.0/maps/maps-web.min.js"></script>
<style>
* { margin: 0; padding: 0; box-sizing: border-box; }
html, body, #map { width: 100%; height: 100%; overflow: hidden; }
.marker-dot {
width: 28px; height: 28px; border-radius: 50%;
display: flex; align-items: center; justify-content: center;
border: 2px solid rgba(255,255,255,0.8);
cursor: pointer;
box-shadow: 0 2px 6px rgba(0,0,0,0.4);
}
.marker-dot.selected {
width: 36px; height: 36px;
border: 3px solid #7c3aed;
box-shadow: 0 0 12px rgba(124,58,237,0.6);
}
.dest-marker {
width: 32px; height: 32px;
display: flex; align-items: center; justify-content: center;
}
</style>
</head>
<body>
<div id="map"></div>
<script>
var map = tt.map({
key: '${apiKey}',
container: 'map',
center: [${center.longitude}, ${center.latitude}],
zoom: ${zoom},
stylesVisibility: { trafficFlow: false, trafficIncidents: false }
});
var markerObjects = {};
var destMarker = null;
var ROUTE_SOURCE = 'tt-route-src';
var ROUTE_LAYER = 'tt-route-lyr';
// File d'attente pour les messages reçus avant que le style soit chargé
var pendingRouteData = null;
var styleLoaded = false;
function clearRoute() {
try { if (map.getLayer(ROUTE_LAYER)) map.removeLayer(ROUTE_LAYER); } catch(e) {}
try { if (map.getSource(ROUTE_SOURCE)) map.removeSource(ROUTE_SOURCE); } catch(e) {}
if (destMarker) { destMarker.remove(); destMarker = null; }
}
function drawRoute(coordinates, destination) {
clearRoute();
if (!destination) return;
// Marker destination (pin rouge)
var pin = document.createElement('div');
pin.className = 'dest-marker';
pin.innerHTML = '<svg width="32" height="32" viewBox="0 0 24 24"><path fill="#ef4444" d="M12 2C8.13 2 5 5.13 5 9c0 5.25 7 13 7 13s7-7.75 7-13c0-3.87-3.13-7-7-7z"/><circle cx="12" cy="9" r="2.5" fill="white"/></svg>';
destMarker = new tt.Marker({ element: pin })
.setLngLat([destination.longitude, destination.latitude])
.addTo(map);
if (!coordinates || coordinates.length < 2) {
map.flyTo({ center: [destination.longitude, destination.latitude], zoom: 15 });
return;
}
// Convertir les coords { latitude, longitude } en [lng, lat] pour GeoJSON
var lngLat = coordinates.map(function(c) {
return [c.longitude, c.latitude];
});
try {
map.addSource(ROUTE_SOURCE, {
type: 'geojson',
data: {
type: 'Feature',
properties: {},
geometry: { type: 'LineString', coordinates: lngLat }
}
});
map.addLayer({
id: ROUTE_LAYER,
type: 'line',
source: ROUTE_SOURCE,
layout: { 'line-join': 'round', 'line-cap': 'round' },
paint: { 'line-color': '#4285F4', 'line-width': 6, 'line-opacity': 0.9 }
});
var bounds = new tt.LngLatBounds();
lngLat.forEach(function(c) { bounds.extend(c); });
map.fitBounds(bounds, { padding: 60, maxZoom: 16, duration: 800 });
} catch(e) {}
}
function handleMessage(data) {
if (data.type === 'updateMarkers') {
Object.values(markerObjects).forEach(function(m) { m.remove(); });
markerObjects = {};
(data.markers || []).forEach(function(m) {
var el = document.createElement('div');
el.className = 'marker-dot' + (m.isSelected ? ' selected' : '');
el.style.backgroundColor = m.color || '#7c3aed';
el.innerHTML = '<svg width="14" height="14" viewBox="0 0 24 24" fill="white"><circle cx="12" cy="12" r="8"/></svg>';
el.addEventListener('click', function() {
window.ReactNativeWebView.postMessage(JSON.stringify({ type: 'markerPress', id: m.id }));
});
var popup = new tt.Popup({ offset: 20, closeButton: false })
.setHTML('<div style="padding:4px 8px;font-size:12px;"><b>' + (m.label || m.id) + '</b>' + (m.description ? '<br>' + m.description : '') + '</div>');
var marker = new tt.Marker({ element: el })
.setLngLat([m.longitude, m.latitude])
.setPopup(popup)
.addTo(map);
markerObjects[m.id] = marker;
});
}
if (data.type === 'drawRoute') {
if (!styleLoaded) {
pendingRouteData = data;
} else {
drawRoute(data.coordinates, data.destination);
}
}
if (data.type === 'clearRoute') {
pendingRouteData = null;
clearRoute();
}
if (data.type === 'fitAll') {
var all = Object.values(markerObjects);
if (all.length > 0) {
var bounds = new tt.LngLatBounds();
all.forEach(function(m) { bounds.extend(m.getLngLat()); });
if (destMarker) bounds.extend(destMarker.getLngLat());
map.fitBounds(bounds, { padding: 60, maxZoom: 15, duration: 800 });
}
}
if (data.type === 'fitCoords') {
if (data.coords && data.coords.length > 0) {
var bounds = new tt.LngLatBounds();
data.coords.forEach(function(c) { bounds.extend([c.longitude, c.latitude]); });
map.fitBounds(bounds, { padding: 80, maxZoom: 15, duration: 800 });
}
}
}
map.on('load', function() {
styleLoaded = true;
if (pendingRouteData) {
drawRoute(pendingRouteData.coordinates, pendingRouteData.destination);
pendingRouteData = null;
}
window.ReactNativeWebView.postMessage(JSON.stringify({ type: 'ready' }));
});
</script>
</body>
</html>`;
}
const styles = StyleSheet.create({
map: { flex: 1 },
});
export default TomTomMap;
@@ -0,0 +1,266 @@
import React, { useEffect, useRef, useMemo } from "react";
import {
Modal,
View,
Text,
TouchableOpacity,
StyleSheet,
Animated,
} from "react-native";
import { Ionicons } from "@expo/vector-icons";
import { spacing, fontSize } from "../../theme";
import { useTheme } from "../../context/ThemeContext";
type AlertType = "success" | "error" | "confirm";
interface AlertModalProps {
visible: boolean;
type?: AlertType;
title: string;
message: string;
onClose: () => void;
onConfirm?: () => void;
confirmText?: string;
cancelText?: string;
}
export default function AlertModal({
visible,
type = "error",
title,
message,
onClose,
onConfirm,
confirmText = "Confirmer",
cancelText = "Annuler",
}: AlertModalProps) {
const { colors } = useTheme();
const scale = useRef(new Animated.Value(0.85)).current;
const opacity = useRef(new Animated.Value(0)).current;
const CONFIG: Record<
AlertType,
{
icon: keyof typeof Ionicons.glyphMap;
color: string;
barColor: string;
}
> = useMemo(
() => ({
success: {
icon: "checkmark-circle",
color: colors.success,
barColor: colors.success,
},
error: {
icon: "alert-circle",
color: colors.danger,
barColor: colors.danger,
},
confirm: {
icon: "help-circle",
color: colors.warning,
barColor: colors.warning,
},
}),
[colors],
);
const cfg = CONFIG[type];
useEffect(() => {
if (visible) {
Animated.parallel([
Animated.spring(scale, {
toValue: 1,
useNativeDriver: true,
tension: 65,
friction: 8,
}),
Animated.timing(opacity, {
toValue: 1,
duration: 200,
useNativeDriver: true,
}),
]).start();
} else {
scale.setValue(0.85);
opacity.setValue(0);
}
}, [visible]);
const styles = useMemo(
() =>
StyleSheet.create({
overlay: {
flex: 1,
backgroundColor: "rgba(0,0,0,0.85)",
justifyContent: "center",
alignItems: "center",
padding: spacing.xl,
},
content: {
backgroundColor: colors.bgSecondary,
borderRadius: 20,
width: "100%",
borderWidth: 1,
borderColor: "rgba(255,255,255,0.08)",
overflow: "hidden",
shadowColor: "#000",
shadowOffset: { width: 0, height: 0 },
shadowOpacity: 0.3,
shadowRadius: 30,
elevation: 20,
},
accentBar: {
height: 3,
width: "100%",
},
body: {
alignItems: "center",
paddingHorizontal: spacing.xl,
paddingTop: spacing.xl,
paddingBottom: spacing.xl,
},
iconCircle: {
width: 56,
height: 56,
borderRadius: 28,
justifyContent: "center",
alignItems: "center",
marginBottom: spacing.l,
},
title: {
color: colors.textWhite,
fontSize: fontSize.lg,
fontWeight: "700",
textAlign: "center",
marginBottom: spacing.s,
},
message: {
color: colors.textSecondary,
fontSize: fontSize.md,
textAlign: "center",
lineHeight: 20,
marginBottom: spacing.xl,
},
buttons: {
flexDirection: "row",
gap: spacing.m,
width: "100%",
justifyContent: "center",
},
btn: {
flex: 1,
paddingVertical: spacing.m,
borderRadius: 12,
alignItems: "center",
},
btnCancel: {
backgroundColor: "rgba(255,255,255,0.08)",
},
btnCancelText: {
color: colors.textSecondary,
fontSize: 15,
fontWeight: "600",
},
btnConfirm: {},
btnConfirmText: {
color: colors.textWhite,
fontSize: 15,
fontWeight: "700",
},
btnOk: {
flex: 0,
paddingHorizontal: spacing.xxl,
},
}),
[colors],
);
return (
<Modal
visible={visible}
transparent
animationType="fade"
onRequestClose={onClose}
>
<View style={styles.overlay}>
<Animated.View
style={[
styles.content,
{ transform: [{ scale }], opacity },
]}
>
<View
style={[
styles.accentBar,
{ backgroundColor: cfg.barColor },
]}
/>
<View style={styles.body}>
<View
style={[
styles.iconCircle,
{ backgroundColor: cfg.color + "20" },
]}
>
<Ionicons
name={cfg.icon}
size={32}
color={cfg.color}
/>
</View>
<Text style={styles.title}>{title}</Text>
<Text style={styles.message}>{message}</Text>
<View style={styles.buttons}>
{type === "confirm" ? (
<>
<TouchableOpacity
style={[styles.btn, styles.btnCancel]}
onPress={onClose}
activeOpacity={0.7}
>
<Text style={styles.btnCancelText}>
{cancelText}
</Text>
</TouchableOpacity>
<TouchableOpacity
style={[
styles.btn,
styles.btnConfirm,
{ backgroundColor: cfg.color },
]}
onPress={() => {
onConfirm?.();
onClose();
}}
activeOpacity={0.7}
>
<Text style={styles.btnConfirmText}>
{confirmText}
</Text>
</TouchableOpacity>
</>
) : (
<TouchableOpacity
style={[
styles.btn,
styles.btnOk,
{ backgroundColor: cfg.color },
]}
onPress={onClose}
activeOpacity={0.7}
>
<Text style={styles.btnConfirmText}>
OK
</Text>
</TouchableOpacity>
)}
</View>
</View>
</Animated.View>
</View>
</Modal>
);
}
@@ -0,0 +1,31 @@
import React from 'react';
import { View, Text, StyleSheet } from 'react-native';
import { spacing, borderRadius, fontSize, fontWeight } from '../../theme';
interface BadgeProps {
label: string;
color: string;
textColor?: string;
}
export default function Badge({ label, color, textColor = '#fff' }: BadgeProps) {
return (
<View style={[styles.badge, { backgroundColor: color + '22', borderColor: color }]}>
<Text style={[styles.text, { color }]}>{label}</Text>
</View>
);
}
const styles = StyleSheet.create({
badge: {
paddingHorizontal: spacing.m,
paddingVertical: spacing.xs,
borderRadius: borderRadius.xl,
borderWidth: 1,
alignSelf: 'flex-start',
},
text: {
fontSize: fontSize.xs,
fontWeight: fontWeight.semibold,
},
});
@@ -0,0 +1,111 @@
import React from "react";
import {
TouchableOpacity,
Text,
StyleSheet,
ActivityIndicator,
type ViewStyle,
type TextStyle,
} from "react-native";
import { spacing, borderRadius, fontSize, fontWeight } from "../../theme";
import { useTheme } from "../../context/ThemeContext";
interface ButtonProps {
title: string;
onPress: () => void;
variant?:
| "primary"
| "secondary"
| "danger"
| "success"
| "warning"
| "outline"
| "ghost";
size?: "sm" | "md" | "lg";
loading?: boolean;
disabled?: boolean;
style?: ViewStyle;
textStyle?: TextStyle;
fullWidth?: boolean;
}
export default function Button({
title,
onPress,
variant = "primary",
size = "md",
loading = false,
disabled = false,
style,
textStyle,
fullWidth = false,
}: ButtonProps) {
const { colors } = useTheme();
const bgColor = {
primary: colors.accent,
secondary: colors.bgCard,
danger: colors.danger,
success: colors.success,
warning: colors.warning,
outline: "transparent",
ghost: "transparent",
}[variant];
const txtColor = variant === "success" ? colors.black : colors.textWhite;
const borderColor = variant === "outline" ? colors.border : "transparent";
const paddingV = { sm: spacing.s, md: spacing.m, lg: spacing.l }[size];
const paddingH = { sm: spacing.m, md: spacing.xl, lg: spacing.xxl }[size];
const fSize = { sm: fontSize.sm, md: fontSize.md, lg: fontSize.lg }[size];
return (
<TouchableOpacity
onPress={onPress}
disabled={disabled || loading}
activeOpacity={0.7}
style={[
styles.base,
{
backgroundColor: bgColor,
borderColor,
paddingVertical: paddingV,
paddingHorizontal: paddingH,
opacity: disabled ? 0.5 : 1,
},
fullWidth && styles.fullWidth,
style,
]}
>
{loading ? (
<ActivityIndicator color={txtColor} size="small" />
) : (
<Text
style={[
styles.text,
{ color: txtColor, fontSize: fSize },
textStyle,
]}
>
{title}
</Text>
)}
</TouchableOpacity>
);
}
const styles = StyleSheet.create({
base: {
borderRadius: borderRadius.md,
alignItems: "center",
justifyContent: "center",
borderWidth: 1,
flexDirection: "row",
},
fullWidth: {
width: "100%",
},
text: {
fontWeight: fontWeight.semibold,
},
});
@@ -0,0 +1,29 @@
import React, { useMemo } from "react";
import { View, StyleSheet, type ViewStyle, type StyleProp } from "react-native";
import { spacing, borderRadius, shadows } from "../../theme";
import { useTheme } from "../../context/ThemeContext";
interface CardProps {
children: React.ReactNode;
style?: StyleProp<ViewStyle>;
}
export default function Card({ children, style }: CardProps) {
const { colors } = useTheme();
const styles = useMemo(
() =>
StyleSheet.create({
card: {
backgroundColor: colors.bgCard,
borderRadius: borderRadius.md,
padding: spacing.l,
borderWidth: 1,
borderColor: colors.borderLight,
},
}),
[colors],
);
return <View style={[styles.card, shadows.md, style]}>{children}</View>;
}
@@ -0,0 +1,42 @@
import React, { useMemo } from "react";
import { View, ActivityIndicator, Text, StyleSheet } from "react-native";
import { spacing, fontSize } from "../../theme";
import { useTheme } from "../../context/ThemeContext";
interface LoadingSpinnerProps {
message?: string;
size?: "small" | "large";
}
export default function LoadingSpinner({
message,
size = "large",
}: LoadingSpinnerProps) {
const { colors } = useTheme();
const styles = useMemo(
() =>
StyleSheet.create({
container: {
flex: 1,
justifyContent: "center",
alignItems: "center",
backgroundColor: colors.bgPrimary,
padding: spacing.xl,
},
text: {
color: colors.textSecondary,
fontSize: fontSize.md,
marginTop: spacing.l,
},
}),
[colors],
);
return (
<View style={styles.container}>
<ActivityIndicator size={size} color={colors.accent} />
{message && <Text style={styles.text}>{message}</Text>}
</View>
);
}
@@ -0,0 +1,198 @@
import React, { type ReactNode, useEffect, useRef, useMemo } from "react";
import {
Modal as RNModal,
View,
ScrollView,
KeyboardAvoidingView,
TouchableOpacity,
Text,
StyleSheet,
Animated,
Platform,
} from "react-native";
import { Ionicons } from "@expo/vector-icons";
import { spacing, borderRadius, fontSize } from "../../theme";
import { useTheme } from "../../context/ThemeContext";
interface ModalProps {
visible: boolean;
onClose: () => void;
title?: string;
children: ReactNode;
icon?: keyof typeof Ionicons.glyphMap;
iconColor?: string;
}
export default function Modal({
visible,
onClose,
title,
children,
icon,
iconColor,
}: ModalProps) {
const { colors } = useTheme();
const scale = useRef(new Animated.Value(0.85)).current;
const opacity = useRef(new Animated.Value(0)).current;
useEffect(() => {
if (visible) {
Animated.parallel([
Animated.spring(scale, {
toValue: 1,
useNativeDriver: true,
tension: 65,
friction: 8,
}),
Animated.timing(opacity, {
toValue: 1,
duration: 200,
useNativeDriver: true,
}),
]).start();
} else {
scale.setValue(0.85);
opacity.setValue(0);
}
}, [visible]);
const styles = useMemo(
() =>
StyleSheet.create({
overlay: {
flex: 1,
backgroundColor: "rgba(0,0,0,0.85)",
justifyContent: "center",
alignItems: "center",
padding: spacing.xl,
},
content: {
backgroundColor: colors.bgSecondary,
borderRadius: 20,
width: "100%",
maxHeight: "80%",
borderWidth: 1,
borderColor: "rgba(255,255,255,0.08)",
overflow: "hidden",
shadowColor: colors.accent,
shadowOffset: { width: 0, height: 0 },
shadowOpacity: 0.15,
shadowRadius: 30,
elevation: 20,
},
accentBar: {
height: 3,
backgroundColor: colors.accent,
width: "100%",
},
header: {
flexDirection: "row",
justifyContent: "space-between",
alignItems: "center",
paddingHorizontal: spacing.xl,
paddingTop: spacing.l,
paddingBottom: spacing.m,
},
titleRow: {
flexDirection: "row",
alignItems: "center",
gap: 10,
flex: 1,
},
iconCircle: {
width: 36,
height: 36,
borderRadius: 18,
backgroundColor: colors.accent + "20",
justifyContent: "center",
alignItems: "center",
},
title: {
color: colors.textWhite,
fontSize: fontSize.lg,
fontWeight: "700",
flex: 1,
},
closeBtn: {
width: 32,
height: 32,
borderRadius: 16,
backgroundColor: "rgba(255,255,255,0.06)",
justifyContent: "center",
alignItems: "center",
},
body: {
paddingHorizontal: spacing.xl,
},
}),
[colors],
);
return (
<RNModal
visible={visible}
transparent
animationType="fade"
onRequestClose={onClose}
>
<KeyboardAvoidingView
style={{ flex: 1 }}
behavior={Platform.OS === "ios" ? "padding" : "height"}
>
<View style={styles.overlay}>
<Animated.View
style={[
styles.content,
{ transform: [{ scale }], opacity },
]}
>
<View style={styles.accentBar} />
<View style={styles.header}>
<View style={styles.titleRow}>
{icon && (
<View
style={[
styles.iconCircle,
iconColor
? {
backgroundColor:
iconColor + "20",
}
: null,
]}
>
<Ionicons
name={icon}
size={20}
color={iconColor || colors.accent}
/>
</View>
)}
{title && <Text style={styles.title}>{title}</Text>}
</View>
<TouchableOpacity
onPress={onClose}
style={styles.closeBtn}
activeOpacity={0.7}
>
<Ionicons
name="close"
size={20}
color={colors.textMuted}
/>
</TouchableOpacity>
</View>
<ScrollView
style={styles.body}
contentContainerStyle={{ paddingBottom: spacing.xl }}
showsVerticalScrollIndicator={false}
keyboardShouldPersistTaps="handled"
>
{children}
</ScrollView>
</Animated.View>
</View>
</KeyboardAvoidingView>
</RNModal>
);
}
@@ -0,0 +1,90 @@
import React, { useMemo } from "react";
import {
View,
TextInput as RNTextInput,
Text,
StyleSheet,
type TextInputProps,
} from "react-native";
import { spacing, borderRadius, fontSize } from "../../theme";
import { useTheme } from "../../context/ThemeContext";
interface CustomTextInputProps extends TextInputProps {
label?: string;
error?: string;
icon?: React.ReactNode;
}
export default function TextInput({
label,
error,
icon,
style,
...props
}: CustomTextInputProps) {
const { colors } = useTheme();
const styles = useMemo(
() =>
StyleSheet.create({
container: {
marginBottom: spacing.l,
},
label: {
color: colors.textSecondary,
fontSize: fontSize.sm,
marginBottom: spacing.s,
},
inputWrapper: {
flexDirection: "row",
alignItems: "center",
backgroundColor: colors.bgInput,
borderRadius: borderRadius.md,
borderWidth: 1,
borderColor: colors.border,
},
inputError: {
borderColor: colors.danger,
},
icon: {
paddingLeft: spacing.m,
},
input: {
flex: 1,
color: colors.textPrimary,
fontSize: fontSize.md,
paddingVertical: spacing.m,
paddingHorizontal: spacing.l,
},
inputWithIcon: {
paddingLeft: spacing.s,
},
errorText: {
color: colors.danger,
fontSize: fontSize.xs,
marginTop: spacing.xs,
},
}),
[colors],
);
return (
<View style={styles.container}>
{label && <Text style={styles.label}>{label}</Text>}
<View style={[styles.inputWrapper, error && styles.inputError]}>
{icon && <View style={styles.icon}>{icon}</View>}
<RNTextInput
style={[
styles.input,
icon ? styles.inputWithIcon : undefined,
style,
]}
placeholderTextColor={colors.textMuted}
selectionColor={colors.accent}
{...props}
/>
</View>
{error && <Text style={styles.errorText}>{error}</Text>}
</View>
);
}
@@ -0,0 +1,108 @@
import React, { useEffect, useRef, useMemo } from "react";
import { Animated, Text, StyleSheet } from "react-native";
import { spacing, borderRadius, fontSize, fontWeight } from "../../theme";
import { useTheme } from "../../context/ThemeContext";
interface ToastProps {
message: string;
type: "success" | "error" | "warning" | "info";
visible: boolean;
onHide: () => void;
duration?: number;
}
export default function Toast({
message,
type,
visible,
onHide,
duration = 3000,
}: ToastProps) {
const { colors } = useTheme();
const opacity = useRef(new Animated.Value(0)).current;
const translateY = useRef(new Animated.Value(-50)).current;
const TYPE_COLORS = useMemo(
() => ({
success: colors.success,
error: colors.danger,
warning: colors.warning,
info: colors.info,
}),
[colors],
);
const styles = useMemo(
() =>
StyleSheet.create({
container: {
position: "absolute",
top: 60,
left: spacing.l,
right: spacing.l,
paddingVertical: spacing.m,
paddingHorizontal: spacing.l,
borderRadius: borderRadius.sm,
zIndex: 9999,
},
text: {
color: colors.black,
fontSize: fontSize.sm,
fontWeight: fontWeight.semibold,
textAlign: "center",
},
}),
[colors],
);
useEffect(() => {
if (visible) {
Animated.parallel([
Animated.timing(opacity, {
toValue: 1,
duration: 300,
useNativeDriver: true,
}),
Animated.timing(translateY, {
toValue: 0,
duration: 300,
useNativeDriver: true,
}),
]).start();
const timer = setTimeout(() => {
Animated.parallel([
Animated.timing(opacity, {
toValue: 0,
duration: 300,
useNativeDriver: true,
}),
Animated.timing(translateY, {
toValue: -50,
duration: 300,
useNativeDriver: true,
}),
]).start(() => onHide());
}, duration);
return () => clearTimeout(timer);
}
}, [visible]);
if (!visible) return null;
return (
<Animated.View
style={[
styles.container,
{
backgroundColor: TYPE_COLORS[type],
opacity,
transform: [{ translateY }],
},
]}
>
<Text style={styles.text}>{message}</Text>
</Animated.View>
);
}
@@ -0,0 +1,88 @@
import React, { createContext, useContext, useState, useEffect, useCallback, type ReactNode } from "react";
import { AppState } from "react-native";
import AsyncStorage from "@react-native-async-storage/async-storage";
import { darkColors, lightColors, type Colors } from "../theme/colors";
import { getSettings } from "../api/api_admin";
type ThemeMode = "dark" | "light";
interface ThemeContextType {
colors: Colors;
mode: ThemeMode;
toggleTheme: () => void;
isDark: boolean;
refreshColors: () => Promise<void>;
}
const STORAGE_KEY = "@theme_mode";
const COLORS_CACHE_KEY = "@admin_colors_cache";
const ThemeContext = createContext<ThemeContextType | undefined>(undefined);
export function ThemeProvider({ children }: { children: ReactNode }) {
const [mode, setMode] = useState<ThemeMode>("dark");
const [colorOverrides, setColorOverrides] = useState<Partial<Colors>>({});
const fetchAdminColors = useCallback(async () => {
try {
const result = await getSettings();
if (result.success && result.settings) {
const s = result.settings;
const overrides: Partial<Colors> = {
...(s.admin_color_primary && { accent: s.admin_color_primary }),
...(s.admin_color_secondary && { secondary: s.admin_color_secondary }),
...(s.admin_color_success && { success: s.admin_color_success }),
...(s.admin_color_danger && { danger: s.admin_color_danger }),
...(s.admin_color_warning && { warning: s.admin_color_warning }),
};
setColorOverrides(overrides);
AsyncStorage.setItem(COLORS_CACHE_KEY, JSON.stringify(overrides)).catch(() => {});
}
} catch {}
}, []);
useEffect(() => {
AsyncStorage.getItem(STORAGE_KEY).then((val) => {
if (val === "light" || val === "dark") setMode(val);
});
AsyncStorage.getItem(COLORS_CACHE_KEY).then((val) => {
if (val) {
try { setColorOverrides(JSON.parse(val)); } catch {}
}
});
fetchAdminColors();
}, [fetchAdminColors]);
useEffect(() => {
const sub = AppState.addEventListener("change", (state) => {
if (state === "active") fetchAdminColors();
});
return () => sub.remove();
}, [fetchAdminColors]);
const toggleTheme = () => {
const next = mode === "dark" ? "light" : "dark";
setMode(next);
AsyncStorage.setItem(STORAGE_KEY, next);
};
const baseColors = mode === "dark" ? darkColors : lightColors;
const colors: Colors = { ...baseColors, ...colorOverrides };
const value: ThemeContextType = {
colors,
mode,
toggleTheme,
isDark: mode === "dark",
refreshColors: fetchAdminColors,
};
return (
<ThemeContext.Provider value={value}>{children}</ThemeContext.Provider>
);
}
export function useTheme() {
const ctx = useContext(ThemeContext);
if (!ctx) throw new Error("useTheme must be used within ThemeProvider");
return ctx;
}
+57
View File
@@ -0,0 +1,57 @@
import { useState, useCallback } from "react";
interface AlertState {
visible: boolean;
type: "success" | "error" | "confirm";
title: string;
message: string;
onConfirm?: () => void;
confirmText?: string;
cancelText?: string;
}
const INITIAL: AlertState = {
visible: false,
type: "error",
title: "",
message: "",
};
export function useAlert() {
const [alert, setAlert] = useState<AlertState>(INITIAL);
const showError = useCallback((title: string, message: string) => {
setAlert({ visible: true, type: "error", title, message });
}, []);
const showSuccess = useCallback((title: string, message: string) => {
setAlert({ visible: true, type: "success", title, message });
}, []);
const showConfirm = useCallback(
(
title: string,
message: string,
onConfirm: () => void,
confirmText?: string,
cancelText?: string,
) => {
setAlert({
visible: true,
type: "confirm",
title,
message,
onConfirm,
confirmText,
cancelText,
});
},
[],
);
const hideAlert = useCallback(() => {
setAlert(INITIAL);
}, []);
return { alert, showError, showSuccess, showConfirm, hideAlert };
}
@@ -0,0 +1,433 @@
import React, { useState, useEffect, useRef, useCallback } from "react";
import {
TouchableOpacity,
View,
Modal,
Text,
ScrollView,
StyleSheet,
} from "react-native";
import { createNativeStackNavigator } from "@react-navigation/native-stack";
import { createBottomTabNavigator } from "@react-navigation/bottom-tabs";
import { useNavigation } from "@react-navigation/native";
import type { NativeStackNavigationProp } from "@react-navigation/native-stack";
import { Ionicons } from "@expo/vector-icons";
import { useAuth } from "../auth/AuthContext";
import { useTheme } from "../context/ThemeContext";
import {
logoutAdmin,
getAdminNotifications,
markAdminNotificationsRead,
} from "../api/api_admin";
import type { AppNotification } from "../api/api_admin";
import { fontSize, spacing } from "../theme";
import type { AdminTabParamList, AdminStackParamList } from "./types";
import DashboardScreen from "../screens/admin/DashboardScreen";
import StatsScreen from "../screens/admin/StatsScreen";
import OrdersScreen from "../screens/admin/OrdersScreen";
import OrderDetailScreen from "../screens/admin/OrderDetailScreen";
import UsersScreen from "../screens/admin/UsersScreen";
import ProductsScreen from "../screens/admin/ProductsScreen";
import CategoriesScreen from "../screens/admin/CategoriesScreen";
import DeliveryScreen from "../screens/admin/DeliveryScreen";
import AlertsScreen from "../screens/admin/AlertsScreen";
import AddressScreen from "../screens/admin/AddressScreen";
import SettingsScreen from "../screens/admin/SettingsScreen";
const Tab = createBottomTabNavigator<AdminTabParamList>();
const Stack = createNativeStackNavigator<AdminStackParamList>();
function AdminTabs() {
const { logout } = useAuth();
const { colors, isDark, toggleTheme } = useTheme();
const navigation = useNavigation<NativeStackNavigationProp<AdminStackParamList>>();
const [notifications, setNotifications] = useState<AppNotification[]>([]);
const [unreadCount, setUnreadCount] = useState(0);
const [modalVisible, setModalVisible] = useState(false);
const intervalRef = useRef<ReturnType<typeof setInterval> | null>(null);
const fetchNotifications = useCallback(async () => {
try {
const res = await getAdminNotifications();
setNotifications(res.notifications ?? []);
setUnreadCount(res.unread_count ?? 0);
} catch { /* ignore */ }
}, []);
useEffect(() => {
fetchNotifications();
intervalRef.current = setInterval(fetchNotifications, 15000);
return () => {
if (intervalRef.current) clearInterval(intervalRef.current);
};
}, [fetchNotifications]);
const openModal = async () => {
setModalVisible(true);
if (unreadCount > 0) {
try {
await markAdminNotificationsRead();
setUnreadCount(0);
setNotifications((prev) => prev.map((n) => ({ ...n, read: true })));
} catch { /* ignore */ }
}
};
const handleLogout = async () => {
await logoutAdmin();
await logout();
};
const formatTime = (iso: string) => {
const diff = Math.floor((Date.now() - new Date(iso).getTime()) / 60000);
if (diff < 1) return "à l'instant";
if (diff < 60) return `il y a ${diff} min`;
const h = Math.floor(diff / 60);
if (h < 24) return `il y a ${h}h`;
return `il y a ${Math.floor(h / 24)}j`;
};
return (
<>
<Tab.Navigator
screenOptions={{
headerStyle: { backgroundColor: isDark ? colors.secondary : "#ffffff" },
headerTintColor: colors.textWhite,
headerRight: () => (
<View
style={{
flexDirection: "row",
alignItems: "center",
marginRight: spacing.l,
}}
>
<TouchableOpacity
onPress={openModal}
style={{ marginRight: spacing.m }}
>
<View>
<Ionicons
name="notifications-outline"
size={22}
color={colors.textSecondary}
/>
{unreadCount > 0 && (
<View
style={[
styles.badge,
{ backgroundColor: colors.danger },
]}
>
<Text style={styles.badgeText}>
{unreadCount > 9 ? "9+" : unreadCount}
</Text>
</View>
)}
</View>
</TouchableOpacity>
<TouchableOpacity
onPress={toggleTheme}
style={{ marginRight: spacing.m }}
>
<Ionicons
name={isDark ? "sunny-outline" : "moon-outline"}
size={22}
color={colors.textSecondary}
/>
</TouchableOpacity>
<TouchableOpacity
onPress={() => navigation.navigate("Settings")}
style={{ marginRight: spacing.m }}
>
<Ionicons
name="settings-outline"
size={22}
color={colors.textSecondary}
/>
</TouchableOpacity>
<TouchableOpacity onPress={handleLogout}>
<Ionicons
name="log-out-outline"
size={24}
color={colors.textSecondary}
/>
</TouchableOpacity>
</View>
),
tabBarStyle: {
backgroundColor: isDark ? colors.secondary : "#ffffff",
borderTopColor: colors.border,
borderTopWidth: 1,
},
tabBarActiveTintColor: colors.accent,
tabBarInactiveTintColor: colors.textMuted,
tabBarLabelStyle: { fontSize: fontSize.xs },
}}
>
<Tab.Screen
name="Dashboard"
component={DashboardScreen}
options={{
title: "Dashboard",
tabBarIcon: ({ color, size }) => (
<Ionicons name="grid-outline" size={size} color={color} />
),
}}
/>
<Tab.Screen
name="Stats"
component={StatsScreen}
options={{
title: "Stats",
tabBarIcon: ({ color, size }) => (
<Ionicons name="bar-chart-outline" size={size} color={color} />
),
}}
/>
<Tab.Screen
name="Orders"
component={OrdersScreen}
options={{
title: "Commandes",
tabBarIcon: ({ color, size }) => (
<Ionicons name="receipt-outline" size={size} color={color} />
),
}}
/>
<Tab.Screen
name="Users"
component={UsersScreen}
options={{
title: "Clients",
tabBarIcon: ({ color, size }) => (
<Ionicons name="people-outline" size={size} color={color} />
),
}}
/>
<Tab.Screen
name="Products"
component={ProductsScreen}
options={{
title: "Produits",
tabBarIcon: ({ color, size }) => (
<Ionicons name="cube-outline" size={size} color={color} />
),
}}
/>
<Tab.Screen
name="Categories"
component={CategoriesScreen}
options={{
title: "Catégories",
tabBarIcon: ({ color, size }) => (
<Ionicons name="pricetag-outline" size={size} color={color} />
),
}}
/>
<Tab.Screen
name="Delivery"
component={DeliveryScreen}
options={{
title: "Livreurs",
tabBarIcon: ({ color, size }) => (
<Ionicons name="bicycle-outline" size={size} color={color} />
),
}}
/>
<Tab.Screen
name="Alerts"
component={AlertsScreen}
options={{
title: "Alertes",
tabBarIcon: ({ color, size }) => (
<Ionicons name="alert-circle-outline" size={size} color={color} />
),
}}
/>
<Tab.Screen
name="Addresses"
component={AddressScreen}
options={{
title: "Adresses",
tabBarIcon: ({ color, size }) => (
<Ionicons name="map-outline" size={size} color={color} />
),
}}
/>
</Tab.Navigator>
<Modal
visible={modalVisible}
transparent
animationType="slide"
onRequestClose={() => setModalVisible(false)}
>
<View style={styles.modalOverlay}>
<View
style={[
styles.modalContainer,
{ backgroundColor: colors.bgSecondary },
]}
>
<View style={styles.modalHeader}>
<Text
style={[styles.modalTitle, { color: colors.textWhite }]}
>
Notifications
</Text>
<TouchableOpacity onPress={() => setModalVisible(false)}>
<Ionicons
name="close"
size={24}
color={colors.textSecondary}
/>
</TouchableOpacity>
</View>
<ScrollView style={styles.notifList}>
{notifications.length === 0 ? (
<Text
style={[
styles.emptyText,
{ color: colors.textMuted },
]}
>
Aucune notification
</Text>
) : (
notifications.map((n, i) => (
<View
key={i}
style={[
styles.notifItem,
{
borderLeftColor: n.read
? colors.border
: colors.accent,
backgroundColor: n.read
? "transparent"
: colors.bgPrimary ?? colors.bgSecondary,
},
]}
>
<Text
style={[
styles.notifMessage,
{ color: colors.textWhite },
]}
>
{n.message}
</Text>
<Text
style={[
styles.notifTime,
{ color: colors.textMuted },
]}
>
{formatTime(n.created_at)}
</Text>
</View>
))
)}
</ScrollView>
</View>
</View>
</Modal>
</>
);
}
export default function AdminNavigator() {
const { colors, isDark } = useTheme();
return (
<Stack.Navigator
screenOptions={{
headerStyle: { backgroundColor: isDark ? colors.secondary : "#ffffff" },
headerTintColor: colors.textWhite,
}}
>
<Stack.Screen
name="AdminTabs"
component={AdminTabs}
options={{ headerShown: false }}
/>
<Stack.Screen
name="OrderDetail"
component={OrderDetailScreen}
options={{ title: "Détail commande" }}
/>
<Stack.Screen
name="Settings"
component={SettingsScreen}
options={{ title: "Paramètres" }}
/>
</Stack.Navigator>
);
}
const styles = StyleSheet.create({
badge: {
position: "absolute",
top: -4,
right: -6,
minWidth: 16,
height: 16,
borderRadius: 8,
alignItems: "center",
justifyContent: "center",
paddingHorizontal: 2,
},
badgeText: {
color: "#fff",
fontSize: 9,
fontWeight: "bold",
},
modalOverlay: {
flex: 1,
backgroundColor: "rgba(0,0,0,0.5)",
justifyContent: "flex-end",
},
modalContainer: {
borderTopLeftRadius: 16,
borderTopRightRadius: 16,
maxHeight: "70%",
paddingBottom: 32,
},
modalHeader: {
flexDirection: "row",
justifyContent: "space-between",
alignItems: "center",
padding: 16,
borderBottomWidth: 1,
borderBottomColor: "rgba(255,255,255,0.1)",
},
modalTitle: {
fontSize: 18,
fontWeight: "bold",
},
notifList: {
padding: 12,
},
notifItem: {
borderLeftWidth: 3,
paddingLeft: 12,
paddingVertical: 10,
marginBottom: 8,
borderRadius: 4,
paddingRight: 8,
},
notifMessage: {
fontSize: 14,
},
notifTime: {
fontSize: 12,
marginTop: 4,
},
emptyText: {
textAlign: "center",
marginTop: 32,
fontSize: 14,
},
});
@@ -0,0 +1,399 @@
import React, { useState, useEffect, useRef, useCallback } from "react";
import {
TouchableOpacity,
View,
Modal,
Text,
ScrollView,
StyleSheet,
} from "react-native";
import { createBottomTabNavigator } from "@react-navigation/bottom-tabs";
import { Ionicons } from "@expo/vector-icons";
import { useAuth } from "../auth/AuthContext";
import { useTheme } from "../context/ThemeContext";
import { logoutAdmin } from "../api/api_admin";
import {
getCabineNotifications,
markCabineNotificationsRead,
} from "../api/api_cabine";
import type { AppNotification } from "../api/api_cabine";
import { fontSize, spacing } from "../theme";
import type { CabineTabParamList } from "./types";
import DashboardScreen from "../screens/cabine/DashboardScreen";
import OrdersScreen from "../screens/cabine/OrdersScreen";
import DeliveryScreen from "../screens/cabine/DeliveryScreen";
import UsersScreen from "../screens/cabine/UsersScreen";
import AlertsScreen from "../screens/cabine/AlertsScreen";
import AddressScreen from "../screens/cabine/AddressScreen";
const Tab = createBottomTabNavigator<CabineTabParamList>();
export default function CabineNavigator() {
const { logout } = useAuth();
const { colors, isDark, toggleTheme } = useTheme();
const [notifications, setNotifications] = useState<AppNotification[]>([]);
const [unreadCount, setUnreadCount] = useState(0);
const [modalVisible, setModalVisible] = useState(false);
const intervalRef = useRef<ReturnType<typeof setInterval> | null>(null);
const fetchNotifications = useCallback(async () => {
try {
const res = await getCabineNotifications();
setNotifications(res.notifications ?? []);
setUnreadCount(res.unread_count ?? 0);
} catch {
/* ignore */
}
}, []);
useEffect(() => {
fetchNotifications();
intervalRef.current = setInterval(fetchNotifications, 15000);
return () => {
if (intervalRef.current) clearInterval(intervalRef.current);
};
}, [fetchNotifications]);
const openModal = async () => {
setModalVisible(true);
if (unreadCount > 0) {
try {
await markCabineNotificationsRead();
setUnreadCount(0);
setNotifications((prev) =>
prev.map((n) => ({ ...n, read: true })),
);
} catch {
/* ignore */
}
}
};
const handleLogout = async () => {
await logoutAdmin();
await logout();
};
const formatTime = (iso: string) => {
const diff = Math.floor((Date.now() - new Date(iso).getTime()) / 60000);
if (diff < 1) return "à l'instant";
if (diff < 60) return `il y a ${diff} min`;
const h = Math.floor(diff / 60);
if (h < 24) return `il y a ${h}h`;
return `il y a ${Math.floor(h / 24)}j`;
};
return (
<>
<Tab.Navigator
screenOptions={{
headerStyle: { backgroundColor: colors.bgSecondary },
headerTintColor: colors.textWhite,
headerRight: () => (
<View
style={{
flexDirection: "row",
alignItems: "center",
marginRight: spacing.l,
}}
>
<TouchableOpacity
onPress={openModal}
style={{ marginRight: spacing.m }}
>
<View>
<Ionicons
name="notifications-outline"
size={22}
color={colors.textSecondary}
/>
{unreadCount > 0 && (
<View
style={[
styles.badge,
{
backgroundColor:
colors.danger,
},
]}
>
<Text style={styles.badgeText}>
{unreadCount > 9
? "9+"
: unreadCount}
</Text>
</View>
)}
</View>
</TouchableOpacity>
<TouchableOpacity
onPress={toggleTheme}
style={{ marginRight: spacing.m }}
>
<Ionicons
name={
isDark
? "sunny-outline"
: "moon-outline"
}
size={22}
color={colors.textSecondary}
/>
</TouchableOpacity>
<TouchableOpacity onPress={handleLogout}>
<Ionicons
name="log-out-outline"
size={24}
color={colors.textSecondary}
/>
</TouchableOpacity>
</View>
),
tabBarStyle: {
backgroundColor: colors.bgSecondary,
borderTopColor: colors.border,
borderTopWidth: 1,
},
tabBarActiveTintColor: colors.info,
tabBarInactiveTintColor: colors.textMuted,
tabBarLabelStyle: { fontSize: fontSize.xs },
}}
>
<Tab.Screen
name="Dashboard"
component={DashboardScreen}
options={{
title: "Dashboard",
tabBarIcon: ({ color, size }) => (
<Ionicons
name="grid-outline"
size={size}
color={color}
/>
),
}}
/>
<Tab.Screen
name="Orders"
component={OrdersScreen}
options={{
title: "Commandes",
tabBarIcon: ({ color, size }) => (
<Ionicons
name="receipt-outline"
size={size}
color={color}
/>
),
}}
/>
<Tab.Screen
name="Delivery"
component={DeliveryScreen}
options={{
title: "Livreurs",
tabBarIcon: ({ color, size }) => (
<Ionicons
name="bicycle-outline"
size={size}
color={color}
/>
),
}}
/>
<Tab.Screen
name="Users"
component={UsersScreen}
options={{
title: "Clients",
tabBarIcon: ({ color, size }) => (
<Ionicons
name="people-outline"
size={size}
color={color}
/>
),
}}
/>
<Tab.Screen
name="Alerts"
component={AlertsScreen}
options={{
title: "Alertes",
tabBarIcon: ({ color, size }) => (
<Ionicons
name="alert-circle-outline"
size={size}
color={color}
/>
),
}}
/>
<Tab.Screen
name="Adresses"
component={AddressScreen}
options={{
title: "Adresses",
tabBarIcon: ({ color, size }) => (
<Ionicons
name="map-outline"
size={size}
color={color}
/>
),
}}
/>
</Tab.Navigator>
<Modal
visible={modalVisible}
transparent
animationType="slide"
onRequestClose={() => setModalVisible(false)}
>
<View style={styles.modalOverlay}>
<View
style={[
styles.modalContainer,
{ backgroundColor: colors.bgSecondary },
]}
>
<View style={styles.modalHeader}>
<Text
style={[
styles.modalTitle,
{ color: colors.textWhite },
]}
>
Notifications
</Text>
<TouchableOpacity
onPress={() => setModalVisible(false)}
>
<Ionicons
name="close"
size={24}
color={colors.textSecondary}
/>
</TouchableOpacity>
</View>
<ScrollView style={styles.notifList}>
{notifications.length === 0 ? (
<Text
style={[
styles.emptyText,
{ color: colors.textMuted },
]}
>
Aucune notification
</Text>
) : (
notifications.map((n, i) => (
<View
key={i}
style={[
styles.notifItem,
{
borderLeftColor: n.read
? colors.border
: colors.info,
backgroundColor: n.read
? "transparent"
: (colors.bgPrimary ??
colors.bgSecondary),
},
]}
>
<Text
style={[
styles.notifMessage,
{ color: colors.textWhite },
]}
>
{n.message}
</Text>
<Text
style={[
styles.notifTime,
{ color: colors.textMuted },
]}
>
{formatTime(n.created_at)}
</Text>
</View>
))
)}
</ScrollView>
</View>
</View>
</Modal>
</>
);
}
const styles = StyleSheet.create({
badge: {
position: "absolute",
top: -4,
right: -6,
minWidth: 16,
height: 16,
borderRadius: 8,
alignItems: "center",
justifyContent: "center",
paddingHorizontal: 2,
},
badgeText: {
color: "#fff",
fontSize: 9,
fontWeight: "bold",
},
modalOverlay: {
flex: 1,
backgroundColor: "rgba(0,0,0,0.5)",
justifyContent: "flex-end",
},
modalContainer: {
borderTopLeftRadius: 16,
borderTopRightRadius: 16,
maxHeight: "70%",
paddingBottom: 32,
},
modalHeader: {
flexDirection: "row",
justifyContent: "space-between",
alignItems: "center",
padding: 16,
borderBottomWidth: 1,
borderBottomColor: "rgba(255,255,255,0.1)",
},
modalTitle: {
fontSize: 18,
fontWeight: "bold",
},
notifList: {
padding: 12,
},
notifItem: {
borderLeftWidth: 3,
paddingLeft: 12,
paddingVertical: 10,
marginBottom: 8,
borderRadius: 4,
paddingRight: 8,
},
notifMessage: {
fontSize: 14,
},
notifTime: {
fontSize: 12,
marginTop: 4,
},
emptyText: {
textAlign: "center",
marginTop: 32,
fontSize: 14,
},
});
@@ -0,0 +1,365 @@
import React, { useState, useEffect, useRef, useCallback } from "react";
import {
TouchableOpacity,
View,
Text,
Modal,
ScrollView,
StyleSheet,
} from "react-native";
import { createBottomTabNavigator } from "@react-navigation/bottom-tabs";
import { Ionicons } from "@expo/vector-icons";
import { useAuth } from "../auth/AuthContext";
import { useTheme } from "../context/ThemeContext";
import { logoutAdmin } from "../api/api_admin";
import {
getLivreurNotifications,
markLivreurNotificationsRead,
} from "../api/api_delivery";
import type { LivreurNotification } from "../api/api_delivery";
import { fontSize, spacing } from "../theme";
import type { DeliveryTabParamList } from "./types";
import DashboardScreen from "../screens/delivery/DashboardScreen";
import StatsScreen from "../screens/delivery/StatsScreen";
import AlertsScreen from "../screens/delivery/AlertsScreen";
import RatingsScreen from "../screens/delivery/RatingsScreen";
const Tab = createBottomTabNavigator<DeliveryTabParamList>();
function formatNotifTime(dateStr: string): string {
try {
const diffMs = Date.now() - new Date(dateStr).getTime();
const diffMin = Math.floor(diffMs / 60000);
if (diffMin < 1) return "À l'instant";
if (diffMin < 60) return `Il y a ${diffMin} min`;
const diffH = Math.floor(diffMin / 60);
if (diffH < 24) return `Il y a ${diffH}h`;
return `Il y a ${Math.floor(diffH / 24)} jours`;
} catch {
return "";
}
}
export default function DeliveryNavigator() {
const { logout } = useAuth();
const { colors, isDark, toggleTheme } = useTheme();
const [notifications, setNotifications] = useState<LivreurNotification[]>([]);
const [unreadCount, setUnreadCount] = useState(0);
const [showModal, setShowModal] = useState(false);
const seenIdsRef = useRef<Set<string>>(new Set());
const isFirstLoad = useRef(true);
const fetchNotifications = useCallback(async () => {
const res = await getLivreurNotifications();
if (!res.success || !res.notifications) return;
setNotifications(res.notifications);
if (!isFirstLoad.current) {
let newUnread = 0;
for (const n of res.notifications) {
if (!n.read) {
const key = `${n.command_id}-${n.type}-${n.created_at}`;
if (!seenIdsRef.current.has(key)) {
seenIdsRef.current.add(key);
newUnread++;
}
}
}
if (newUnread > 0) {
setUnreadCount((prev) => prev + newUnread);
}
} else {
for (const n of res.notifications) {
const key = `${n.command_id}-${n.type}-${n.created_at}`;
seenIdsRef.current.add(key);
}
setUnreadCount(res.unread_count ?? 0);
isFirstLoad.current = false;
}
}, []);
useEffect(() => {
fetchNotifications();
const interval = setInterval(fetchNotifications, 15000);
return () => clearInterval(interval);
}, [fetchNotifications]);
const openModal = async () => {
setShowModal(true);
if (unreadCount > 0) {
await markLivreurNotificationsRead();
setUnreadCount(0);
setNotifications((prev) => prev.map((n) => ({ ...n, read: true })));
}
};
const handleLogout = async () => {
await logoutAdmin();
await logout();
};
return (
<>
<Tab.Navigator
screenOptions={{
headerStyle: { backgroundColor: colors.bgSecondary },
headerTintColor: colors.textWhite,
headerRight: () => (
<View
style={{
flexDirection: "row",
alignItems: "center",
marginRight: spacing.l,
gap: spacing.m,
}}
>
{/* Cloche notifications */}
<TouchableOpacity
onPress={openModal}
style={{ position: "relative" }}
>
<Ionicons
name="notifications-outline"
size={24}
color={colors.textSecondary}
/>
{unreadCount > 0 && (
<View style={styles.badge}>
<Text style={styles.badgeText}>
{unreadCount > 9 ? "9+" : unreadCount}
</Text>
</View>
)}
</TouchableOpacity>
<TouchableOpacity onPress={toggleTheme}>
<Ionicons
name={isDark ? "sunny-outline" : "moon-outline"}
size={22}
color={colors.textSecondary}
/>
</TouchableOpacity>
<TouchableOpacity onPress={handleLogout}>
<Ionicons
name="log-out-outline"
size={24}
color={colors.textSecondary}
/>
</TouchableOpacity>
</View>
),
tabBarStyle: {
backgroundColor: colors.bgSecondary,
borderTopColor: colors.border,
borderTopWidth: 1,
},
tabBarActiveTintColor: colors.success,
tabBarInactiveTintColor: colors.textMuted,
tabBarLabelStyle: { fontSize: fontSize.xs },
}}
>
<Tab.Screen
name="Dashboard"
component={DashboardScreen}
options={{
title: "Livraisons",
tabBarIcon: ({ color, size }) => (
<Ionicons
name="navigate-outline"
size={size}
color={color}
/>
),
}}
/>
<Tab.Screen
name="Stats"
component={StatsScreen}
options={{
title: "Stats",
tabBarIcon: ({ color, size }) => (
<Ionicons
name="stats-chart-outline"
size={size}
color={color}
/>
),
}}
/>
<Tab.Screen
name="Alerts"
component={AlertsScreen}
options={{
title: "Alertes",
tabBarIcon: ({ color, size }) => (
<Ionicons
name="alert-circle-outline"
size={size}
color={color}
/>
),
}}
/>
<Tab.Screen
name="Ratings"
component={RatingsScreen}
options={{
title: "Mes avis",
tabBarIcon: ({ color, size }) => (
<Ionicons
name="star-outline"
size={size}
color={color}
/>
),
}}
/>
</Tab.Navigator>
{/* Modal notifications */}
<Modal
visible={showModal}
animationType="slide"
transparent
onRequestClose={() => setShowModal(false)}
>
<View style={styles.overlay}>
<View
style={[
styles.modalContent,
{ backgroundColor: colors.bgSecondary },
]}
>
<View style={styles.modalHeader}>
<Text
style={[styles.modalTitle, { color: colors.textWhite }]}
>
Notifications
</Text>
<TouchableOpacity onPress={() => setShowModal(false)}>
<Ionicons
name="close"
size={24}
color={colors.textSecondary}
/>
</TouchableOpacity>
</View>
<ScrollView style={styles.notifList}>
{notifications.length === 0 ? (
<Text
style={[
styles.emptyText,
{ color: colors.textMuted },
]}
>
Aucune notification
</Text>
) : (
notifications.map((n, i) => (
<View
key={i}
style={[
styles.notifItem,
{
borderLeftColor: n.read
? colors.border
: colors.success,
backgroundColor: n.read
? "transparent"
: colors.bgPrimary ?? colors.bgSecondary,
},
]}
>
<Text
style={[
styles.notifMessage,
{ color: colors.textWhite },
]}
>
{n.message}
</Text>
<Text
style={[
styles.notifTime,
{ color: colors.textMuted },
]}
>
{formatNotifTime(n.created_at)}
</Text>
</View>
))
)}
</ScrollView>
</View>
</View>
</Modal>
</>
);
}
const styles = StyleSheet.create({
badge: {
position: "absolute",
top: -4,
right: -4,
backgroundColor: "#ef4444",
borderRadius: 10,
minWidth: 18,
height: 18,
justifyContent: "center",
alignItems: "center",
},
badgeText: {
color: "#fff",
fontSize: 10,
fontWeight: "bold",
},
overlay: {
flex: 1,
backgroundColor: "rgba(0,0,0,0.5)",
justifyContent: "flex-end",
},
modalContent: {
borderTopLeftRadius: 16,
borderTopRightRadius: 16,
maxHeight: "70%",
paddingBottom: 32,
},
modalHeader: {
flexDirection: "row",
justifyContent: "space-between",
alignItems: "center",
padding: 16,
borderBottomWidth: 1,
borderBottomColor: "rgba(255,255,255,0.1)",
},
modalTitle: {
fontSize: 18,
fontWeight: "bold",
},
notifList: {
padding: 12,
},
emptyText: {
textAlign: "center",
marginTop: 32,
fontSize: 14,
},
notifItem: {
borderLeftWidth: 3,
paddingLeft: 12,
paddingVertical: 10,
marginBottom: 8,
borderRadius: 4,
paddingRight: 8,
},
notifMessage: {
fontSize: 14,
},
notifTime: {
fontSize: 12,
marginTop: 4,
},
});
@@ -0,0 +1,41 @@
export type AuthStackParamList = {
ServerConfig: undefined;
RoleSelect: undefined;
AdminLogin: undefined;
CabineLogin: undefined;
DeliveryLogin: undefined;
};
export type AdminTabParamList = {
Dashboard: undefined;
Stats: undefined;
Orders: undefined;
Users: undefined;
Products: undefined;
Categories: undefined;
Delivery: undefined;
Alerts: undefined;
Addresses: undefined;
};
export type AdminStackParamList = {
AdminTabs: undefined;
OrderDetail: { orderId: number };
Settings: undefined;
};
export type CabineTabParamList = {
Dashboard: undefined;
Orders: undefined;
Delivery: undefined;
Users: undefined;
Alerts: undefined;
Adresses: undefined;
};
export type DeliveryTabParamList = {
Dashboard: undefined;
Stats: undefined;
Alerts: undefined;
Ratings: undefined;
};
@@ -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>
);
}
+96
View File
@@ -0,0 +1,96 @@
export const darkColors = {
// Backgrounds
bgPrimary: "#0a0a0a",
bgSecondary: "#1a1a1a",
bgCard: "#1e1e1e",
bgInput: "#2a2a2a",
bgModal: "rgba(0,0,0,0.95)",
// Text
textPrimary: "rgba(255, 255, 255, 0.87)",
textSecondary: "rgba(255, 255, 255, 0.6)",
textMuted: "rgba(255, 255, 255, 0.4)",
textWhite: "#ffffff",
// Accent
accent: "#7c3aed",
accentDark: "#6d28d9",
accentLight: "#8b5cf6",
secondary: "#22d3ee",
// Status
success: "#4ade80",
successDark: "#22c55e",
successDarker: "#16a34a",
danger: "#ef4444",
dangerDark: "#dc2626",
warning: "#f59e0b",
info: "#3b82f6",
// Category colors
categoryWeedHash: "#10b981",
categoryTous: "#9333ea",
categoryZipette: "#f5f5f0",
categoryGros: "#3dc2f7",
// Borders
border: "#333333",
borderLight: "#222222",
borderSubtle: "rgba(255, 255, 255, 0.1)",
// Misc
overlay: "rgba(0, 0, 0, 0.5)",
transparent: "transparent",
white: "#ffffff",
black: "#000000",
} as const;
export const lightColors: Colors = {
// Backgrounds
bgPrimary: "#f2f2f7",
bgSecondary: "#ffffff",
bgCard: "#ffffff",
bgInput: "#e5e5ea",
bgModal: "rgba(255,255,255,0.95)",
// Text
textPrimary: "rgba(0, 0, 0, 0.87)",
textSecondary: "rgba(0, 0, 0, 0.6)",
textMuted: "rgba(0, 0, 0, 0.4)",
textWhite: "#000000",
// Accent
accent: "#7c3aed",
accentDark: "#6d28d9",
accentLight: "#8b5cf6",
secondary: "#0891b2",
// Status
success: "#16a34a",
successDark: "#15803d",
successDarker: "#166534",
danger: "#dc2626",
dangerDark: "#b91c1c",
warning: "#d97706",
info: "#2563eb",
// Category colors
categoryWeedHash: "#10b981",
categoryTous: "#9333ea",
categoryZipette: "#1a1a1a",
categoryGros: "#0ea5e9",
// Borders
border: "#d1d5db",
borderLight: "#e5e7eb",
borderSubtle: "rgba(0, 0, 0, 0.08)",
// Misc
overlay: "rgba(0, 0, 0, 0.3)",
transparent: "transparent",
white: "#ffffff",
black: "#000000",
};
export type Colors = { [K in keyof typeof darkColors]: string };
export type ColorName = keyof Colors;
+5
View File
@@ -0,0 +1,5 @@
export { darkColors, lightColors } from "./colors";
export type { Colors } from "./colors";
export { spacing, borderRadius } from "./spacing";
export { fontSize, fontWeight, fontFamily } from "./typography";
export { shadows } from "./shadows";
+40
View File
@@ -0,0 +1,40 @@
import { Platform } from 'react-native';
export const shadows = {
sm: Platform.select({
ios: {
shadowColor: '#000',
shadowOffset: { width: 0, height: 1 },
shadowOpacity: 0.2,
shadowRadius: 2,
},
android: {
elevation: 2,
},
default: {},
}),
md: Platform.select({
ios: {
shadowColor: '#000',
shadowOffset: { width: 0, height: 2 },
shadowOpacity: 0.3,
shadowRadius: 4,
},
android: {
elevation: 4,
},
default: {},
}),
lg: Platform.select({
ios: {
shadowColor: '#000',
shadowOffset: { width: 0, height: 4 },
shadowOpacity: 0.4,
shadowRadius: 8,
},
android: {
elevation: 8,
},
default: {},
}),
} as const;
+17
View File
@@ -0,0 +1,17 @@
export const spacing = {
xs: 4,
s: 8,
m: 12,
l: 16,
xl: 24,
xxl: 32,
xxxl: 48,
} as const;
export const borderRadius = {
sm: 8,
md: 12,
lg: 16,
xl: 25,
full: 9999,
} as const;
@@ -0,0 +1,24 @@
import { Platform } from 'react-native';
export const fontFamily = Platform.select({
ios: 'System',
android: 'Roboto',
default: 'System',
});
export const fontSize = {
xs: 11,
sm: 13,
md: 15,
lg: 17,
xl: 20,
xxl: 28,
title: 34,
} as const;
export const fontWeight = {
regular: '400' as const,
medium: '500' as const,
semibold: '600' as const,
bold: '700' as const,
};
@@ -0,0 +1,30 @@
import type { Colors } from "../theme/colors";
export const STATUS_LABELS: Record<string, string> = {
pending: "En attente",
assigned: "Assignée",
en_route: "En route",
arrived: "Arrivée",
livre: "Livrée",
delivered: "Livré",
approved: "Terminée",
cancelled: "Annulé",
available: "Disponible",
busy: "Occupé",
offline: "Hors ligne",
};
export const getStatusColors = (colors: Colors): Record<string, string> => ({
pending: colors.warning,
assigned: colors.info,
en_route: colors.info,
arrived: colors.accent,
livre: colors.success,
delivered: colors.success,
approved: colors.successDark,
cancelled: colors.danger,
available: colors.success,
busy: colors.warning,
offline: colors.textMuted,
});
@@ -0,0 +1,36 @@
const PROTOCOL_RE = /^https?:\/\//i;
// Hostname label: alnum, may contain hyphens but not at the edges.
const HOST_RE =
/^([a-zA-Z0-9]([a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?)(\.[a-zA-Z0-9]([a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?)*$/;
const isValidPort = (port: string) => {
if (!/^\d{1,5}$/.test(port)) return false;
const n = Number(port);
return n >= 1 && n <= 65535;
};
// Accepts either a full URL ("https://exemple.com", "http://exemple.com:8080")
// or a bare host/IP with a port ("192.168.1.10:8000", "exemple.com:3000").
// Returns a normalized absolute base URL, or null if the input is invalid.
export function normalizeServerUrl(raw: string): string | null {
const trimmed = raw.trim();
if (!trimmed) return null;
const hasProtocol = PROTOCOL_RE.test(trimmed);
const withoutProtocol = trimmed.replace(PROTOCOL_RE, "");
const withoutTrailingSlash = withoutProtocol.replace(/\/+$/, "");
const parts = withoutTrailingSlash.split(":");
if (parts.length > 2) return null; // unsupported (e.g. raw IPv6)
const [host, port] = parts;
if (!host || !HOST_RE.test(host)) return null;
if (port !== undefined && !isValidPort(port)) return null;
const protocol = hasProtocol
? trimmed.match(PROTOCOL_RE)![0].toLowerCase()
: "http://";
return `${protocol}${withoutTrailingSlash}`;
}
+6
View File
@@ -0,0 +1,6 @@
{
"extends": "expo/tsconfig.base",
"compilerOptions": {
"strict": true
}
}