Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 14 additions & 0 deletions common/assets/styles/root.scss
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
1 change: 1 addition & 0 deletions common/utils/apiQueries/apiFragments.js
Original file line number Diff line number Diff line change
Expand Up @@ -225,6 +225,7 @@ export const FULL_EMPLOYMENT_FRAGMENT = gql`
email
hasAdminRights
latestInviteEmailTime
lastActiveAt
teamId
business {
transportType
Expand Down
13 changes: 13 additions & 0 deletions common/utils/employeeStatus.js
Original file line number Diff line number Diff line change
@@ -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);
};
32 changes: 31 additions & 1 deletion common/utils/matomoTags.js
Original file line number Diff line number Diff line change
Expand Up @@ -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 = {
Expand Down Expand Up @@ -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
});
231 changes: 177 additions & 54 deletions web/admin/modals/TerminateEmploymentModal.js
Original file line number Diff line number Diff line change
@@ -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 (
<Modal
size="sm"
size="lg"
open={open}
handleClose={handleClose}
title="Fin du rattachement"
Expand All @@ -46,53 +139,83 @@ export default function TerminateEmploymentModal({
</>
}
/>
<Box my={2} mt={4} className="flex-row-center">
<form
noValidate
autoComplete="off"
onSubmit={async e => {
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"
>
<MobileDatePicker
label="Date de fin du rattachement"
value={endDate}
inputFormat="d MMMM yyyy"
minDate={minDate}
onChange={setEndDate}
cancelText={null}
disableCloseOnSelect={false}
disableMaskedInput={true}
maxDate={today}
renderInput={props => (
<TextField {...props} variant="outlined" />
)}
/>
</form>
</Box>
<form id="terminate-employment-form" onSubmit={handleSubmit}>
<div className="fr-table fr-table--bordered fr-table--layout-fixed">
<table>
<thead>
<tr>
<th
scope="col"
style={{
width: "10%",
minWidth: "auto",
borderRight: "1px solid var(--border-default-grey)"
}}
></th>
<th scope="col" style={{ width: "45%" }}>Salarié</th>
<th scope="col" style={{ width: "45%" }}>
Date de fin de rattachement
</th>
</tr>
</thead>
<tbody>
{selectedEmployees.map(emp => (
<tr key={emp.employmentId}>
<td
style={{
textAlign: "center",
backgroundColor: "var(--background-alt-grey)",
borderRight: "1px solid var(--border-default-grey)"
}}
>
<Checkbox
checked={emp.selected}
onChange={() => toggleEmployee(emp.employmentId)}
inputProps={{
"aria-label": `Sélectionner ${formatPersonName(emp)}`
}}
sx={{ padding: 0 }}
/>
</td>
<td>{formatPersonName(emp)}</td>
<td>
<Input
label={null}
hideLabel
disabled={!emp.selected}
style={{ marginBottom: 0, maxWidth: "200px" }}
state={emp.error ? "error" : "default"}
stateRelatedMessage={emp.error}
nativeInputProps={{
type: "date",
value: formatDateForInput(emp.endDate),
onChange: e =>
updateEndDate(
emp.employmentId,
e.target.value ? new Date(e.target.value) : null
),
max: formatDateForInput(todayForMaxDate),
min: emp.startDate
? formatDateForInput(new Date(emp.startDate))
: undefined
}}
/>
</td>
</tr>
))}
</tbody>
</table>
</div>
</form>
</>
}
actions={
<>
<LoadingButton
type="submit"
loading={loading}
form="terminate-employment-form"
disabled={selectedCount === 0}
loading={loading}
>
Mettre fin
</LoadingButton>
Expand Down
Loading