This commit is contained in:
@@ -0,0 +1,9 @@
|
|||||||
|
node_modules
|
||||||
|
.expo/
|
||||||
|
dist
|
||||||
|
build
|
||||||
|
*.log
|
||||||
|
|
||||||
|
# code signing
|
||||||
|
*.pem
|
||||||
|
!certs/certificate.pem
|
||||||
@@ -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"} />;
|
||||||
|
}
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
module.exports = ({ config }) => {
|
||||||
|
const updateUrl = process.env.EXPO_PUBLIC_UPDATE_URL;
|
||||||
|
|
||||||
|
return {
|
||||||
|
...config,
|
||||||
|
updates: {
|
||||||
|
...config.updates,
|
||||||
|
...(updateUrl ? { url: updateUrl } : {}),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
};
|
||||||
@@ -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 |
@@ -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-----
|
||||||
@@ -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"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
import { registerRootComponent } from 'expo';
|
||||||
|
import App from './App';
|
||||||
|
|
||||||
|
registerRootComponent(App);
|
||||||
Generated
+8791
File diff suppressed because it is too large
Load Diff
@@ -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
@@ -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 */
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -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" };
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -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;
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
@@ -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";
|
||||||
@@ -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;
|
||||||
@@ -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}`;
|
||||||
|
}
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
{
|
||||||
|
"extends": "expo/tsconfig.base",
|
||||||
|
"compilerOptions": {
|
||||||
|
"strict": true
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user