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