diff --git a/omnex-plateform-app/App.tsx b/omnex-plateform-app/App.tsx index e936e22..7be8b7b 100644 --- a/omnex-plateform-app/App.tsx +++ b/omnex-plateform-app/App.tsx @@ -108,8 +108,8 @@ export default function App() { await Updates.fetchUpdateAsync(); await Updates.reloadAsync(); } - } catch { - // Silently ignore update errors + } catch (e) { + console.error("[OTA] Échec de la vérification/application de la mise à jour:", e); } }; checkForUpdate(); diff --git a/omnex-plateform-app/src/api/api_admin.ts b/omnex-plateform-app/src/api/api_admin.ts index c442501..615905c 100644 --- a/omnex-plateform-app/src/api/api_admin.ts +++ b/omnex-plateform-app/src/api/api_admin.ts @@ -1107,24 +1107,23 @@ export interface PointsTier { points: number; } +export interface RewardProductQuantity { + product_id: number; + quantity: number; // quantité individuelle de ce produit (palier de prix catalogue, ex: 1g) +} + export interface RewardCategoryConfig { category: string; type: "free_product" | "half_price_product"; all_products: boolean; - product_ids: number[]; -} - -export interface RewardItem { - product_id: number; - quantity: number; - price: number; + quantity: number; // quantité uniforme si all_products = true + products: RewardProductQuantity[]; // produits + quantité individuelle si all_products = false } export interface PointsReward { threshold: number; description: string; category_configs: RewardCategoryConfig[]; - reward_items: RewardItem[]; } export interface PointsPool { diff --git a/omnex-plateform-app/src/api/tomtom.ts b/omnex-plateform-app/src/api/tomtom.ts index 2a3bb8f..56b96e4 100644 --- a/omnex-plateform-app/src/api/tomtom.ts +++ b/omnex-plateform-app/src/api/tomtom.ts @@ -123,7 +123,6 @@ export async function calculateRoute( } } - // Fallback: straight line if (coordinates.length === 0) { coordinates.push(origin, destination); } diff --git a/omnex-plateform-app/src/navigation/DeliveryNavigator.tsx b/omnex-plateform-app/src/navigation/DeliveryNavigator.tsx index 9888260..a7697cc 100644 --- a/omnex-plateform-app/src/navigation/DeliveryNavigator.tsx +++ b/omnex-plateform-app/src/navigation/DeliveryNavigator.tsx @@ -218,6 +218,7 @@ export default function DeliveryNavigator() { /> + {/* Modal notifications */} setRatingsModal(null)} > - setRatingsModal(null)}> - {}}> - - - - Avis — {ratingsModal?.username} - - setRatingsModal(null)}> - - - - {ratingsLoading ? ( - Chargement... - ) : ratingsModal && ratingsModal.count > 0 ? ( - <> - - {[1,2,3,4,5].map((s) => ( - - ))} - - {ratingsModal.average.toFixed(1)} - - - ({ratingsModal.count} avis) - - - - {ratingsModal.ratings.map((r) => ( - - - {r.client_username} - - {new Date(r.created_at).toLocaleDateString("fr-FR")} - - - - {[1,2,3,4,5].map((s) => ( - - ))} - - {r.comment !== "" && ( - "{r.comment}" - )} - - ))} - - - ) : ( - Aucun avis pour ce livreur - )} + + + + + Avis — {ratingsModal?.username} + + setRatingsModal(null)}> + + - - + {ratingsLoading ? ( + Chargement... + ) : ratingsModal && ratingsModal.count > 0 ? ( + <> + + {[1,2,3,4,5].map((s) => ( + + ))} + + {ratingsModal.average.toFixed(1)} + + + ({ratingsModal.count} avis) + + + + {ratingsModal.ratings.map((r) => ( + + + {r.client_username} + + {new Date(r.created_at).toLocaleDateString("fr-FR")} + + + + {[1,2,3,4,5].map((s) => ( + + ))} + + {r.comment !== "" && ( + "{r.comment}" + )} + + ))} + + + ) : ( + Aucun avis pour ce livreur + )} + + {/* ── Modal historique de connexion livreur ── */} @@ -1148,12 +1148,8 @@ export default function DeliveryScreen() { animationType="slide" onRequestClose={() => setLoginHistoryModal(null)} > - setLoginHistoryModal(null)} - > - {}}> - + + Connexions — {loginHistoryModal?.username} @@ -1258,7 +1254,7 @@ export default function DeliveryScreen() { ) : loginHistoryModal && loginHistoryModal.weeks.length > 0 ? ( - + {loginHistoryModal.weeks.map((week) => ( @@ -1310,9 +1306,8 @@ export default function DeliveryScreen() { Aucune connexion ce mois-ci )} - - - + + ); diff --git a/omnex-plateform-app/src/screens/admin/SettingsScreen.tsx b/omnex-plateform-app/src/screens/admin/SettingsScreen.tsx index e58d03a..278fdfb 100644 --- a/omnex-plateform-app/src/screens/admin/SettingsScreen.tsx +++ b/omnex-plateform-app/src/screens/admin/SettingsScreen.tsx @@ -17,7 +17,7 @@ import { Ionicons } from "@expo/vector-icons"; import { spacing, fontSize, borderRadius } from "../../theme"; import { useTheme } from "../../context/ThemeContext"; 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 { AppSettings, Category, CategoryRoute, DeliveryModeConfig, PointsTier, PointsPool, PointsReward, RewardCategoryConfig, DaySchedule, DeliverySchedule, PostalZone } from "../../api/api_admin"; import type { Product } from "../../api/types"; import AlertModal from "../../components/ui/AlertModal"; import { useAlert } from "../../hooks/useAlert"; @@ -49,7 +49,7 @@ const DAYS: { key: keyof DeliverySchedule; label: string }[] = [ // ────────────────────────────────────────────────────────────── // Composant section accordéon générique -// ────────────────────────────────────────────────────────────── + function AccordionSection({ title, badge, @@ -695,7 +695,6 @@ const EMPTY_REWARD: PointsReward = { threshold: 20, description: "", category_configs: [], - reward_items: [], }; // ────────────────────────────────────────────────────────────── @@ -717,10 +716,29 @@ function CategoryProductPicker({ 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 }); + const exists = catConfig.products.some((pq) => pq.product_id === id); + if (exists) { + onChange({ ...catConfig, products: catConfig.products.filter((pq) => pq.product_id !== id), all_products: false }); + return; + } + // Présélectionne le premier palier de prix actif du produit, plutôt + // qu'une valeur arbitraire qui pourrait ne correspondre à aucun palier réel. + const prod = catProducts.find((p) => p.id === id); + const firstTier = (prod?.prices ?? []).find((pr) => pr.active_price !== false); + onChange({ + ...catConfig, + products: [...catConfig.products, { product_id: id, quantity: firstTier?.quantity ?? 0 }], + all_products: false, + }); + }; + + const updateProductQuantity = (id: number, quantity: number) => { + onChange({ + ...catConfig, + products: catConfig.products.map((pq) => + pq.product_id === id ? { ...pq, quantity } : pq + ), + }); }; return ( @@ -728,7 +746,7 @@ function CategoryProductPicker({ {/* Toggle tous / sélection */} onChange({ ...catConfig, all_products: true, product_ids: [] })} + onPress={() => onChange({ ...catConfig, all_products: true, products: [] })} style={{ flexDirection: "row", alignItems: "center", gap: spacing.xs, paddingHorizontal: spacing.m, paddingVertical: spacing.xs, @@ -759,34 +777,103 @@ function CategoryProductPicker({ - {/* Liste des produits si mode sélection */} + {/* Mode "Tous" : une quantité uniforme pour tous les produits de la catégorie */} + {catConfig.all_products && ( + + Quantité : + 0 ? String(catConfig.quantity) : ""} + onChangeText={(v) => { + const n = parseFloat(v); + onChange({ ...catConfig, quantity: isNaN(n) ? 0 : n }); + }} + placeholder="1" + placeholderTextColor={colors.textMuted} + /> + + doit correspondre à un palier de prix existant + + + )} + + {/* Mode "Sélection" : chaque produit choisi a sa propre quantité + (ex: produit A à 2g offerts, produit B à 1g offert) */} {!catConfig.all_products && ( - + {catProducts.length === 0 ? ( Aucun produit dans cette catégorie - ) : catProducts.map((p) => { - const sel = catConfig.product_ids.includes(p.id); - return ( - 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 && } - - {p.name} - - - ); - })} + ) : ( + + {catProducts.map((p) => { + const sel = catConfig.products.some((pq) => pq.product_id === p.id); + return ( + 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 && } + + {p.name} + + + ); + })} + + )} + + {catConfig.products.length > 0 && ( + + {catConfig.products.map((pq) => { + const prod = catProducts.find((p) => p.id === pq.product_id); + const tiers = (prod?.prices ?? []).filter((pr) => pr.active_price !== false); + return ( + + + {prod?.name ?? `Produit #${pq.product_id}`} + + + {tiers.length === 0 ? ( + + Aucun palier de prix actif pour ce produit + + ) : tiers.map((tier) => { + const isSel = pq.quantity === tier.quantity; + return ( + updateProductQuantity(pq.product_id, tier.quantity)} + style={{ + paddingHorizontal: spacing.s, paddingVertical: 3, + borderRadius: borderRadius.sm, borderWidth: 1.5, + borderColor: isSel ? REWARD_ACCENT : colors.border, + backgroundColor: isSel ? REWARD_ACCENT + "22" : "transparent", + flexDirection: "row", alignItems: "center", gap: 4, + }} + > + {isSel && } + + {tier.quantity}{prod?.unit ?? ""} · {tier.price}€ + + + ); + })} + + + ); + })} + + )} )} @@ -819,29 +906,44 @@ function CentralRewardSection({ const update = (patch: Partial) => onChange({ ...r, ...patch }); - const getCatConfig = (catName: string): RewardCategoryConfig => - r.category_configs.find((c) => c.category === catName) ?? - { category: catName, type: "free_product", all_products: true, product_ids: [] }; + // Une catégorie peut avoir jusqu'à deux configs actives en parallèle + // (une "free_product" et une "half_price_product"), chacune avec sa + // propre sélection de produits — d'où la recherche par (catégorie, type). + const getCatConfig = (catName: string, type: RewardCategoryConfig["type"]): RewardCategoryConfig => + r.category_configs.find((c) => c.category === catName && c.type === type) ?? + { category: catName, type, all_products: true, products: [], quantity: 1 }; + + const isTypeEnabled = (catName: string, type: RewardCategoryConfig["type"]) => + r.category_configs.some((c) => c.category === catName && c.type === type); 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) }); + const toggleCategoryType = (catName: string, type: RewardCategoryConfig["type"]) => { + if (isTypeEnabled(catName, type)) { + update({ category_configs: r.category_configs.filter((c) => !(c.category === catName && c.type === type)) }); } else { - update({ category_configs: [...r.category_configs, { category: catName, type: "free_product", all_products: true, product_ids: [] }] }); + update({ category_configs: [...r.category_configs, { category: catName, type, all_products: true, products: [], quantity: 1 }] }); } }; const updateCatConfig = (cfg: RewardCategoryConfig) => { update({ category_configs: r.category_configs.map((c) => - c.category === cfg.category ? cfg : c + c.category === cfg.category && c.type === cfg.type ? cfg : c ), }); }; + const [expandedCats, setExpandedCats] = useState>(new Set()); + const toggleExpanded = (catName: string) => { + setExpandedCats((prev) => { + const next = new Set(prev); + if (next.has(catName)) next.delete(catName); else next.add(catName); + return next; + }); + }; + const rewardBadge = ( - {/* Produits récompense — proposés au client selon le type choisi - pour la catégorie de chaque produit (voir "Catégories éligibles" ci-dessous) */} - - Produits ajoutés au panier - - Quand le client réclame sa récompense, ces produits sont automatiquement ajoutés à son panier — gratuits ou à -50% selon le type configuré pour la catégorie du produit. Il doit commander au moins un produit normal. - - - {r.reward_items.map((item, idx) => { - const allProds = Object.values(productsByCategory).flat(); - const prod = allProds.find((p) => p.id === item.product_id); - return ( - - {/* Sélecteur produit */} - - {allProds.map((p) => { - const sel = item.product_id === p.id; - return ( - { - 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 && } - - {p.name} ({p.category}) - - - ); - })} - - - {/* Quantité + Prix + Supprimer */} - - - Qté : - 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} - /> - - - Prix : - 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} - /> - - - update({ reward_items: r.reward_items.filter((_, i) => i !== idx) })} - style={{ padding: spacing.xs }} - > - - - - - {prod && ( - - {prod.name}{item.quantity > 0 ? ` · x${item.quantity}` : ""}{item.price > 0 ? ` · ${item.price}€` : ""} - - )} - {!prod && ( - - Sélectionnez un produit ci-dessus - - )} - - ); - })} - - {Object.values(productsByCategory).flat().length === 0 ? ( - Aucun produit disponible - ) : ( - 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", - }} - > - - Ajouter un produit récompense - - )} - - - {/* Catégories éligibles — chaque catégorie choisit son propre type (produit offert ou -50%), qui s'applique aux produits récompense de cette catégorie configurés ci-dessus */} Catégories éligibles - Sélectionnez les catégories, choisissez le type de récompense pour chacune, puis tous les produits ou une sélection. + Sélectionnez une catégorie pour activer, indépendamment, un lot de produits offerts et/ou un lot de produits à -50%, chacun avec sa propre sélection de produits. {allCategories.length === 0 ? ( Aucune catégorie disponible @@ -1048,12 +1022,13 @@ function CentralRewardSection({ {allCategories.map((cat) => { const selected = isCatSelected(cat.name); + const expanded = expandedCats.has(cat.name); const catColor = cat.color || REWARD_ACCENT; return ( - {/* Chip catégorie */} + {/* Chip catégorie (couleur = au moins un type actif, clic = déplier/replier) */} toggleCategory(cat.name)} + onPress={() => toggleExpanded(cat.name)} style={{ flexDirection: "row", alignItems: "center", gap: spacing.xs, alignSelf: "flex-start", @@ -1068,46 +1043,52 @@ function CentralRewardSection({ {cat.name} - {/* Type + sélecteur produits (visible si catégorie sélectionnée) */} - {selected && ( - - - {REWARD_TYPES.map((rt) => { - const cfg = getCatConfig(cat.name); - const sel = (cfg.type || "free_product") === rt.value; - return ( + {/* Les deux types de récompense, activables indépendamment */} + {expanded && ( + + {REWARD_TYPES.map((rt) => { + const typeEnabled = isTypeEnabled(cat.name, rt.value); + return ( + updateCatConfig({ ...cfg, type: rt.value })} + onPress={() => toggleCategoryType(cat.name, rt.value)} style={{ flexDirection: "row", alignItems: "center", gap: 4, + alignSelf: "flex-start", paddingHorizontal: spacing.s, paddingVertical: 4, borderRadius: borderRadius.sm, borderWidth: 1.5, - borderColor: sel ? REWARD_ACCENT : colors.border, - backgroundColor: sel ? REWARD_ACCENT + "22" : "transparent", + borderColor: typeEnabled ? REWARD_ACCENT : colors.border, + backgroundColor: typeEnabled ? REWARD_ACCENT + "22" : "transparent", }} > - - + + + {rt.label} - ); - })} - - + {typeEnabled && ( + + )} + + ); + })} )} @@ -1118,7 +1099,7 @@ function CentralRewardSection({ {/* Récapitulatif */} - {(r.category_configs.length > 0 || r.reward_items.filter((it) => it.product_id > 0).length > 0) && ( + {r.category_configs.length > 0 && ( Récapitulatif @@ -1127,22 +1108,13 @@ function CentralRewardSection({ {r.description !== "" && ( "{r.description}" )} - {r.category_configs.map((cfg) => ( - - • {REWARD_TYPES.find((x) => x.value === (cfg.type || "free_product"))?.label} : {cfg.category} — {cfg.all_products ? "tous les produits" : `${cfg.product_ids.length} produit(s)`} + {r.category_configs.map((cfg, idx) => ( + + • {REWARD_TYPES.find((x) => x.value === (cfg.type || "free_product"))?.label} : {cfg.category} — {cfg.all_products + ? `tous les produits · qté ${cfg.quantity > 0 ? cfg.quantity : 1}` + : `${cfg.products.length} produit(s) : ${cfg.products.map((pq) => `#${pq.product_id}×${pq.quantity}`).join(", ")}`} ))} - {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 ( - - - - {prod?.name ?? `Produit #${it.product_id}`}{it.quantity > 0 ? ` · x${it.quantity}` : ""}{it.price > 0 ? ` · ${it.price}€` : ""} - - - ); - })} )} @@ -1277,7 +1249,13 @@ export default function SettingsScreen() { 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 ?? [] } + ? { + ...s.points_reward, + category_configs: (s.points_reward.category_configs ?? []).map((cfg) => ({ + ...cfg, + products: cfg.products ?? [], + })), + } : null, }); } diff --git a/omnex-plateform-client/src/api/api.ts b/omnex-plateform-client/src/api/api.ts index 58afaa1..644e33b 100644 --- a/omnex-plateform-client/src/api/api.ts +++ b/omnex-plateform-client/src/api/api.ts @@ -961,13 +961,18 @@ export const toggle2FA = async ( // 🏆 POINTS — RÉCOMPENSES // ============================================ +export type RewardConfigProduct = { + product_id: number; + product_name: string; + quantity: number; +}; + export type RewardCategoryConfig = { category: string; type: "free_product" | "half_price_product"; all_products: boolean; - product_ids: number[]; - product_names: string[]; - amount: number; + products: RewardConfigProduct[]; + quantity: number; }; export type RewardItemConfig = { diff --git a/omnex-plateform-client/src/screens/client/OrderHistoryScreen.tsx b/omnex-plateform-client/src/screens/client/OrderHistoryScreen.tsx index f1dc37a..ee4a6bc 100644 --- a/omnex-plateform-client/src/screens/client/OrderHistoryScreen.tsx +++ b/omnex-plateform-client/src/screens/client/OrderHistoryScreen.tsx @@ -830,14 +830,14 @@ export default function OrderHistoryScreen() { , ] : ( - cfg.product_names ?? + cfg.products ?? [] ).map( ( - name, + p, ) => ( { - name + p.product_name }