diff --git a/common/assets/styles/root.scss b/common/assets/styles/root.scss
index d8f062fc1..dcbb54030 100644
--- a/common/assets/styles/root.scss
+++ b/common/assets/styles/root.scss
@@ -395,3 +395,17 @@ video:not([href])[controls] {
main {
scroll-margin-top: 200px;
}
+
+// DSFR tooltip z-index and overflow fix for ReactVirtualized tables
+.fr-tooltip {
+ z-index: 1100 !important;
+}
+
+// Allow tooltip to overflow from table cells
+.ReactVirtualized__Table__rowColumn:has(.fr-tooltip) {
+ overflow: visible !important;
+}
+
+.ReactVirtualized__Table__row:has(.fr-tooltip) {
+ overflow: visible !important;
+}
diff --git a/common/utils/apiQueries/apiFragments.js b/common/utils/apiQueries/apiFragments.js
index d6a9dfe2c..604857aa0 100644
--- a/common/utils/apiQueries/apiFragments.js
+++ b/common/utils/apiQueries/apiFragments.js
@@ -225,6 +225,7 @@ export const FULL_EMPLOYMENT_FRAGMENT = gql`
email
hasAdminRights
latestInviteEmailTime
+ lastActiveAt
teamId
business {
transportType
diff --git a/common/utils/employeeStatus.js b/common/utils/employeeStatus.js
new file mode 100644
index 000000000..2df60891b
--- /dev/null
+++ b/common/utils/employeeStatus.js
@@ -0,0 +1,13 @@
+import { DAY, frenchFormatDateStringOrTimeStamp, now } from "./time";
+
+export const INACTIVITY_THRESHOLD_DAYS = 90;
+
+export const isInactiveMoreThan3Months = lastActiveAt => {
+ if (!lastActiveAt) return false;
+ return now() - lastActiveAt > DAY * INACTIVITY_THRESHOLD_DAYS;
+};
+
+export const formatLastActiveDate = lastActiveAt => {
+ if (!lastActiveAt) return "";
+ return frenchFormatDateStringOrTimeStamp(lastActiveAt * 1000);
+};
diff --git a/common/utils/matomoTags.js b/common/utils/matomoTags.js
index 08b947184..d5fd8a566 100644
--- a/common/utils/matomoTags.js
+++ b/common/utils/matomoTags.js
@@ -19,7 +19,11 @@ export const MATOMO_ACTIONS = {
INVITE_NEW_EMPLOYEE: "invite-new-employee",
INVITE_NEW_EMPLOYEE_SUBMIT: "invite-new-employee-submit",
INVITE_MISSING_EMPLOYEES: "invite-missing-employees",
- INVITE_EMAIL_LIST: "invite-email-list"
+ INVITE_EMAIL_LIST: "invite-email-list",
+ INACTIVE_EMPLOYEES_BANNER_VIEW: "inactive-employees-banner-view",
+ INACTIVE_EMPLOYEES_BANNER_CLICK: "inactive-employees-banner-click",
+ BATCH_TERMINATE_MODAL_OPEN: "batch-terminate-modal-open",
+ BATCH_TERMINATE_MODAL_SUBMIT: "batch-terminate-modal-submit"
};
export const EDIT_ACTIVITY_IN_MISSION_PANEL = {
@@ -248,3 +252,29 @@ export const INVITE_NEW_EMPLOYEE_SUBMIT = {
action: MATOMO_ACTIONS.INVITE_NEW_EMPLOYEE_SUBMIT,
name: "Soumission invitation nouveau salarié"
};
+
+export const INACTIVE_EMPLOYEES_BANNER_VIEW = inactiveCount => ({
+ category: MATOMO_CATEGORIES.ADMIN_EMPLOYEE_INVITATION,
+ action: MATOMO_ACTIONS.INACTIVE_EMPLOYEES_BANNER_VIEW,
+ name: "Affichage bandeau salariés inactifs",
+ value: inactiveCount
+});
+
+export const INACTIVE_EMPLOYEES_BANNER_CLICK = {
+ category: MATOMO_CATEGORIES.ADMIN_EMPLOYEE_INVITATION,
+ action: MATOMO_ACTIONS.INACTIVE_EMPLOYEES_BANNER_CLICK,
+ name: "Clic sur lien bandeau salariés inactifs"
+};
+
+export const BATCH_TERMINATE_MODAL_OPEN = {
+ category: MATOMO_CATEGORIES.ADMIN_EMPLOYEE_INVITATION,
+ action: MATOMO_ACTIONS.BATCH_TERMINATE_MODAL_OPEN,
+ name: "Ouverture modale détachement en masse"
+};
+
+export const BATCH_TERMINATE_MODAL_SUBMIT = terminatedCount => ({
+ category: MATOMO_CATEGORIES.ADMIN_EMPLOYEE_INVITATION,
+ action: MATOMO_ACTIONS.BATCH_TERMINATE_MODAL_SUBMIT,
+ name: "Soumission détachement en masse (nb détachements)",
+ value: terminatedCount
+});
diff --git a/web/admin/modals/TerminateEmploymentModal.js b/web/admin/modals/TerminateEmploymentModal.js
index a8520bccf..da7fee7d8 100644
--- a/web/admin/modals/TerminateEmploymentModal.js
+++ b/web/admin/modals/TerminateEmploymentModal.js
@@ -1,32 +1,125 @@
import React from "react";
-import TextField from "@mui/material/TextField";
-import Box from "@mui/material/Box";
+import Checkbox from "@mui/material/Checkbox";
+import { Input } from "@codegouvfr/react-dsfr/Input";
import { LoadingButton } from "common/components/LoadingButton";
import { useSnackbarAlerts } from "../../common/Snackbar";
-import { graphQLErrorMatchesCode } from "common/utils/errors";
-import { MobileDatePicker } from "@mui/x-date-pickers";
+import { formatApiError, graphQLErrorMatchesCode } from "common/utils/errors";
+import { formatPersonName } from "common/utils/coworkers";
+import { useMatomo } from "@datapunt/matomo-tracker-react";
+import {
+ BATCH_TERMINATE_MODAL_OPEN,
+ BATCH_TERMINATE_MODAL_SUBMIT
+} from "common/utils/matomoTags";
import Notice from "../../common/Notice";
import Modal from "../../common/Modal";
export default function TerminateEmploymentModal({
open,
- terminateEmployment,
- minDate,
- handleClose
+ handleClose,
+ inactiveEmployees,
+ terminateEmployment
}) {
- const [endDate, setEndDate] = React.useState(null);
- const [loading, setLoading] = React.useState(false);
-
+ const { trackEvent } = useMatomo();
const alerts = useSnackbarAlerts();
- const today = new Date();
+ const formatDateForInput = date =>
+ date ? date.toISOString().split("T")[0] : "";
+
+ const todayForMaxDate = React.useMemo(() => new Date(), []);
+ const [selectedEmployees, setSelectedEmployees] = React.useState([]);
+ const [loading, setLoading] = React.useState(false);
+ const hasTrackedOpen = React.useRef(false);
React.useEffect(() => {
- setEndDate(minDate && minDate > today ? minDate : today);
- }, [minDate]);
+ if (open && inactiveEmployees?.length > 0) {
+ const today = new Date();
+ if (!hasTrackedOpen.current) {
+ trackEvent(BATCH_TERMINATE_MODAL_OPEN);
+ hasTrackedOpen.current = true;
+ }
+ setSelectedEmployees(
+ inactiveEmployees.map(emp => ({
+ ...emp,
+ selected: true,
+ endDate: today,
+ error: null
+ }))
+ );
+ }
+ if (!open) {
+ hasTrackedOpen.current = false;
+ }
+ }, [open, inactiveEmployees, trackEvent]);
+
+ const toggleEmployee = employmentId => {
+ setSelectedEmployees(prev =>
+ prev.map(emp =>
+ emp.employmentId === employmentId
+ ? { ...emp, selected: !emp.selected }
+ : emp
+ )
+ );
+ };
+
+ const updateEndDate = (employmentId, newDate) => {
+ setSelectedEmployees(prev =>
+ prev.map(emp =>
+ emp.employmentId === employmentId
+ ? { ...emp, endDate: newDate, error: null }
+ : emp
+ )
+ );
+ };
+
+ const setEmployeeError = (employmentId, error) => {
+ setSelectedEmployees(prev =>
+ prev.map(emp =>
+ emp.employmentId === employmentId ? { ...emp, error } : emp
+ )
+ );
+ };
+
+ const selectedCount = selectedEmployees.filter(emp => emp.selected).length;
+
+ const handleSubmit = async e => {
+ e.preventDefault();
+ const toTerminate = selectedEmployees.filter(emp => emp.selected);
+ if (toTerminate.length === 0) return;
+
+ setLoading(true);
+ let successCount = 0;
+ let errorCount = 0;
+
+ for (const emp of toTerminate) {
+ try {
+ await terminateEmployment(emp.employmentId, emp.endDate);
+ successCount++;
+ } catch (err) {
+ errorCount++;
+ const errorMessage = graphQLErrorMatchesCode(err, "INVALID_INPUTS")
+ ? "La date de fin doit être postérieure à la date de début."
+ : formatApiError(err);
+ setEmployeeError(emp.employmentId, errorMessage);
+ }
+ }
+
+ if (successCount > 0) {
+ trackEvent(BATCH_TERMINATE_MODAL_SUBMIT(successCount));
+ alerts.success(
+ `${successCount} détachement(s) terminé(s)`,
+ "batch-terminate-success",
+ 6000
+ );
+ }
+
+ setLoading(false);
+ if (errorCount === 0) {
+ handleClose();
+ }
+ };
return (