diff --git a/frontend-admin/app.json b/frontend-admin/app.json index 800bf641..3dbf50ae 100644 --- a/frontend-admin/app.json +++ b/frontend-admin/app.json @@ -36,6 +36,15 @@ "plugins": [ "expo-font", "expo-location", + [ + "expo-notifications", + { + "icon": "./assets/icon.png", + "color": "#ffffff", + "androidMode": "default", + "androidCollapsedTitle": "#{unread_notifications} nouvelles commandes" + } + ], [ "expo-build-properties", { diff --git a/frontend-admin/src/api/api_admin.ts b/frontend-admin/src/api/api_admin.ts index cefcae2f..aae2d563 100644 --- a/frontend-admin/src/api/api_admin.ts +++ b/frontend-admin/src/api/api_admin.ts @@ -471,6 +471,44 @@ export const deleteClientAdmin = async (clientId: number) => { return { success: true, message: data.message }; }; +// ============================================ +// PARRAINAGE ADMIN +// ============================================ + +export const creditClientReferral = async ( + username: string, + amount: number, +): Promise<{ success: boolean; balance?: number; message?: string }> => { + try { + const { data } = await apiClient.post( + `${V2}/admin/protected/client/${username}/referral/credit`, + { amount }, + ); + return { success: true, balance: data.balance, message: data.message }; + } catch (error: any) { + return { + success: false, + message: error.response?.data?.error || "Erreur crédit parrainage", + }; + } +}; + +export const getClientReferralAdmin = async ( + username: string, +): Promise<{ success: boolean; balance?: number; message?: string }> => { + try { + const { data } = await apiClient.get( + `${V2}/admin/protected/client/${username}/referral`, + ); + return { success: true, balance: data.balance }; + } catch (error: any) { + return { + success: false, + message: error.response?.data?.error || "Erreur récupération", + }; + } +}; + // ============================================ // ALERTES // ============================================ @@ -676,6 +714,7 @@ export interface AppSettings { points_separated: boolean; points_weed_tiers: PointsTier[]; points_zipette_tiers: PointsTier[]; + referral_enabled: boolean; } export const getSettings = async (): Promise<{ success: boolean; settings?: AppSettings; error?: string }> => { @@ -687,6 +726,35 @@ export const getSettings = async (): Promise<{ success: boolean; settings?: AppS } }; +export const registerAdminPushToken = async (pushToken: string): Promise => { + try { + await apiClient.post(`${V2}/admin/protected/push-token`, { push_token: pushToken }); + } catch { /* ignore */ } +}; + +export const unregisterAdminPushToken = async (): Promise => { + try { + await apiClient.delete(`${V2}/admin/protected/push-token`); + } catch { /* ignore */ } +}; + +export interface AppNotification { + command_id: number; + type: string; + message: string; + created_at: string; + read: boolean; +} + +export const getAdminNotifications = async (): Promise<{ notifications: AppNotification[]; unread_count: number }> => { + const { data } = await apiClient.get(`${V2}/admin/protected/notifications`); + return data; +}; + +export const markAdminNotificationsRead = async (): Promise => { + await apiClient.post(`${V2}/admin/protected/notifications/read`); +}; + export const updateSettings = async ( settings: AppSettings, ): Promise<{ success: boolean; settings?: AppSettings; error?: string }> => { diff --git a/frontend-admin/src/api/api_cabine.ts b/frontend-admin/src/api/api_cabine.ts index f95c1736..fefb9f8c 100644 --- a/frontend-admin/src/api/api_cabine.ts +++ b/frontend-admin/src/api/api_cabine.ts @@ -351,6 +351,35 @@ export interface PublicSettings { points_separated: boolean; } +export const registerCabinePushToken = async (pushToken: string): Promise => { + try { + await apiClient.post(`${API}/push-token`, { push_token: pushToken }); + } catch { /* ignore */ } +}; + +export const unregisterCabinePushToken = async (): Promise => { + try { + await apiClient.delete(`${API}/push-token`); + } catch { /* ignore */ } +}; + +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 => { + await apiClient.post(`${API}/notifications/read`); +}; + export const getPublicSettings = async (): Promise => { try { const { data } = await apiClient.get(`http://5.181.0.112/api/v1/app-settings`); diff --git a/frontend-admin/src/api/types.ts b/frontend-admin/src/api/types.ts index 03cf1542..eae55d34 100644 --- a/frontend-admin/src/api/types.ts +++ b/frontend-admin/src/api/types.ts @@ -31,6 +31,7 @@ export interface ClientResponse { amende: number; cancellations_count: number; last_penalty_reason?: string; + referral_balance?: number; } export interface CommandResponse { diff --git a/frontend-admin/src/navigation/AdminNavigator.tsx b/frontend-admin/src/navigation/AdminNavigator.tsx index dd70f2cc..233b91b1 100644 --- a/frontend-admin/src/navigation/AdminNavigator.tsx +++ b/frontend-admin/src/navigation/AdminNavigator.tsx @@ -1,5 +1,12 @@ -import React from "react"; -import { TouchableOpacity, View } from "react-native"; +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"; @@ -7,7 +14,12 @@ 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 } from "../api/api_admin"; +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"; @@ -30,176 +42,288 @@ function AdminTabs() { const { colors, isDark, toggleTheme } = useTheme(); const navigation = useNavigation>(); + const [notifications, setNotifications] = useState([]); + const [unreadCount, setUnreadCount] = useState(0); + const [modalVisible, setModalVisible] = useState(false); + const intervalRef = useRef | 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 ( - ( + <> + ( + + + + + {unreadCount > 0 && ( + + + {unreadCount > 9 ? "9+" : unreadCount} + + + )} + + + + + + navigation.navigate("Settings")} + style={{ marginRight: spacing.m }} + > + + + + + + + ), + tabBarStyle: { + backgroundColor: colors.bgSecondary, + borderTopColor: colors.border, + borderTopWidth: 1, + }, + tabBarActiveTintColor: colors.accent, + tabBarInactiveTintColor: colors.textMuted, + tabBarLabelStyle: { fontSize: fontSize.xs }, + }} + > + ( + + ), + }} + /> + ( + + ), + }} + /> + ( + + ), + }} + /> + ( + + ), + }} + /> + ( + + ), + }} + /> + ( + + ), + }} + /> + ( + + ), + }} + /> + ( + + ), + }} + /> + + + setModalVisible(false)} + > + - - - - navigation.navigate("Settings")} - style={{ marginRight: spacing.m }} - > - - - - - + + + Notifications + + setModalVisible(false)}> + + + + + {notifications.length === 0 ? ( + + Aucune notification + + ) : ( + notifications.map((n, i) => ( + + + {n.message} + + + {formatTime(n.created_at)} + + + )) + )} + - ), - tabBarStyle: { - backgroundColor: colors.bgSecondary, - borderTopColor: colors.border, - borderTopWidth: 1, - }, - tabBarActiveTintColor: colors.accent, - tabBarInactiveTintColor: colors.textMuted, - tabBarLabelStyle: { fontSize: fontSize.xs }, - }} - > - ( - - ), - }} - /> - ( - - ), - }} - /> - ( - - ), - }} - /> - ( - - ), - }} - /> - ( - - ), - }} - /> - ( - - ), - }} - /> - ( - - ), - }} - /> - ( - - ), - }} - /> - + + + ); } @@ -231,3 +355,68 @@ export default function AdminNavigator() { ); } + +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, + }, +}); diff --git a/frontend-admin/src/navigation/CabineNavigator.tsx b/frontend-admin/src/navigation/CabineNavigator.tsx index 3777133d..79665660 100644 --- a/frontend-admin/src/navigation/CabineNavigator.tsx +++ b/frontend-admin/src/navigation/CabineNavigator.tsx @@ -1,10 +1,22 @@ -import React from "react"; -import { TouchableOpacity, View } from "react-native"; +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"; @@ -20,123 +32,298 @@ export default function CabineNavigator() { const { logout } = useAuth(); const { colors, isDark, toggleTheme } = useTheme(); + const [notifications, setNotifications] = useState([]); + const [unreadCount, setUnreadCount] = useState(0); + const [modalVisible, setModalVisible] = useState(false); + const intervalRef = useRef | 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 ( - ( - - + ( + - - - - - + + + + {unreadCount > 0 && ( + + + {unreadCount > 9 ? "9+" : unreadCount} + + + )} + + + + + + + + + + ), + tabBarStyle: { + backgroundColor: colors.bgSecondary, + borderTopColor: colors.border, + borderTopWidth: 1, + }, + tabBarActiveTintColor: colors.info, + tabBarInactiveTintColor: colors.textMuted, + tabBarLabelStyle: { fontSize: fontSize.xs }, + }} + > + ( + + ), + }} + /> + ( + + ), + }} + /> + ( + + ), + }} + /> + ( + + ), + }} + /> + ( + + ), + }} + /> + + + setModalVisible(false)} + > + + + + + Notifications + + setModalVisible(false)}> + + + + + {notifications.length === 0 ? ( + + Aucune notification + + ) : ( + notifications.map((n, i) => ( + + + {n.message} + + + {formatTime(n.created_at)} + + + )) + )} + - ), - tabBarStyle: { - backgroundColor: colors.bgSecondary, - borderTopColor: colors.border, - borderTopWidth: 1, - }, - tabBarActiveTintColor: colors.info, - tabBarInactiveTintColor: colors.textMuted, - tabBarLabelStyle: { fontSize: fontSize.xs }, - }} - > - ( - - ), - }} - /> - ( - - ), - }} - /> - ( - - ), - }} - /> - ( - - ), - }} - /> - ( - - ), - }} - /> - + + + ); } + +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, + }, +}); diff --git a/frontend-admin/src/screens/admin/DashboardScreen.tsx b/frontend-admin/src/screens/admin/DashboardScreen.tsx index c390075b..0e769cf9 100644 --- a/frontend-admin/src/screens/admin/DashboardScreen.tsx +++ b/frontend-admin/src/screens/admin/DashboardScreen.tsx @@ -16,7 +16,10 @@ import { getAllClients, getAvailableDeliveryPersons, getCommandCountByStatus, + registerAdminPushToken, + unregisterAdminPushToken, } from "../../api/api_admin"; +import { getExpoPushToken } from "../../utils/pushTokenUtils"; import LoadingSpinner from "../../components/ui/LoadingSpinner"; interface StatCard { @@ -33,6 +36,14 @@ export default function DashboardScreen() { const [loading, setLoading] = useState(true); const [refreshing, setRefreshing] = useState(false); + // Enregistrement push token admin au montage + useEffect(() => { + getExpoPushToken().then((token) => { + if (token) registerAdminPushToken(token); + }); + return () => { unregisterAdminPushToken(); }; + }, []); + const loadStats = useCallback(async () => { try { const [allCmd, pending, enRoute, completed, clients, livreurs] = diff --git a/frontend-admin/src/screens/admin/SettingsScreen.tsx b/frontend-admin/src/screens/admin/SettingsScreen.tsx index b9563532..f4dc669e 100644 --- a/frontend-admin/src/screens/admin/SettingsScreen.tsx +++ b/frontend-admin/src/screens/admin/SettingsScreen.tsx @@ -149,6 +149,7 @@ export default function SettingsScreen() { points_separated: true, points_weed_tiers: [], points_zipette_tiers: [], + referral_enabled: true, }); const [categories, setCategories] = useState([]); @@ -350,6 +351,28 @@ export default function SettingsScreen() { + {/* Parrainage */} + + Parrainage + + + Parrainage activé + + Les clients peuvent voir et utiliser leur solde parrainage au checkout.{"\n"} + Désactivé : le solde reste en base mais ne peut plus être utilisé. + + + + setSettings((prev) => ({ ...prev, referral_enabled: v })) + } + trackColor={{ false: colors.border, true: colors.accent }} + thumbColor="#fff" + /> + + + {/* Système de points */} Système de points diff --git a/frontend-admin/src/screens/admin/UsersScreen.tsx b/frontend-admin/src/screens/admin/UsersScreen.tsx index b9473afb..84522fb9 100644 --- a/frontend-admin/src/screens/admin/UsersScreen.tsx +++ b/frontend-admin/src/screens/admin/UsersScreen.tsx @@ -19,6 +19,7 @@ import { deleteClientAdmin, createClientByAdmin, createUserByAdmin, + creditClientReferral, } from "../../api/api_admin"; import type { ClientResponse } from "../../api/types"; import LoadingSpinner from "../../components/ui/LoadingSpinner"; @@ -155,6 +156,15 @@ export default function UsersScreen() { const [createTel, setCreateTel] = useState(""); const [creating, setCreating] = useState(false); + // Referral credit modal + const [referralModal, setReferralModal] = useState<{ + visible: boolean; + username: string; + currentBalance: number; + }>({ visible: false, username: "", currentBalance: 0 }); + const [referralAmount, setReferralAmount] = useState(""); + const [referralLoading, setReferralLoading] = useState(false); + const { alert, showError, showConfirm, hideAlert } = useAlert(); // -------------------------------------------------- @@ -318,6 +328,40 @@ export default function UsersScreen() { ); }; + // -------------------------------------------------- + // Referral credit + // -------------------------------------------------- + const openReferralModal = (client: ClientResponse) => { + setReferralAmount(""); + setReferralModal({ + visible: true, + username: client.username, + currentBalance: client.referral_balance ?? 0, + }); + }; + + const handleCreditReferral = async () => { + const amount = parseFloat(referralAmount); + if (isNaN(amount) || amount <= 0) { + showError("Erreur", "Entrez un montant valide"); + return; + } + setReferralLoading(true); + try { + const res = await creditClientReferral(referralModal.username, amount); + if (res.success) { + setReferralModal((prev) => ({ ...prev, visible: false })); + await loadData(); + } else { + showError("Erreur", res.message || "Echec du crédit"); + } + } catch (e: any) { + showError("Erreur", e.message); + } finally { + setReferralLoading(false); + } + }; + // -------------------------------------------------- // Create user // -------------------------------------------------- @@ -603,16 +647,28 @@ export default function UsersScreen() { {isClient && ( - openEditClient(item.clientData!)} - > - - + <> + openReferralModal(item.clientData!)} + > + + + openEditClient(item.clientData!)} + > + + + )} {(item.role === "livreur" || item.role === "cabine") && ( @@ -704,6 +760,19 @@ export default function UsersScreen() { Annul. + {(item.clientData.referral_balance ?? 0) > 0 && ( + + + {(item.clientData.referral_balance ?? 0).toFixed(0)}€ + + Parrain + + )} )} @@ -977,6 +1046,35 @@ export default function UsersScreen() { confirmText={alert.confirmText} cancelText={alert.cancelText} /> + + {/* Modal crédit parrainage */} + setReferralModal((prev) => ({ ...prev, visible: false }))} + title={`Crédit parrainage — ${referralModal.username}`} + icon="gift-outline" + iconColor={colors.success} + > + + Solde actuel : {referralModal.currentBalance.toFixed(2)} € + + } + /> +