Repository: MTES-MCT/mobilic
Pull request #673: Feat/admin validates after autovalidation
Author: login: tristan-gueguen
Created: 2025-05-22T16:36:39Z | Merged: 2025-05-27T15:41:39Z

https://trello.com/c/9DQuLS6J/2026-modification-gestionnaire-apr%C3%A8s-validation-automatique-de-mission


Diff:
diff --git a/common/assets/styles/root.scss b/common/assets/styles/root.scss
index 2524ed7ee..57df064f5 100644
--- a/common/assets/styles/root.scss
+++ b/common/assets/styles/root.scss
@@ -373,6 +373,10 @@ video:not([href])[controls] {
 
 .fr-accordion  {
 
+  .fr-collapse {
+    padding-left: 2rem;
+  }
+
   &.success {
     background-color: var(--background-alt-green-emeraude);
     --hover: var(--background-contrast-green-emeraude);
@@ -463,6 +467,43 @@ video:not([href])[controls] {
       --active: var(--background-open-red-marianne-active);
     }
   }
+
+  &.warning {
+    background-color: var(--background-contrast-warning);
+    --idle: transparent;
+    --hover: var(--background-contrast-warning-hover);
+
+    .fr-accordion__btn{
+      color: var(--text-default-warning);
+
+      &::before {
+        content: '';
+        -webkit-mask-image: url("data:image/svg+xml;base64,PHN2ZyB4bWxucz0naHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmcnIHdpZHRoPScyNCcgaGVpZ2h0PScyNCcgdmlld0JveD0nMCAwIDI0IDI0Jz48cGF0aCBkPSdtMTIuODY2IDMgOS41MjYgMTYuNWExIDEgMCAwIDEtLjg2NiAxLjVIMi40NzRhMSAxIDAgMCAxLS44NjYtMS41TDExLjEzNCAzYTEgMSAwIDAgMSAxLjczMiAwWk0xMSAxNnYyaDJ2LTJoLTJabTAtN3Y1aDJWOWgtMlonLz48L3N2Zz4=");
+        mask-image: url("data:image/svg+xml;base64,PHN2ZyB4bWxucz0naHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmcnIHdpZHRoPScyNCcgaGVpZ2h0PScyNCcgdmlld0JveD0nMCAwIDI0IDI0Jz48cGF0aCBkPSdtMTIuODY2IDMgOS41MjYgMTYuNWExIDEgMCAwIDEtLjg2NiAxLjVIMi40NzRhMSAxIDAgMCAxLS44NjYtMS41TDExLjEzNCAzYTEgMSAwIDAgMSAxLjczMiAwWk0xMSAxNnYyaDJ2LTJoLTJabTAtN3Y1aDJWOWgtMlonLz48L3N2Zz4=");
+        --icon-size: 1.5rem;
+        flex: 0 0 auto;
+        display: inline-block;
+        vertical-align: calc((0.75em - var(--icon-size)) * 0.5);
+        background-color: currentColor;
+        width: var(--icon-size);
+        height: var(--icon-size);
+        -webkit-mask-size: 100% 100%;
+        mask-size: 100% 100%;
+        margin-right: 0.5rem;
+      }
+    }
+
+    .fr-accordion__btn[aria-expanded="true"] {
+      background-color: var(--background-contrast-warning-hover);
+      --idle: transparent;
+      --hover: var(--background-open-red-marianne-hover);
+      --active: var(--background-contrast-warning-active);
+    }
+  }
+}
+
+.fr-accordion__btn {
+  padding-left: 1.5rem;
 }
 
 .fr-btn--secondary.error {
diff --git a/common/utils/apiFragments.js b/common/utils/apiFragments.js
index eaf9afb4f..843805cb9 100644
--- a/common/utils/apiFragments.js
+++ b/common/utils/apiFragments.js
@@ -56,6 +56,7 @@ export const FULL_MISSION_FRAGMENT = gql`
       receptionTime
       isAdmin
       isAuto
+      justification
       userId
     }
     vehicle {
diff --git a/common/utils/apiQueries.js b/common/utils/apiQueries.js
index 916e7a688..8b3524090 100644
--- a/common/utils/apiQueries.js
+++ b/common/utils/apiQueries.js
@@ -83,6 +83,7 @@ export const ALL_MISSION_RESOURCES_WITH_HISTORY_QUERY = gql`
         id
         isAdmin
         isAuto
+        justification
         userId
         receptionTime
         missionId
@@ -328,6 +329,7 @@ export const CONTROLLER_READ_MISSION_DETAILS = gql`
           id
           isAdmin
           isAuto
+          justification
           userId
           receptionTime
           missionId
@@ -766,6 +768,7 @@ export const ADMIN_COMPANIES_QUERY = gql`
                 receptionTime
                 isAdmin
                 isAuto
+                justification
                 userId
               }
               vehicle {
@@ -816,6 +819,7 @@ export const ADMIN_COMPANIES_QUERY = gql`
                 receptionTime
                 isAdmin
                 isAuto
+                justification
                 userId
               }
               vehicle {
@@ -1640,6 +1644,7 @@ export const VALIDATE_MISSION_MUTATION = gql`
     $activityItems: [BulkActivityItem]
     $expendituresCancelIds: [Int]
     $expendituresInputs: [BulkExpenditureItem]
+    $justification: OverValidationJustificationEnum
   ) {
     activities {
       validateMission(
@@ -1649,6 +1654,7 @@ export const VALIDATE_MISSION_MUTATION = gql`
         activityItems: $activityItems
         expendituresCancelIds: $expendituresCancelIds
         expendituresInputs: $expendituresInputs
+        justification: $justification
       ) {
         ...FullMissionData
       }
diff --git a/common/utils/mission.js b/common/utils/mission.js
index cd4965f31..b1ba0bc87 100644
--- a/common/utils/mission.js
+++ b/common/utils/mission.js
@@ -176,8 +176,19 @@ export function computeMissionStats(m, users) {
       adminValidation:
         m.validations.find(v => v.userId?.toString() === userId && v.isAdmin) ||
         adminGlobalValidation,
+      adminAutoValidation: m.validations.find(
+        v => v.userId?.toString() === userId && v.isAdmin && v.isAuto
+      ),
+      adminManualValidation: m.validations.find(
+        v => v.userId?.toString() === userId && v.isAdmin && !v.isAuto
+      ),
       workerValidation: getWorkerValidationForUser(m.validations, user?.id),
-      lastActivitySubmitterId
+      lastActivitySubmitterId,
+      validations: m.validations.filter(
+        v =>
+          (!v.userId && v.submitterId.toString() === userId) ||
+          v.userId.toString() === userId
+      )
     };
   });
   const missionNotUpdatedForTooLong = values(userStats).some(
diff --git a/web/admin/components/ActivitiesCard.js b/web/admin/components/ActivitiesCard.js
index 191480fd8..1370bf189 100644
--- a/web/admin/components/ActivitiesCard.js
+++ b/web/admin/components/ActivitiesCard.js
@@ -29,7 +29,9 @@ export function ActivitiesCard({
   fromTime,
   untilTime,
   datetimeFormatter = formatTimeOfDay,
-  titleProps = {}
+  titleProps = {},
+  actionButtonLabel = "",
+  onActionButtonClick = null
 }) {
   const classes = useActivitiesCardStyles();
   const ref = React.useRef();
@@ -129,6 +131,8 @@ export function ActivitiesCard({
       extraPaddingBelowTitle
       loading={loading}
       titleProps={titleProps}
+      actionButtonLabel={actionButtonLabel}
+      onActionButtonClick={onActionButtonClick}
     >
       <Grid container key={2} spacing={2}>
         <Grid item xs={12}>
diff --git a/web/admin/components/MissionDetails/MissionDetails.js b/web/admin/components/MissionDetails/MissionDetails.js
index a6a30edb0..2938995c2 100644
--- a/web/admin/components/MissionDetails/MissionDetails.js
+++ b/web/admin/components/MissionDetails/MissionDetails.js
@@ -84,6 +84,11 @@ export function MissionDetails({
 
   const [usersToAdd, setUsersToAdd] = React.useState([]);
 
+  const [
+    overrideValidationJustification,
+    setOverrideValidationJustification
+  ] = React.useState("");
+
   const [
     entriesToValidateByAdmin,
     setEntriesToValidateByAdmin
@@ -98,6 +103,12 @@ export function MissionDetails({
   );
   const [userIdsWithEntries, setUserIdsWithEntries] = React.useState(false);
 
+  const overrideValidation = () => {
+    modals.open("overrideValidation", {
+      updateJustification: setOverrideValidationJustification
+    });
+  };
+
   async function loadMission() {
     const alreadyFetchedMission = missionsSelector(adminStore)?.find(
       m => m.id === missionId
@@ -142,9 +153,15 @@ export function MissionDetails({
     () =>
       !isMissionHoliday &&
       !isMissionDeleted &&
-      !missionHasAtLeastOneAdminValidation(mission) &&
-      (entriesToValidateByAdmin?.length > 0 || adminMayOverrideValidation),
-    [entriesToValidateByAdmin, adminMayOverrideValidation]
+      (overrideValidationJustification ||
+        (!missionHasAtLeastOneAdminValidation(mission) &&
+          (entriesToValidateByAdmin?.length > 0 ||
+            adminMayOverrideValidation))),
+    [
+      entriesToValidateByAdmin,
+      adminMayOverrideValidation,
+      overrideValidationJustification
+    ]
   );
 
   const onValidate = async () => {
@@ -152,7 +169,11 @@ export function MissionDetails({
     const usersToValidate = entriesToValidateByAdmin.map(
       workerEntryToValidate => workerEntryToValidate.user.id
     );
-    await missionActions.validateMission(usersToValidate);
+    await missionActions.validateMission(
+      usersToValidate,
+      overrideValidationJustification
+    );
+    setOverrideValidationJustification("");
     setLoading(false);
   };
 
@@ -172,7 +193,8 @@ export function MissionDetails({
       entryToBeValidatedByAdmin(
         workerEntry,
         adminStore.userId,
-        adminMayOverrideValidation
+        adminMayOverrideValidation,
+        overrideValidationJustification
       )
     );
     setEntriesToValidateByAdmin(toBeValidatedByAdmin);
@@ -182,7 +204,11 @@ export function MissionDetails({
     );
     setEntriesValidatedByAdmin(validatedByAdmin);
     setEntriesToValidateByWorker(toValidateByWorker);
-  }, [workerEntries, adminMayOverrideValidation]);
+  }, [
+    workerEntries,
+    adminMayOverrideValidation,
+    overrideValidationJustification
+  ]);
 
   React.useEffect(() => {
     if (mission) {
@@ -391,7 +417,8 @@ export function MissionDetails({
                     entryToBeValidatedByAdmin(
                       e,
                       adminStore.userId,
-                      adminMayOverrideValidation
+                      adminMayOverrideValidation,
+                      overrideValidationJustification
                     ) || !e.activities
                       ? () =>
                           modals.open("activityRevision", {
@@ -415,7 +442,8 @@ export function MissionDetails({
                     entryToBeValidatedByAdmin(
                       e,
                       adminStore.userId,
-                      adminMayOverrideValidation
+                      adminMayOverrideValidation,
+                      overrideValidationJustification
                     ) || !e.activities
                       ? async entry =>
                           modals.open("activityRevision", {
@@ -451,7 +479,8 @@ export function MissionDetails({
                     entryToBeValidatedByAdmin(
                       e,
                       adminStore.userId,
-                      adminMayOverrideValidation
+                      adminMayOverrideValidation,
+                      overrideValidationJustification
                     ) || !e.activities
                       ? (newExps, oldExps) =>
                           editUserExpenditures(
@@ -533,6 +562,7 @@ export function MissionDetails({
                   showExpenditures={showExpenditures}
                   day={day}
                   displayIcon={false}
+                  overrideValidation={overrideValidation}
                 />
               </ListItem>
             ))}
diff --git a/web/admin/components/MissionEmployeeCard.js b/web/admin/components/MissionEmployeeCard.js
index abd8b6183..531bcae23 100644
--- a/web/admin/components/MissionEmployeeCard.js
+++ b/web/admin/components/MissionEmployeeCard.js
@@ -55,7 +55,8 @@ export function MissionEmployeeCard({
   isDeleted = false,
   defaultOpen = false,
   displayIcon = true,
-  headingComponent
+  headingComponent,
+  overrideValidation = null
 }) {
   const stats = mission.userStats[user.id.toString()] || {};
   const activities = stats.activities || [];
@@ -81,6 +82,9 @@ export function MissionEmployeeCard({
 
   const cacheContradictoryInfoInAdminStore = useCacheContradictoryInfoInAdminStore();
 
+  const adminAutoValidationOnly =
+    stats.adminAutoValidation && !stats.adminManualValidation;
+
   return (
     <Accordion
       variant="outlined"
@@ -188,7 +192,11 @@ export function MissionEmployeeCard({
                 linkUrl="https://faq.mobilic.beta.gouv.fr/usages-et-fonctionnement-de-mobilic/suivi-et-validation-du-temps-de-travail#en-tant-que-gestionnaire-je-peux-uniquement-modifier-et-valider-les-missions-validees-par-les-salari"
               />
             ) : (
-              <MissionValidations mission={mission} userId={user.id} />
+              <MissionValidations
+                mission={mission}
+                validations={stats.validations}
+                userId={user.id}
+              />
             ))}
           <Grid item container spacing={2} alignItems="stretch">
             <Grid xs={12} sm={6} item className={classes.cardRecapKPIContainer}>
@@ -280,6 +288,11 @@ export function MissionEmployeeCard({
                 variant: "h6",
                 component: getNextHeadingComponent(headingComponent)
               }}
+              {...(overrideValidation &&
+                adminAutoValidationOnly && {
+                  onActionButtonClick: overrideValidation,
+                  actionButtonLabel: "J'ai été absent : modifier les saisies"
+                })}
             />
           </Grid>
           {showExpenditures && (
diff --git a/web/admin/modals/OverrideValidationJustificationModal.js b/web/admin/modals/OverrideValidationJustificationModal.js
new file mode 100644
index 000000000..a4d4a053d
--- /dev/null
+++ b/web/admin/modals/OverrideValidationJustificationModal.js
@@ -0,0 +1,76 @@
+import React from "react";
+import Modal from "../../common/Modal";
+import { Select } from "@codegouvfr/react-dsfr/Select";
+import { Button } from "@codegouvfr/react-dsfr/Button";
+
+export const JUSTIFICATIONS = [
+  {
+    value: "professional",
+    key: "PROFESSIONAL",
+    label: "Raisons professionnelles"
+  },
+  {
+    value: "time_off",
+    key: "TIME_OFF",
+    label: "Congé"
+  },
+  {
+    value: "personal",
+    key: "PERSONAL",
+    label: "Raisons personnelles"
+  }
+];
+export default function OverrideValidationJustificationModal({
+  open,
+  handleClose,
+  updateJustification
+}) {
+  const [justification, setJustification] = React.useState(
+    JUSTIFICATIONS[0].value
+  );
+
+  const _handleSubmit = React.useCallback(() => {
+    updateJustification(justification);
+    if (handleClose) {
+      handleClose();
+    }
+  }, [justification]);
+
+  return (
+    <Modal
+      open={open}
+      handleClose={handleClose}
+      title="Veuillez justifier cette modification"
+      size="sm"
+      content={
+        <>
+          <p>
+            La mission ayant été validée automatiquement, un motif est
+            obligatoire pour valider la mission.
+          </p>
+          <Select
+            label="Motif"
+            nativeSelectProps={{
+              onChange: e => setJustification(e.target.value),
+              value: justification.value
+            }}
+          >
+            {JUSTIFICATIONS.map(({ value, label }) => (
+              <option key={value} value={value}>
+                {label}
+              </option>
+            ))}
+          </Select>
+        </>
+      }
+      actions={
+        <>
+          <Button onClick={handleClose} priority="secondary">
+            Annuler
+          </Button>
+          <Button onClick={() => _handleSubmit()}>Enregistrer</Button>
+        </>
+      }
+    />
+  );
+}
diff --git a/web/admin/selectors/validationEntriesSelectors.js b/web/admin/selectors/validationEntriesSelectors.js
index 72edde899..2a3fa4bc4 100644
--- a/web/admin/selectors/validationEntriesSelectors.js
+++ b/web/admin/selectors/validationEntriesSelectors.js
@@ -33,14 +33,28 @@ export const missionToValidationEntries = mission =>
 export const entryToBeValidatedByAdmin = (
   tableEntry,
   currentUserId,
-  adminCanBypass = false
-) =>
-  !tableEntry.isDeleted &&
-  !tableEntry.adminValidation &&
-  (entryValidatedByWorkerOrOutdated(tableEntry) ||
+  adminCanBypass = false,
+  overrideValidationJustification = ""
+) => {
+  if (tableEntry.isDeleted) {
+    return false;
+  }
+
+  if (tableEntry.adminManualValidation) {
+    return false;
+  }
+
+  if (tableEntry.adminAutoValidation) {
+    return !!overrideValidationJustification;
+  }
+
+  return (
+    entryValidatedByWorkerOrOutdated(tableEntry) ||
     tableEntry.lastActivitySubmitterId === currentUserId ||
     adminCanBypass ||
-    tableEntry.activities?.length === 0);
+    tableEntry.activities?.length === 0
+  );
+};
 
 export const entryToBeValidatedByWorker = tableEntry =>
   !tableEntry.adminValidation && !tableEntry.workerValidation;
diff --git a/web/admin/utils/missionActions.js b/web/admin/utils/missionActions.js
index f68f30f86..ea58c880a 100644
--- a/web/admin/utils/missionActions.js
+++ b/web/admin/utils/missionActions.js
@@ -187,10 +187,19 @@ async function createComment(api, mission, adminStore, text) {
   mission.comments.push(comment);
 }
 
-async function validateMission(api, mission, adminStore, userToValidate) {
+async function validateMission(
+  api,
+  mission,
+  adminStore,
+  usersToValidate,
+  overrideValidationJustification
+) {
   const apiResponse = await api.graphQlMutate(VALIDATE_MISSION_MUTATION, {
     missionId: mission.id,
-    usersIds: userToValidate,
+    usersIds: usersToValidate,
+    ...(overrideValidationJustification
+      ? { justification: overrideValidationJustification }
+      : {}),
     activityItems: getPayloadFromVirtualActivities(
       adminStore.virtualActivities
     ),
diff --git a/web/modals.js b/web/modals.js
index 1c5835cec..e20c619c5 100644
--- a/web/modals.js
+++ b/web/modals.js
@@ -29,6 +29,7 @@ import TerminateEmploymentModal from "./admin/modals/TerminateEmploymentModal";
 import BatchInviteModal from "./admin/modals/BatchInviteModal";
 import SuccessMessageModal from "./admin/modals/SuccessMessage";
 import WebinarsModal from "./admin/modals/WebinarsModal";
+import OverrideValidationJustificationModal from "./admin/modals/OverrideValidationJustificationModal";
 
 // home
 import ChangeEmailModal from "./pwa/modals/ChangeEmailModal";
@@ -93,5 +94,6 @@ export const MODAL_DICT = {
   blockedTime: BlockedTimeModal,
   successMessage: SuccessMessageModal,
   webinars: WebinarsModal,
-  controlPicture: ControlPicture
+  controlPicture: ControlPicture,
+  overrideValidation: OverrideValidationJustificationModal
 };
diff --git a/web/pwa/components/MissionDetails.js b/web/pwa/components/MissionDetails.js
index 59a4aff4b..634502aa5 100644
--- a/web/pwa/components/MissionDetails.js
+++ b/web/pwa/components/MissionDetails.js
@@ -229,7 +229,11 @@ export function MissionDetails({
     <AlternateColors inverseColors={inverseColors}>
       <MissionReviewSection title="Historique des validations">
         {mission.validations && (
-          <MissionValidations mission={mission} userId={userId} />
+          <MissionValidations
+            mission={mission}
+            validations={mission.validations}
+            userId={userId}
+          />
         )}
       </MissionReviewSection>
       <MissionReviewSection
diff --git a/web/pwa/components/MissionValidations.js b/web/pwa/components/MissionValidations.js
index 5ffab4ac2..16e768f52 100644
--- a/web/pwa/components/MissionValidations.js
+++ b/web/pwa/components/MissionValidations.js
@@ -4,6 +4,7 @@ import { Accordion } from "@codegouvfr/react-dsfr/Accordion";
 import { formatDay } from "common/utils/time";
 import Notice from "../../common/Notice";
 import { getWorkerValidationForUser } from "common/utils/mission";
+import { JUSTIFICATIONS } from "../../admin/modals/OverrideValidationJustificationModal";
 
 const EmployeeValidation = validation => {
   const label = validation.isAuto
@@ -47,17 +48,36 @@ const AdminAutoValidation = validation => (
     validation du salarié.
   </Accordion>
 );
-const AdminManualValidation = validation => (
-  <Notice
-    title={`Validée par le gestionnaire le ${formatDay(
-      validation.receptionTime,
-      true
-    )}`}
-    type="success"
-    lessPadding
-    withBorders
-  />
-);
+const AdminManualValidation = (validation, hasOverriden) => {
+  if (hasOverriden) {
+    return (
+      <Accordion
+        label="Modifiée par le gestionnaire après validation automatique"
+        className="warning"
+      >
+        Le gestionnaire a modifié la mission après sa validation pour la raison
+        suivante :{" "}
+        {
+          JUSTIFICATIONS.find(
+            justification => justification.key === validation.justification
+          )?.label
+        }
+        .
+      </Accordion>
+    );
+  }
+  return (
+    <Notice
+      title={`Validée par le gestionnaire le ${formatDay(
+        validation.receptionTime,
+        true
+      )}`}
+      type="success"
+      lessPadding
+      withBorders
+    />
+  );
+};
 
 const NoAdminValidation = () => (
   <Notice
@@ -68,7 +88,7 @@ const NoAdminValidation = () => (
   />
 );
 
-export const MissionValidations = ({ mission, userId }) => {
+export const MissionValidations = ({ mission, validations, userId }) => {
   const doNotDisplayValidations = mission.isDeleted && !mission.complete;
 
   if (doNotDisplayValidations) {
@@ -80,7 +100,7 @@ export const MissionValidations = ({ mission, userId }) => {
     userId
   );
 
-  const adminValidations = mission.validations.filter(
+  const adminValidations = validations.filter(
     v => v.isAdmin && (!v.userId || v.userId === userId)
   );
   const adminAutoValidation = adminValidations.find(
@@ -95,7 +115,8 @@ export const MissionValidations = ({ mission, userId }) => {
         ? EmployeeValidation(employeeValidation)
         : NoEmployeeValidation()}
       {adminAutoValidation && AdminAutoValidation(adminAutoValidation)}
-      {adminManualValidation && AdminManualValidation(adminManualValidation)}
+      {adminManualValidation &&
+        AdminManualValidation(adminManualValidation, adminAutoValidation)}
       {!adminAutoValidation && !adminManualValidation && NoAdminValidation()}
     </Stack>
   );


Reviews:
user: login: sandrica89
body: Je n'ai pas tout regardé, j'ai l'impression qu'il y a beaucoup de changements qui viennent d'un merge de master sur la branche directement sans passer par la branche epic ou du ticket précédent.
Peut être modifier la destination de la PR pour la branche du ticket précédent ?
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-05-26T11:09:22Z
user: login: tristan-gueguen
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-05-27T12:10:09Z
user: login: tristan-gueguen
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-05-27T12:13:38Z
user: login: tristan-gueguen
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-05-27T14:33:10Z

Review comments:
diff_hunk: @@ -0,0 +1,126 @@
+import React from "react";
+import { Stack } from "@mui/material";
+import { Accordion } from "@codegouvfr/react-dsfr/Accordion";
+import { formatDay } from "common/utils/time";
+import Notice from "../../common/Notice";
+import { MOTIFS } from "../../admin/modals/OverrideValidationJustificationModal";
+
+const EmployeeValidation = validation => {
+  const label = validation.isAuto
+    ? `Validée automatiquement à la place du salarié le ${formatDay(
+        validation.receptionTime,
+        true
+      )}`
+    : `Validée par le salarié le ${formatDay(validation.receptionTime, true)}`;
+
+  const description = validation.isAuto
+    ? "La validation automatique s'effectue 24 heures après le début de la mission si le salarié ne l'a pas validée avant."
+    : "";
+
+  return description ? (
+    <Accordion label={label} className="success">
+      {description}
+    </Accordion>
+  ) : (
+    <Notice title={label} type="warning" lessPadding withBorders />
+  );
+};
+
+const NoEmployeeValidation = () => (
+  <Notice
+    title="Validation par le salarié en attente"
+    type="warning"
+    withBorders
+    lessPadding
+  />
+);
+
+const AdminAutoValidation = validation => (
+  <Accordion
+    label={`Validée automatiquement à la place du gestionnaire le ${formatDay(
+      validation.receptionTime,
+      true
+    )}`}
+    className="success"
+  >
+    La validation automatique s'effectue au plus tard 2 jours ouvrés après la
+    validation du salarié.
+  </Accordion>
+);
+const AdminManualValidation = (validation, hasOverriden) => {
+  if (hasOverriden) {
+    return (
+      <Accordion
+        label="Modifiée par le gestionnaire après validation automatique"
+        className="warning"
+      >
+        Le gestionnaire a modifié la mission après sa validation pour la raison
+        suivante :{" "}
+        {MOTIFS.find(motif => motif.key === validation.justification)?.label}.
+      </Accordion>
+    );
+  }
+  return (
+    <Notice
+      title={`Validée par le gestionnaire le ${formatDay(
+        validation.receptionTime,
+        true
+      )}`}
+      type="success"
+      lessPadding
+      withBorders
+    />
+  );
+};
+
+const NoAdminValidation = () => (
+  <Notice
+    title="Validation par un gestionnaire en attente"
+    type="warning"
+    withBorders
+    lessPadding
+  />
+);
+
+export const MissionValidations = ({
+  mission,
+  validations,
+  userId,
+  justification
+}) => {
+  const doNotDisplayValidations = mission.isDeleted && !mission.complete;
+
+  if (doNotDisplayValidations) {
+    return;
+  }
+
+  // Should we use getWorkerValidationForUser ?
+  const employeeValidation = validations.find(
+    validation => !validation.isAdmin
+  );
+
+  const adminValidations = validations.filter(
+    v => v.isAdmin && (!v.userId || v.userId === userId)
+  );
+  const adminAutoValidation = adminValidations.find(
+    validation => validation.isAuto
+  );
+  const adminManualValidation = adminValidations.find(
+    validation => !validation.isAuto
+  );
+  return (
+    <Stack direction="column" textAlign="left">
+      {employeeValidation
+        ? EmployeeValidation(employeeValidation)
+        : NoEmployeeValidation()}
+      {adminAutoValidation && AdminAutoValidation(adminAutoValidation)}
+      {adminManualValidation &&
+        AdminManualValidation(
+          adminManualValidation,
+          adminAutoValidation,
+          justification
path: web/pwa/components/MissionValidations.js
user: login: sandrica89
body: issue: remontée par sonar : "This function expects 2 arguments, but 3 were provided." 
la justification est récupéré dans le premier argument
created_at: 2025-05-26T10:32:53Z
author_association: CONTRIBUTOR
diff_hunk: @@ -38,7 +38,8 @@ export function parseMissionPayloadFromBackend(missionPayload, userId) {
     deletedAt: missionPayload.deletedAt,
     deletedBy: missionPayload.deletedBy,
     isHoliday:
-      missionPayload.isHoliday !== undefined ? missionPayload.isHoliday : false
+      missionPayload.isHoliday !== undefined ? missionPayload.isHoliday : false,
+    validations: missionPayload.validations
path: common/utils/mission.js
user: login: sandrica89
body: suggestion(non-blocking): renommer pour éviter la confusion avec `validation` et `adminValidation`, peut être `allValidations` ?

question: d'ailleurs a-t-on toujours besoin des 3 ?
created_at: 2025-05-26T10:35:35Z
author_association: CONTRIBUTOR
diff_hunk: @@ -175,8 +176,19 @@ export function computeMissionStats(m, users) {
       adminValidation:
         m.validations.find(v => v.userId?.toString() === userId && v.isAdmin) ||
         adminGlobalValidation,
+      adminAutoValidation: m.validations.find(
+        v => v.userId?.toString() === userId && v.isAdmin && v.isAuto
+      ),
+      adminManualValidation: m.validations.find(
+        v => v.userId?.toString() === userId && v.isAdmin && !v.isAuto
+      ),
       workerValidation: getWorkerValidationForUser(m.validations, user?.id),
-      lastActivitySubmitterId
+      lastActivitySubmitterId,
+      validations: m.validations.filter(
+        v =>
+          (!v.userId && v.submitterId.toString() === userId) ||
+          v.userId.toString() === userId
+      )
path: common/utils/mission.js
user: login: sandrica89
body: note: j'ai l'impression qu'on refait ici de la logique déjà présente côté serveur ; à voir avec la suite de la PR si pertinent
created_at: 2025-05-26T10:37:52Z
author_association: CONTRIBUTOR
diff_hunk: @@ -98,6 +103,12 @@ export function MissionDetails({
   );
   const [userIdsWithEntries, setUserIdsWithEntries] = React.useState(false);
 
+  const overrideValidation = () => {
+    modals.open("overrideValidation", {
+      updateMotif: setOverrideValidationJustification
path: web/admin/components/MissionDetails/MissionDetails.js
user: login: sandrica89
body: suggestion(non-blocking): utiliser un seul terme pour la même chose : `updateJustification`
created_at: 2025-05-26T10:39:58Z
author_association: CONTRIBUTOR
diff_hunk: @@ -172,7 +193,8 @@ export function MissionDetails({
       entryToBeValidatedByAdmin(
         workerEntry,
         adminStore.userId,
-        adminMayOverrideValidation
+        adminMayOverrideValidation,
+        overrideValidationJustification
path: web/admin/components/MissionDetails/MissionDetails.js
user: login: sandrica89
body: suggestion(non-blocking): je me demande si `overrideValidationJustification` ne pourrait pas être intégré à `adminMayOverrideValidation`, ça éviterait de l'avoir un peu partout
created_at: 2025-05-26T10:46:51Z
author_association: CONTRIBUTOR
diff_hunk: @@ -178,28 +178,15 @@ export function MissionEmployeeCard({
       <AccordionDetails style={{ display: "block" }}>
         <Grid container spacing={2} direction="column" wrap="nowrap">
           {!isDeleted && (
-            <>
-              <Grid item>
-                {isAdminBypassingEmployeeValidation ? (
-                  <Notice
-                    type="warning"
-                    description={
-                      <>La validation par le salarié n'a pas eu lieu pour </>
-                    }
-                    linkText="l'une des raisons suivantes."
-                    linkUrl="https://faq.mobilic.beta.gouv.fr/usages-et-fonctionnement-de-mobilic/suivi-et-validation-du-temps-de-travail#en-tant-que-gestionnaire-je-peux-uniquement-modifier-et-valider-les-missions-validees-par-les-salari"
-                  />
-                ) : (
-                  <MissionValidationInfo validation={stats.workerValidation} />
-                )}
-              </Grid>
-              <Grid item>
-                <MissionValidationInfo
-                  validation={stats.adminValidation}
-                  isAdmin
-                />
-              </Grid>
-            </>
+            <MissionValidations
+              mission={mission}
+              validations={stats.validations}
+              userId={user.id}
+              {...(overrideValidation &&
+                adminAutoValidationOnly && {
+                  overrideManualValidation: overrideValidation
path: web/admin/components/MissionEmployeeCard.js
user: login: sandrica89
body: question: "overrideManualValidation" ? ça laisse penser qu'on peut revalider derrière une validation manuelle, est-ce une erreur dans le nom ?
created_at: 2025-05-26T10:49:25Z
author_association: CONTRIBUTOR
diff_hunk: @@ -178,28 +178,15 @@ export function MissionEmployeeCard({
       <AccordionDetails style={{ display: "block" }}>
         <Grid container spacing={2} direction="column" wrap="nowrap">
           {!isDeleted && (
-            <>
-              <Grid item>
-                {isAdminBypassingEmployeeValidation ? (
-                  <Notice
-                    type="warning"
-                    description={
-                      <>La validation par le salarié n'a pas eu lieu pour </>
-                    }
-                    linkText="l'une des raisons suivantes."
-                    linkUrl="https://faq.mobilic.beta.gouv.fr/usages-et-fonctionnement-de-mobilic/suivi-et-validation-du-temps-de-travail#en-tant-que-gestionnaire-je-peux-uniquement-modifier-et-valider-les-missions-validees-par-les-salari"
-                  />
-                ) : (
-                  <MissionValidationInfo validation={stats.workerValidation} />
-                )}
-              </Grid>
-              <Grid item>
-                <MissionValidationInfo
-                  validation={stats.adminValidation}
-                  isAdmin
-                />
-              </Grid>
-            </>
+            <MissionValidations
+              mission={mission}
+              validations={stats.validations}
+              userId={user.id}
+              {...(overrideValidation &&
+                adminAutoValidationOnly && {
path: web/admin/components/MissionEmployeeCard.js
user: login: sandrica89
body: question: pourquoi ces 2 conditions ici ? si `overrideValidation = true` alors forcément `adminAutoValidationOnly = true` non ?
created_at: 2025-05-26T10:50:47Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,74 @@
+import React from "react";
+import Modal from "../../common/Modal";
+import { Select } from "@codegouvfr/react-dsfr/Select";
+import { Button } from "@codegouvfr/react-dsfr/Button";
+
+export const MOTIFS = [
+  {
+    value: "professional",
+    key: "PROFESSIONAL",
+    label: "Raisons professionelles"
path: web/admin/modals/OverrideValidationJustificationModal.js
user: login: sandrica89
body: todo: typo "professionnelles" (2 "n")
created_at: 2025-05-26T10:51:37Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,74 @@
+import React from "react";
+import Modal from "../../common/Modal";
+import { Select } from "@codegouvfr/react-dsfr/Select";
+import { Button } from "@codegouvfr/react-dsfr/Button";
+
+export const MOTIFS = [
+  {
+    value: "professional",
+    key: "PROFESSIONAL",
+    label: "Raisons professionelles"
+  },
+  {
+    value: "time_off",
+    key: "TIME_OFF",
+    label: "Congé"
+  },
+  {
+    value: "personal",
+    key: "PERSONAL",
+    label: "Raisons personnelles"
+  }
+];
+export default function OverrideValidationJustificationModal({
+  open,
+  handleClose,
+  updateMotif
+}) {
+  const [motif, setMotif] = React.useState(MOTIFS[0].value);
path: web/admin/modals/OverrideValidationJustificationModal.js
user: login: sandrica89
body: suggestion(non-blocking): utiliser un seul terme pour la même chose : motif ou justification partout
created_at: 2025-05-26T10:54:48Z
author_association: CONTRIBUTOR
diff_hunk: @@ -33,14 +33,28 @@ export const missionToValidationEntries = mission =>
 export const entryToBeValidatedByAdmin = (
   tableEntry,
   currentUserId,
-  adminCanBypass = false
-) =>
-  !tableEntry.isDeleted &&
-  !tableEntry.adminValidation &&
-  (entryValidatedByWorkerOrOutdated(tableEntry) ||
+  adminCanBypass = false,
+  overrideValidationReason = ""
path: web/admin/selectors/validationEntriesSelectors.js
user: login: sandrica89
body: suggestion(non-blocking): un 3e terme reason :) (utiliser motif ou justification)
created_at: 2025-05-26T10:56:33Z
author_association: CONTRIBUTOR
diff_hunk: @@ -187,10 +187,11 @@ async function createComment(api, mission, adminStore, text) {
   mission.comments.push(comment);
 }
 
-async function validateMission(api, mission, adminStore, userToValidate) {
+async function validateMission(api, mission, adminStore, args) {
path: web/admin/utils/missionActions.js
user: login: sandrica89
body: question: pourquoi regrouper les 2 valeurs dans un seul argument ?
created_at: 2025-05-26T10:59:01Z
author_association: CONTRIBUTOR
diff_hunk: @@ -82,9 +82,10 @@ export function ContradictoryChanges({
     return classes.missionEvent;
   };
 
-  const changesHistory = contradictoryInfo[1];
-  const loadingEmployeeVersion = contradictoryInfo[2];
-  const contradictoryComputationError = contradictoryInfo[5];
+  const changesHistory = contradictoryInfo.eventsHistory;
+  const loadingEmployeeVersion = contradictoryInfo.isComputingContradictory;
+  const contradictoryComputationError =
+    contradictoryInfo.contradictoryComputationError;
path: web/pwa/components/ContradictoryChanges.js
user: login: sandrica89
body: praise: nice refacto :raised_hands: 
created_at: 2025-05-26T11:01:10Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,126 @@
+import React from "react";
+import { Stack } from "@mui/material";
+import { Accordion } from "@codegouvfr/react-dsfr/Accordion";
+import { formatDay } from "common/utils/time";
+import Notice from "../../common/Notice";
+import { MOTIFS } from "../../admin/modals/OverrideValidationJustificationModal";
+
+const EmployeeValidation = validation => {
+  const label = validation.isAuto
+    ? `Validée automatiquement à la place du salarié le ${formatDay(
+        validation.receptionTime,
+        true
+      )}`
+    : `Validée par le salarié le ${formatDay(validation.receptionTime, true)}`;
+
+  const description = validation.isAuto
+    ? "La validation automatique s'effectue 24 heures après le début de la mission si le salarié ne l'a pas validée avant."
+    : "";
+
+  return description ? (
+    <Accordion label={label} className="success">
+      {description}
+    </Accordion>
+  ) : (
+    <Notice title={label} type="warning" lessPadding withBorders />
+  );
+};
+
+const NoEmployeeValidation = () => (
+  <Notice
+    title="Validation par le salarié en attente"
+    type="warning"
+    withBorders
+    lessPadding
+  />
+);
+
+const AdminAutoValidation = validation => (
+  <Accordion
+    label={`Validée automatiquement à la place du gestionnaire le ${formatDay(
+      validation.receptionTime,
+      true
+    )}`}
+    className="success"
+  >
+    La validation automatique s'effectue au plus tard 2 jours ouvrés après la
+    validation du salarié.
+  </Accordion>
+);
+const AdminManualValidation = (validation, hasOverriden) => {
+  if (hasOverriden) {
+    return (
+      <Accordion
+        label="Modifiée par le gestionnaire après validation automatique"
+        className="warning"
+      >
+        Le gestionnaire a modifié la mission après sa validation pour la raison
+        suivante :{" "}
+        {MOTIFS.find(motif => motif.key === validation.justification)?.label}.
+      </Accordion>
+    );
+  }
+  return (
+    <Notice
+      title={`Validée par le gestionnaire le ${formatDay(
+        validation.receptionTime,
+        true
+      )}`}
+      type="success"
+      lessPadding
+      withBorders
+    />
+  );
+};
+
+const NoAdminValidation = () => (
+  <Notice
+    title="Validation par un gestionnaire en attente"
+    type="warning"
+    withBorders
+    lessPadding
+  />
+);
+
+export const MissionValidations = ({
+  mission,
+  validations,
+  userId,
+  justification
+}) => {
+  const doNotDisplayValidations = mission.isDeleted && !mission.complete;
+
+  if (doNotDisplayValidations) {
+    return;
+  }
+
+  // Should we use getWorkerValidationForUser ?
+  const employeeValidation = validations.find(
+    validation => !validation.isAdmin
+  );
+
+  const adminValidations = validations.filter(
+    v => v.isAdmin && (!v.userId || v.userId === userId)
+  );
+  const adminAutoValidation = adminValidations.find(
+    validation => validation.isAuto
+  );
+  const adminManualValidation = adminValidations.find(
+    validation => !validation.isAuto
+  );
path: web/pwa/components/MissionValidations.js
user: login: sandrica89
body: note: j'ai l'impression qu'on refait encore de la logique déjà présente ailleurs :thinking: 
created_at: 2025-05-26T11:03:30Z
author_association: CONTRIBUTOR
diff_hunk: @@ -38,7 +38,8 @@ export function parseMissionPayloadFromBackend(missionPayload, userId) {
     deletedAt: missionPayload.deletedAt,
     deletedBy: missionPayload.deletedBy,
     isHoliday:
-      missionPayload.isHoliday !== undefined ? missionPayload.isHoliday : false
+      missionPayload.isHoliday !== undefined ? missionPayload.isHoliday : false,
+    validations: missionPayload.validations
path: common/utils/mission.js
user: login: tristan-gueguen
body: j'ai eu peur de retirer `validation` et `adminValidation` car je crois que c'est utilisé à beaucoup d'endroits et j'avais peur de tout casser.
created_at: 2025-05-27T12:10:09Z
author_association: CONTRIBUTOR
diff_hunk: @@ -187,10 +187,11 @@ async function createComment(api, mission, adminStore, text) {
   mission.comments.push(comment);
 }
 
-async function validateMission(api, mission, adminStore, userToValidate) {
+async function validateMission(api, mission, adminStore, args) {
path: web/admin/utils/missionActions.js
user: login: tristan-gueguen
body: je n'ai pas réussi à ajouter d'autres arguments du fait de la manière dont le code existant était fait. les autres missionActions qui prennent plusieurs arguments procèdent comme ça aussi
created_at: 2025-05-27T12:13:38Z
author_association: CONTRIBUTOR
diff_hunk: @@ -187,10 +187,11 @@ async function createComment(api, mission, adminStore, text) {
   mission.comments.push(comment);
 }
 
-async function validateMission(api, mission, adminStore, userToValidate) {
+async function validateMission(api, mission, adminStore, args) {
path: web/admin/utils/missionActions.js
user: login: tristan-gueguen
body: en fait on a réussi
created_at: 2025-05-27T14:33:10Z
author_association: CONTRIBUTOR

PR comments:
user: login: tristanrobert
created_at: 2025-05-22T16:36:53Z
body: ![](https://res.cloudinary.com/snyk/image/upload/r-d/scm-platform/snyk-pull-requests/pr-banner-default.svg)

### :tada: **Snyk checks have passed. No issues have been found so far.**

:white_check_mark: **security/snyk** check is complete. No issues have been found. [(View Details)](https://app.snyk.io/org/mtes-mct/pr-checks/82d0888c-ee94-430c-a203-2e420aacece4?refs=1869d740acfd15b783f1dc97f44637b2841662c1&source=prChecksComment)



author_association: CONTRIBUTOR
user: login: sonarqubecloud[bot]
created_at: 2025-05-27T15:30:00Z
body: ## [![Quality Gate Passed](https://sonarsource.github.io/sonarcloud-github-static-resources/v2/checks/QualityGateBadge/qg-passed-20px.png 'Quality Gate Passed')](https://sonarcloud.io/dashboard?id=MTES-MCT_mobilic&pullRequest=673) **Quality Gate passed**  
Issues  
![](https://sonarsource.github.io/sonarcloud-github-static-resources/v2/common/passed-16px.png '') [12 New issues](https://sonarcloud.io/project/issues?id=MTES-MCT_mobilic&pullRequest=673&issueStatuses=OPEN,CONFIRMED&sinceLeakPeriod=true)  
![](https://sonarsource.github.io/sonarcloud-github-static-resources/v2/common/accepted-16px.png '') [0 Accepted issues](https://sonarcloud.io/project/issues?id=MTES-MCT_mobilic&pullRequest=673&issueStatuses=ACCEPTED)

Measures  
![](https://sonarsource.github.io/sonarcloud-github-static-resources/v2/common/passed-16px.png '') [0 Security Hotspots](https://sonarcloud.io/project/security_hotspots?id=MTES-MCT_mobilic&pullRequest=673&issueStatuses=OPEN,CONFIRMED&sinceLeakPeriod=true)  
![](https://sonarsource.github.io/sonarcloud-github-static-resources/v2/common/passed-16px.png '') [0.0% Coverage on New Code](https://sonarcloud.io/component_measures?id=MTES-MCT_mobilic&pullRequest=673&metric=new_coverage&view=list)  
![](https://sonarsource.github.io/sonarcloud-github-static-resources/v2/common/passed-16px.png '') [0.0% Duplication on New Code](https://sonarcloud.io/component_measures?id=MTES-MCT_mobilic&pullRequest=673&metric=new_duplicated_lines_density&view=list)  
  
[See analysis details on SonarQube Cloud](https://sonarcloud.io/dashboard?id=MTES-MCT_mobilic&pullRequest=673)


author_association: NONE