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 ( } /> - -
{ - e.preventDefault(); - setLoading(true); - await alerts.withApiErrorHandling( - async () => { - await terminateEmployment(endDate); - handleClose(); - }, - "terminate-employment", - gqlError => { - if (graphQLErrorMatchesCode(gqlError, "INVALID_INPUTS")) { - return "Impossible de terminer à cette date. La date de fin du rattachement doit être postérieure à la date de début."; - } - } - ); - setLoading(false); - }} - id="terminate-employment-form" - > - ( - - )} - /> - -
+
+
+ + + + + + + + + + {selectedEmployees.map(emp => ( + + + + + + ))} + +
Salarié + Date de fin de rattachement +
+ toggleEmployee(emp.employmentId)} + inputProps={{ + "aria-label": `Sélectionner ${formatPersonName(emp)}` + }} + sx={{ padding: 0 }} + /> + {formatPersonName(emp)} + + updateEndDate( + emp.employmentId, + e.target.value ? new Date(e.target.value) : null + ), + max: formatDateForInput(todayForMaxDate), + min: emp.startDate + ? formatDateForInput(new Date(emp.startDate)) + : undefined + }} + /> +
+
+
} actions={ <> Mettre fin diff --git a/web/admin/panels/Employees.js b/web/admin/panels/Employees.js index 4492538ed..31805d961 100644 --- a/web/admin/panels/Employees.js +++ b/web/admin/panels/Employees.js @@ -18,6 +18,10 @@ import { isoFormatLocalDate, now } from "common/utils/time"; +import { + isInactiveMoreThan3Months, + formatLastActiveDate +} from "common/utils/employeeStatus"; import { ADMIN_ACTIONS } from "../store/reducers/root"; import { EMPLOYMENT_ROLE } from "common/utils/employments"; import { TeamFilter } from "../components/TeamFilter"; @@ -38,7 +42,9 @@ import { INVITE_NEW_EMPLOYEE_SUBMIT, INVITE_EMAIL_LIST_CLICK, INVITE_MISSING_EMPLOYEES_CLICK, - INVITE_NEW_EMPLOYEE_CLICK + INVITE_NEW_EMPLOYEE_CLICK, + INACTIVE_EMPLOYEES_BANNER_VIEW, + INACTIVE_EMPLOYEES_BANNER_CLICK } from "common/utils/matomoTags"; import { BATCH_CREATE_WORKER_EMPLOYMENTS_MUTATION, @@ -48,6 +54,8 @@ import { SEND_INVITATIONS_REMINDERS, TERMINATE_EMPLOYMENT_MUTATION } from "common/utils/apiQueries/employments"; +import { Tooltip } from "@codegouvfr/react-dsfr/Tooltip"; +import { Badge } from "@codegouvfr/react-dsfr/Badge"; const useStyles = makeStyles((theme) => ({ title: { @@ -66,6 +74,10 @@ const useStyles = makeStyles((theme) => ({ }, hideButton: { marginLeft: theme.spacing(2) + }, + badgeDetache: { + backgroundColor: "#E5E5E5 !important", + color: "#929292 !important" } })); @@ -329,22 +341,51 @@ export function Employees({ company, containerRef }) { format: (remindButton) => remindButton }); + const EmployeeStatusBadge = ({ isDetached, isInactive, lastActiveAt }) => { + if (isDetached) { + return ( + + {"détaché".toUpperCase()} + + ); + } + if (isInactive) { + return ( + + + {"inactif".toUpperCase()} + + + ); + } + return null; + }; + const validEmploymentColumns = [ { - label: "Nom", + label: "Salarié", name: "lastName", align: "left", sortable: true, - minWidth: 120, - overflowTooltip: true + minWidth: 200, + overflowTooltip: true, + format: (lastName, entry) => + `${entry.firstName} ${lastName}` }, { - label: "Prénom", - name: "firstName", - align: "left", + label: "", + name: "statusBadge", + align: "right", sortable: true, - minWidth: 120, - overflowTooltip: true + minWidth: 80, + baseWidth: 80, + format: (_, entry) => ( + + ) }, { label: "Identifiant", @@ -511,23 +552,32 @@ export function Employees({ company, containerRef }) { ); }) .filter((e) => e.isAcknowledged) - .map((e) => ({ - pending: false, - id: e.user.id, - email: e.user.email, - employmentId: e.id, - lastName: e.user.lastName, - firstName: e.user.firstName, - name: formatPersonName(e.user), - startDate: e.startDate, - endDate: e.endDate, - active: !e.endDate || e.endDate >= today, - hasAdminRights: e.hasAdminRights ? 1 : 0, - teamId: e.teamId, - userId: e.user.id, - companyId: e.company.id, - business: e.business - })), + .map((e) => { + const isDetached = !!e.endDate; + const isInactive = !isDetached && isInactiveMoreThan3Months(e.lastActiveAt); + const statusBadge = isDetached ? 2 : isInactive ? 1 : 0; + return { + pending: false, + id: e.user.id, + email: e.user.email, + employmentId: e.id, + lastName: e.user.lastName, + firstName: e.user.firstName, + name: formatPersonName(e.user), + startDate: e.startDate, + endDate: e.endDate, + active: !e.endDate || e.endDate >= today, + hasAdminRights: e.hasAdminRights ? 1 : 0, + teamId: e.teamId, + userId: e.user.id, + companyId: e.company.id, + business: e.business, + lastActiveAt: e.lastActiveAt, + isDetached, + isInactive, + statusBadge + }; + }), [companyEmployments, selectedTeamIds] ); @@ -541,6 +591,32 @@ export function Employees({ company, containerRef }) { [validEmployments] ); + const inactiveEmployees = React.useMemo( + () => activeValidEmployments.filter((e) => e.isInactive), + [activeValidEmployments] + ); + + const [isBannerDismissed, setIsBannerDismissed] = React.useState(false); + const shouldShowInactiveBanner = + inactiveEmployees.length >= 3 && !isBannerDismissed; + + const [hasTrackedBannerView, setHasTrackedBannerView] = React.useState(false); + + React.useEffect(() => { + if (shouldShowInactiveBanner && !hasTrackedBannerView) { + trackEvent(INACTIVE_EMPLOYEES_BANNER_VIEW(inactiveEmployees.length)); + setHasTrackedBannerView(true); + } + }, [shouldShowInactiveBanner, hasTrackedBannerView, inactiveEmployees.length]); + + const handleOpenBatchTerminateModal = () => { + trackEvent(INACTIVE_EMPLOYEES_BANNER_CLICK); + modals.open("terminateEmployment", { + inactiveEmployees, + terminateEmployment + }); + }; + const employeeProgressData = useEmployeeProgress( company, activeValidEmployments @@ -715,9 +791,8 @@ export function Employees({ company, containerRef }) { `Mettre fin au rattachement du gestionnaire ${employment.name}`, () => modals.open("terminateEmployment", { - minDate: new Date(empl.startDate), - terminateEmployment: async (endDate) => - terminateEmployment(empl.employmentId, endDate) + inactiveEmployees: [empl], + terminateEmployment }), true ) @@ -953,6 +1028,36 @@ export function Employees({ company, containerRef }) { renseigné. Veuillez en sélectionner un pour chaque salarié actif." /> )} + {shouldShowInactiveBanner && ( + setIsBannerDismissed(true)} + description={ + <> + {formatPersonName(inactiveEmployees[0])} et{" "} + {inactiveEmployees.length - 1} autres salariés n'ont pas + enregistré de temps de travail depuis 3 mois. Pensez à détacher + ces salariés s'ils ne font plus partie de votre entreprise, en + sélectionnant l'option "mettre fin au rattachement", ou{" "} + + + } + /> + )} {adminStore?.teams?.length > 0 && (