@@ -49,6 +49,7 @@ import { useFondPlan } from "@/hooks/use-fond-plan"
4949import { calibrerFond , distance , formatDistance } from "@/lib/plan-fond-utils"
5050import { croissanceCulture , envergureArbreADate } from "@/lib/plan-croissance"
5151import { projeterGpsSurPlan , projeterPlanSurGps } from "@/lib/gps-plan-utils"
52+ import { partitionnerArbresPourSauvegarde } from "@/lib/plan-sauvegarde"
5253
5354// Palier 4 (perf) : dialogs lourds chargés à la demande, hors du bundle
5455// initial de l'éditeur (le plus gros écran client de l'app).
@@ -380,6 +381,22 @@ function JardinContent() {
380381 }
381382 } , [ selectedParcelleId ] )
382383
384+ /**
385+ * QA 2026-07-30 — Une attente de confirmation GPS ne doit pas survivre au
386+ * changement de parcelle ni au démontage : l'arbre concerné n'est plus à
387+ * l'écran, sa confirmation ne peut plus être tranchée, et le compteur
388+ * resterait affiché sans action possible.
389+ */
390+ React . useEffect ( ( ) => {
391+ const purger = ( ) => {
392+ if ( pendingGpsMovesRef . current . size === 0 ) return
393+ pendingGpsMovesRef . current . clear ( )
394+ setPendingGpsMovesCount ( 0 )
395+ }
396+ purger ( )
397+ return purger
398+ } , [ selectedParcelleId ] )
399+
383400 // Charger les especes pour la création de cultures
384401 const fetchEspeces = React . useCallback ( async ( ) => {
385402 try {
@@ -596,7 +613,15 @@ function JardinContent() {
596613 selectedParcelleId ,
597614 ] )
598615
599- const arbresGpsProjetables = React . useMemo ( ( ) => {
616+ /**
617+ * QA 2026-07-30 — On comptait seulement les arbres alignables. Un arbre dont
618+ * le relevé GPS tombe hors de la parcelle affichée n'est pas projetable : il
619+ * était dessiné à l'origine du plan sans le moindre avertissement, ce qui
620+ * invitait à le déplacer… et donc à écraser son relevé terrain par une
621+ * position fictive. On distingue désormais les deux populations.
622+ */
623+ const statsArbresGps = React . useMemo ( ( ) => {
624+ const inerte = { projetables : 0 , nonProjetables : 0 }
600625 if (
601626 ! selectedParcelleId ||
602627 selectedParcelleId === "none" ||
@@ -607,25 +632,36 @@ function JardinContent() {
607632 ! fond . contour ||
608633 ! fondDimensions
609634 ) {
610- return 0
635+ return inerte
611636 }
612- return arbres . filter (
613- ( arbre ) =>
614- arbre . parcelleGeoId === selectedParcelleId &&
615- arbre . gpsLat != null &&
616- arbre . gpsLng != null &&
617- projeterGpsSurPlan ( {
618- gpsLat : arbre . gpsLat ,
619- gpsLng : arbre . gpsLng ,
620- geometryGeoJson : selectedParcelle . geometry ,
621- contour : fond . contour ! ,
622- fond,
623- imageWidth : fondDimensions . width ,
624- imageHeight : fondDimensions . height ,
625- } ) !== null
626- ) . length
637+ let projetables = 0
638+ let nonProjetables = 0
639+ for ( const arbre of arbres ) {
640+ if (
641+ arbre . parcelleGeoId !== selectedParcelleId ||
642+ arbre . gpsLat == null ||
643+ arbre . gpsLng == null
644+ ) {
645+ continue
646+ }
647+ const projection = projeterGpsSurPlan ( {
648+ gpsLat : arbre . gpsLat ,
649+ gpsLng : arbre . gpsLng ,
650+ geometryGeoJson : selectedParcelle . geometry ,
651+ contour : fond . contour ! ,
652+ fond,
653+ imageWidth : fondDimensions . width ,
654+ imageHeight : fondDimensions . height ,
655+ } )
656+ if ( projection !== null ) projetables += 1
657+ else nonProjetables += 1
658+ }
659+ return { projetables, nonProjetables }
627660 } , [ arbres , fond , fondDimensions , selectedParcelle , selectedParcelleId ] )
628661
662+ const arbresGpsProjetables = statsArbresGps . projetables
663+ const arbresGpsHorsCadre = statsArbresGps . nonProjetables
664+
629665 const convertirPositionPlanEnGps = React . useCallback (
630666 ( arbre : Arbre , x : number , y : number ) => {
631667 if (
@@ -948,9 +984,13 @@ function JardinContent() {
948984
949985 // Sauvegarder les positions
950986 const handleSave = async ( ) => {
951- // Une position GPS en cours de confirmation ne doit jamais être persistée
952- // avec l'ancien relevé terrain.
953- if ( pendingGpsMovesRef . current . size > 0 ) return
987+ // QA 2026-07-30 — Une position GPS en attente de confirmation ne doit pas
988+ // être persistée, mais le verrou portait sur TOUTE la sauvegarde : une
989+ // modale abandonnée sans réponse rendait planches, objets et tous les
990+ // autres arbres non sauvegardables, silencieusement et sans issue (il n'y a
991+ // pas de bouton « Enregistrer » manuel). Cf. src/lib/plan-sauvegarde.ts.
992+ const { aEcrire : arbresAEcrire , differes : arbresDifferes } =
993+ partitionnerArbresPourSauvegarde ( arbres , pendingGpsMovesRef . current . keys ( ) )
954994 setSaving ( true )
955995 try {
956996 const planchePromises = planches . map ( p =>
@@ -984,7 +1024,7 @@ function JardinContent() {
9841024 } )
9851025 )
9861026
987- const arbrePromises = arbres . map ( a =>
1027+ const arbrePromises = arbresAEcrire . map ( a =>
9881028 fetch ( `/api/arbres/${ a . id } ` , {
9891029 method : "PUT" ,
9901030 headers : { "Content-Type" : "application/json" } ,
@@ -1028,7 +1068,9 @@ function JardinContent() {
10281068 if ( results . every ( r => r . ok ) ) {
10291069 toast ( {
10301070 title : "Plan sauvegardé" ,
1031- description : "Les positions ont été enregistrées"
1071+ description : arbresDifferes . length > 0
1072+ ? `Positions enregistrées. ${ arbresDifferes . length } arbre${ arbresDifferes . length > 1 ? "s" : "" } en attente de confirmation GPS ${ arbresDifferes . length > 1 ? "n'ont" : "n'a" } pas été déplacé${ arbresDifferes . length > 1 ? "s" : "" } .`
1073+ : "Les positions ont été enregistrées"
10321074 } )
10331075 setHasChanges ( false )
10341076 } else {
@@ -1051,7 +1093,10 @@ function JardinContent() {
10511093
10521094 // Auto-save débounced (1s après le dernier changement)
10531095 React . useEffect ( ( ) => {
1054- if ( ! hasChanges || saving || pendingGpsMovesCount > 0 ) return
1096+ // `pendingGpsMovesCount` ne bloque plus l'auto-save (cf. handleSave) : il
1097+ // reste dans les dépendances pour relancer une sauvegarde dès qu'une
1098+ // confirmation est tranchée.
1099+ if ( ! hasChanges || saving ) return
10551100 if ( autoSaveTimer . current ) clearTimeout ( autoSaveTimer . current )
10561101 autoSaveTimer . current = setTimeout ( ( ) => {
10571102 handleSave ( )
@@ -1803,6 +1848,14 @@ function JardinContent() {
18031848 { saving && (
18041849 < span className = "text-xs text-muted-foreground animate-pulse" > Sauvegarde...</ span >
18051850 ) }
1851+ { pendingGpsMovesCount > 0 && (
1852+ < span
1853+ className = "rounded-md bg-amber-100 px-2 py-1 text-xs font-medium text-amber-800"
1854+ title = "Confirmez ou annulez le déplacement pour enregistrer la nouvelle position GPS."
1855+ >
1856+ { pendingGpsMovesCount } position GPS à confirmer
1857+ </ span >
1858+ ) }
18061859 </ div >
18071860
18081861 < div className = "flex w-full flex-wrap items-center gap-2 xl:hidden" >
@@ -1907,6 +1960,11 @@ function JardinContent() {
19071960 { saving && (
19081961 < span className = "w-full text-xs text-muted-foreground animate-pulse" > Sauvegarde...</ span >
19091962 ) }
1963+ { pendingGpsMovesCount > 0 && (
1964+ < span className = "w-full rounded-md bg-amber-100 px-2 py-1 text-xs font-medium text-amber-800" >
1965+ { pendingGpsMovesCount } position GPS à confirmer
1966+ </ span >
1967+ ) }
19101968 </ div >
19111969 </ div >
19121970 </ header >
@@ -3010,6 +3068,14 @@ function JardinContent() {
30103068 le fond géoréférencé.
30113069 </ p >
30123070 ) }
3071+ { arbresGpsHorsCadre > 0 && (
3072+ < p className = "text-amber-700" >
3073+ • { arbresGpsHorsCadre } arbre{ arbresGpsHorsCadre > 1 ? "s" : "" } { arbresGpsHorsCadre > 1 ? "ont" : "a" } un
3074+ relevé GPS situé hors de cette parcelle : { arbresGpsHorsCadre > 1 ? "leur position" : "sa position" } sur
3075+ le plan n'est pas significative. Corrigez le relevé depuis la fiche de l'arbre plutôt
3076+ qu'en le déplaçant ici, ce qui écraserait les coordonnées réelles.
3077+ </ p >
3078+ ) }
30133079 </ CardContent >
30143080 </ Card >
30153081 </ div >
0 commit comments