chore: build

This commit is contained in:
2026-06-14 18:09:44 +02:00
parent 3a0f725159
commit 6d4e0862ff
45 changed files with 3745 additions and 873 deletions
+123 -31
View File
@@ -1,4 +1,5 @@
import apiClient, { API_BASE_URL } from "./client";
import apiClient from "./client";
import { API_BASE_URL } from "./client";
import type {
AuthResponse,
ClientResponse,
@@ -52,6 +53,72 @@ export const logoutAdmin = async (): Promise<void> => {
}
};
export interface StatsSummary {
total_orders: number;
total_revenue: number;
peak_weekday: string;
top_product: string;
avg_per_day: number;
}
export interface WeekdayStat {
weekday: string;
count: number;
}
export interface DayStat {
day: string;
label: string;
count: number;
}
export interface DayRevenueStat {
day: string;
label: string;
revenue: number;
}
export interface HourStat {
hour: number;
label: string;
count: number;
revenue: number;
}
export interface ProductStat {
product_id: number;
name: string;
quantity: number;
order_count: number;
revenue: number;
category: string;
category_color: string;
}
export interface QuantityStat {
quantity: number;
order_count: number;
total_sold: number;
revenue: number;
}
export interface ProductQuantityBreakdown {
product_id: number;
name: string;
category_color: string;
total_orders: number;
quantities: QuantityStat[];
}
export interface AdminStats {
summary: StatsSummary;
by_weekday: WeekdayStat[];
by_day_30: DayStat[];
by_day_revenue: DayRevenueStat[];
by_hour: HourStat[];
top_products: ProductStat[];
by_quantity: ProductQuantityBreakdown[];
}
export const getAdminStats = async (): Promise<AdminStats> => {
const { data } = await apiClient.get(`${V2}/admin/protected/stats`);
return data;
};
export const getAllClients = async (): Promise<ClientResponse[]> => {
const { data } = await apiClient.get(`${V2}/admin/protected/all/clients`);
return data.clients || [];
@@ -84,9 +151,6 @@ export const updateUserByAdmin = async (
return { success: true, message: data.message, user: data.user };
};
// ============================================
// COMMANDES
export const getAllCommands = async (status?: string, username?: string) => {
let url = `${V2}/admin/protected/orders`;
const params: string[] = [];
@@ -171,8 +235,11 @@ export const updateCommandAddress = async (
export const validateCommand = async (commandId: number) => {
const { data } = await apiClient.post(
`${V2}/admin/protected/orders/${commandId}/force-validate`,
{ command_id: commandId },
);
return { success: true, message: data.message };
const validated = (data.validated ?? []) as { command_id: number; points_awarded: number }[];
const points = validated.find((v) => v.command_id === commandId)?.points_awarded ?? 0;
return { success: true, points_awarded: points, validated_count: data.validated_count ?? 0 };
};
export const proposeAddressChangeAdmin = async (
@@ -197,31 +264,22 @@ export const notifyClientToDescend = async (commandId: number) => {
};
};
export const confirmReceptionAdmin = async (commandId: number) => {
const { data } = await apiClient.post(
`${V2}/admin/protected/orders/${commandId}/confirm-reception`,
);
return {
success: true,
message: data.message,
points_earned: data.points_earned,
client_username: data.client_username,
};
};
// ============================================
// LIVREURS
// ============================================
export const getAvailableDeliveryPersons = async () => {
const { data } = await apiClient.get(
`${V2}/admin/protected/delivery-persons`,
);
return {
success: true,
livreurs: data.livreurs || [],
count: data.count || 0,
};
try {
const { data } = await apiClient.get(
`${V2}/admin/protected/delivery-persons`,
);
return {
success: true,
livreurs: data.livreurs || [],
count: data.count || 0,
};
} catch {
return { success: false, livreurs: [], count: 0 };
}
};
export const assignDeliveryPerson = async (
@@ -370,10 +428,6 @@ export const getDeliverymanLocationForCommand = async (commandId: number) => {
}
};
// ============================================
// PRODUITS
// ============================================
export const getAllProductsAdmin = async (): Promise<{
success: boolean;
data: Product[];
@@ -445,7 +499,7 @@ export const createUserByAdmin = async (data: {
role: string;
}) => {
const { data: res } = await apiClient.post(
`${V2}/admin/auth/register`,
`${V2}/admin/protected/users`,
data,
);
return {
@@ -710,6 +764,20 @@ export const deleteProductMediaAdmin = async (
return { success: true, message: data.message };
};
export const activateProductPrice = async (priceId: number) => {
const { data } = await apiClient.post(
`${V2}/admin/protected/active/product/price/${priceId}`,
);
return { success: true, message: data.message };
};
export const deactivateProductPrice = async (priceId: number) => {
const { data } = await apiClient.post(
`${V2}/admin/protected/desactive/product/price/${priceId}`,
);
return { success: true, message: data.message };
};
// ============================================
// ADDRESSES
// ============================================
@@ -849,6 +917,26 @@ export interface PointsTier {
points: number;
}
export interface RewardCategoryConfig {
category: string;
all_products: boolean;
product_ids: number[];
}
export interface RewardItem {
product_id: number;
quantity: number;
price: number;
}
export interface PointsReward {
threshold: number;
type: "free_product" | "half_price_product" | "custom";
description: string;
category_configs: RewardCategoryConfig[];
reward_items: RewardItem[];
}
export interface PointsPool {
key: string;
name: string;
@@ -947,6 +1035,7 @@ export interface AppSettings {
penalty_tiers: PenaltyTier[];
points_enabled: boolean;
points_pools: PointsPool[];
points_reward?: PointsReward | null;
referral_enabled: boolean;
delivery_schedule: DeliverySchedule;
postal_zones: PostalZone[];
@@ -958,7 +1047,10 @@ export interface AppSettings {
telegram_bot_token: string;
telegram_bot_username: string;
telegram_notifications_enabled: boolean;
telegram_2fa_enabled: boolean;
delivery_mode: DeliveryModeConfig;
shop_name: string;
contact_telegram: string;
}
export const getSettings = async (): Promise<{
+37 -13
View File
@@ -1,4 +1,7 @@
import apiClient, { API_BASE_URL } from "./client";
import apiClient from "./client";
import { API_BASE_URL } from "./client";
//@ts
import type {
OrderItem,
DeliveryPerson,
@@ -39,10 +42,6 @@ export const confirmReceptionCabine = async (commandId: number) => {
};
};
// ============================================
// PENALITES
// ============================================
export const applyClientPenalty = async (
clientUsername: string,
reason: string,
@@ -70,7 +69,6 @@ export const resetClientPenalties = async (clientUsername: string) => {
return { success: true, message: data.message };
};
// pool: index dans pool_names/pool_keys, -1 = reset tous les points
export const resetClientPoints = async (
clientUsername: string,
pool: number = -1,
@@ -96,10 +94,6 @@ export const getPenaltiesStats = async () => {
return { success: true, data: data.data };
};
// ============================================
// COMMANDES
// ============================================
export const getCancelledOrders = async () => {
const { data } = await apiClient.get(`${API}/commands/cancelled`);
return {
@@ -200,7 +194,7 @@ export const getAllDeliveryPersonsWithDetails = async (): Promise<{
users.map(async (u: any): Promise<DeliveryPerson> => {
try {
const { data: details } = await apiClient.get(
`${V2}/admin/protected/delivery-persons/${u.username}`,
`${API}/delivery-persons/${u.username}`,
);
const d = details.deliveryman || details;
const parsedStatus = parseStatus(d.status);
@@ -373,7 +367,7 @@ export const markCabineNotificationsRead = async (): Promise<void> => {
export const getPublicSettings = async (): Promise<PublicSettings> => {
try {
const { data } = await apiClient.get(
`https://5.181.0.112.nip.io/api/v1/app-settings`,
`${API_BASE_URL}/api/v1/app-settings`,
);
return {
penalties_enabled: data.penalties_enabled ?? true,
@@ -433,11 +427,41 @@ export const getAllAddresses = async (): Promise<
}));
};
// ============================================
// 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 = "https://5.181.0.112.nip.io/api/v1/cabine";
const CABINE_API = `${API_BASE_URL}/api/v1/cabine`;
export const getCabineTelegramStatus = async (): Promise<{
linked: boolean;
+24 -13
View File
@@ -1,4 +1,5 @@
import apiClient, { API_BASE_URL } from "./client";
import apiClient from "./client";
import { API_BASE_URL } from "./client";
import type {
DeliveryStatus,
QueueInfo,
@@ -9,10 +10,6 @@ import type {
const API = `${API_BASE_URL}/api/v1/livreur`;
// ============================================
// STATUT
// ============================================
export const getMyStatus = async (): Promise<{
success: boolean;
status?: DeliveryStatus;
@@ -45,10 +42,6 @@ export const updateMyStatus = async (
}
};
// ============================================
// QUEUE
// ============================================
export const getMyQueue = async (): Promise<{
success: boolean;
queue_info?: QueueInfo;
@@ -65,10 +58,6 @@ export const getMyQueue = async (): Promise<{
}
};
// ============================================
// LIVRAISONS
// ============================================
export const getMyDeliveries = async (): Promise<{
success: boolean;
deliveries?: DeliveryItem[];
@@ -383,6 +372,28 @@ export const ISSUE_LABELS: Record<IssueType, string> = {
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[];
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 || [],
};
} catch (error: any) {
return { success: false, error: error.response?.data?.error || "Erreur réseau" };
}
};
export const reportDeliveryIssue = async (
deliveryId: number,
issueType: IssueType,
-2
View File
@@ -12,7 +12,6 @@ const apiClient = axios.create({
},
});
// Request interceptor: attach JWT token
apiClient.interceptors.request.use(async (config) => {
const isAdminRoute =
config.url?.includes("/api/v2/") ||
@@ -27,7 +26,6 @@ apiClient.interceptors.request.use(async (config) => {
return config;
});
// Response interceptor: handle common errors
apiClient.interceptors.response.use(
(response) => response,
(error) => {
-5
View File
@@ -2,7 +2,6 @@ import axios from "axios";
const TOMTOM_API_KEY = "MERY8I7LMeYVSLKO5WuV73W9rKJpBLoB";
// ---- Types ----
export interface LatLng {
latitude: number;
longitude: number;
@@ -24,7 +23,6 @@ export interface NavigationInstruction {
isActive: boolean;
}
// ---- Maneuver translations (FR) ----
export const maneuverTranslations: Record<string, string> = {
TURN_LEFT: "Tournez à gauche",
TURN_RIGHT: "Tournez à droite",
@@ -48,7 +46,6 @@ export const maneuverTranslations: Record<string, string> = {
WAYPOINT_REACHED: "Point de passage atteint",
};
// Ionicons name per maneuver
export const maneuverIcons: Record<string, string> = {
TURN_LEFT: "arrow-back",
TURN_RIGHT: "arrow-forward",
@@ -79,7 +76,6 @@ function formatDistance(meters: number): string {
return `${(meters / 1000).toFixed(1)} km`;
}
// ---- Geocode address → coords ----
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`;
@@ -96,7 +92,6 @@ export async function geocodeAddress(address: string): Promise<LatLng | null> {
return null;
}
// ---- Calculate route ----
export async function calculateRoute(
origin: LatLng,
destination: LatLng,
+2 -1
View File
@@ -102,8 +102,9 @@ export interface Product {
category: string;
stock: number;
unit: string;
prices?: Array<{ quantity: number; price: number }>;
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;
}
@@ -24,6 +24,7 @@ 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";
@@ -175,6 +176,16 @@ function AdminTabs() {
),
}}
/>
<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}
+1
View File
@@ -7,6 +7,7 @@ export type AuthStackParamList = {
export type AdminTabParamList = {
Dashboard: undefined;
Stats: undefined;
Orders: undefined;
Users: undefined;
Products: undefined;
@@ -6,7 +6,6 @@ import {
ScrollView,
TouchableOpacity,
Modal,
StatusBar,
Alert,
useWindowDimensions,
} from "react-native";
@@ -16,7 +15,7 @@ import {
type RouteProp,
} from "@react-navigation/native";
import { Ionicons } from "@expo/vector-icons";
import MapView, { Marker, Polyline, PROVIDER_DEFAULT } from "react-native-maps";
import TomTomMap, { type TomTomMapRef, type TomTomMarker } from "../../components/TomTomMap";
import { spacing, fontSize, borderRadius } from "../../theme";
import { useTheme } from "../../context/ThemeContext";
import {
@@ -24,13 +23,12 @@ import {
getCommandItems,
updateCommandStatus,
validateCommand,
confirmReceptionAdmin,
notifyClientToDescend,
getDeliveryPersonDetails,
deleteCommandItem,
deleteCommand,
} from "../../api/api_admin";
import { geocodeAddress, calculateRoute } from "../../api/tomtom";
import { calculateRoute, geocodeAddress } from "../../api/tomtom";
import type { RouteInfo, LatLng } from "../../api/tomtom";
import type { AdminStackParamList } from "../../navigation/types";
import StatusBadge from "../../components/StatusBadge";
@@ -55,11 +53,12 @@ export default function OrderDetailScreen() {
const [showItemsModal, setShowItemsModal] = useState(false);
// Map / tracking
const mapRef = useRef<MapView | null>(null);
const fullscreenMapRef = useRef<MapView | null>(null);
const mapRef = useRef<TomTomMapRef | null>(null);
const fullscreenMapRef = useRef<TomTomMapRef | null>(null);
const [mapFullscreen, setMapFullscreen] = useState(false);
const [livreurCoords, setLivreurCoords] = useState<LatLng | null>(null);
const [destCoords, setDestCoords] = useState<LatLng | null>(null);
const [livreurMarkers, setLivreurMarkers] = useState<TomTomMarker[]>([]);
const [routeInfo, setRouteInfo] = useState<RouteInfo | null>(null);
const [mapLoading, setMapLoading] = useState(false);
const { alert, showError, showSuccess, hideAlert } = useAlert();
@@ -72,7 +71,7 @@ export default function OrderDetailScreen() {
getCommandItems(orderId),
]);
setCommand(cmdRes.command);
setItems(itemsRes.items);
setItems(itemsRes.items ?? []);
// If livreur assigned, fetch their location and calc route
const cmd = cmdRes.command;
@@ -99,13 +98,21 @@ export default function OrderDetailScreen() {
return () => clearInterval(interval);
}, [orderId]);
// Rejoue la route sur la carte fullscreen quand elle s'ouvre
useEffect(() => {
if (mapFullscreen && livreurCoords && destCoords) {
setTimeout(() => {
fullscreenMapRef.current?.calcRoute(livreurCoords, destCoords);
}, 600);
}
}, [mapFullscreen]);
const loadLivreurRoute = async (
livreurUsername: string,
deliveryAddress: string,
) => {
setMapLoading(true);
try {
// Get livreur location
const details = await getDeliveryPersonDetails(livreurUsername);
const loc = details?.location;
if (!loc?.latitude || !loc?.longitude) {
@@ -113,24 +120,26 @@ export default function OrderDetailScreen() {
return;
}
const origin: LatLng = {
const origin: LatLng = { latitude: loc.latitude, longitude: loc.longitude };
setLivreurCoords(origin);
setLivreurMarkers([{
id: livreurUsername,
latitude: loc.latitude,
longitude: loc.longitude,
};
setLivreurCoords(origin);
color: "#22c55e",
label: livreurUsername,
description: "Livreur",
}]);
// Geocode destination
// Dessine la route sur la carte et récupère les infos (distance/durée)
const dest = await geocodeAddress(deliveryAddress);
if (!dest) {
setMapLoading(false);
return;
}
setDestCoords(dest);
// Calculate route
const result = await calculateRoute(origin, dest);
if (result) {
setRouteInfo(result.route);
if (dest) {
setDestCoords(dest);
const result = await calculateRoute(origin, dest);
if (result) {
setRouteInfo(result.route);
mapRef.current?.calcRoute(origin, dest);
}
}
} catch {
/* silent */
@@ -138,27 +147,6 @@ export default function OrderDetailScreen() {
setMapLoading(false);
};
const fitMapToRoute = (ref: React.RefObject<MapView | null>) => {
if (ref.current && livreurCoords && destCoords) {
ref.current.fitToCoordinates(
[
{
latitude: livreurCoords.latitude,
longitude: livreurCoords.longitude,
},
{
latitude: destCoords.latitude,
longitude: destCoords.longitude,
},
],
{
edgePadding: { top: 80, right: 60, bottom: 80, left: 60 },
animated: true,
},
);
}
};
const handleValidate = async () => {
try {
await validateCommand(orderId);
@@ -205,7 +193,7 @@ export default function OrderDetailScreen() {
try {
await deleteCommandItem(orderId, itemId);
const itemsRes = await getCommandItems(orderId);
setItems(itemsRes.items);
setItems(itemsRes.items ?? []);
const cmdRes = await getCommandByID(orderId);
setCommand(cmdRes.command);
} catch (e: any) {
@@ -217,20 +205,6 @@ export default function OrderDetailScreen() {
);
};
const handleConfirmReception = async () => {
try {
const res = await confirmReceptionAdmin(orderId);
showSuccess(
"Réception confirmée",
`${res.points_earned} point(s) attribués au client ${res.client_username}`,
);
const updated = await getCommandByID(orderId);
setCommand(updated.command);
} catch (e: any) {
showError("Erreur", e.message);
}
};
const handleDeleteCommand = () => {
Alert.alert(
"Supprimer la commande",
@@ -316,39 +290,6 @@ export default function OrderDetailScreen() {
},
map: { width: "100%", height: MAP_HEIGHT },
driverMarkerOuter: {
width: 34,
height: 34,
borderRadius: 17,
backgroundColor: colors.success + "40",
justifyContent: "center",
alignItems: "center",
},
driverMarkerInner: {
width: 26,
height: 26,
borderRadius: 13,
backgroundColor: colors.success,
justifyContent: "center",
alignItems: "center",
},
destMarkerOuter: {
width: 30,
height: 30,
borderRadius: 15,
backgroundColor: colors.danger + "40",
justifyContent: "center",
alignItems: "center",
},
destMarkerInner: {
width: 22,
height: 22,
borderRadius: 11,
backgroundColor: colors.danger,
justifyContent: "center",
alignItems: "center",
},
routeOverlay: {
position: "absolute",
top: spacing.s,
@@ -409,6 +350,81 @@ export default function OrderDetailScreen() {
marginTop: spacing.xs,
},
// Grouped items
subItemRow: {
flexDirection: "row",
justifyContent: "space-between",
alignItems: "center",
paddingVertical: spacing.xs,
paddingLeft: spacing.m,
borderLeftWidth: 2,
borderLeftColor: colors.border,
marginLeft: spacing.xs,
marginBottom: 2,
},
subItemText: {
color: colors.textSecondary,
fontSize: fontSize.sm,
flex: 1,
},
groupTotalRow: {
flexDirection: "row",
justifyContent: "space-between",
alignItems: "center",
marginTop: spacing.s,
paddingTop: spacing.s,
borderTopWidth: 1,
borderTopColor: colors.border,
},
groupTotalQty: {
color: colors.textWhite,
fontSize: fontSize.sm,
fontWeight: "700",
},
groupTotalPrice: {
color: colors.accent,
fontSize: fontSize.md,
fontWeight: "700",
},
categoryBadge: {
fontSize: fontSize.xs,
color: colors.accent,
fontWeight: "600",
marginRight: spacing.s,
textTransform: "uppercase",
},
// Category summary
categorySummaryCard: {
marginTop: spacing.s,
},
categoryRow: {
flexDirection: "row",
justifyContent: "space-between",
alignItems: "center",
paddingVertical: spacing.s,
borderBottomWidth: 1,
borderBottomColor: colors.border,
},
categoryName: {
color: colors.textSecondary,
fontSize: fontSize.sm,
fontWeight: "600",
flex: 1,
},
categoryQty: {
color: colors.textWhite,
fontSize: fontSize.sm,
fontWeight: "700",
marginRight: spacing.l,
},
categoryTotal: {
color: colors.accent,
fontSize: fontSize.sm,
fontWeight: "700",
minWidth: 70,
textAlign: "right",
},
// Items modal
itemsModalOverlay: {
flex: 1,
@@ -476,6 +492,30 @@ export default function OrderDetailScreen() {
[colors, MAP_HEIGHT],
);
// Groupement des items par product_id
const productGroups = (items ?? []).reduce<Record<string, any[]>>(
(acc, item) => {
const key = String(item.product_id || item.produit);
if (!acc[key]) acc[key] = [];
acc[key].push(item);
return acc;
},
{},
);
const groupedList = Object.values(productGroups);
// Récapitulatif par catégorie
const categoryTotals = (items ?? []).reduce<
Record<string, { qty: number; total: number }>
>((acc, item) => {
const cat = item.category || "Autre";
if (!acc[cat]) acc[cat] = { qty: 0, total: 0 };
acc[cat].qty += item.quantite ?? 0;
acc[cat].total += item.prix ?? 0;
return acc;
}, {});
const categoryEntries = Object.entries(categoryTotals);
if (loading) return <LoadingSpinner message="Chargement..." />;
if (!command)
return (
@@ -496,74 +536,21 @@ export default function OrderDetailScreen() {
visible={mapFullscreen}
animationType="fade"
onRequestClose={() => setMapFullscreen(false)}
statusBarTranslucent
>
<StatusBar hidden={mapFullscreen} />
<View style={styles.fullscreenContainer}>
{livreurCoords && (
<MapView
ref={fullscreenMapRef}
provider={PROVIDER_DEFAULT}
style={styles.fullscreenMap}
initialRegion={{
latitude: livreurCoords.latitude,
longitude: livreurCoords.longitude,
latitudeDelta: 0.02,
longitudeDelta: 0.02,
}}
onMapReady={() => fitMapToRoute(fullscreenMapRef)}
showsCompass
showsScale
>
<Marker
coordinate={livreurCoords}
title={command.livreur_assign}
>
<View style={styles.driverMarkerOuter}>
<View style={styles.driverMarkerInner}>
<Ionicons
name="bicycle"
size={14}
color={colors.white}
/>
</View>
</View>
</Marker>
{destCoords && (
<Marker
coordinate={destCoords}
title="Destination"
>
<View style={styles.destMarkerOuter}>
<View style={styles.destMarkerInner}>
<Ionicons
name="flag"
size={12}
color={colors.white}
/>
</View>
</View>
</Marker>
)}
{routeInfo && routeInfo.coordinates.length > 0 && (
<Polyline
coordinates={routeInfo.coordinates}
strokeColor="#4285F4"
strokeWidth={5}
/>
)}
</MapView>
)}
<TomTomMap
ref={fullscreenMapRef}
style={styles.fullscreenMap}
markers={livreurMarkers}
initialCenter={livreurCoords ?? undefined}
initialZoom={14}
/>
<View style={styles.fullscreenTopBar}>
<TouchableOpacity
style={styles.closeBtn}
onPress={() => setMapFullscreen(false)}
>
<Ionicons
name="close"
size={24}
color={colors.white}
/>
<Ionicons name="close" size={24} color={colors.white} />
</TouchableOpacity>
<Text style={styles.fullscreenTitle}>
{routeInfo
@@ -584,12 +571,30 @@ export default function OrderDetailScreen() {
<Text style={styles.info}>Client: {command.username}</Text>
<Text style={styles.info}>Adresse: {command.adresse}</Text>
<Text style={styles.info}>
Total: {command.total_prix?.toFixed(2)}
Total brut: {command.total_prix?.toFixed(2)}
</Text>
{command.referral_used > 0 && (
<Text style={[styles.info, { color: colors.success }]}>
Parrainage utilisé: -{command.referral_used?.toFixed(2)}
</Text>
<>
<Text style={[styles.info, { color: colors.success }]}>
Parrainage utilisé: -
{command.referral_used?.toFixed(2)}
</Text>
<Text
style={[
styles.info,
{
fontWeight: "700",
color: colors.textPrimary,
},
]}
>
Net:{" "}
{(
command.total_prix - command.referral_used
).toFixed(2)}{" "}
</Text>
</>
)}
{command.livreur_assign && (
<Text style={styles.info}>
@@ -600,29 +605,53 @@ export default function OrderDetailScreen() {
{new Date(command.created_at).toLocaleString("fr-FR")}
</Text>
{command.status === "cancelled" && command.cancel_reason ? (
<View style={{
marginTop: spacing.m,
padding: spacing.m,
backgroundColor: colors.danger + "18",
borderRadius: borderRadius.sm,
borderLeftWidth: 3,
borderLeftColor: colors.danger,
}}>
<Text style={{ color: colors.danger, fontSize: fontSize.xs, fontWeight: "700", marginBottom: 4, textTransform: "uppercase", letterSpacing: 0.5 }}>
<View
style={{
marginTop: spacing.m,
padding: spacing.m,
backgroundColor: colors.danger + "18",
borderRadius: borderRadius.sm,
borderLeftWidth: 3,
borderLeftColor: colors.danger,
}}
>
<Text
style={{
color: colors.danger,
fontSize: fontSize.xs,
fontWeight: "700",
marginBottom: 4,
textTransform: "uppercase",
letterSpacing: 0.5,
}}
>
Motif d'annulation
</Text>
<Text style={{ color: colors.textSecondary, fontSize: fontSize.sm }}>
<Text
style={{
color: colors.textSecondary,
fontSize: fontSize.sm,
}}
>
{command.cancel_reason}
</Text>
</View>
) : command.status === "cancelled" ? (
<View style={{
marginTop: spacing.m,
padding: spacing.m,
backgroundColor: colors.bgSecondary,
borderRadius: borderRadius.sm,
}}>
<Text style={{ color: colors.textMuted, fontSize: fontSize.sm, fontStyle: "italic" }}>
<View
style={{
marginTop: spacing.m,
padding: spacing.m,
backgroundColor: colors.bgSecondary,
borderRadius: borderRadius.sm,
}}
>
<Text
style={{
color: colors.textMuted,
fontSize: fontSize.sm,
fontStyle: "italic",
}}
>
Aucun motif fourni
</Text>
</View>
@@ -635,69 +664,21 @@ export default function OrderDetailScreen() {
<Text style={styles.sectionTitle}>Suivi du livreur</Text>
{hasMap ? (
<View style={styles.mapContainer}>
<MapView
<TomTomMap
ref={mapRef}
provider={PROVIDER_DEFAULT}
style={styles.map}
initialRegion={{
latitude: livreurCoords!.latitude,
longitude: livreurCoords!.longitude,
latitudeDelta: 0.02,
longitudeDelta: 0.02,
}}
onMapReady={() => fitMapToRoute(mapRef)}
>
<Marker
coordinate={livreurCoords!}
title={command.livreur_assign}
>
<View style={styles.driverMarkerOuter}>
<View style={styles.driverMarkerInner}>
<Ionicons
name="bicycle"
size={14}
color={colors.white}
/>
</View>
</View>
</Marker>
{destCoords && (
<Marker
coordinate={destCoords}
title="Destination"
>
<View style={styles.destMarkerOuter}>
<View
style={styles.destMarkerInner}
>
<Ionicons
name="flag"
size={12}
color={colors.white}
/>
</View>
</View>
</Marker>
)}
{routeInfo &&
routeInfo.coordinates.length > 0 && (
<Polyline
coordinates={routeInfo.coordinates}
strokeColor="#4285F4"
strokeWidth={4}
/>
)}
</MapView>
markers={livreurMarkers}
initialCenter={livreurCoords ?? undefined}
initialZoom={14}
/>
{/* Route info overlay */}
{routeInfo && (
<View style={styles.routeOverlay}>
<Text style={styles.routeOverlayUser}>
{command.livreur_assign}
</Text>
<Text style={styles.routeOverlayInfo}>
{routeInfo.distance} ·{" "}
{routeInfo.duration}
{routeInfo.distance} · {routeInfo.duration}
</Text>
</View>
)}
@@ -742,40 +723,101 @@ export default function OrderDetailScreen() {
</>
)}
{/* Items */}
{/* Items groupés par produit */}
<Text style={styles.sectionTitle}>Articles ({items.length})</Text>
{items.map((item: any) => (
<Card key={item.id} style={{ marginBottom: spacing.s }}>
<View style={styles.row}>
<Text style={[styles.itemName, { flex: 1 }]}>
{item.produit ?? item.product_name}
</Text>
<TouchableOpacity
style={styles.itemDeleteBtn}
onPress={() =>
handleDeleteItem(
item.id,
item.produit ?? item.product_name,
)
}
>
<Ionicons
name="trash-outline"
size={18}
color={colors.danger}
/>
</TouchableOpacity>
</View>
<View style={styles.itemDetails}>
<Text style={styles.info}>
Quantité: {item.quantite}
</Text>
<Text style={styles.info}>
Prix: {item.prix?.toFixed(2)}
</Text>
</View>
</Card>
))}
{groupedList.map((group, gi) => {
const rep = group[0];
const name = rep.produit ?? rep.product_name;
const unit = rep.unit || "";
const totalQty = group.reduce(
(s: number, it: any) => s + (it.quantite ?? 0),
0,
);
const totalPrice = group.reduce(
(s: number, it: any) => s + (it.prix ?? 0),
0,
);
const isMultiple = group.length > 1;
return (
<Card
key={`${rep.product_id}-${gi}`}
style={{ marginBottom: spacing.s }}
>
<View style={styles.row}>
<Text style={[styles.itemName, { flex: 1 }]}>
{name}
</Text>
{rep.category ? (
<Text style={styles.categoryBadge}>
{rep.category}
</Text>
) : null}
<TouchableOpacity
style={styles.itemDeleteBtn}
onPress={() => handleDeleteItem(rep.id, name)}
>
<Ionicons
name="trash-outline"
size={18}
color={colors.danger}
/>
</TouchableOpacity>
</View>
{isMultiple &&
group.map((item: any, i: number) => (
<View key={item.id} style={styles.subItemRow}>
<Text style={styles.subItemText}>
{item.quantite}
{unit} {item.prix?.toFixed(2)}
</Text>
<TouchableOpacity
onPress={() =>
handleDeleteItem(item.id, name)
}
>
<Ionicons
name="remove-circle-outline"
size={16}
color={colors.danger}
/>
</TouchableOpacity>
</View>
))}
<View style={styles.groupTotalRow}>
<Text style={styles.groupTotalQty}>
{isMultiple
? `Total: ${totalQty}${unit}`
: `${totalQty}${unit}`}
</Text>
<Text style={styles.groupTotalPrice}>
{totalPrice.toFixed(2)}
</Text>
</View>
</Card>
);
})}
{/* Récapitulatif par catégorie */}
{categoryEntries.length > 0 && (
<>
<Text style={styles.sectionTitle}>Par catégorie</Text>
<Card style={styles.categorySummaryCard}>
{categoryEntries.map(([cat, data]) => (
<View key={cat} style={styles.categoryRow}>
<Text style={styles.categoryName}>{cat}</Text>
<Text style={styles.categoryQty}>
{data.qty.toFixed(
data.qty % 1 === 0 ? 0 : 2,
)}
</Text>
<Text style={styles.categoryTotal}>
{data.total.toFixed(2)}
</Text>
</View>
))}
</Card>
</>
)}
{/* Actions */}
<Text style={styles.sectionTitle}>Actions</Text>
@@ -823,15 +865,7 @@ export default function OrderDetailScreen() {
style={{ marginTop: spacing.s }}
/>
)}
{!["approved", "cancelled"].includes(command.status) && (
<Button
title="Confirmer la commande"
onPress={handleConfirmReception}
variant="primary"
fullWidth
style={{ marginTop: spacing.s }}
/>
)}
<Button
title="Supprimer la commande"
onPress={handleDeleteCommand}
@@ -865,48 +899,132 @@ export default function OrderDetailScreen() {
</TouchableOpacity>
</View>
<ScrollView showsVerticalScrollIndicator={false}>
{items.map((item: any) => (
{groupedList.map((group, gi) => {
const rep = group[0];
const name = rep.produit ?? rep.product_name;
const unit = rep.unit || "";
const totalQty = group.reduce(
(s: number, it: any) =>
s + (it.quantite ?? 0),
0,
);
const totalPrice = group.reduce(
(s: number, it: any) => s + (it.prix ?? 0),
0,
);
const isMultiple = group.length > 1;
return (
<View
key={`modal-${rep.product_id}-${gi}`}
style={styles.itemsModalCard}
>
<View style={styles.row}>
<Text
style={[
styles.itemName,
{ flex: 1 },
]}
>
{name}
</Text>
{rep.category ? (
<Text
style={styles.categoryBadge}
>
{rep.category}
</Text>
) : null}
<TouchableOpacity
style={styles.itemDeleteBtn}
onPress={() => {
setShowItemsModal(false);
handleDeleteItem(
rep.id,
name,
);
}}
>
<Ionicons
name="trash-outline"
size={18}
color={colors.danger}
/>
</TouchableOpacity>
</View>
{isMultiple &&
group.map((item: any) => (
<View
key={item.id}
style={styles.subItemRow}
>
<Text
style={
styles.subItemText
}
>
{item.quantite}
{unit} {" "}
{item.prix?.toFixed(2)}{" "}
</Text>
</View>
))}
<View style={styles.groupTotalRow}>
<Text style={styles.groupTotalQty}>
{isMultiple
? `Total: ${totalQty}${unit}`
: `${totalQty}${unit}`}
</Text>
<Text
style={styles.groupTotalPrice}
>
{totalPrice.toFixed(2)}
</Text>
</View>
</View>
);
})}
{/* Récapitulatif catégories dans le modal */}
{categoryEntries.length > 0 && (
<View
key={item.id}
style={styles.itemsModalCard}
style={{
marginTop: spacing.m,
borderTopWidth: 1,
borderTopColor: colors.border,
paddingTop: spacing.m,
}}
>
<View style={styles.row}>
<Text
style={[
styles.itemName,
{ flex: 1 },
]}
<Text
style={[
styles.itemsModalTitle,
{
fontSize: fontSize.md,
marginBottom: spacing.s,
},
]}
>
Par catégorie
</Text>
{categoryEntries.map(([cat, data]) => (
<View
key={cat}
style={styles.categoryRow}
>
{item.produit ?? item.product_name}
</Text>
<TouchableOpacity
style={styles.itemDeleteBtn}
onPress={() => {
setShowItemsModal(false);
handleDeleteItem(
item.id,
item.produit ??
item.product_name,
);
}}
>
<Ionicons
name="trash-outline"
size={18}
color={colors.danger}
/>
</TouchableOpacity>
</View>
<View style={styles.itemDetails}>
<Text style={styles.info}>
Quantité: {item.quantite}
</Text>
<Text style={styles.info}>
Prix: {item.prix?.toFixed(2)}
</Text>
</View>
<Text style={styles.categoryName}>
{cat}
</Text>
<Text style={styles.categoryQty}>
{data.qty.toFixed(
data.qty % 1 === 0 ? 0 : 2,
)}
</Text>
<Text style={styles.categoryTotal}>
{data.total.toFixed(2)}
</Text>
</View>
))}
</View>
))}
)}
{items.length === 0 && (
<Text style={styles.empty}>Aucun article</Text>
)}
@@ -23,7 +23,8 @@ import {
updateCommandStatus,
getCommandItems,
notifyClientToDescend,
confirmReceptionAdmin,
validateCommand,
deleteCommand,
deleteCommandItem,
proposeAddressChangeAdmin,
@@ -220,23 +221,23 @@ export default function OrdersScreen() {
}
};
const handleConfirmReception = (commandId: number) => {
const handleForceValidate = (commandId: number) => {
showConfirm(
"Confirmer la commande",
`Confirmer la réception de la commande #${commandId} ?`,
"Finaliser la commande",
`Finaliser définitivement la commande #${commandId} et attribuer les points au client ?`,
async () => {
try {
const res = await confirmReceptionAdmin(commandId);
const res = await validateCommand(commandId);
showSuccess(
"Réception confirmée",
`${res.points_earned} point(s) attribués au client ${res.client_username}`,
"Commande finalisée",
`${res.points_awarded} point(s) attribués au client`,
);
await loadData();
} catch (e: any) {
showError("Erreur", e.message);
}
},
"Confirmer",
"Finaliser",
);
};
@@ -443,6 +444,11 @@ export default function OrdersScreen() {
color: colors.textSecondary,
fontSize: fontSize.xs,
},
livreurCancelBadge: {
flexDirection: "row",
alignItems: "center",
marginBottom: spacing.xs,
},
proposedAddressBox: {
backgroundColor: colors.bgPrimary,
borderRadius: borderRadius.sm,
@@ -642,14 +648,21 @@ export default function OrdersScreen() {
{/* Total + parrainage */}
<Text style={styles.cardText}>
<Text style={{ color: colors.textMuted }}>Total : </Text>
<Text style={{ color: colors.textMuted }}>
{(item.referral_used ?? 0) > 0 ? "Total brut : " : "Total : "}
</Text>
{item.total_prix.toFixed(2)}
{(item.referral_used ?? 0) > 0 && (
<Text style={{ color: colors.accent }}>
{" "}(parrainage -{item.referral_used!.toFixed(2)} )
</Text>
)}
</Text>
{(item.referral_used ?? 0) > 0 && (
<>
<Text style={[styles.cardText, { color: colors.success }]}>
Parrainage : -{item.referral_used!.toFixed(2)}
</Text>
<Text style={[styles.cardText, { fontWeight: "700" }]}>
Net : {(item.total_prix - item.referral_used!).toFixed(2)}
</Text>
</>
)}
{/* Livreur */}
{!!item.livreur_assign && (
@@ -662,8 +675,18 @@ export default function OrdersScreen() {
{/* Raison annulation */}
{isCancelled && !!item.cancel_reason && (
<View style={styles.cancelReasonBox}>
{item.cancel_reason.startsWith("[Livreur:") && (
<View style={styles.livreurCancelBadge}>
<Ionicons name="bicycle-outline" size={12} color={colors.danger} />
<Text style={[styles.cancelReasonText, { color: colors.danger, fontWeight: "600" }]}>
{" "}Annulé par le livreur
</Text>
</View>
)}
<Text style={styles.cancelReasonText}>
Raison : {item.cancel_reason}
{item.cancel_reason.startsWith("[Livreur:")
? item.cancel_reason.replace(/^\[Livreur: [^\]]+\] /, "")
: item.cancel_reason}
</Text>
</View>
)}
@@ -751,14 +774,15 @@ export default function OrdersScreen() {
},
condition: item.status === "assigned",
},
{
label: "Confirmer réception",
icon: "checkmark-circle-outline" as keyof typeof Ionicons.glyphMap,
label: "Finaliser commande",
icon: "trophy-outline" as keyof typeof Ionicons.glyphMap,
onPress: () => {
setOpenMenuId(null);
handleConfirmReception(item.id);
handleForceValidate(item.id);
},
condition: item.status === "livre",
condition: !isDone && item.status !== "livre",
},
{
label: "Supprimer",
@@ -787,8 +811,18 @@ export default function OrdersScreen() {
<Text style={styles.cardText}>Client: {item.username}</Text>
<Text style={styles.cardText}>Adresse: {item.adresse}</Text>
<Text style={styles.cardText}>
Total: {item.total_prix.toFixed(2)}
{(item.referral_used ?? 0) > 0 ? "Total brut" : "Total"}: {item.total_prix.toFixed(2)}
</Text>
{(item.referral_used ?? 0) > 0 && (
<>
<Text style={[styles.cardText, { color: colors.success }]}>
Parrainage: -{(item.referral_used ?? 0).toFixed(2)}
</Text>
<Text style={[styles.cardText, { fontWeight: "700" }]}>
Net: {(item.total_prix - (item.referral_used ?? 0)).toFixed(2)}
</Text>
</>
)}
{item.livreur_assign && (
<Text style={styles.cardText}>
Livreur: {item.livreur_assign}
@@ -1096,12 +1130,22 @@ export default function OrdersScreen() {
</View>
)}
{itemsModal.commandInfo?.total_prix != null && (
<Text style={styles.modalTotal}>
{Number(
itemsModal.commandInfo.total_prix,
).toFixed(2)}{" "}
</Text>
<>
<Text style={styles.modalTotal}>
{(itemsModal.commandInfo.referral_used ?? 0) > 0 ? "Brut : " : ""}
{Number(itemsModal.commandInfo.total_prix).toFixed(2)}
</Text>
{(itemsModal.commandInfo.referral_used ?? 0) > 0 && (
<>
<Text style={[styles.modalTotal, { color: colors.success, fontSize: 13 }]}>
Parrainage : -{Number(itemsModal.commandInfo.referral_used).toFixed(2)}
</Text>
<Text style={[styles.modalTotal, { fontWeight: "700" }]}>
Net : {(Number(itemsModal.commandInfo.total_prix) - Number(itemsModal.commandInfo.referral_used)).toFixed(2)}
</Text>
</>
)}
</>
)}
</View>
)}
@@ -26,6 +26,8 @@ import {
deleteProductAdmin,
uploadProductMediaAdmin,
deleteProductMediaAdmin,
activateProductPrice,
deactivateProductPrice,
getCategories,
} from "../../api/api_admin";
import type { Category } from "../../api/api_admin";
@@ -40,8 +42,10 @@ import { useAlert } from "../../hooks/useAlert";
// Types
// --------------------------------------------------
interface PriceRow {
id?: number;
quantity: string;
price: string;
active: boolean;
}
interface MediaItem {
id?: number;
@@ -74,6 +78,7 @@ interface FormState {
stock: string;
unit: string;
prices: PriceRow[];
comingSoon: boolean;
}
const emptyForm = (firstCategory = ""): FormState => ({
@@ -82,7 +87,8 @@ const emptyForm = (firstCategory = ""): FormState => ({
description: "",
stock: "",
unit: "u",
prices: [{ quantity: "1", price: "" }],
prices: [{ quantity: "1", price: "", active: true }],
comingSoon: false,
});
// ==================================================
@@ -159,13 +165,16 @@ export default function ProductsScreen() {
description: product.description || "",
stock: product.stock.toString(),
unit: product.unit || "u",
comingSoon: product.coming_soon ?? false,
prices:
product.prices && product.prices.length > 0
? product.prices.map((p) => ({
id: p.id,
quantity: p.quantity.toString(),
price: p.price.toString(),
active: p.active_price ?? true,
}))
: [{ quantity: "1", price: "" }],
: [{ quantity: "1", price: "", active: true }],
});
setExistingMedia(
(product.media || []).map((m) => ({
@@ -191,13 +200,27 @@ export default function ProductsScreen() {
const addPriceRow = () =>
setForm((f) => ({
...f,
prices: [...f.prices, { quantity: "1", price: "" }],
prices: [...f.prices, { quantity: "1", price: "", active: true }],
}));
const togglePriceActive = (idx: number) =>
setForm((f) => {
const prices = [...f.prices];
prices[idx] = { ...prices[idx], active: !prices[idx].active };
return { ...f, prices };
});
const removePriceRow = (idx: number) =>
setForm((f) => ({
...f,
prices: f.prices.filter((_, i) => i !== idx),
}));
setForm((f) => {
const target = f.prices[idx];
if (target.id) {
// Prix existant en DB → désactiver au lieu de supprimer
const prices = [...f.prices];
prices[idx] = { ...target, active: false };
return { ...f, prices };
}
// Nouveau prix jamais sauvegardé → supprimer de la liste
return { ...f, prices: f.prices.filter((_, i) => i !== idx) };
});
const updatePriceField = (
idx: number,
field: "quantity" | "price",
@@ -292,6 +315,7 @@ export default function ProductsScreen() {
const prices = form.prices.map((p) => ({
quantity: parseFloat(p.quantity),
price: parseFloat(p.price),
active_price: p.active,
}));
try {
@@ -318,6 +342,7 @@ export default function ProductsScreen() {
stock: parseFloat(form.stock),
unit: form.unit,
prices,
coming_soon: form.comingSoon,
});
productId = editingProduct.id;
} else {
@@ -328,12 +353,12 @@ export default function ProductsScreen() {
fd.append("description", form.description.trim());
fd.append("stock", form.stock);
fd.append("unit", form.unit);
fd.append("coming_soon", form.comingSoon ? "true" : "false");
// ✅ FIX PRINCIPAL : Envoyer les prix au format que Go attend
// Backend attend : prices[0][quantity], prices[0][price], etc.
prices.forEach((p, i) => {
fd.append(`prices[${i}][quantity]`, String(p.quantity));
fd.append(`prices[${i}][price]`, String(p.price));
fd.append(`prices[${i}][active_price]`, p.active_price ? "true" : "false");
});
// Attacher les médias en attente
@@ -607,6 +632,22 @@ export default function ProductsScreen() {
catBtnText: { color: colors.textMuted, fontSize: fontSize.sm },
catBtnTextActive: { color: colors.accent, fontWeight: "600" },
comingSoonBtn: {
borderWidth: 1,
borderColor: colors.textMuted,
borderRadius: 8,
paddingVertical: 10,
paddingHorizontal: 14,
marginBottom: 16,
alignItems: "center",
},
comingSoonBtnActive: {
borderColor: "#22c55e",
backgroundColor: "#22c55e20",
},
comingSoonBtnText: { color: colors.textMuted, fontSize: fontSize.sm },
comingSoonBtnTextActive: { color: "#22c55e", fontWeight: "700" },
// Prices
sectionHeader: {
flexDirection: "row",
@@ -647,6 +688,7 @@ export default function ProductsScreen() {
paddingHorizontal: spacing.m,
},
removePriceBtn: { padding: spacing.s, marginBottom: 4 },
toggleActiveBtn: { padding: spacing.s, marginBottom: 4 },
// Media section
mediaSectionBox: { marginTop: spacing.m },
@@ -803,11 +845,24 @@ export default function ProductsScreen() {
)}
<Text style={styles.info}>Stock: {item.stock} {item.unit || "u"}</Text>
{item.prices && item.prices.length > 0 && (
<Text style={styles.info}>
{item.prices
.map((p) => `${p.quantity}${item.unit || "u"} = ${p.price}`)
.join(" | ")}
</Text>
<View style={{ flexDirection: "row", flexWrap: "wrap", gap: 4, marginTop: 2 }}>
{item.prices.map((p, i) => (
<Text
key={i}
style={[
styles.info,
p.active_price === false && {
textDecorationLine: "line-through",
color: colors.textMuted,
opacity: 0.5,
},
]}
>
{p.quantity}{item.unit || "u"} = {p.price}
{i < item.prices!.length - 1 ? " |" : ""}
</Text>
))}
</View>
)}
{item.description && (
<Text style={styles.desc} numberOfLines={2}>
@@ -965,6 +1020,31 @@ export default function ProductsScreen() {
keyboardType="numeric"
/>
{/* À venir */}
<TouchableOpacity
style={[
styles.comingSoonBtn,
form.comingSoon && styles.comingSoonBtnActive,
]}
onPress={() =>
setForm((f) => {
const next = !f.comingSoon;
return {
...f,
comingSoon: next,
prices: f.prices.map((p) => ({ ...p, active: !next })),
};
})
}
>
<Text style={[
styles.comingSoonBtnText,
form.comingSoon && styles.comingSoonBtnTextActive,
]}>
{form.comingSoon ? "À venir (activé)" : "Marquer comme «À venir»"}
</Text>
</TouchableOpacity>
{/* Unité de mesure */}
<Text style={styles.label}>Unité de mesure *</Text>
<View style={styles.catRow}>
@@ -1055,6 +1135,16 @@ export default function ProductsScreen() {
}
/>
</View>
<TouchableOpacity
style={styles.toggleActiveBtn}
onPress={() => togglePriceActive(idx)}
>
<Ionicons
name={p.active ? "checkmark-circle" : "close-circle"}
size={22}
color={p.active ? colors.success || "#22c55e" : colors.danger}
/>
</TouchableOpacity>
{form.prices.length > 1 && (
<TouchableOpacity
style={styles.removePriceBtn}
@@ -15,8 +15,9 @@ import { useNavigation } from "@react-navigation/native";
import { Ionicons } from "@expo/vector-icons";
import { spacing, fontSize, borderRadius } from "../../theme";
import { useTheme } from "../../context/ThemeContext";
import { getSettings, updateSettings, getCategories, getAvailableDeliveryPersons, DEFAULT_DELIVERY_SCHEDULE, DEFAULT_POSTAL_ZONES } from "../../api/api_admin";
import type { AppSettings, Category, CategoryRoute, DeliveryModeConfig, PointsTier, PointsPool, DaySchedule, DeliverySchedule, PostalZone } from "../../api/api_admin";
import { getSettings, updateSettings, getCategories, getAvailableDeliveryPersons, getAllProductsAdmin, DEFAULT_DELIVERY_SCHEDULE, DEFAULT_POSTAL_ZONES } from "../../api/api_admin";
import type { AppSettings, Category, CategoryRoute, DeliveryModeConfig, PointsTier, PointsPool, PointsReward, RewardCategoryConfig, RewardItem, DaySchedule, DeliverySchedule, PostalZone } from "../../api/api_admin";
import type { Product } from "../../api/types";
import AlertModal from "../../components/ui/AlertModal";
import { useAlert } from "../../hooks/useAlert";
@@ -45,6 +46,51 @@ const DAYS: { key: keyof DeliverySchedule; label: string }[] = [
{ key: "sunday", label: "Dimanche" },
];
// ──────────────────────────────────────────────────────────────
// Composant section accordéon générique
// ──────────────────────────────────────────────────────────────
function AccordionSection({
title,
badge,
children,
colors,
s,
}: {
title: string;
badge?: React.ReactNode;
children: React.ReactNode;
colors: any;
s: any;
}) {
const [open, setOpen] = useState(false);
return (
<View style={s.section}>
<TouchableOpacity
onPress={() => setOpen((o) => !o)}
activeOpacity={0.7}
style={{
flexDirection: "row",
alignItems: "center",
justifyContent: "space-between",
paddingHorizontal: spacing.l,
paddingVertical: spacing.m,
}}
>
<Text style={[s.sectionTitle, { paddingHorizontal: 0, paddingTop: 0, paddingBottom: 0 }]}>{title}</Text>
<View style={{ flexDirection: "row", alignItems: "center", gap: spacing.s }}>
{badge}
<Ionicons name={open ? "chevron-up" : "chevron-down"} size={18} color={colors.textMuted} />
</View>
</TouchableOpacity>
{open && (
<View style={{ borderTopWidth: 1, borderTopColor: colors.border }}>
{children}
</View>
)}
</View>
);
}
function DeliveryScheduleSection({
schedule,
onChange,
@@ -62,8 +108,7 @@ function DeliveryScheduleSection({
};
return (
<View style={s.section}>
<Text style={s.sectionTitle}>Horaires de livraison</Text>
<AccordionSection title="Horaires de livraison" colors={colors} s={s}>
<Text style={[s.hint, { paddingTop: spacing.s }]}>
Définissez les jours et horaires d'ouverture pour la livraison.
</Text>
@@ -132,7 +177,7 @@ function DeliveryScheduleSection({
</View>
);
})}
</View>
</AccordionSection>
);
}
@@ -199,8 +244,7 @@ function PostalZonesSection({
};
return (
<View style={s.section}>
<Text style={s.sectionTitle}>Zones de livraison</Text>
<AccordionSection title="Zones de livraison" colors={colors} s={s}>
<Text style={[s.hint, { paddingTop: spacing.s }]}>
Définissez les zones de livraison et leur montant minimum de commande.
</Text>
@@ -435,7 +479,7 @@ function PostalZonesSection({
</Text>
</TouchableOpacity>
)}
</View>
</AccordionSection>
);
}
@@ -546,24 +590,20 @@ function TiersSection({
onChange(tiers.filter((_, idx) => idx !== i));
};
const badge = (
<View style={{
paddingHorizontal: spacing.s, paddingVertical: 2, borderRadius: 10,
backgroundColor: active ? accentColor + "25" : colors.border + "40",
borderWidth: 1, borderColor: active ? accentColor : colors.border,
}}>
<Text style={{ fontSize: fontSize.xs, fontWeight: "700", color: active ? accentColor : colors.textMuted }}>
{active ? "Actif" : "Ignoré"}
</Text>
</View>
);
return (
<View style={s.section}>
<View style={{ flexDirection: "row", alignItems: "center", justifyContent: "space-between", paddingRight: spacing.l }}>
<Text style={s.sectionTitle}>{title}</Text>
<View style={{
paddingHorizontal: spacing.s,
paddingVertical: 2,
borderRadius: 10,
backgroundColor: active ? accentColor + "25" : colors.border + "40",
borderWidth: 1,
borderColor: active ? accentColor : colors.border,
marginBottom: spacing.s,
}}>
<Text style={{ fontSize: fontSize.xs, fontWeight: "700", color: active ? accentColor : colors.textMuted }}>
{active ? "Actif" : "Ignoré"}
</Text>
</View>
</View>
<AccordionSection title={title} badge={badge} colors={colors} s={s}>
<Text style={s.hint}>
Définissez chaque palier : de X€ à Y€ = N points.{"\n"}
Max = 0 signifie illimité (pas de borne supérieure).
@@ -639,10 +679,482 @@ function TiersSection({
Ajouter un palier
</Text>
</TouchableOpacity>
</AccordionSection>
);
}
const REWARD_ACCENT = "#f59e0b";
const REWARD_TYPES: { value: PointsReward["type"]; label: string; icon: string }[] = [
{ value: "free_product", label: "Produit offert", icon: "gift-outline" },
{ value: "half_price_product", label: "Produit à -50%", icon: "pricetag-outline" },
];
const EMPTY_REWARD: PointsReward = {
threshold: 20,
type: "free_product",
description: "",
category_configs: [],
reward_items: [],
};
// ──────────────────────────────────────────────────────────────
// Sélecteur de produits pour une catégorie dans la récompense
// ──────────────────────────────────────────────────────────────
function CategoryProductPicker({
catConfig,
products,
onChange,
colors,
s,
}: {
catConfig: RewardCategoryConfig;
products: Product[];
onChange: (cfg: RewardCategoryConfig) => void;
colors: any;
s: any;
}) {
const catProducts = products.filter((p) => p.category === catConfig.category);
const toggleProduct = (id: number) => {
const ids = catConfig.product_ids.includes(id)
? catConfig.product_ids.filter((x) => x !== id)
: [...catConfig.product_ids, id];
onChange({ ...catConfig, product_ids: ids, all_products: false });
};
return (
<View style={{ marginTop: spacing.s, paddingLeft: spacing.m, gap: spacing.s }}>
{/* Toggle tous / sélection */}
<View style={{ flexDirection: "row", gap: spacing.s }}>
<TouchableOpacity
onPress={() => onChange({ ...catConfig, all_products: true, product_ids: [] })}
style={{
flexDirection: "row", alignItems: "center", gap: spacing.xs,
paddingHorizontal: spacing.m, paddingVertical: spacing.xs,
borderRadius: borderRadius.full, borderWidth: 1.5,
borderColor: catConfig.all_products ? REWARD_ACCENT : colors.border,
backgroundColor: catConfig.all_products ? REWARD_ACCENT + "22" : "transparent",
}}
>
<Ionicons name="checkmark-done-outline" size={13} color={catConfig.all_products ? REWARD_ACCENT : colors.textMuted} />
<Text style={{ fontSize: 12, fontWeight: catConfig.all_products ? "700" : "400", color: catConfig.all_products ? REWARD_ACCENT : colors.textMuted }}>
Tous ({catProducts.length})
</Text>
</TouchableOpacity>
<TouchableOpacity
onPress={() => onChange({ ...catConfig, all_products: false })}
style={{
flexDirection: "row", alignItems: "center", gap: spacing.xs,
paddingHorizontal: spacing.m, paddingVertical: spacing.xs,
borderRadius: borderRadius.full, borderWidth: 1.5,
borderColor: !catConfig.all_products ? REWARD_ACCENT : colors.border,
backgroundColor: !catConfig.all_products ? REWARD_ACCENT + "22" : "transparent",
}}
>
<Ionicons name="list-outline" size={13} color={!catConfig.all_products ? REWARD_ACCENT : colors.textMuted} />
<Text style={{ fontSize: 12, fontWeight: !catConfig.all_products ? "700" : "400", color: !catConfig.all_products ? REWARD_ACCENT : colors.textMuted }}>
Sélection
</Text>
</TouchableOpacity>
</View>
{/* Liste des produits si mode sélection */}
{!catConfig.all_products && (
<View style={{ flexDirection: "row", flexWrap: "wrap", gap: spacing.xs }}>
{catProducts.length === 0 ? (
<Text style={{ fontSize: 12, color: colors.textMuted, fontStyle: "italic" }}>
Aucun produit dans cette catégorie
</Text>
) : catProducts.map((p) => {
const sel = catConfig.product_ids.includes(p.id);
return (
<TouchableOpacity
key={p.id}
onPress={() => toggleProduct(p.id)}
style={{
paddingHorizontal: spacing.s, paddingVertical: 4,
borderRadius: borderRadius.sm, borderWidth: 1.5,
borderColor: sel ? REWARD_ACCENT : colors.border,
backgroundColor: sel ? REWARD_ACCENT + "22" : "transparent",
flexDirection: "row", alignItems: "center", gap: 4,
}}
>
{sel && <Ionicons name="checkmark" size={11} color={REWARD_ACCENT} />}
<Text style={{ fontSize: 12, fontWeight: sel ? "700" : "400", color: sel ? REWARD_ACCENT : colors.textMuted }}>
{p.name}
</Text>
</TouchableOpacity>
);
})}
</View>
)}
</View>
);
}
// ──────────────────────────────────────────────────────────────
// Section centralisée récompenses par palier
// ──────────────────────────────────────────────────────────────
function CentralRewardSection({
reward,
allCategories,
productsByCategory,
onChange,
colors,
s,
}: {
reward: PointsReward | null | undefined;
allCategories: Category[];
productsByCategory: Record<string, Product[]>;
onChange: (reward: PointsReward | null) => void;
colors: any;
s: any;
}) {
const { width: screenWidth } = useWindowDimensions();
const enabled = !!reward;
const rawR = reward ?? EMPTY_REWARD;
const r: PointsReward = { ...rawR, category_configs: rawR.category_configs ?? [] };
const update = (patch: Partial<PointsReward>) =>
onChange({ ...r, ...patch });
const getCatConfig = (catName: string): RewardCategoryConfig =>
r.category_configs.find((c) => c.category === catName) ??
{ category: catName, all_products: true, product_ids: [] };
const isCatSelected = (catName: string) =>
r.category_configs.some((c) => c.category === catName);
const toggleCategory = (catName: string) => {
if (isCatSelected(catName)) {
update({ category_configs: r.category_configs.filter((c) => c.category !== catName) });
} else {
update({ category_configs: [...r.category_configs, { category: catName, all_products: true, product_ids: [] }] });
}
};
const updateCatConfig = (cfg: RewardCategoryConfig) => {
update({
category_configs: r.category_configs.map((c) =>
c.category === cfg.category ? cfg : c
),
});
};
const rewardBadge = (
<View style={{
paddingHorizontal: spacing.s, paddingVertical: 2, borderRadius: 10,
backgroundColor: enabled ? REWARD_ACCENT + "25" : colors.border + "40",
borderWidth: 1, borderColor: enabled ? REWARD_ACCENT : colors.border,
}}>
<Text style={{ fontSize: 10, fontWeight: "700", color: enabled ? REWARD_ACCENT : colors.textMuted }}>
{enabled ? "Activée" : "Désactivée"}
</Text>
</View>
);
return (
<AccordionSection title="Récompenses par palier" badge={rewardBadge} colors={colors} s={s}>
<View style={[s.row, s.rowFirst]}>
<View style={s.rowLeft}>
<Text style={s.rowLabel}>Récompense activée</Text>
<Text style={s.rowDesc}>
Le seuil s'applique indépendamment à chaque type de points.
</Text>
</View>
<Switch
value={enabled}
onValueChange={(v) => onChange(v ? EMPTY_REWARD : null)}
trackColor={{ false: colors.border, true: REWARD_ACCENT }}
thumbColor="#fff"
/>
</View>
{enabled && (
<View style={{ borderTopWidth: 1, borderTopColor: colors.border, paddingHorizontal: spacing.l, paddingTop: spacing.l, paddingBottom: spacing.l, gap: spacing.l }}>
{/* Seuil */}
<View style={{ flexDirection: "row", alignItems: "center", gap: spacing.m }}>
<View style={{ flex: 1 }}>
<Text style={s.rowLabel}>Seuil de points</Text>
<Text style={s.rowDesc}>
Dès X points cumulés dans un type, la récompense est débloquée pour ce type.
</Text>
</View>
<View style={{ flexDirection: "row", alignItems: "center", gap: spacing.xs }}>
<TextInput
style={[s.thresholdInput, { width: screenWidth < 380 ? 52 : 64 }]}
keyboardType="number-pad"
value={String(r.threshold)}
onChangeText={(v) => { const n = parseInt(v, 10); if (!isNaN(n) && n > 0) update({ threshold: n }); }}
/>
<Text style={s.thresholdSep}>pts</Text>
</View>
</View>
{/* Type */}
<View>
<Text style={[s.rowLabel, { marginBottom: spacing.s }]}>Type de récompense</Text>
<View style={{ flexDirection: "row", flexWrap: "wrap", gap: spacing.s }}>
{REWARD_TYPES.map((rt) => {
const sel = r.type === rt.value;
return (
<TouchableOpacity
key={rt.value}
onPress={() => update({ type: rt.value })}
style={{
flexDirection: "row", alignItems: "center", gap: spacing.xs,
paddingHorizontal: spacing.m, paddingVertical: spacing.s,
borderRadius: borderRadius.sm, borderWidth: 1.5,
borderColor: sel ? REWARD_ACCENT : colors.border,
backgroundColor: sel ? REWARD_ACCENT + "20" : "transparent",
}}
>
<Ionicons name={rt.icon as any} size={14} color={sel ? REWARD_ACCENT : colors.textMuted} />
<Text style={{ fontSize: 13, fontWeight: sel ? "700" : "400", color: sel ? REWARD_ACCENT : colors.textMuted }}>
{rt.label}
</Text>
</TouchableOpacity>
);
})}
</View>
</View>
{/* Description */}
<View>
<Text style={[s.rowLabel, { marginBottom: spacing.xs }]}>Description affichée au client</Text>
<TextInput
style={[s.thresholdInput, { width: "100%", textAlign: "left", paddingHorizontal: spacing.m, paddingVertical: spacing.s, height: 72, textAlignVertical: "top" }]}
value={r.description}
onChangeText={(v) => update({ description: v })}
placeholder="Ex : Un produit 30€ de ton choix parmi les produits conditionnés en 30€"
placeholderTextColor={colors.textMuted}
multiline
/>
</View>
{/* Produits récompense — uniquement pour le type "Produit offert" */}
{r.type === "free_product" && (
<View>
<Text style={[s.rowLabel, { marginBottom: spacing.xs }]}>Produits ajoutés au panier</Text>
<Text style={[s.rowDesc, { marginBottom: spacing.m }]}>
Quand le client réclame sa récompense, ces produits sont automatiquement ajoutés à son panier (gratuits). Il doit commander au moins un produit normal.
</Text>
<View style={{ gap: spacing.s }}>
{r.reward_items.map((item, idx) => {
const allProds = Object.values(productsByCategory).flat();
const prod = allProds.find((p) => p.id === item.product_id);
return (
<View
key={idx}
style={{
borderWidth: 1, borderColor: REWARD_ACCENT + "44",
borderRadius: borderRadius.sm, padding: spacing.m,
backgroundColor: REWARD_ACCENT + "08", gap: spacing.s,
}}
>
{/* Sélecteur produit */}
<View style={{ flexDirection: "row", flexWrap: "wrap", gap: spacing.xs }}>
{allProds.map((p) => {
const sel = item.product_id === p.id;
return (
<TouchableOpacity
key={p.id}
onPress={() => {
const updated = r.reward_items.map((it, i) =>
i === idx ? { ...it, product_id: p.id } : it
);
update({ reward_items: updated });
}}
style={{
flexDirection: "row", alignItems: "center", gap: 4,
paddingHorizontal: spacing.s, paddingVertical: 4,
borderRadius: borderRadius.sm, borderWidth: 1.5,
borderColor: sel ? REWARD_ACCENT : colors.border,
backgroundColor: sel ? REWARD_ACCENT + "22" : "transparent",
}}
>
{sel && <Ionicons name="checkmark" size={11} color={REWARD_ACCENT} />}
<Text style={{ fontSize: 12, fontWeight: sel ? "700" : "400", color: sel ? REWARD_ACCENT : colors.textMuted }}>
{p.name} ({p.category})
</Text>
</TouchableOpacity>
);
})}
</View>
{/* Quantité + Prix + Supprimer */}
<View style={{ flexDirection: "row", alignItems: "center", gap: spacing.m }}>
<View style={{ flex: 1, flexDirection: "row", alignItems: "center", gap: spacing.xs }}>
<Text style={{ fontSize: 12, color: colors.textMuted }}>Qté :</Text>
<TextInput
style={[s.thresholdInput, { width: 56, fontSize: 13 }]}
keyboardType="decimal-pad"
value={item.quantity > 0 ? String(item.quantity) : ""}
onChangeText={(v) => {
const n = parseFloat(v);
const updated = r.reward_items.map((it, i) =>
i === idx ? { ...it, quantity: isNaN(n) ? 0 : n } : it
);
update({ reward_items: updated });
}}
placeholder="1"
placeholderTextColor={colors.textMuted}
/>
</View>
<View style={{ flex: 1, flexDirection: "row", alignItems: "center", gap: spacing.xs }}>
<Text style={{ fontSize: 12, color: colors.textMuted }}>Prix :</Text>
<TextInput
style={[s.thresholdInput, { width: 56, fontSize: 13 }]}
keyboardType="decimal-pad"
value={item.price > 0 ? String(item.price) : ""}
onChangeText={(v) => {
const n = parseFloat(v);
const updated = r.reward_items.map((it, i) =>
i === idx ? { ...it, price: isNaN(n) ? 0 : n } : it
);
update({ reward_items: updated });
}}
placeholder="0"
placeholderTextColor={colors.textMuted}
/>
<Text style={{ fontSize: 12, color: colors.textMuted }}></Text>
</View>
<TouchableOpacity
onPress={() => update({ reward_items: r.reward_items.filter((_, i) => i !== idx) })}
style={{ padding: spacing.xs }}
>
<Ionicons name="trash-outline" size={18} color="#ef4444" />
</TouchableOpacity>
</View>
{prod && (
<Text style={{ fontSize: 11, color: REWARD_ACCENT, fontStyle: "italic" }}>
{prod.name}{item.quantity > 0 ? ` · x${item.quantity}` : ""}{item.price > 0 ? ` · ${item.price}` : ""}
</Text>
)}
{!prod && (
<Text style={{ fontSize: 11, color: colors.textMuted, fontStyle: "italic" }}>
Sélectionnez un produit ci-dessus
</Text>
)}
</View>
);
})}
{Object.values(productsByCategory).flat().length === 0 ? (
<Text style={[s.hint, { paddingHorizontal: 0 }]}>Aucun produit disponible</Text>
) : (
<TouchableOpacity
onPress={() => update({ reward_items: [...r.reward_items, { product_id: 0, quantity: 1, price: 0 }] })}
style={{
flexDirection: "row", alignItems: "center", justifyContent: "center", gap: spacing.xs,
paddingHorizontal: spacing.m, paddingVertical: spacing.s,
borderRadius: borderRadius.sm, borderWidth: 1.5,
borderColor: REWARD_ACCENT + "66",
}}
>
<Ionicons name="add-circle-outline" size={16} color={REWARD_ACCENT} />
<Text style={{ fontSize: 13, color: REWARD_ACCENT, fontWeight: "600" }}>Ajouter un produit récompense</Text>
</TouchableOpacity>
)}
</View>
</View>
)}
{/* Catégories éligibles — uniquement pour le type "Produit à -50%" */}
{r.type === "half_price_product" && (
<View>
<Text style={[s.rowLabel, { marginBottom: spacing.xs }]}>Catégories éligibles à -50%</Text>
<Text style={[s.rowDesc, { marginBottom: spacing.m }]}>
Sélectionnez les catégories, puis pour chacune choisissez tous les produits ou une sélection.
</Text>
{allCategories.length === 0 ? (
<Text style={[s.hint, { paddingHorizontal: 0 }]}>Aucune catégorie disponible</Text>
) : (
<View style={{ gap: spacing.m }}>
{allCategories.map((cat) => {
const selected = isCatSelected(cat.name);
const catColor = cat.color || REWARD_ACCENT;
return (
<View key={cat.name}>
{/* Chip catégorie */}
<TouchableOpacity
onPress={() => toggleCategory(cat.name)}
style={{
flexDirection: "row", alignItems: "center", gap: spacing.xs,
alignSelf: "flex-start",
paddingHorizontal: spacing.m, paddingVertical: spacing.s,
borderRadius: borderRadius.full, borderWidth: 1.5,
borderColor: selected ? catColor : colors.border,
backgroundColor: selected ? catColor + "22" : "transparent",
}}
>
<View style={{ width: 8, height: 8, borderRadius: 4, backgroundColor: catColor }} />
<Text style={{ fontSize: 13, fontWeight: selected ? "700" : "400", color: selected ? catColor : colors.textMuted }}>
{cat.name}
</Text>
<Ionicons
name={selected ? "chevron-down" : "chevron-forward"}
size={12}
color={selected ? catColor : colors.textMuted}
/>
</TouchableOpacity>
{/* Sélecteur produits (visible si catégorie sélectionnée) */}
{selected && (
<CategoryProductPicker
catConfig={getCatConfig(cat.name)}
products={productsByCategory[cat.name] ?? []}
onChange={updateCatConfig}
colors={colors}
s={s}
/>
)}
</View>
);
})}
</View>
)}
</View>
)}
{/* Récapitulatif */}
{(r.category_configs.length > 0 || r.reward_items.filter((it) => it.product_id > 0).length > 0) && (
<View style={{ backgroundColor: REWARD_ACCENT + "12", borderRadius: borderRadius.sm, borderLeftWidth: 3, borderLeftColor: REWARD_ACCENT, padding: spacing.m, gap: 4 }}>
<Text style={{ fontSize: 13, fontWeight: "700", color: REWARD_ACCENT }}>Récapitulatif</Text>
<Text style={{ fontSize: 13, color: colors.textPrimary }}>
Dès <Text style={{ fontWeight: "700" }}>{r.threshold} pts</Text> par type {" "}
{REWARD_TYPES.find((x) => x.value === r.type)?.label}
</Text>
{r.description !== "" && (
<Text style={{ fontSize: 12, color: colors.textMuted, fontStyle: "italic" }}>"{r.description}"</Text>
)}
{r.type === "half_price_product" && r.category_configs.map((cfg) => (
<Text key={cfg.category} style={{ fontSize: 12, color: colors.textSecondary }}>
Éligible à -50% : {cfg.category} {cfg.all_products ? "tous les produits" : `${cfg.product_ids.length} produit(s)`}
</Text>
))}
{r.type === "free_product" && r.reward_items.filter((it) => it.product_id > 0).map((it, idx) => {
const prod = Object.values(productsByCategory).flat().find((p) => p.id === it.product_id);
return (
<View key={idx} style={{ flexDirection: "row", alignItems: "center", gap: 4 }}>
<Ionicons name="gift-outline" size={11} color={REWARD_ACCENT} />
<Text style={{ fontSize: 12, color: colors.textSecondary }}>
{prod?.name ?? `Produit #${it.product_id}`}{it.quantity > 0 ? ` · x${it.quantity}` : ""}{it.price > 0 ? ` · ${it.price}` : ""}
</Text>
</View>
);
})}
</View>
)}
</View>
)}
</AccordionSection>
);
}
export default function SettingsScreen() {
const { colors } = useTheme();
const { alert, showError, showSuccess, hideAlert } = useAlert();
@@ -677,11 +1189,16 @@ export default function SettingsScreen() {
telegram_bot_token: "",
telegram_bot_username: "",
telegram_notifications_enabled: false,
telegram_2fa_enabled: false,
delivery_mode: { mode: "single" as const, category_routes: [] },
shop_name: "Milieu-Nantais",
contact_telegram: "",
points_reward: null,
});
const [showApiKey, setShowApiKey] = useState(false);
const [showIpnSecret, setShowIpnSecret] = useState(false);
const [categories, setCategories] = useState<Category[]>([]);
const [productsByCategory, setProductsByCategory] = useState<Record<string, Product[]>>({});
const [livreurs, setLivreurs] = useState<string[]>([]);
// Refs pour l'auto-sauvegarde au départ de la page
@@ -711,30 +1228,56 @@ export default function SettingsScreen() {
const loadData = useCallback(async () => {
setLoading(true);
isLoaded.current = false;
const [settingsRes, categoriesRes, livreursRes] = await Promise.all([
getSettings(),
getCategories(),
getAvailableDeliveryPersons(),
]);
if (settingsRes.success && settingsRes.settings) {
setSettings({
...settingsRes.settings,
points_pools: settingsRes.settings.points_pools ?? [],
delivery_schedule: settingsRes.settings.delivery_schedule ?? DEFAULT_DELIVERY_SCHEDULE,
postal_zones: settingsRes.settings.postal_zones ?? DEFAULT_POSTAL_ZONES,
delivery_mode: settingsRes.settings.delivery_mode ?? { mode: "single", category_routes: [] },
});
try {
const [settingsRes, categoriesRes, livreursRes, productsRes] = await Promise.all([
getSettings(),
getCategories(),
getAvailableDeliveryPersons(),
getAllProductsAdmin(),
]);
if (settingsRes.success && settingsRes.settings) {
const s = settingsRes.settings;
setSettings({
...s,
penalty_tiers: s.penalty_tiers ?? [],
points_pools: (s.points_pools ?? []).map((p: any) => ({
...p,
categories: p.categories ?? [],
tiers: p.tiers ?? [],
})),
nowpayments_currencies: s.nowpayments_currencies ?? [],
delivery_schedule: s.delivery_schedule ?? DEFAULT_DELIVERY_SCHEDULE,
postal_zones: s.postal_zones ?? DEFAULT_POSTAL_ZONES,
delivery_mode: {
...(s.delivery_mode ?? { mode: "single" as const }),
category_routes: s.delivery_mode?.category_routes ?? [],
},
points_reward: s.points_reward
? { ...s.points_reward, category_configs: s.points_reward.category_configs ?? [], reward_items: s.points_reward.reward_items ?? [] }
: null,
});
}
if (categoriesRes) {
setCategories(categoriesRes.filter((c) => !c.is_coming_soon));
}
if (livreursRes.success) {
setLivreurs(livreursRes.livreurs.map((l: any) => l.username));
}
if (productsRes.success) {
const byCategory: Record<string, Product[]> = {};
for (const p of productsRes.data) {
const cat = p.category || "";
if (!byCategory[cat]) byCategory[cat] = [];
byCategory[cat].push(p);
}
setProductsByCategory(byCategory);
}
} catch (e) {
console.error("[SettingsScreen] loadData error:", e);
} finally {
setLoading(false);
setTimeout(() => { isLoaded.current = true; isDirty.current = false; }, 0);
}
if (categoriesRes) {
setCategories(categoriesRes.filter((c) => !c.is_coming_soon));
}
if (livreursRes.success) {
setLivreurs(livreursRes.livreurs.map((l: any) => l.username));
}
setLoading(false);
// Marquer comme chargé après un tick pour que le useEffect de settings ne
// considère pas le setSettings initial comme une modification utilisateur
setTimeout(() => { isLoaded.current = true; isDirty.current = false; }, 0);
}, []);
useEffect(() => {
@@ -939,9 +1482,28 @@ export default function SettingsScreen() {
return (
<View style={s.container}>
<ScrollView contentContainerStyle={s.content}>
{/* Personnalisation */}
<AccordionSection title="Personnalisation" colors={colors} s={s}>
<View style={[s.row, s.rowFirst]}>
<View style={s.rowLeft}>
<Text style={s.rowLabel}>Nom du shop</Text>
<Text style={s.rowDesc}>Affiché dans la sidebar du site client</Text>
</View>
</View>
<View style={{ paddingHorizontal: spacing.l, paddingBottom: spacing.l }}>
<TextInput
style={s.input}
value={settings.shop_name}
onChangeText={(v) => setSettings((p) => ({ ...p, shop_name: v }))}
placeholder="Ex: Milieu-Nantais"
placeholderTextColor={colors.textMuted}
autoCorrect={false}
/>
</View>
</AccordionSection>
{/* Amendes */}
<View style={s.section}>
<Text style={s.sectionTitle}>Amendes</Text>
<AccordionSection title="Amendes" colors={colors} s={s}>
<View style={[s.row, s.rowFirst]}>
<View style={s.rowLeft}>
<Text style={s.rowLabel}>Amendes activées</Text>
@@ -1001,7 +1563,7 @@ export default function SettingsScreen() {
<Text style={[s.rowDesc, { width: inputMd, textAlign: "center" }]}>Montant</Text>
<View style={{ width: 28 }} />
</View>
{settings.penalty_tiers.map((tier, i) => (
{(settings.penalty_tiers ?? []).map((tier, i) => (
<View key={i} style={{ flexDirection: "row", alignItems: "center", width: "100%", gap: spacing.s }}>
<TextInput
style={[s.thresholdInput, { flex: 1, textAlign: "center" }]}
@@ -1044,11 +1606,10 @@ export default function SettingsScreen() {
</View>
))}
</View>
</View>
</AccordionSection>
{/* Parrainage */}
<View style={s.section}>
<Text style={s.sectionTitle}>Parrainage</Text>
<AccordionSection title="Parrainage" colors={colors} s={s}>
<View style={[s.row, s.rowFirst]}>
<View style={s.rowLeft}>
<Text style={s.rowLabel}>Parrainage activé</Text>
@@ -1066,11 +1627,10 @@ export default function SettingsScreen() {
thumbColor="#fff"
/>
</View>
</View>
</AccordionSection>
{/* Système de points */}
<View style={s.section}>
<Text style={s.sectionTitle}>Système de points</Text>
<AccordionSection title="Système de points" colors={colors} s={s}>
<View style={[s.row, s.rowFirst]}>
<View style={s.rowLeft}>
<Text style={s.rowLabel}>Points activés</Text>
@@ -1126,11 +1686,10 @@ export default function SettingsScreen() {
</Text>
</TouchableOpacity>
</View>
</View>
</AccordionSection>
{/* Attribution des catégories */}
<View style={s.section}>
<Text style={s.sectionTitle}>Attribution des catégories aux points</Text>
<AccordionSection title="Attribution des catégories aux points" colors={colors} s={s}>
<Text style={s.hint}>
Choisissez quel type de point est attribué pour chaque catégorie de produit.{"\n"}
Vous pouvez modifier les attributions à tout moment.
@@ -1217,7 +1776,7 @@ export default function SettingsScreen() {
Ajoutez au moins un type de point pour assigner des catégories.
</Text>
)}
</View>
</AccordionSection>
{/* Barèmes de points — un par pool */}
{pools.map((pool, i) => (
@@ -1233,6 +1792,16 @@ export default function SettingsScreen() {
/>
))}
{/* Récompense centralisée par palier */}
<CentralRewardSection
reward={settings.points_reward ?? null}
allCategories={categories}
productsByCategory={productsByCategory}
onChange={(reward) => setSettings((p) => ({ ...p, points_reward: reward }))}
colors={colors}
s={s}
/>
{/* Horaires de livraison */}
<DeliveryScheduleSection
schedule={settings.delivery_schedule ?? DEFAULT_DELIVERY_SCHEDULE}
@@ -1250,8 +1819,7 @@ export default function SettingsScreen() {
/>
{/* Paiement crypto */}
<View style={s.section}>
<Text style={s.sectionTitle}>Paiement crypto</Text>
<AccordionSection title="Paiement crypto" colors={colors} s={s}>
{/* Toggle activation */}
<View style={[s.row, s.rowFirst]}>
@@ -1393,13 +1961,12 @@ export default function SettingsScreen() {
)}
</View>
</View>
</View>
</AccordionSection>
{/* ============================================ */}
{/* 🤖 TELEGRAM */}
{/* ============================================ */}
<View style={s.section}>
<Text style={s.sectionTitle}>Notifications Telegram</Text>
<AccordionSection title="Notifications Telegram" colors={colors} s={s}>
<View style={s.row}>
<View style={s.rowLeft}>
<Text style={s.rowLabel}>Notifications activées</Text>
@@ -1412,6 +1979,18 @@ export default function SettingsScreen() {
thumbColor="#fff"
/>
</View>
<View style={s.row}>
<View style={s.rowLeft}>
<Text style={s.rowLabel}>Authentification 2FA</Text>
<Text style={s.rowDesc}>Permettre aux clients d'activer la double authentification via Telegram lors de la connexion</Text>
</View>
<Switch
value={settings.telegram_2fa_enabled}
onValueChange={(v) => setSettings((prev) => ({ ...prev, telegram_2fa_enabled: v }))}
trackColor={{ false: colors.border, true: colors.accent }}
thumbColor="#fff"
/>
</View>
<View style={{ paddingHorizontal: spacing.l, paddingBottom: spacing.l, gap: spacing.m }}>
<Text style={s.rowDesc}>
Configurez le bot Telegram pour envoyer des notifications aux utilisateurs qui ont lié leur compte.
@@ -1451,14 +2030,38 @@ export default function SettingsScreen() {
<Text style={{ color: "#10b981", fontSize: fontSize.sm }}>Bot configuré @{settings.telegram_bot_username}</Text>
</View>
)}
<View>
<Text style={[s.rowLabel, { marginBottom: spacing.xs }]}>Contact SAV Telegram</Text>
<Text style={[s.rowDesc, { marginBottom: spacing.s }]}>
Username du compte Telegram SAV (sans le @). Utilisé pour les boutons de contact client.
</Text>
<View style={{ flexDirection: "row", alignItems: "center", gap: spacing.s }}>
<Text style={{ fontSize: fontSize.md, color: colors.textMuted }}>@</Text>
<TextInput
style={[s.input, { flex: 1 }]}
value={settings.contact_telegram}
onChangeText={(v) => setSettings((p) => ({ ...p, contact_telegram: v }))}
placeholder="MonSAV"
placeholderTextColor={colors.textMuted}
autoCapitalize="none"
autoCorrect={false}
/>
</View>
{settings.contact_telegram !== "" && (
<View style={{ flexDirection: "row", alignItems: "center", gap: spacing.s, marginTop: spacing.s, padding: spacing.m, backgroundColor: "#0088cc22", borderRadius: borderRadius.sm }}>
<Ionicons name="paper-plane-outline" size={16} color="#0088cc" />
<Text style={{ color: "#0088cc", fontSize: fontSize.sm }}>@{settings.contact_telegram}</Text>
</View>
)}
</View>
</View>
</View>
</AccordionSection>
{/* ============================================ */}
{/* 🚚 MODE DE LIVRAISON */}
{/* ============================================ */}
<View style={s.section}>
<Text style={s.sectionTitle}>Mode de livraison</Text>
<AccordionSection title="Mode de livraison" colors={colors} s={s}>
<Text style={[s.hint, { paddingTop: spacing.s, paddingHorizontal: spacing.l }]}>
Choisissez comment les commandes sont assignées aux livreurs.
</Text>
@@ -1577,7 +2180,7 @@ export default function SettingsScreen() {
</TouchableOpacity>
</View>
)}
</View>
</AccordionSection>
<TouchableOpacity
style={s.saveButton}
@@ -0,0 +1,500 @@
import React, { useState, useCallback, useMemo } from "react";
import {
View,
Text,
ScrollView,
StyleSheet,
RefreshControl,
TouchableOpacity,
} from "react-native";
import { Ionicons } from "@expo/vector-icons";
import { useFocusEffect } from "@react-navigation/native";
import { useTheme } from "../../context/ThemeContext";
import { spacing, fontSize, borderRadius } from "../../theme";
import { shadows } from "../../theme/shadows";
import LoadingSpinner from "../../components/ui/LoadingSpinner";
import { getAdminStats } from "../../api/api_admin";
import type { AdminStats, WeekdayStat, DayStat, DayRevenueStat, HourStat, ProductStat, ProductQuantityBreakdown } from "../../api/api_admin";
// ── Palette graphiques ────────────────────────────────────────────────────────
const CHART_ACCENT = "#6366f1";
const CHART_GREEN = "#10b981";
const CHART_AMBER = "#f59e0b";
const CHART_RED = "#ef4444";
const CHART_BLUE = "#3b82f6";
// ── Utilitaires ───────────────────────────────────────────────────────────────
const maxOf = (arr: number[]) => arr.length ? Math.max(...arr) : 1;
const fmtNum = (n: number) =>
n >= 1000 ? `${(n / 1000).toFixed(1)}k` : String(Math.round(n));
const fmtEuro = (n: number) =>
n >= 1000 ? `${(n / 1000).toFixed(1)}k€` : `${Math.round(n)}`;
// ── Barre horizontale ─────────────────────────────────────────────────────────
function HBar({
label, value, max, color, right,
}: {
label: string; value: number; max: number; color: string; right?: string;
}) {
const pct = max > 0 ? Math.max((value / max) * 100, value > 0 ? 2 : 0) : 0;
const { colors } = useTheme();
return (
<View style={hBarStyles.row}>
<Text style={[hBarStyles.label, { color: colors.textMuted }]} numberOfLines={1}>
{label}
</Text>
<View style={[hBarStyles.track, { backgroundColor: colors.borderLight }]}>
<View style={[hBarStyles.fill, { width: `${pct}%`, backgroundColor: color }]} />
</View>
<Text style={[hBarStyles.value, { color: colors.textPrimary }]}>
{right ?? fmtNum(value)}
</Text>
</View>
);
}
const hBarStyles = StyleSheet.create({
row: { flexDirection: "row", alignItems: "center", marginBottom: spacing.s, gap: spacing.s },
label: { width: 80, fontSize: fontSize.xs, flexShrink: 0 },
track: { flex: 1, height: 18, borderRadius: 4, overflow: "hidden" },
fill: { height: "100%", borderRadius: 4 },
value: { width: 46, fontSize: fontSize.xs, textAlign: "right" },
});
// ── Barres verticales (sparkline 30 jours — commandes) ───────────────────────
function SparkLine({ data, color }: { data: DayStat[]; color: string }) {
const { colors } = useTheme();
if (!data.length) return null;
const max = maxOf(data.map((d) => d.count));
const BAR_H = 56;
return (
<View>
<View style={{ flexDirection: "row", alignItems: "flex-end", height: BAR_H, gap: 2 }}>
{data.map((d, i) => (
<View
key={i}
style={{
flex: 1,
height: max > 0 ? Math.max((d.count / max) * BAR_H, d.count > 0 ? 3 : 0) : 0,
backgroundColor: color,
borderRadius: 2,
opacity: 0.85,
}}
/>
))}
</View>
<View style={{ flexDirection: "row", justifyContent: "space-between", marginTop: 4 }}>
<Text style={{ color: colors.textMuted, fontSize: 9 }}>{data[0]?.label}</Text>
<Text style={{ color: colors.textMuted, fontSize: 9 }}>{data[data.length - 1]?.label}</Text>
</View>
</View>
);
}
// ── Sparkline revenus 30 jours ────────────────────────────────────────────────
function SparkLineRevenue({ data, color }: { data: DayRevenueStat[]; color: string }) {
const { colors } = useTheme();
if (!data.length) return null;
const max = Math.max(...data.map((d) => d.revenue), 1);
const BAR_H = 56;
return (
<View>
<View style={{ flexDirection: "row", alignItems: "flex-end", height: BAR_H, gap: 2 }}>
{data.map((d, i) => (
<View
key={i}
style={{
flex: 1,
height: max > 0 ? Math.max((d.revenue / max) * BAR_H, d.revenue > 0 ? 3 : 0) : 0,
backgroundColor: color,
borderRadius: 2,
opacity: 0.85,
}}
/>
))}
</View>
<View style={{ flexDirection: "row", justifyContent: "space-between", marginTop: 4 }}>
<Text style={{ color: colors.textMuted, fontSize: 9 }}>{data[0]?.label}</Text>
<Text style={{ color: colors.textMuted, fontSize: 9 }}>{data[data.length - 1]?.label}</Text>
</View>
</View>
);
}
// ── Carte résumé ──────────────────────────────────────────────────────────────
function SummaryCard({
icon, label, value, color,
}: {
icon: keyof typeof Ionicons.glyphMap; label: string; value: string; color: string;
}) {
const { colors } = useTheme();
return (
<View style={[sumStyles.card, shadows.sm, { backgroundColor: colors.bgCard, borderColor: colors.borderLight }]}>
<View style={[sumStyles.iconWrap, { backgroundColor: color + "22" }]}>
<Ionicons name={icon} size={20} color={color} />
</View>
<Text style={[sumStyles.val, { color: colors.textPrimary }]}>{value}</Text>
<Text style={[sumStyles.lbl, { color: colors.textMuted }]}>{label}</Text>
</View>
);
}
const sumStyles = StyleSheet.create({
card: { flex: 1, borderRadius: borderRadius.md, padding: spacing.m, borderWidth: 1, alignItems: "center", minWidth: "45%" },
iconWrap:{ width: 36, height: 36, borderRadius: 18, justifyContent: "center", alignItems: "center", marginBottom: spacing.xs },
val: { fontSize: fontSize.lg, fontWeight: "700" },
lbl: { fontSize: fontSize.xs, marginTop: 2, textAlign: "center" },
});
// ── Séparateur de section ─────────────────────────────────────────────────────
function Section({ title, icon, children }: { title: string; icon: keyof typeof Ionicons.glyphMap; children: React.ReactNode }) {
const { colors } = useTheme();
return (
<View style={[secStyles.card, { backgroundColor: colors.bgCard, borderColor: colors.borderLight }]}>
<View style={secStyles.header}>
<Ionicons name={icon} size={16} color={CHART_ACCENT} />
<Text style={[secStyles.title, { color: colors.textPrimary }]}>{title}</Text>
</View>
{children}
</View>
);
}
const secStyles = StyleSheet.create({
card: { borderRadius: borderRadius.md, padding: spacing.l, marginBottom: spacing.m, borderWidth: 1 },
header: { flexDirection: "row", alignItems: "center", gap: spacing.s, marginBottom: spacing.m },
title: { fontSize: fontSize.md, fontWeight: "600" },
});
// ── Sélecteur de période top produits ────────────────────────────────────────
type ProdSort = "quantity" | "orders" | "revenue";
// ═══════════════════════════════════════════════════════════════════════════════
export default function StatsScreen() {
const { colors } = useTheme();
const [stats, setStats] = useState<AdminStats | null>(null);
const [loading, setLoading] = useState(true);
const [refreshing, setRefreshing] = useState(false);
const [prodSort, setProdSort] = useState<ProdSort>("quantity");
const loadStats = useCallback(async () => {
try {
const data = await getAdminStats();
setStats(data);
} catch { /* ignore */ }
setLoading(false);
}, []);
useFocusEffect(useCallback(() => { loadStats(); }, [loadStats]));
const onRefresh = async () => {
setRefreshing(true);
await loadStats();
setRefreshing(false);
};
// Produits triés selon le sélecteur
const sortedProducts = useMemo<ProductStat[]>(() => {
if (!stats?.top_products) return [];
return [...stats.top_products].sort((a, b) => {
if (prodSort === "orders") return b.order_count - a.order_count;
if (prodSort === "revenue") return b.revenue - a.revenue;
return b.quantity - a.quantity;
});
}, [stats, prodSort]);
const styles = useMemo(() => StyleSheet.create({
container: { flex: 1, backgroundColor: colors.bgPrimary },
content: { padding: spacing.l, paddingBottom: spacing.xxxl },
pageTitle: { fontSize: fontSize.xl, fontWeight: "700", color: colors.textPrimary, marginBottom: spacing.xs },
pageSubtitle: { fontSize: fontSize.sm, color: colors.textMuted, marginBottom: spacing.l },
summaryRow: { flexDirection: "row", gap: spacing.m, marginBottom: spacing.m, flexWrap: "wrap" },
sortRow: { flexDirection: "row", gap: spacing.s, marginBottom: spacing.m },
sortBtn: { paddingHorizontal: spacing.m, paddingVertical: spacing.xs, borderRadius: borderRadius.sm, borderWidth: 1 },
sortBtnText:{ fontSize: fontSize.xs, fontWeight: "600" },
emptyText: { color: colors.textMuted, fontSize: fontSize.sm, textAlign: "center", paddingVertical: spacing.l },
}), [colors]);
if (loading) return <LoadingSpinner message="Chargement des statistiques..." />;
const s = stats?.summary;
const wdMax = maxOf((stats?.by_weekday ?? []).map((w: WeekdayStat) => w.count));
const prodMax = maxOf(sortedProducts.map((p) =>
prodSort === "orders" ? p.order_count : prodSort === "revenue" ? p.revenue : p.quantity,
));
return (
<ScrollView
style={styles.container}
contentContainerStyle={styles.content}
refreshControl={<RefreshControl refreshing={refreshing} onRefresh={onRefresh} tintColor={CHART_ACCENT} />}
>
<Text style={styles.pageTitle}>Statistiques</Text>
<Text style={styles.pageSubtitle}>Activité globale & produits</Text>
{/* ── Cartes résumé ── */}
<View style={styles.summaryRow}>
<SummaryCard
icon="receipt-outline"
label="Commandes totales"
value={fmtNum(s?.total_orders ?? 0)}
color={CHART_ACCENT}
/>
<SummaryCard
icon="cash-outline"
label="Revenus (terminées)"
value={fmtEuro(s?.total_revenue ?? 0)}
color={CHART_GREEN}
/>
</View>
<View style={styles.summaryRow}>
<SummaryCard
icon="trending-up-outline"
label="Moy. commandes/jour"
value={(s?.avg_per_day ?? 0).toFixed(1)}
color={CHART_BLUE}
/>
<SummaryCard
icon="trophy-outline"
label="Jour de pointe"
value={s?.peak_weekday ?? "—"}
color={CHART_AMBER}
/>
</View>
{/* ── Évolution 30 jours (commandes) ── */}
<Section title="30 derniers jours — commandes" icon="bar-chart-outline">
{stats?.by_day_30?.length ? (
<SparkLine data={stats.by_day_30} color={CHART_ACCENT} />
) : (
<Text style={styles.emptyText}>Aucune donnée</Text>
)}
</Section>
{/* ── Revenus par jour (30 jours) ── */}
{(() => {
const revData = stats?.by_day_revenue ?? [];
const revMax = Math.max(...revData.map((d) => d.revenue), 1);
const best = [...revData].sort((a, b) => b.revenue - a.revenue)[0];
return (
<Section title="Revenus par jour (30j)" icon="trending-up-outline">
{revData.length ? (
<>
<SparkLineRevenue data={revData} color={CHART_GREEN} />
<View style={{ marginTop: spacing.m }}>
{[...revData].reverse().map((d) => (
<HBar
key={d.day}
label={d.label}
value={d.revenue}
max={revMax}
color={best && d.day === best.day ? CHART_GREEN : CHART_ACCENT + "bb"}
right={fmtEuro(d.revenue)}
/>
))}
</View>
{best && (
<View style={{ flexDirection: "row", alignItems: "center", gap: spacing.xs, marginTop: spacing.s }}>
<Ionicons name="star-outline" size={13} color={CHART_GREEN} />
<Text style={{ color: colors.textMuted, fontSize: fontSize.xs }}>
Meilleure journée :{" "}
<Text style={{ color: CHART_GREEN, fontWeight: "600" }}>
{best.label} · {fmtEuro(best.revenue)}
</Text>
</Text>
</View>
)}
</>
) : (
<Text style={styles.emptyText}>Aucune donnée de revenu</Text>
)}
</Section>
);
})()}
{/* ── Heures d'affluence ── */}
{(() => {
const hourData: HourStat[] = stats?.by_hour ?? [];
const peakHour = hourData.reduce<HourStat | null>(
(best, h) => (!best || h.count > best.count ? h : best),
null,
);
const hourMax = maxOf(hourData.map((h) => h.count));
return (
<Section title="Heures d'affluence" icon="time-outline">
{hourData.some((h) => h.count > 0) ? (
<>
{hourData.map((h) => (
<HBar
key={h.hour}
label={h.label}
value={h.count}
max={hourMax}
color={h.count === hourMax && hourMax > 0 ? CHART_AMBER : CHART_BLUE}
right={h.count > 0 ? String(h.count) : "—"}
/>
))}
{peakHour && peakHour.count > 0 && (
<View style={{ flexDirection: "row", alignItems: "center", gap: spacing.xs, marginTop: spacing.s }}>
<Ionicons name="flame-outline" size={13} color={CHART_AMBER} />
<Text style={{ color: colors.textMuted, fontSize: fontSize.xs }}>
Heure de pointe :{" "}
<Text style={{ color: CHART_AMBER, fontWeight: "600" }}>
{peakHour.label} · {peakHour.count} cmd · {fmtEuro(peakHour.revenue)}
</Text>
</Text>
</View>
)}
</>
) : (
<Text style={styles.emptyText}>Aucune donnée horaire</Text>
)}
</Section>
);
})()}
{/* ── Commandes par jour de la semaine ── */}
<Section title="Jours d'affluence" icon="calendar-outline">
{(stats?.by_weekday ?? []).map((w: WeekdayStat) => (
<HBar
key={w.weekday}
label={w.weekday.slice(0, 3)}
value={w.count}
max={wdMax}
color={w.count === wdMax && wdMax > 0 ? CHART_AMBER : CHART_ACCENT}
right={String(w.count)}
/>
))}
{wdMax > 0 && s?.peak_weekday && (
<View style={{ flexDirection: "row", alignItems: "center", gap: spacing.xs, marginTop: spacing.s }}>
<Ionicons name="flame-outline" size={13} color={CHART_AMBER} />
<Text style={{ color: colors.textMuted, fontSize: fontSize.xs }}>
Pic d'activité : <Text style={{ color: CHART_AMBER, fontWeight: "600" }}>{s.peak_weekday}</Text>
</Text>
</View>
)}
</Section>
{/* ── Doses les plus populaires par produit ── */}
{(() => {
const qtyData: ProductQuantityBreakdown[] = (stats?.by_quantity ?? []).filter(
(p) => p.quantities.length >= 1,
);
if (!qtyData.length) return null;
return (
<Section title="Doses populaires par produit" icon="flask-outline">
{qtyData.map((product) => {
const peakCount = product.quantities[0]?.order_count ?? 1;
return (
<View
key={product.product_id}
style={{
marginBottom: spacing.m,
paddingBottom: spacing.m,
borderBottomWidth: 1,
borderBottomColor: colors.borderLight,
}}
>
{/* Nom du produit avec pastille couleur */}
<View style={{ flexDirection: "row", alignItems: "center", gap: spacing.xs, marginBottom: spacing.s }}>
<View style={{ width: 10, height: 10, borderRadius: 5, backgroundColor: product.category_color }} />
<Text style={{ color: colors.textPrimary, fontSize: fontSize.sm, fontWeight: "600" }} numberOfLines={1}>
{product.name}
</Text>
</View>
{/* Barre par dose */}
{product.quantities.map((q, i) => {
const pct = peakCount > 0 ? Math.max((q.order_count / peakCount) * 100, q.order_count > 0 ? 2 : 0) : 0;
const isPeak = i === 0;
const label = Number.isInteger(q.quantity)
? `${q.quantity}g`
: `${q.quantity}g`;
return (
<View key={q.quantity} style={{ flexDirection: "row", alignItems: "center", marginBottom: 4, gap: spacing.s }}>
<Text style={{ width: 44, fontSize: fontSize.xs, color: isPeak ? product.category_color : colors.textMuted, fontWeight: isPeak ? "700" : "400" }}>
{label}
</Text>
<View style={{ flex: 1, height: 14, borderRadius: 3, backgroundColor: colors.borderLight, overflow: "hidden" }}>
<View style={{ width: `${pct}%`, height: "100%", borderRadius: 3, backgroundColor: isPeak ? product.category_color : product.category_color + "66" }} />
</View>
<Text style={{ width: 54, fontSize: fontSize.xs, textAlign: "right", color: isPeak ? product.category_color : colors.textMuted, fontWeight: isPeak ? "700" : "400" }}>
{q.order_count} cmd
</Text>
{isPeak && (
<Ionicons name="flame" size={11} color={CHART_AMBER} />
)}
</View>
);
})}
</View>
);
})}
</Section>
);
})()}
{/* ── Top produits ── */}
<Section title="Top produits" icon="cube-outline">
{/* Sélecteur tri */}
<View style={styles.sortRow}>
{(["quantity", "orders", "revenue"] as ProdSort[]).map((key) => {
const labels = { quantity: "Quantité", orders: "Commandes", revenue: "Revenus" };
const active = prodSort === key;
return (
<TouchableOpacity
key={key}
style={[
styles.sortBtn,
{
backgroundColor: active ? CHART_ACCENT + "22" : "transparent",
borderColor: active ? CHART_ACCENT : colors.borderLight,
},
]}
onPress={() => setProdSort(key)}
>
<Text style={[styles.sortBtnText, { color: active ? CHART_ACCENT : colors.textMuted }]}>
{labels[key]}
</Text>
</TouchableOpacity>
);
})}
</View>
{sortedProducts.length === 0 ? (
<Text style={styles.emptyText}>Aucune donnée produit</Text>
) : (
sortedProducts.map((p) => {
const val =
prodSort === "orders" ? p.order_count
: prodSort === "revenue" ? p.revenue
: p.quantity;
const rightLabel =
prodSort === "revenue" ? fmtEuro(p.revenue)
: prodSort === "orders" ? `${p.order_count} cmd`
: `×${fmtNum(p.quantity)}`;
return (
<HBar
key={p.product_id}
label={p.name}
value={val}
max={prodMax}
color={p.category_color || CHART_ACCENT}
right={rightLabel}
/>
);
})
)}
{/* Produit le moins vendu */}
{sortedProducts.length > 1 && prodSort === "quantity" && (
<View style={{ flexDirection: "row", alignItems: "center", gap: spacing.xs, marginTop: spacing.s }}>
<Ionicons name="arrow-down-circle-outline" size={13} color={CHART_RED} />
<Text style={{ color: colors.textMuted, fontSize: fontSize.xs }}>
Moins vendu :{" "}
<Text style={{ color: CHART_RED, fontWeight: "600" }}>
{sortedProducts[sortedProducts.length - 1]?.name}
</Text>
</Text>
</View>
)}
</Section>
</ScrollView>
);
}
@@ -60,7 +60,7 @@ export default function AdminLoginScreen() {
heroTitle: {
fontSize: screenWidth < 380 ? fontSize.xl : fontSize.xxl,
fontWeight: "800",
color: colors.textWhite,
color: colors.textPrimary,
marginTop: spacing.m,
letterSpacing: 0.5,
},
@@ -60,7 +60,7 @@ export default function CabineLoginScreen() {
heroTitle: {
fontSize: screenWidth < 380 ? fontSize.xl : fontSize.xxl,
fontWeight: "800",
color: colors.textWhite,
color: colors.textPrimary,
marginTop: spacing.m,
letterSpacing: 0.5,
},
@@ -60,7 +60,7 @@ export default function DeliveryLoginScreen() {
heroTitle: {
fontSize: screenWidth < 380 ? fontSize.xl : fontSize.xxl,
fontWeight: "800",
color: colors.textWhite,
color: colors.textPrimary,
marginTop: spacing.m,
letterSpacing: 0.5,
},
@@ -15,8 +15,8 @@ import { spacing, fontSize, borderRadius } from "../../theme";
import { useTheme } from "../../context/ThemeContext";
import { shadows } from "../../theme/shadows";
import { useAuth } from "../../auth/AuthContext";
import { getAllCommands } from "../../api/api_admin";
import {
getCabineCommands,
getAllDeliveryPersonsWithDetails,
getCabineTelegramStatus,
generateCabineLinkToken,
@@ -46,7 +46,7 @@ export default function DashboardScreen() {
const loadStats = useCallback(async () => {
try {
const [allCmd, livreursRes] = await Promise.all([
getAllCommands(),
getCabineCommands(),
getAllDeliveryPersonsWithDetails(),
]);
const cmds = allCmd.commands;
@@ -12,7 +12,6 @@ import {
import { Ionicons } from "@expo/vector-icons";
import { spacing, fontSize, borderRadius } from "../../theme";
import { useTheme } from "../../context/ThemeContext";
import { getAllCommands } from "../../api/api_admin";
import {
getCommandItems,
deleteCommand,
@@ -21,6 +20,7 @@ import {
getCabineLivreursList,
assignDeliveryPersonByCabine,
proposeAddressChangeCabine,
getCabineCommands,
} from "../../api/api_cabine";
import type { CommandResponse } from "../../api/types";
import StatusBadge from "../../components/StatusBadge";
@@ -78,7 +78,7 @@ export default function OrdersScreen() {
const loadData = useCallback(async () => {
try {
const result = await getAllCommands();
const result = await getCabineCommands();
setCommands(
result.commands.filter(
(c: CommandResponse) =>
@@ -487,8 +487,18 @@ export default function OrdersScreen() {
<Text style={styles.info}>Client: {item.username}</Text>
<Text style={styles.info}>Adresse: {item.adresse}</Text>
<Text style={styles.info}>
Total: {item.total_prix.toFixed(2)}
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}
@@ -672,7 +682,7 @@ export default function OrdersScreen() {
</Text>
<Text style={styles.itemMeta}>
Qté:{" "}
{item.quantite ?? item.quantity} ·{" "}
{item.quantite ?? item.quantity}{item.unit || ""} ·{" "}
{(item.prix ?? item.price)?.toFixed(
2,
)}{" "}
@@ -2,8 +2,7 @@ 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 { getAllClients } from "../../api/api_admin";
import { applyClientPenalty, resetClientPenalties, resetClientPoints, getPublicSettings } from "../../api/api_cabine";
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";
@@ -35,7 +34,7 @@ export default function UsersScreen() {
const loadData = useCallback(async () => {
try {
const [clientsData, settings] = await Promise.all([
getAllClients(),
getCabineAllClients(),
getPublicSettings(),
]);
setClients(clientsData);
@@ -75,7 +75,7 @@ interface EnrichedDelivery extends DeliveryItem {
clientUsername?: string;
clientNom?: string;
clientPrenom?: string;
items?: Array<{ produit: string; quantite: number; prix: number }>;
items?: Array<{ produit: string; quantite: number; prix: number; unit?: string; is_reward?: boolean }>;
}
export default function DashboardScreen() {
@@ -115,9 +115,9 @@ export default function DashboardScreen() {
useState<EnrichedDelivery | null>(null);
// Telegram
const [tgLinked, setTgLinked] = useState(false);
const [tgEnabled, setTgEnabled] = useState(false);
const [tgLoading, setTgLoading] = useState(false);
const [tgLinked, setTgLinked] = useState(false);
const [tgEnabled, setTgEnabled] = useState(false);
const [tgLoading, setTgLoading] = useState(false);
const [cancelModal, setCancelModal] = useState<{
visible: boolean;
deliveryId: number | null;
@@ -125,6 +125,12 @@ export default function DashboardScreen() {
description: string;
}>({ visible: false, deliveryId: null, issueType: null, description: "" });
const ABSENT_TIMEOUT_SECS = 300; // 5 minutes
const arrivedAtRef = useRef<Record<number, number>>({});
const [elapsedSeconds, setElapsedSeconds] = useState<
Record<number, number>
>({});
const STATUS_COLORS: Record<string, string> = useMemo(
() => ({
available: colors.success,
@@ -276,11 +282,21 @@ export default function DashboardScreen() {
setDeliveries(enriched);
// Enregistrer le timestamp d'arrivée pour les livraisons "arrived"
for (const d of enriched) {
if (
d.status === "arrived" ||
(d.status === "livre" && !arrivedAtRef.current[d.id])
) {
arrivedAtRef.current[d.id] = Date.now();
} else if (d.status !== "arrived" && d.status !== "livre") {
delete arrivedAtRef.current[d.id];
}
}
const activeDelivery =
enriched.find(
(d) =>
d.status === "en_route",
) || enriched.find((d) => d.status === "assigned");
enriched.find((d) => d.status === "en_route") ||
enriched.find((d) => d.status === "assigned");
if (activeDelivery && activeDelivery.adresse) {
calcRoute(activeDelivery.adresse);
}
@@ -292,7 +308,10 @@ export default function DashboardScreen() {
useEffect(() => {
loadData();
getLivreurTelegramStatus().then((s) => { setTgLinked(s.linked); setTgEnabled(s.enabled); });
getLivreurTelegramStatus().then((s) => {
setTgLinked(s.linked);
setTgEnabled(s.enabled);
});
}, [loadData]);
useEffect(() => {
@@ -302,6 +321,18 @@ export default function DashboardScreen() {
return () => clearInterval(interval);
}, [loadData]);
useEffect(() => {
const interval = setInterval(() => {
const now = Date.now();
const updated: Record<number, number> = {};
for (const [id, ts] of Object.entries(arrivedAtRef.current)) {
updated[Number(id)] = Math.floor((now - ts) / 1000);
}
setElapsedSeconds(updated);
}, 1000);
return () => clearInterval(interval);
}, []);
// Quand GPS devient disponible, rejouer la route en attente
useEffect(() => {
if (lastCoords && pendingRouteAddress.current) {
@@ -524,7 +555,12 @@ export default function DashboardScreen() {
cancelModal.description,
);
}
setCancelModal({ visible: false, deliveryId: null, issueType: null, description: "" });
setCancelModal({
visible: false,
deliveryId: null,
issueType: null,
description: "",
});
if (res.success) {
showSuccess("Succès", "Livraison annulée");
loadData();
@@ -533,11 +569,36 @@ export default function DashboardScreen() {
}
};
const handleClientAbsent = async (deliveryId: number) => {
const lat = lastCoords?.lat || 0;
const lng = lastCoords?.lng || 0;
const res = await updateDeliveryStatus(
deliveryId,
"cancelled",
lat,
lng,
"Client absent",
);
if (res.success) {
await reportDeliveryIssue(
deliveryId,
"client_absent",
"Client non présent après attente",
);
delete arrivedAtRef.current[deliveryId];
showSuccess("Commande annulée", "Une amende a été appliquée au client");
loadData();
} else {
showError("Erreur", res.error || "Erreur");
}
};
const openNavigation = async (deliveryId: number, address: string) => {
const res = await getDeliveryNavLink(deliveryId);
const link = res.success && res.waze_app
? res.waze_app
: `waze://?q=${encodeURIComponent(address)}&navigate=yes`;
const link =
res.success && res.waze_app
? res.waze_app
: `waze://?q=${encodeURIComponent(address)}&navigate=yes`;
Linking.openURL(link);
};
@@ -638,18 +699,32 @@ export default function DashboardScreen() {
Produits ({item.items.length})
</Text>
</View>
{item.items.some(p => p.is_reward) && (
<View style={{ flexDirection: "row", alignItems: "center", gap: 6, backgroundColor: "rgba(245,158,11,0.12)", borderRadius: 6, padding: 6, marginBottom: 6 }}>
<Ionicons name="gift-outline" size={15} color="#f59e0b" />
<Text style={{ fontSize: 13, color: "#f59e0b", fontWeight: "700" }}>
Cette commande contient un article offert (récompense client)
</Text>
</View>
)}
{item.items.map((prod, idx) => (
<View key={idx} style={styles.itemRow}>
<View style={{ flex: 1 }}>
<Text style={styles.itemName}>
{prod.produit}
</Text>
<View style={{ flexDirection: "row", alignItems: "center", gap: 4 }}>
<Text style={styles.itemName}>{prod.produit}</Text>
{prod.is_reward && (
<View style={{ flexDirection: "row", alignItems: "center", gap: 2, backgroundColor: "rgba(245,158,11,0.15)", borderRadius: 4, paddingHorizontal: 4, paddingVertical: 1 }}>
<Ionicons name="gift-outline" size={10} color="#f59e0b" />
<Text style={{ fontSize: 10, color: "#f59e0b", fontWeight: "700" }}>Offert</Text>
</View>
)}
</View>
<Text style={styles.itemQty}>
Quantité: {prod.quantite}
Quantité: {prod.quantite}{prod.unit || ""}
</Text>
</View>
<Text style={styles.itemPrice}>
{(prod.prix ?? 0).toFixed(2)}
<Text style={[styles.itemPrice, prod.is_reward ? { color: "#10b981" } : {}]}>
{prod.is_reward ? "Offert" : `${(prod.prix ?? 0).toFixed(2)}`}
</Text>
</View>
))}
@@ -660,14 +735,34 @@ export default function DashboardScreen() {
</Text>
</View>
{(item.referral_used ?? 0) > 0 && (
<View style={[styles.totalRow, { marginTop: 2 }]}>
<Text style={[styles.totalLabel, { color: colors.success }]}>
Parrainage client
</Text>
<Text style={[styles.totalValue, { color: colors.success }]}>
-{(item.referral_used ?? 0).toFixed(2)}
</Text>
</View>
<>
<View style={[styles.totalRow, { marginTop: 2 }]}>
<Text
style={[
styles.totalLabel,
{ color: colors.success },
]}
>
Parrainage client
</Text>
<Text
style={[
styles.totalValue,
{ color: colors.success },
]}
>
-{(item.referral_used ?? 0).toFixed(2)}
</Text>
</View>
<View style={[styles.totalRow, { marginTop: 2 }]}>
<Text style={[styles.totalLabel, { fontWeight: "700" }]}>
Net à encaisser
</Text>
<Text style={[styles.totalValue, { fontWeight: "700" }]}>
{((item.total_prix ?? 0) - (item.referral_used ?? 0)).toFixed(2)}
</Text>
</View>
</>
)}
</View>
)}
@@ -675,12 +770,28 @@ export default function DashboardScreen() {
{(!item.items || item.items.length === 0) && (
<>
<Text style={styles.priceOnly}>
{item.total_prix ?? 0}
{(item.referral_used ?? 0) > 0 ? "Brut : " : ""}
{(item.total_prix ?? 0)}
</Text>
{(item.referral_used ?? 0) > 0 && (
<Text style={[styles.priceOnly, { color: colors.success, marginTop: 2 }]}>
Parrainage: -{(item.referral_used ?? 0).toFixed(2)}
</Text>
<>
<Text
style={[
styles.priceOnly,
{ color: colors.success, marginTop: 2 },
]}
>
Parrainage: -{(item.referral_used ?? 0).toFixed(2)}
</Text>
<Text
style={[
styles.priceOnly,
{ fontWeight: "700", marginTop: 2 },
]}
>
Net à encaisser: {((item.total_prix ?? 0) - (item.referral_used ?? 0)).toFixed(2)}
</Text>
</>
)}
</>
)}
@@ -735,27 +846,52 @@ export default function DashboardScreen() {
}}
/>
)}
{item.status === "arrived" && (
<View style={{ flexDirection: "row", gap: spacing.s, marginTop: spacing.s }}>
{item.status === "arrived" && (() => {
const elapsed = elapsedSeconds[item.id] || 0;
const remaining = Math.max(0, ABSENT_TIMEOUT_SECS - elapsed);
const showAbsent = elapsed >= ABSENT_TIMEOUT_SECS;
const mm = String(Math.floor(remaining / 60)).padStart(2, "0");
const ss = String(remaining % 60).padStart(2, "0");
return (
<View style={{ marginTop: spacing.s, gap: spacing.s }}>
<View style={{ flexDirection: "row", gap: spacing.s }}>
<Button
title="Terminer"
onPress={() => handleCompleteDelivery(item.id)}
style={{ flex: 1, backgroundColor: colors.accent }}
/>
<Button
title="Annuler"
onPress={() =>
setCancelModal({
visible: true,
deliveryId: item.id,
issueType: null,
description: "",
})
}
style={{ flex: 1, backgroundColor: colors.danger }}
/>
</View>
<Button
title="Terminer"
onPress={() => handleCompleteDelivery(item.id)}
style={{ flex: 1, backgroundColor: colors.accent }}
/>
<Button
title="Annuler"
onPress={() =>
setCancelModal({
visible: true,
deliveryId: item.id,
issueType: null,
description: "",
})
}
style={{ flex: 1, backgroundColor: colors.danger }}
title="Client pas là"
onPress={() => handleClientAbsent(item.id)}
style={{ backgroundColor: colors.warning }}
/>
{showAbsent ? (
<Button
title="Client absent (5 min écoulées)"
onPress={() => handleClientAbsent(item.id)}
style={{ backgroundColor: colors.danger }}
/>
) : (
<Text style={{ color: colors.textMuted, fontSize: fontSize.sm, textAlign: "center" }}>
Client absent dans {mm}:{ss}
</Text>
)}
</View>
)}
);
})()}
</Card>
);
};
@@ -777,7 +913,10 @@ export default function DashboardScreen() {
const handleUnlinkTelegram = async () => {
await unlinkLivreurTelegram();
setTgLinked(false);
showSuccess("Telegram délié", "Vous ne recevrez plus de notifications Telegram.");
showSuccess(
"Telegram délié",
"Vous ne recevrez plus de notifications Telegram.",
);
};
const renderHeader = () => (
@@ -904,26 +1043,119 @@ export default function DashboardScreen() {
{/* Carte Telegram */}
{tgEnabled && (
<View style={{ marginHorizontal: spacing.l, marginBottom: spacing.m, 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.s }}>
<Ionicons name="paper-plane-outline" size={18} color="#2AABEE" />
<Text style={{ color: colors.textPrimary, fontSize: fontSize.md, fontWeight: "600" }}>Notifications Telegram</Text>
<View
style={{
marginHorizontal: spacing.l,
marginBottom: spacing.m,
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.s,
}}
>
<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 lié</Text>
<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 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</Text>
<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
</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
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>
@@ -1769,21 +2001,37 @@ export default function DashboardScreen() {
</Text>
{detailsDelivery?.items &&
detailsDelivery.items.length > 0 ? (
detailsDelivery.items.map((prod, idx) => (
<View key={idx} style={styles.detailProductRow}>
<View style={{ flex: 1 }}>
<Text style={styles.detailProductName}>
{prod.produit}
</Text>
<Text style={styles.detailProductQty}>
Quantité : {prod.quantite}
<>
{detailsDelivery.items.some(p => p.is_reward) && (
<View style={{ flexDirection: "row", alignItems: "center", gap: 6, backgroundColor: "rgba(245,158,11,0.12)", borderRadius: 8, padding: 8, marginBottom: 8 }}>
<Ionicons name="gift-outline" size={16} color="#f59e0b" />
<Text style={{ fontSize: 13, color: "#f59e0b", fontWeight: "700" }}>
Cette commande contient un article offert (récompense client)
</Text>
</View>
<Text style={styles.detailProductPrice}>
{(prod.prix ?? 0).toFixed(2)}
</Text>
</View>
))
)}
{detailsDelivery.items.map((prod, idx) => (
<View key={idx} style={styles.detailProductRow}>
<View style={{ flex: 1 }}>
<View style={{ flexDirection: "row", alignItems: "center", gap: 4 }}>
<Text style={styles.detailProductName}>{prod.produit}</Text>
{prod.is_reward && (
<View style={{ flexDirection: "row", alignItems: "center", gap: 2, backgroundColor: "rgba(245,158,11,0.15)", borderRadius: 4, paddingHorizontal: 4, paddingVertical: 1 }}>
<Ionicons name="gift-outline" size={11} color="#f59e0b" />
<Text style={{ fontSize: 11, color: "#f59e0b", fontWeight: "700" }}>Offert</Text>
</View>
)}
</View>
<Text style={styles.detailProductQty}>
Quantité : {prod.quantite}{prod.unit || ""}
</Text>
</View>
<Text style={[styles.detailProductPrice, prod.is_reward ? { color: "#10b981" } : {}]}>
{prod.is_reward ? "Offert" : `${(prod.prix ?? 0).toFixed(2)}`}
</Text>
</View>
))}
</>
) : (
<Text style={styles.detailEmpty}>Aucun produit</Text>
)}
@@ -1796,14 +2044,34 @@ export default function DashboardScreen() {
</Text>
</View>
{(detailsDelivery?.referral_used ?? 0) > 0 && (
<View style={[styles.detailTotalRow, { marginTop: 4 }]}>
<Text style={[styles.detailTotalLabel, { color: colors.success }]}>
Parrainage client
</Text>
<Text style={[styles.detailTotalValue, { color: colors.success }]}>
-{detailsDelivery?.referral_used?.toFixed(2)}
</Text>
</View>
<>
<View style={[styles.detailTotalRow, { marginTop: 4 }]}>
<Text
style={[
styles.detailTotalLabel,
{ color: colors.success },
]}
>
Parrainage client
</Text>
<Text
style={[
styles.detailTotalValue,
{ color: colors.success },
]}
>
-{detailsDelivery?.referral_used?.toFixed(2)}
</Text>
</View>
<View style={[styles.detailTotalRow, { marginTop: 4 }]}>
<Text style={[styles.detailTotalLabel, { fontWeight: "700" }]}>
Net à encaisser
</Text>
<Text style={[styles.detailTotalValue, { fontWeight: "700" }]}>
{((detailsDelivery?.total_prix ?? 0) - (detailsDelivery?.referral_used ?? 0)).toFixed(2)}
</Text>
</View>
</>
)}
</ScrollView>
</DetailsModal>
@@ -1873,31 +2141,71 @@ export default function DashboardScreen() {
<DetailsModal
visible={cancelModal.visible}
onClose={() =>
setCancelModal({ visible: false, deliveryId: null, issueType: null, description: "" })
setCancelModal({
visible: false,
deliveryId: null,
issueType: null,
description: "",
})
}
title="Motif de non-livraison"
icon="close-circle-outline"
>
<Text style={[styles.cancelInput, { color: colors.textSecondary, fontSize: 13, marginBottom: spacing.s, backgroundColor: "transparent", borderWidth: 0, padding: 0 }]}>
<Text
style={[
styles.cancelInput,
{
color: colors.textSecondary,
fontSize: 13,
marginBottom: spacing.s,
backgroundColor: "transparent",
borderWidth: 0,
padding: 0,
},
]}
>
Sélectionnez un motif
</Text>
<View style={{ flexDirection: "row", flexWrap: "wrap", gap: spacing.s, marginBottom: spacing.m }}>
<View
style={{
flexDirection: "row",
flexWrap: "wrap",
gap: spacing.s,
marginBottom: spacing.m,
}}
>
{(Object.keys(ISSUE_LABELS) as IssueType[]).map((type) => {
const selected = cancelModal.issueType === type;
return (
<TouchableOpacity
key={type}
onPress={() => setCancelModal((prev) => ({ ...prev, issueType: type }))}
onPress={() =>
setCancelModal((prev) => ({
...prev,
issueType: type,
}))
}
style={{
paddingHorizontal: spacing.m,
paddingVertical: spacing.s,
borderRadius: 20,
borderWidth: 1,
borderColor: selected ? colors.danger : colors.border,
backgroundColor: selected ? colors.danger + "22" : colors.bgCard,
borderColor: selected
? colors.danger
: colors.border,
backgroundColor: selected
? colors.danger + "22"
: colors.bgCard,
}}
>
<Text style={{ color: selected ? colors.danger : colors.textSecondary, fontSize: 13 }}>
<Text
style={{
color: selected
? colors.danger
: colors.textSecondary,
fontSize: 13,
}}
>
{ISSUE_LABELS[type]}
</Text>
</TouchableOpacity>
@@ -1915,11 +2223,16 @@ export default function DashboardScreen() {
multiline
/>
<TouchableOpacity
style={[styles.cancelConfirmBtn, !cancelModal.issueType && { opacity: 0.4 }]}
style={[
styles.cancelConfirmBtn,
!cancelModal.issueType && { opacity: 0.4 },
]}
onPress={handleCancelDelivery}
disabled={!cancelModal.issueType}
>
<Text style={styles.cancelConfirmText}>Confirmer l'annulation</Text>
<Text style={styles.cancelConfirmText}>
Confirmer l'annulation
</Text>
</TouchableOpacity>
</DetailsModal>
@@ -5,34 +5,114 @@ import {
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, getMyStatus } from "../../api/api_delivery";
import type { DeliveryItem } from "../../api/types";
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 [loading, setLoading] = useState(true);
const [refreshing, setRefreshing] = useState(false);
const [period, setPeriod] = useState<Period>("week");
const loadData = useCallback(async () => {
try {
const res = await getMyDeliveries();
if (res.success && res.deliveries) setDeliveries(res.deliveries);
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 ?? []);
}
} catch {
/* ignore */
}
setLoading(false);
}, []);
useEffect(() => {
loadData();
}, [loadData]);
useEffect(() => { loadData(); }, [loadData]);
const onRefresh = async () => {
setRefreshing(true);
await loadData();
@@ -40,39 +120,22 @@ export default function StatsScreen() {
};
const total = deliveries.length;
const completed = deliveries.filter((d) => d.status === "livre").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 totalRevenue = deliveries
.filter((d) => d.status === "livre")
.reduce((s, d) => s + d.total_prix, 0);
const stats = [
{
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,
},
];
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(
() =>
@@ -84,57 +147,123 @@ export default function StatsScreen() {
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,
},
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: "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}
/>
}
refreshControl={<RefreshControl refreshing={refreshing} onRefresh={onRefresh} tintColor={colors.success} />}
>
<Text style={styles.title}>Mes performances</Text>
{/* Cartes résumé */}
<View style={styles.grid}>
{stats.map((s, i) => (
{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.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>
);
}