import type { NavigationLinks, TierType, TourStop, TourTotals } from '~/modules/field-sales/types/tour' /** * Composable de planification de tournee (M6.5). * * Porte la logique PURE de l'ecran de planification, isolee de Vue/Nuxt pour * etre testable directement (Vitest) : * - reordonnancement des etapes (drag & drop) + renumerotation des positions ; * - recalcul instantane des totaux (trajets + visites) pour le feedback UI, * avant le retour serveur du /compute ; * - construction des deep links de navigation « Y aller » (Waze/Google/Apple). * * Les coordonnees et libelles des etapes sur Tiers referentiel ne sont pas * portes par tour_stop:read : l'ecran les resout via GET /visitable_tiers/{id} * et alimente un `PlanningStop` enrichi, sur lequel operent ces fonctions. */ /** Coordonnees WGS84 minimales d'une cible. */ export interface LatLng { latitude: number longitude: number } /** * Etape « enrichie » manipulee par l'ecran : l'etape API + le libelle, l'adresse * et les coordonnees resolus (depuis le Tiers pour une etape referentiel, depuis * les colonnes custom_* pour un point libre). */ export interface PlanningStop extends TourStop { /** Nom affichable (raison sociale du Tiers ou libelle du point libre). */ label: string /** Adresse formatee sur une ligne. */ displayAddress: string /** Coordonnees resolues, ou null si l'etape n'est pas geolocalisee (RG-6.05). */ latitude: number | null longitude: number | null } /** Vitesse moyenne par defaut (km/h) — alignee sur HaversineRouteEngine (back). */ const DEFAULT_SPEED_KMH = 50 /** * Deplace l'etape `fromIndex` vers `toIndex` et renumerote toutes les positions * (0-indexees, contigues). Retourne un NOUVEAU tableau (pas de mutation). */ export function reorderStops(stops: readonly T[], fromIndex: number, toIndex: number): T[] { const next = [...stops] if (fromIndex < 0 || fromIndex >= next.length || toIndex < 0 || toIndex >= next.length) { return next } const [moved] = next.splice(fromIndex, 1) if (moved === undefined) { return next } next.splice(toIndex, 0, moved) return next.map((stop, index) => ({ ...stop, position: index })) } /** * Recalcule les totaux d'une tournee a partir des legs deja calcules et des * durees de visite (RG-6.11). Duree totale = trajets + visites. */ export function computeTotals(stops: readonly PlanningStop[], defaultVisitMinutes: number): TourTotals { let totalDistanceM = 0 let travelDurationS = 0 let visitDurationS = 0 for (const stop of stops) { totalDistanceM += stop.legDistanceM ?? 0 travelDurationS += stop.legDurationS ?? 0 visitDurationS += (stop.visitMinutes ?? defaultVisitMinutes) * 60 } return { totalDistanceM, travelDurationS, visitDurationS, totalDurationS: travelDurationS + visitDurationS, visitCount: stops.length, } } /** * Deep links de navigation vers une cible geolocalisee (spec M6 § 6.1). * Waze/Google Maps ne prennent qu'UNE destination -> navigation etape par etape * (HP-M6-7 assume). Retourne null si la cible n'a pas de coordonnees. */ export function buildNavigationLinks(target: { latitude?: number | null, longitude?: number | null } | null): NavigationLinks | null { if (target == null || target.latitude == null || target.longitude == null) { return null } const lat = target.latitude const lng = target.longitude return { waze: `https://waze.com/ul?ll=${lat},${lng}&navigate=yes`, google: `https://www.google.com/maps/dir/?api=1&destination=${lat},${lng}`, apple: `https://maps.apple.com/?daddr=${lat},${lng}`, } } /** Vrai si l'etape est geolocalisee (entre dans le calcul de trajet, RG-6.05). */ export function isStopLocated(stop: Pick): boolean { return stop.latitude != null && stop.longitude != null } /** Estime une duree de trajet (s) a partir d'une distance (m) et la vitesse moyenne. */ export function estimateDurationSeconds(distanceMeters: number, speedKmh: number = DEFAULT_SPEED_KMH): number { if (speedKmh <= 0) { return 0 } return Math.round((distanceMeters / 1000) / speedKmh * 3600) } /** Formate une distance (m) en « 12,3 km » ou « 850 m ». */ export function formatDistance(meters: number | null): string { if (meters == null) { return '—' } if (meters < 1000) { return `${Math.round(meters)} m` } return `${(meters / 1000).toFixed(1).replace('.', ',')} km` } /** Formate une duree (s) en « 1 h 25 » ou « 25 min ». */ export function formatDuration(seconds: number | null): string { if (seconds == null) { return '—' } const totalMinutes = Math.round(seconds / 60) const hours = Math.floor(totalMinutes / 60) const minutes = totalMinutes % 60 if (hours === 0) { return `${minutes} min` } return `${hours} h ${String(minutes).padStart(2, '0')}` } /** Extrait l'heure « HH:MM » d'une chaine ISO (eta / departureTime). */ export function formatTime(iso: string | null): string { if (iso == null || iso === '') { return '—' } const match = iso.match(/(\d{2}):(\d{2})/) return match ? `${match[1]}:${match[2]}` : '—' } /** Libelle FR court d'un type de Tiers (pour la couleur/le badge du pin). */ export function tierTypeLabel(type: TierType): string { switch (type) { case 'client': return 'Client' case 'supplier': return 'Fournisseur' default: return 'Point libre' } } export function useTourPlanning() { return { reorderStops, computeTotals, buildNavigationLinks, isStopLocated, estimateDurationSeconds, formatDistance, formatDuration, formatTime, tierTypeLabel, } }