chore: build
This commit is contained in:
@@ -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<{
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user