diff --git a/server/src/http/routes/specific.routes/organisme.routes.ts b/server/src/http/routes/specific.routes/organisme.routes.ts index bc2a08b87..0655298fc 100644 --- a/server/src/http/routes/specific.routes/organisme.routes.ts +++ b/server/src/http/routes/specific.routes/organisme.routes.ts @@ -1,64 +1,260 @@ -import { compact, get } from "lodash-es"; -import { ObjectId } from "mongodb"; +import { ObjectId } from "bson"; import { + STATUT_APPRENANT, getAnneesScolaireListFromDate, getSIFADate, requiredApprenantAdresseFieldsSifa, requiredFieldsSifa, } from "shared"; -import { isEligibleSIFA } from "@/common/actions/sifa.actions/sifa.actions"; import { effectifsDECADb, effectifsDb, organismesDb } from "@/common/model/collections"; -export async function getOrganismeEffectifs(organismeId: ObjectId, sifa = false) { +export async function getOrganismeEffectifs( + organismeId: ObjectId, + sifa: boolean = false, + options: { + pageIndex: number; + pageSize: number; + search: string; + filters: any; + sortField: string | null; + sortOrder: string | null; + } = { pageIndex: 0, pageSize: 10, search: "", filters: {}, sortField: null, sortOrder: null } +) { const organisme = await organismesDb().findOne({ _id: organismeId }); + const { pageIndex, pageSize, search, filters, sortField, sortOrder } = options; const isDeca = !organisme?.is_transmission_target; const db = isDeca ? effectifsDECADb() : effectifsDb(); - const effectifs = await db - .find({ - organisme_id: organismeId, - ...(sifa - ? { - annee_scolaire: { - $in: getAnneesScolaireListFromDate(sifa ? getSIFADate(new Date()) : new Date()), + const parsedFilters = Object.entries(filters).reduce( + (acc, [key, value]) => { + if (typeof value === "string" && value.startsWith("[") && value.endsWith("]")) { + try { + const parsed = JSON.parse(value); + acc[key] = Array.isArray(parsed) ? parsed : [parsed]; + } catch { + acc[key] = [value]; + } + } else if (Array.isArray(value)) { + acc[key] = value; + } + return acc; + }, + {} as Record + ); + + const matchConditions = { + organisme_id: organismeId, + ...(sifa && { + annee_scolaire: { + $in: getAnneesScolaireListFromDate(sifa ? getSIFADate(new Date()) : new Date()), + }, + }), + ...Object.keys(parsedFilters).reduce((acc, key) => { + if (parsedFilters[key]?.length > 0) { + const fieldKey = + key === "formation_libelle_long" + ? "formation.libelle_long" + : key === "statut_courant" + ? "_computed.statut.en_cours" + : key; + acc[fieldKey] = { $in: parsedFilters[key] }; + } + return acc; + }, {}), + ...(typeof search === "string" && + search.trim() && { + $or: [ + { "apprenant.nom": { $regex: search.trim(), $options: "i" } }, + { "apprenant.prenom": { $regex: search.trim(), $options: "i" } }, + ], + }), + }; + + const fieldMap: Record = { + nom: "apprenant.nom", + prenom: "apprenant.prenom", + formation: "formation.libelle_long", + statut_courant: "_computed.statut.en_cours", + annee_scolaire: "annee_scolaire", + }; + + const sortConditions = + sortField && sortOrder + ? { [fieldMap[sortField] || sortField]: sortOrder === "desc" ? -1 : 1 } + : { "formation.annee_scolaire": -1 }; + + const pipeline = [ + { + $facet: { + allFilters: [ + { + $match: { + organisme_id: organismeId, + ...(sifa && { + annee_scolaire: { + $in: getAnneesScolaireListFromDate(sifa ? getSIFADate(new Date()) : new Date()), + }, + "_computed.statut.parcours": { + $elemMatch: { + valeur: STATUT_APPRENANT.APPRENTI, + date: { + $eq: { + $arrayElemAt: [ + { + $filter: { + input: "$_computed.statut.parcours", + as: "parcours", + cond: { $eq: ["$$parcours.valeur", STATUT_APPRENANT.APPRENTI] }, + }, + }, + -1, + ], + }, + }, + }, + }, + }), + }, + }, + { + $group: { + _id: null, + annee_scolaire: { $addToSet: "$annee_scolaire" }, + source: { $addToSet: "$source" }, + statut_courant: { $addToSet: "$_computed.statut.en_cours" }, + formation_libelle_long: { $addToSet: "$formation.libelle_long" }, }, - } - : {}), - }) - .toArray(); + }, + { + $project: { + _id: 0, + annee_scolaire: 1, + source: 1, + statut_courant: 1, + formation_libelle_long: 1, + }, + }, + ], + results: [ + { $match: matchConditions }, + { $sort: sortConditions }, + { $skip: pageIndex * pageSize }, + { $limit: pageSize }, + { + $project: { + id: { $toString: "$_id" }, + id_erp_apprenant: 1, + organisme_id: 1, + annee_scolaire: 1, + source: 1, + validation_errors: 1, + formation: 1, + nom: "$apprenant.nom", + prenom: "$apprenant.prenom", + date_de_naissance: "$apprenant.date_de_naissance", + historique_statut: "$apprenant.historique_statut", + statut: "$_computed.statut", + ...(sifa + ? { + requiredSifa: { + $filter: { + input: { + $setUnion: [ + requiredFieldsSifa, + { + $cond: [{ $not: "$apprenant.adresse.complete" }, requiredApprenantAdresseFieldsSifa, []], + }, + ], + }, + as: "fieldName", + cond: { $or: [{ $not: { $ifNull: ["$$fieldName", false] } }] }, + }, + }, + } + : {}), + }, + }, + ], + totalCount: [ + { $match: matchConditions }, + { + $count: "count", + }, + ], + }, + }, + { + $project: { + filters: { + annee_scolaire: { $arrayElemAt: ["$allFilters.annee_scolaire", 0] }, + source: { $arrayElemAt: ["$allFilters.source", 0] }, + statut_courant: { $arrayElemAt: ["$allFilters.statut_courant", 0] }, + formation_libelle_long: { $arrayElemAt: ["$allFilters.formation_libelle_long", 0] }, + }, + results: "$results", + total: { $arrayElemAt: ["$totalCount.count", 0] }, + }, + }, + ]; + + const [data] = await db.aggregate(pipeline).toArray(); return { fromDECA: isDeca, - organismesEffectifs: effectifs - .filter((effectif) => !sifa || isEligibleSIFA(effectif._computed?.statut)) - .map((effectif) => ({ - id: effectif._id.toString(), - id_erp_apprenant: effectif.id_erp_apprenant, + total: data?.total || 0, + filters: data?.filters || {}, + organismesEffectifs: data?.results || [], + }; +} + +export async function getAllOrganismeEffectifsIds(organismeId: ObjectId, sifa = false) { + const organisme = await organismesDb().findOne({ _id: organismeId }); + const isDeca = !organisme?.is_transmission_target; + const db = isDeca ? effectifsDECADb() : effectifsDb(); + + const pipeline = [ + { + $match: { organisme_id: organismeId, - annee_scolaire: effectif.annee_scolaire, - source: effectif.source, - validation_errors: effectif.validation_errors, - formation: effectif.formation, - nom: effectif.apprenant.nom, - prenom: effectif.apprenant.prenom, - date_de_naissance: effectif.apprenant.date_de_naissance, - historique_statut: effectif.apprenant.historique_statut, - statut: effectif._computed?.statut, - ...(sifa - ? { - requiredSifa: compact( - [ - ...(!effectif.apprenant.adresse?.complete - ? [...requiredFieldsSifa, ...requiredApprenantAdresseFieldsSifa] - : requiredFieldsSifa), - ].map((fieldName) => - !get(effectif, fieldName) || get(effectif, fieldName) === "" ? fieldName : undefined - ) - ), - } - : {}), - })), + ...(sifa && { + annee_scolaire: { + $in: getAnneesScolaireListFromDate(sifa ? getSIFADate(new Date()) : new Date()), + }, + "_computed.statut.parcours": { + $elemMatch: { + valeur: STATUT_APPRENANT.APPRENTI, + date: { + $eq: { + $arrayElemAt: [ + { + $filter: { + input: "$_computed.statut.parcours", + as: "parcours", + cond: { $eq: ["$$parcours.valeur", STATUT_APPRENANT.APPRENTI] }, + }, + }, + -1, + ], + }, + }, + }, + }, + }), + }, + }, + { + $project: { + id: { $toString: "$_id" }, + }, + }, + ]; + + const effectifs = await db.aggregate(pipeline).toArray(); + + return { + fromDECA: isDeca, + organismesEffectifs: effectifs, }; } @@ -70,7 +266,7 @@ export async function updateOrganismeEffectifs( } ) { if (!update["apprenant.type_cfa"]) return; - const { fromDECA, organismesEffectifs } = await getOrganismeEffectifs(organismeId, sifa); + const { fromDECA, organismesEffectifs } = await getAllOrganismeEffectifsIds(organismeId, sifa); !fromDECA && (await effectifsDb().updateMany( diff --git a/server/src/http/server.ts b/server/src/http/server.ts index 5a4194069..4c9857d0d 100644 --- a/server/src/http/server.ts +++ b/server/src/http/server.ts @@ -570,7 +570,18 @@ function setupRoutes(app: Application) { "/effectifs", requireOrganismePermission("manageEffectifs"), returnResult(async (req, res) => { - return await getOrganismeEffectifs(res.locals.organismeId, req.query.sifa === "true"); + const { pageIndex, pageSize, search, sortField, sortOrder, sifa, ...filters } = req.query; + + const options = { + pageIndex: parseInt(pageIndex, 10) || 0, + pageSize: parseInt(pageSize, 10) || 10, + search: search || "", + sortField, + sortOrder, + filters, + }; + + return await getOrganismeEffectifs(res.locals.organismeId, sifa === "true", options); }) ) .put( diff --git a/ui/common/utils/stringUtils.ts b/ui/common/utils/stringUtils.ts index 1420307a8..abef76f9e 100644 --- a/ui/common/utils/stringUtils.ts +++ b/ui/common/utils/stringUtils.ts @@ -39,3 +39,8 @@ export const toPascalCase = (string) => .replace(new RegExp(/[^\w\s]/, "g"), "") .replace(new RegExp(/\s+(.)(\w*)/, "g"), ($1, $2, $3) => `${$2.toUpperCase() + $3}`) .replace(new RegExp(/\w/), (s) => s.toUpperCase()); + +export function capitalizeWords(str: string): string { + const formattedString = str.toLowerCase().replace(/_/g, " "); + return formattedString.charAt(0).toUpperCase() + formattedString.slice(1); +} diff --git a/ui/components/Table/TableWithApi.tsx b/ui/components/Table/TableWithApi.tsx new file mode 100644 index 000000000..96afe51bd --- /dev/null +++ b/ui/components/Table/TableWithApi.tsx @@ -0,0 +1,292 @@ +import { ChevronLeftIcon, ChevronRightIcon } from "@chakra-ui/icons"; +import { Box, Button, Flex, HStack, Select, SystemProps, Table, Tbody, Td, Th, Thead, Tr } from "@chakra-ui/react"; +import { + ColumnDef, + PaginationState, + Row, + SortingState, + flexRender, + functionalUpdate, + getCoreRowModel, + getPaginationRowModel, + getSortedRowModel, + useReactTable, +} from "@tanstack/react-table"; +import { Fragment, useState, useMemo } from "react"; + +import { FirstPageIcon, LastPageIcon } from "@/modules/dashboard/icons"; +import { AddFill, SubtractLine } from "@/theme/components/icons"; + +const DEFAULT_COL_SIZE = 220; + +interface TableWithApiProps extends SystemProps { + columns: ColumnDef[]; + data: T[]; + total?: number; + noDataMessage?: string; + expandAllRows?: boolean; + enableRowExpansion?: boolean; + sortingState?: SortingState; + paginationState?: PaginationState; + variant?: string; + showPagination?: boolean; + isLoading?: boolean; + onSortingChange?: (state: SortingState) => any; + onPaginationChange?: (state: PaginationState) => any; + renderSubComponent?: (row: Row) => React.ReactElement; + renderDivider?: () => React.ReactElement; +} + +function TableWithApi(props: TableWithApiProps) { + const [expandedRows, setExpandedRows] = useState>( + props.expandAllRows ? new Set(props.data.map((row) => row.id)) : new Set() + ); + + const totalPages = useMemo(() => { + if (!props.total) return 1; + return Math.ceil(props.total / (props.paginationState?.pageSize || 20)); + }, [props.total, props.paginationState?.pageSize]); + + const toggleRowExpansion = (rowId: string) => { + setExpandedRows((prev) => { + const newSet = new Set(); + if (!prev.has(rowId)) { + newSet.add(rowId); + } + return newSet; + }); + }; + + const columnWidths = useMemo(() => { + return props.columns.reduce( + (acc, col) => { + const columnId = "accessorKey" in col ? col.accessorKey : col.id; + if (columnId) { + acc[columnId as string] = col.size || DEFAULT_COL_SIZE; + } + return acc; + }, + {} as Record + ); + }, [props.columns]); + + const table = useReactTable({ + data: props.data, + columns: props.columns, + pageCount: totalPages, + state: { + pagination: props.paginationState ?? { + pageIndex: 0, + pageSize: 20, + }, + sorting: props.sortingState, + }, + manualPagination: true, + onPaginationChange: (updater) => { + const newState = functionalUpdate(updater, table.getState().pagination); + props.onPaginationChange?.(newState); + }, + onSortingChange: (updater) => { + const newState = functionalUpdate(updater, table.getState().sorting); + props.onSortingChange?.(newState); + }, + getCoreRowModel: getCoreRowModel(), + getSortedRowModel: getSortedRowModel(), + getPaginationRowModel: getPaginationRowModel(), + }); + + const { pageIndex } = table.getState().pagination; + + return ( + <> + + + {table.getHeaderGroups().map((headerGroup, index) => ( + + {headerGroup.headers.map((header, headerIndex) => { + const columnId = + "accessorKey" in header.column.columnDef ? header.column.columnDef.accessorKey : header.column.id; + const width = columnId ? columnWidths[columnId as string] || "auto" : "auto"; + + return ( + + ); + })} + {props.enableRowExpansion && } + + ))} + + + {!props.data || props.data.length === 0 ? ( + + + + ) : ( + table.getRowModel().rows.map((row) => { + const isExpanded = expandedRows.has(row.original.id); + + return ( + + props.enableRowExpansion && toggleRowExpansion(row.original.id)} + cursor={props.enableRowExpansion ? "pointer" : "default"} + bg={isExpanded ? "#E3E3FD" : "inherit"} + > + {row.getVisibleCells().map((cell, cellIndex) => ( + + ))} + {props.enableRowExpansion && ( + + )} + + + {isExpanded && props.renderSubComponent && ( + + + + )} + + {props.renderDivider && ( + + + + )} + + ); + }) + )} + +
div > .sort-icon": { + display: "inline-block", + color: "black", + }, + } + : undefined + } + paddingBottom={3} + bg="white" + > + {header.isPlaceholder ? null : ( + + {flexRender(header.column.columnDef.header, header.getContext())} + + {header.column.getIsSorted() === "desc" && } + {header.column.getIsSorted() === "asc" && } + {header.column.getIsSorted() === false && ( + + ▼ + + )} + + + )} +
+ {props.noDataMessage ?? "Aucun résultat"} +
+ {flexRender(cell.column.columnDef.cell, cell.getContext())} + + + + +
+ {props.renderSubComponent(row)} +
+ {props.renderDivider()} +
+ + {props.showPagination !== false && ( + + + + + + {pageIndex - 1 > 0 && ( + + )} + {pageIndex > 0 && ( + + )} + + {pageIndex + 1 < totalPages && ( + + )} + {pageIndex + 2 < totalPages && ( + + )} + + + + + + + + + + )} + + ); +} + +export default TableWithApi; diff --git a/ui/modules/effectifs/EffectifsPage.tsx b/ui/modules/effectifs/EffectifsPage.tsx index 39665d738..38bb10f08 100644 --- a/ui/modules/effectifs/EffectifsPage.tsx +++ b/ui/modules/effectifs/EffectifsPage.tsx @@ -1,36 +1,20 @@ import { AddIcon } from "@chakra-ui/icons"; -import { - Box, - Button, - Center, - Circle, - Container, - Heading, - HStack, - Spinner, - Switch, - Text, - VStack, -} from "@chakra-ui/react"; +import { Box, Container, Heading, HStack } from "@chakra-ui/react"; import { useQuery } from "@tanstack/react-query"; -import groupBy from "lodash.groupby"; +import { SortingState } from "@tanstack/react-table"; import { useRouter } from "next/router"; -import { useMemo, useState } from "react"; +import { useEffect, useState } from "react"; import { useSetRecoilState } from "recoil"; -import { DuplicateEffectifGroupPagination, EFFECTIFS_GROUP, getStatutApprenantNameFromCode } from "shared"; +import { DuplicateEffectifGroupPagination, EFFECTIFS_GROUP, getAnneeScolaireFromDate } from "shared"; -import { effectifsExportColumns } from "@/common/exports"; import { _get } from "@/common/httpClient"; import { Organisme } from "@/common/internal/Organisme"; -import { exportDataAsXlsx } from "@/common/utils/exportUtils"; -import DownloadButton from "@/components/buttons/DownloadButton"; import Link from "@/components/Links/Link"; import SupportLink from "@/components/Links/SupportLink"; import SimplePage from "@/components/Page/SimplePage"; import { effectifFromDecaAtom, effectifsStateAtom } from "../mon-espace/effectifs/engine/atoms"; -import EffectifsTableContainer from "../mon-espace/effectifs/engine/EffectifTableContainer"; -import { Input } from "../mon-espace/effectifs/engine/formEngine/components/Input/Input"; +import EffectifsTable from "../mon-espace/effectifs/engine/effectifsTable/EffectifsTable"; import BandeauTransmission from "../organismes/BandeauTransmission"; import BandeauDuplicatsEffectifs from "./BandeauDuplicatsEffectifs"; @@ -39,41 +23,193 @@ interface EffectifsPageProps { organisme: Organisme; modePublique: boolean; } -function EffectifsPage(props: EffectifsPageProps) { - // Booléen temporaire pour la désactivation temporaire du bouton de téléchargement de la liste - const TMP_DEACTIVATE_DOWNLOAD_BUTTON = true; +function EffectifsPage(props: EffectifsPageProps) { const router = useRouter(); const setCurrentEffectifsState = useSetRecoilState(effectifsStateAtom); const setEffectifFromDecaState = useSetRecoilState(effectifFromDecaAtom); - const [searchValue, setSearchValue] = useState(""); - const [showOnlyErrors, setShowOnlyErrors] = useState(false); - const [filtreAnneeScolaire, setFiltreAnneeScolaire] = useState("all"); - - const { - data: organismesEffectifs, - isFetching, - refetch, - } = useQuery(["organismes", props.organisme._id, "effectifs"], async () => { - const { fromDECA, organismesEffectifs } = await _get(`/api/v1/organismes/${props.organisme._id}/effectifs`); - // met à jour l'état de validation de chaque effectif (nécessaire pour le formulaire) - setCurrentEffectifsState( - organismesEffectifs.reduce((acc, { id, validation_errors }) => { - acc.set(id, { validation_errors, requiredSifa: [] }); - return acc; - }, new Map()) - ); - setEffectifFromDecaState(fromDECA); - return organismesEffectifs; + + const [pagination, setPagination] = useState({ pageIndex: 0, pageSize: 10 }); + const [search, setSearch] = useState(""); + const [filters, setFilters] = useState>({ + annee_scolaire: [getAnneeScolaireFromDate(new Date())], }); + const [sort, setSort] = useState([{ desc: true, id: "annee_scolaire" }]); + + useEffect(() => { + const parseFilter = (key: string, value: string | string[] | undefined) => { + if (value) { + const values = Array.isArray(value) ? value : [value]; + try { + return values.map((v) => { + const decodedValue = decodeURIComponent(v); + return decodedValue.startsWith("[") && decodedValue.endsWith("]") + ? JSON.parse(decodedValue) + : [decodedValue]; + }); + } catch { + return values.map((v) => decodeURIComponent(v)); + } + } + return undefined; + }; + + const mergedFilters: Record = { ...filters }; + + const filterKeys = ["formation", "statut_courant", "annee_scolaire", "source"]; + + filterKeys.forEach((key) => { + const parsedFilter = parseFilter(key, router.query[key]); + if (parsedFilter) { + mergedFilters[key] = parsedFilter.flat(); + } + }); + + if (JSON.stringify(mergedFilters) !== JSON.stringify(filters)) { + setFilters(mergedFilters); + } + }, [router.query]); + + const { data, isFetching, refetch } = useQuery( + ["organismes", props.organisme._id, "effectifs", pagination, search, filters, sort], + async () => { + const response = await _get(`/api/v1/organismes/${props.organisme._id}/effectifs`, { + params: { + pageIndex: pagination.pageIndex, + pageSize: pagination.pageSize, + search, + sortField: sort[0]?.id, + sortOrder: sort[0]?.desc ? "desc" : "asc", + ...filters, + }, + }); + + const { fromDECA, total, filters: returnedFilters, organismesEffectifs } = response; + + setCurrentEffectifsState( + organismesEffectifs.reduce((acc, { id, validation_errors }) => { + acc.set(id, { validation_errors, requiredSifa: [] }); + return acc; + }, new Map()) + ); + + setEffectifFromDecaState(fromDECA); + + return { total, filters: returnedFilters, organismesEffectifs }; + }, + { keepPreviousData: true } + ); const { data: duplicates } = useQuery(["organismes", props.organisme._id, "duplicates"], () => _get(`/api/v1/organismes/${props.organisme?._id}/duplicates`) ); - const effectifsByAnneeScolaire = useMemo(() => groupBy(organismesEffectifs, "annee_scolaire"), [organismesEffectifs]); + const handlePaginationChange = (newPagination) => { + setPagination(newPagination); + + router.push( + { + pathname: router.pathname, + query: { + ...router.query, + pageIndex: newPagination.pageIndex, + pageSize: newPagination.pageSize, + }, + }, + undefined, + { shallow: true } + ); + }; + + const handleSearchChange = (value: string) => { + setSearch(value); + + router.push( + { + pathname: router.pathname, + query: { ...router.query, search: value }, + }, + undefined, + { shallow: true } + ); + }; + + const handleFilterChange = (newFilters: Record) => { + setPagination({ ...pagination, pageIndex: 0 }); + const mergedFilters = { ...filters }; + + Object.entries(newFilters).forEach(([key, values]) => { + if (values.length > 0) { + mergedFilters[key] = values; + } else { + delete mergedFilters[key]; + } + }); + + const queryFilters = Object.entries(mergedFilters).reduce( + (acc, [key, values]) => { + acc[key] = JSON.stringify(values); + return acc; + }, + {} as Record + ); + + const updatedQuery = { ...router.query, ...queryFilters }; + Object.keys(router.query).forEach((key) => { + if (!queryFilters[key]) { + delete updatedQuery[key]; + } + }); + + setFilters(mergedFilters); + + router.push( + { + pathname: router.pathname, + query: updatedQuery, + }, + undefined, + { shallow: true } + ); + }; + + const handleSortChange = (newSort: SortingState) => { + setPagination({ ...pagination, pageIndex: 0 }); + setSort(newSort); + + router.push( + { + pathname: router.pathname, + query: { + ...router.query, + sortField: newSort[0]?.id, + sortOrder: newSort[0]?.desc ? "desc" : "asc", + }, + }, + undefined, + { shallow: true } + ); + }; + + const resetFilters = () => { + setFilters({}); + setSearch(""); + + const { organismeId } = router.query; + + const updatedQuery = organismeId ? { organismeId } : {}; + router.push( + { + pathname: router.pathname, + query: updatedQuery, + }, + undefined, + { shallow: true } + ); + }; const title = `${props.modePublique ? "Ses" : "Mes"} effectifs`; + return ( @@ -90,48 +226,10 @@ function EffectifsPage(props: EffectifsPageProps) { Ajouter via fichier Excel - {!TMP_DEACTIVATE_DOWNLOAD_BUTTON && organismesEffectifs?.length ? ( - { - exportDataAsXlsx( - `tdb-effectifs-${filtreAnneeScolaire}.xlsx`, - organismesEffectifs?.map((effectif) => { - return { - organisme_uai: props.organisme.uai, - organisme_siret: props.organisme.siret, - organisme_nom: props.organisme.raison_sociale, - organisme_nature: props.organisme.nature, - apprenant_nom: effectif.nom, - apprenant_prenom: effectif.prenom, - apprenant_date_de_naissance: effectif.date_de_naissance, - apprenant_statut: getStatutApprenantNameFromCode( - effectif.historique_statut?.sort((a, b) => { - return new Date(a.date_statut).getTime() - new Date(b.date_statut).getTime(); - })[0]?.valeur_statut - ), - formation_annee: effectif.formation?.annee, - formation_cfd: effectif.formation?.cfd, - formation_date_debut_formation: effectif.formation?.periode?.[0], - formation_date_fin_formation: effectif.formation?.periode?.[1], - formation_libelle_long: effectif.formation?.libelle_long, - formation_niveau: effectif.formation?.niveau, - formation_rncp: effectif.formation?.rncp, - }; - }) || [], - effectifsExportColumns - ); - }} - > - Télécharger la liste - - ) : null} - {organismesEffectifs && organismesEffectifs.length === 0 && ( + {data?.organismesEffectifs?.length === 0 && Object.keys(filters).length === 0 && ( )} @@ -139,105 +237,26 @@ function EffectifsPage(props: EffectifsPageProps) { )} - {isFetching && ( -
- -
- )} - - {organismesEffectifs && organismesEffectifs.length > 0 && ( - <> - - { - setSearchValue(value.trim()); - }, - }} - w="35%" - /> - - - - - Filtrer : - - - { - setShowOnlyErrors(e.target.checked); - }} - /> - Afficher uniquement les données en erreur - - - - Par année scolaire - setFiltreAnneeScolaire("all")} active={filtreAnneeScolaire === "all"}> - Toutes - - {Object.keys(effectifsByAnneeScolaire).map((anneeScolaire) => { - return ( - setFiltreAnneeScolaire(anneeScolaire)} - key={anneeScolaire} - active={anneeScolaire === filtreAnneeScolaire} - > - {anneeScolaire} - - ); - })} - - - - )} - - {Object.entries(effectifsByAnneeScolaire).map(([anneeScolaire, effectifs]) => { - if (filtreAnneeScolaire !== "all" && anneeScolaire !== filtreAnneeScolaire) { - return null; - } - const orgaEffectifs = showOnlyErrors - ? effectifs.filter((effectif) => effectif.validation_errors.length) - : effectifs; - const effectifsByCfd: { [cfd: string]: any[] } = groupBy(orgaEffectifs, "formation.cfd"); - return ( - - - {anneeScolaire} {!searchValue ? `- ${orgaEffectifs.length} apprenant(es) total` : ""} - - - {Object.entries(effectifsByCfd).map(([cfd, effectifs]) => { - const { formation } = effectifs[0]; - return ( - - ); - })} - - - ); - })} +
@@ -245,16 +264,3 @@ function EffectifsPage(props: EffectifsPageProps) { } export default EffectifsPage; - -const BadgeButton = ({ onClick, active = false, children, ...props }) => { - return ( - - ); -}; diff --git a/ui/modules/mon-espace/SIFA/SIFAPage.tsx b/ui/modules/mon-espace/SIFA/SIFAPage.tsx index d72ffc7ef..ae14c7055 100644 --- a/ui/modules/mon-espace/SIFA/SIFAPage.tsx +++ b/ui/modules/mon-espace/SIFA/SIFAPage.tsx @@ -1,27 +1,23 @@ import { ChevronDownIcon, ChevronUpIcon } from "@chakra-ui/icons"; import { - Center, - Heading, - Spinner, Box, - Flex, - Text, - HStack, - Switch, + Collapse, Container, - FormControl, - FormLabel, - UnorderedList, - ListItem, + Flex, Grid, + Heading, + HStack, Image, - Collapse, + ListItem, + Text, + UnorderedList, } from "@chakra-ui/react"; -import { useQuery, useQueryClient } from "@tanstack/react-query"; -import groupBy from "lodash.groupby"; -import { ChangeEvent, useEffect, useMemo, useRef, useState } from "react"; -import { useRecoilValue, useSetRecoilState } from "recoil"; -import { DuplicateEffectifGroupPagination, getSIFADate, SIFA_GROUP } from "shared"; +import { useQuery } from "@tanstack/react-query"; +import { SortingState } from "@tanstack/react-table"; +import { useRouter } from "next/router"; +import { useEffect, useState } from "react"; +import { useSetRecoilState } from "recoil"; +import { DuplicateEffectifGroupPagination, SIFA_GROUP } from "shared"; import { _get, _getBlob } from "@/common/httpClient"; import { Organisme } from "@/common/internal/Organisme"; @@ -32,88 +28,209 @@ import Link from "@/components/Links/Link"; import SupportLink from "@/components/Links/SupportLink"; import { BasicModal } from "@/components/Modals/BasicModal"; import Ribbons from "@/components/Ribbons/Ribbons"; -import { organismeAtom } from "@/hooks/organismeAtoms"; import { usePlausibleTracking } from "@/hooks/plausible"; import useToaster from "@/hooks/useToaster"; import BandeauDuplicatsEffectifs from "@/modules/effectifs/BandeauDuplicatsEffectifs"; -import { effectifsStateAtom, effectifFromDecaAtom } from "@/modules/mon-espace/effectifs/engine/atoms"; -import EffectifTableContainer from "@/modules/mon-espace/effectifs/engine/EffectifTableContainer"; -import { Input } from "@/modules/mon-espace/effectifs/engine/formEngine/components/Input/Input"; import InfoTeleversementSIFA from "@/modules/organismes/InfoTeleversementSIFA"; -import { DownloadLine, ExternalLinkLine } from "@/theme/components/icons"; +import { ExternalLinkLine, DownloadLine } from "@/theme/components/icons"; import Eye from "@/theme/components/icons/Eye"; -function useOrganismesEffectifs(organismeId: string) { - const setCurrentEffectifsState = useSetRecoilState(effectifsStateAtom); - const queryClient = useQueryClient(); - const prevOrganismeId = useRef(null); - const setEffectifFromDecaState = useSetRecoilState(effectifFromDecaAtom); +import { effectifsStateAtom, effectifFromDecaAtom } from "../effectifs/engine/atoms"; - useEffect(() => { - if (prevOrganismeId.current !== organismeId) { - prevOrganismeId.current = organismeId; - // FIX ME: reset toutes les queries ?! Cet effect est probablement à supprimer car inutile - // queryClient.resetQueries("organismesEffectifs", { exact: true }); - } - }, [queryClient, organismeId]); - - const { - data: organismesEffectifs, - isLoading, - isFetching, - refetch, - } = useQuery(["organismesEffectifs", organismeId], async () => { - const { fromDECA, organismesEffectifs } = await _get(`/api/v1/organismes/${organismeId}/effectifs?sifa=true`); - const newEffectifsState = new Map(); - for (const { id, validation_errors, requiredSifa } of organismesEffectifs as any) { - newEffectifsState.set(id, { validation_errors, requiredSifa }); - } - setCurrentEffectifsState(newEffectifsState); - setEffectifFromDecaState(fromDECA); - - return organismesEffectifs; - }); - - return { isLoading: isFetching || isLoading, organismesEffectifs: organismesEffectifs || [], refetch }; -} +import SIFAEffectifsTable from "./SIFATable/SIFAEffectifsTable"; interface SIFAPageProps { organisme: Organisme; modePublique: boolean; } -const SIFAPage = (props: SIFAPageProps) => { +function SIFAPage(props: SIFAPageProps) { + const router = useRouter(); const { trackPlausibleEvent } = usePlausibleTracking(); const { toastWarning, toastSuccess } = useToaster(); - const organisme = useRecoilValue(organismeAtom); - const { isLoading, organismesEffectifs, refetch } = useOrganismesEffectifs(organisme._id); + const setCurrentEffectifsState = useSetRecoilState(effectifsStateAtom); + const setEffectifFromDecaState = useSetRecoilState(effectifFromDecaAtom); + + const [pagination, setPagination] = useState({ pageIndex: 0, pageSize: 10 }); + const [search, setSearch] = useState(""); + const [filters, setFilters] = useState>({}); + const [sort, setSort] = useState([{ desc: true, id: "annee_scolaire" }]); const [show, setShow] = useState(false); - const handleToggle = () => { - setShow(!show); - }; - const [searchValue, setSearchValue] = useState(""); + useEffect(() => { + const parseFilter = (key: string, value: string | string[] | undefined) => { + if (value) { + const values = Array.isArray(value) ? value : [value]; + try { + return values.map((v) => { + const decodedValue = decodeURIComponent(v); + return decodedValue.startsWith("[") && decodedValue.endsWith("]") + ? JSON.parse(decodedValue) + : [decodedValue]; + }); + } catch { + return values.map((v) => decodeURIComponent(v)); + } + } + return undefined; + }; + + const filters: Record = {}; + + const filterKeys = ["formation", "source"]; + + filterKeys.forEach((key) => { + const parsedFilter = parseFilter(key, router.query[key]); + if (parsedFilter) { + filters[key] = parsedFilter.flat(); + } + }); + + setFilters(filters); + }, [router.query]); + + const { data, isFetching, refetch } = useQuery( + ["organismes", props.organisme._id, "effectifs", pagination, search, filters, sort], + async () => { + const response = await _get(`/api/v1/organismes/${props.organisme._id}/effectifs`, { + params: { + pageIndex: pagination.pageIndex, + pageSize: pagination.pageSize, + search, + sortField: sort[0]?.id, + sortOrder: sort[0]?.desc ? "desc" : "asc", + annee_scolaire: ["2024-2025"], + sifa: true, + ...filters, + }, + }); + + const { fromDECA, total, filters: returnedFilters, organismesEffectifs } = response; + + setCurrentEffectifsState( + organismesEffectifs.reduce((acc, { id, validation_errors }) => { + acc.set(id, { validation_errors, requiredSifa: [] }); + return acc; + }, new Map()) + ); + + setEffectifFromDecaState(fromDECA); - const organismesEffectifsGroupedBySco: any = useMemo( - () => groupBy(organismesEffectifs, "annee_scolaire"), - [organismesEffectifs] + return { total, filters: returnedFilters, organismesEffectifs }; + }, + { keepPreviousData: true } ); - const [showOnlyMissingSifa, setShowOnlyMissingSifa] = useState(false); - const [hasTrackedMissingSifa, setHasTrackedMissingSifa] = useState(false); const { data: duplicates } = useQuery(["organismes", props.organisme._id, "duplicates"], () => _get(`/api/v1/organismes/${props.organisme?._id}/duplicates`) ); - const handleToggleMissingSifaChange = (e: ChangeEvent) => { - if (!hasTrackedMissingSifa) { - trackPlausibleEvent("clic_toggle_sifa_données_manquantes"); - setHasTrackedMissingSifa(true); - } - setShowOnlyMissingSifa(e.target.checked); + const handleToggle = () => { + setShow(!show); + }; + + const handlePaginationChange = (newPagination) => { + setPagination(newPagination); + + router.push( + { + pathname: router.pathname, + query: { + ...router.query, + pageIndex: newPagination.pageIndex, + pageSize: newPagination.pageSize, + }, + }, + undefined, + { shallow: true } + ); + }; + + const handleSearchChange = (value: string) => { + setSearch(value); + + router.push( + { + pathname: router.pathname, + query: { ...router.query, search: value }, + }, + undefined, + { shallow: true } + ); + }; + + const handleFilterChange = (newFilters: Record) => { + setPagination({ ...pagination, pageIndex: 0 }); + const mergedFilters = { ...filters }; + + Object.entries(newFilters).forEach(([key, values]) => { + if (values.length > 0) { + mergedFilters[key] = values; + } else { + delete mergedFilters[key]; + } + }); + + const queryFilters = Object.entries(mergedFilters).reduce( + (acc, [key, values]) => { + acc[key] = JSON.stringify(values); + return acc; + }, + {} as Record + ); + + const updatedQuery = { ...router.query, ...queryFilters }; + Object.keys(router.query).forEach((key) => { + if (!queryFilters[key]) { + delete updatedQuery[key]; + } + }); + + setFilters(mergedFilters); + router.push( + { + pathname: router.pathname, + query: updatedQuery, + }, + undefined, + { shallow: true } + ); + }; + + const handleSortChange = (newSort: SortingState) => { + setPagination({ ...pagination, pageIndex: 0 }); + setSort(newSort); + + router.push( + { + pathname: router.pathname, + query: { + ...router.query, + sortField: newSort[0]?.id, + sortOrder: newSort[0]?.desc ? "desc" : "asc", + }, + }, + undefined, + { shallow: true } + ); + }; + + const resetFilters = () => { + setFilters({}); + setSearch(""); + + router.push( + { + pathname: router.pathname, + query: {}, + }, + undefined, + { shallow: true } + ); }; const handleToastOnSifaDownload = () => { + const organismesEffectifs = data?.organismesEffectifs || []; const nbEffectifsInvalides = organismesEffectifs.filter((effectif) => effectif.requiredSifa.length > 0).length; nbEffectifsInvalides > 0 @@ -133,14 +250,6 @@ const SIFAPage = (props: SIFAPageProps) => { ); }; - if (isLoading) { - return ( -
- -
- ); - } - return ( @@ -155,9 +264,9 @@ const SIFAPage = (props: SIFAPageProps) => { action={async () => { trackPlausibleEvent("telechargement_sifa"); downloadObject( - await _getBlob(`/api/v1/organismes/${organisme._id}/sifa-export`), + await _getBlob(`/api/v1/organismes/${props.organisme._id}/sifa-export`), `tdb-données-sifa-${ - organisme.enseigne ?? organisme.raison_sociale ?? "Organisme inconnu" + props.organisme.enseigne ?? props.organisme.raison_sociale ?? "Organisme inconnu" }-${new Date().toLocaleDateString()}.csv`, "text/plain" ); @@ -309,45 +418,6 @@ const SIFAPage = (props: SIFAPageProps) => { - - - Vous avez {organismesEffectifs.length} effectifs au total, en contrat au 31 décembre{" "} - {getSIFADate(new Date()).getUTCFullYear()}. - - - setSearchValue(value.trim())} - value={searchValue} - w="35%" - mb={0} - /> - - - - - Afficher uniquement les données manquantes pour SIFA - - - - - {!props.modePublique && duplicates && duplicates?.totalItems > 0 && ( @@ -355,37 +425,28 @@ const SIFAPage = (props: SIFAPageProps) => { )} - {Object.entries(organismesEffectifsGroupedBySco).map(([anneSco, orgaE]: [string, any]) => { - const orgaEffectifs = showOnlyMissingSifa ? orgaE.filter((ef) => ef.requiredSifa.length) : orgaE; - const effectifsByCfd = groupBy(orgaEffectifs, "formation.cfd"); - return ( - - - {anneSco} {!searchValue ? `- ${orgaEffectifs.length} apprenant(es) total` : ""} - - - {Object.entries(effectifsByCfd).map(([cfd, effectifs]: [string, any[]]) => { - const { formation } = effectifs[0]; - return ( - - ); - })} - - - ); - })} + ); -}; +} export default SIFAPage; diff --git a/ui/modules/mon-espace/SIFA/SIFATable/SIFAEffectifsColumns.tsx b/ui/modules/mon-espace/SIFA/SIFATable/SIFAEffectifsColumns.tsx new file mode 100644 index 000000000..4fcb781a2 --- /dev/null +++ b/ui/modules/mon-espace/SIFA/SIFATable/SIFAEffectifsColumns.tsx @@ -0,0 +1,269 @@ +import { Alert, Box, HStack, ListItem, Text, UnorderedList, VStack } from "@chakra-ui/react"; +import { DateTime } from "luxon"; +import { useRecoilValue } from "recoil"; +import { getStatut } from "shared"; + +import { capitalizeWords } from "@/common/utils/stringUtils"; +import { InfoTooltip } from "@/components/Tooltip/InfoTooltip"; +import { ValidateIcon } from "@/theme/components/icons"; + +import { effectifStateSelector } from "../../effectifs/engine/formEngine/atoms"; + +const SIFAeffectifsTableColumnsDefs = ({ modeSifa, organismesEffectifs }) => [ + { + accessorKey: "nom", + header: () => ( + <> + Nom{" "} + + + ), + cell: ({ row, getValue }) => , + size: 160, + }, + { + accessorKey: "prenom", + header: () => ( + <> + Prénom{" "} + + + ), + cell: ({ row, getValue }) => ( + + ), + size: 160, + }, + { + accessorKey: "formation", + header: () => "Formation", + cell: ({ row }) => { + return ( + + {row.original?.formation?.libelle_long || "Libellé manquant"} + + CFD : {row.original?.formation?.cfd} - RNCP : {row.original?.formation?.cfd} + + + ); + }, + size: 350, + }, + { + accessorKey: "source", + header: () => ( + <> + Source{" "} + + "Source de la donnée"} + contentComponent={() => ( + + + Ce champ indique la provenance de la donnée. Par exemple, la donnée est transmise par un ERP ou via un + téléversement de fichier Excel, ou encore de plateforme DECA (Dépôt des Contrats d’Alternance). + + + )} + aria-label="Informations sur la répartition des effectifs au national" + /> + + ), + cell: ({ row, getValue }) => ( + + ), + size: 100, + }, + { + accessorKey: "statut_courant", + header: () => ( + <> + Statut actuel{" "} + "Statut actuel"} + contentComponent={() => ( + + Un jeune peut être : + + apprenti en contrat + inscrit sans contrat signé + en rupture de contrat + en fin de formation (diplômé) + abandon (a quitté le CFA) + + + )} + aria-label="Informations sur la répartition des effectifs au national" + /> + + ), + cell: ({ row }) => { + const statut = row.original?.statut; + + if (!statut || !statut.parcours.length) { + return ( + + Aucun statut + + ); + } + + const historiqueSorted = statut.parcours.sort( + (a, b) => new Date(a.date_statut).getTime() - new Date(b.date_statut).getTime() + ); + const current = [...historiqueSorted].pop(); + + return ( + + {getStatut(current.valeur)} + + depuis le {DateTime.fromISO(current.date).setLocale("fr-FR").toFormat("dd/MM/yyyy")} + + + ); + }, + size: 150, + enableSorting: false, + }, + { + accessorKey: "state", + header: () => ( + + État de la donnée + État de la donnée} + contentComponent={() => ( + + {modeSifa ? ( + <> + + Cette colonne indique si les données obligatoires pour l’enquête SIFA sont complètes ou manquantes. + + + Si manquante(s), veuillez les compléter à la source (sur votre ERP si votre organisme transmet via + API). Les modifications apportées seront visibles dès le lendemain sur cette page. + + + Note : vous pouvez aussi télécharger le fichier en l’état, mais il faudra compléter + les données manquantes sur ce dernier. + + + ) : ( + Ce champ indique si les données affichées contiennent des erreurs ou non. + )} + + )} + /> + + ), + size: 200, + enableSorting: false, + cell: ({ row }) => { + const { id } = organismesEffectifs[row.id]; + // eslint-disable-next-line react-hooks/rules-of-hooks + const { validation_errors, requiredSifa } = useRecoilValue(effectifStateSelector(id)); // Not the best; THIS IS AN EXCEPTION; This should not be reproduce anywhere else + + const MissingSIFA = ({ requiredSifa }) => { + if (!requiredSifa?.length) + return ( + + Complète pour SIFA + + ); + + return ( + + + {requiredSifa.length} manquante(s) pour SIFA + "Champ(s) manquant(s) :"} + contentComponent={() => ( + + + {requiredSifa.map((fieldName, i) => ( + {fieldName} + ))} + + + Veuillez le(s) corriger/compléter sur : + + + + + votre outil de gestion (ex : Gestibase, Ypareo) si vous transmettez par API. La donnée + apparaîtra sur le Tableau de bord dans les prochaines 24 heures. + + + + + le Tableau de bord de l’apprentissage si vous avez transmis vos effectifs via fichier Excel. + + + + + )} + /> + + + ); + }; + + const ValidationsErrorsInfo = ({ validation_errors }) => { + if (!validation_errors?.length) return null; + return ( + + + {validation_errors.length} erreur(s) de transmission + + ( + + Champ(s) en erreur(s) : + + {validation_errors.map(({ fieldName }, i) => ( + {fieldName} + ))} + + + )} + /> + + ); + }; + + return ( + + {modeSifa && } + + + ); + }, + }, +]; + +const ShowErrorInCell = ({ item, fieldName, value }) => { + const { validation_errors } = item; + const validation_error = validation_errors?.find((e) => e.fieldName === fieldName); + if (validation_error) { + return ( + + + {validation_error.inputValue || "VIDE"} + + + ); + } + return value; +}; + +export default SIFAeffectifsTableColumnsDefs; diff --git a/ui/modules/mon-espace/SIFA/SIFATable/SIFAEffectifsFilterPanel.tsx b/ui/modules/mon-espace/SIFA/SIFATable/SIFAEffectifsFilterPanel.tsx new file mode 100644 index 000000000..20a8e4829 --- /dev/null +++ b/ui/modules/mon-espace/SIFA/SIFATable/SIFAEffectifsFilterPanel.tsx @@ -0,0 +1,66 @@ +import { Button, Stack, Text } from "@chakra-ui/react"; +import { useState } from "react"; + +import EffectifsFilterComponent from "../../effectifs/engine/effectifsTable/EffectifsFilterComponent"; + +interface SIFAEffectifsFilterPanelProps { + filters: Record; + availableFilters: Record; + onFilterChange: (filters: Record) => void; + resetFilters: () => void; +} + +const SIFAEffectifsFilterPanel: React.FC = ({ + filters, + availableFilters, + onFilterChange, + resetFilters, +}) => { + const [openFilter, setOpenFilter] = useState(null); + + const handleCheckboxChange = (filterKey: string, selectedValues: string[]) => { + const updatedFilters = { ...filters, [filterKey]: selectedValues }; + onFilterChange(updatedFilters); + }; + + return ( + + + FILTRER PAR + + + {/* Source */} + {availableFilters?.source && ( + handleCheckboxChange("source", values)} + isOpen={openFilter === "source"} + setIsOpen={(isOpen) => setOpenFilter(isOpen ? "source" : null)} + /> + )} + + {/* Formation */} + {availableFilters?.formation_libelle_long && ( + handleCheckboxChange("formation_libelle_long", values)} + isOpen={openFilter === "formation_libelle_long"} + setIsOpen={(isOpen) => setOpenFilter(isOpen ? "formation_libelle_long" : null)} + /> + )} + + + + + ); +}; + +export default SIFAEffectifsFilterPanel; diff --git a/ui/modules/mon-espace/SIFA/SIFATable/SIFAEffectifsTable.tsx b/ui/modules/mon-espace/SIFA/SIFATable/SIFAEffectifsTable.tsx new file mode 100644 index 000000000..234b28eda --- /dev/null +++ b/ui/modules/mon-espace/SIFA/SIFATable/SIFAEffectifsTable.tsx @@ -0,0 +1,183 @@ +import { SearchIcon } from "@chakra-ui/icons"; +import { Box, Button, Divider, HStack, Input, InputGroup, InputRightElement, Switch, Text } from "@chakra-ui/react"; +import { UseQueryResult } from "@tanstack/react-query"; +import { ColumnDef, Row, SortingState } from "@tanstack/react-table"; +import { useRouter } from "next/router"; +import { useState, useEffect } from "react"; +import { getSIFADate } from "shared"; + +import { Organisme } from "@/common/internal/Organisme"; +import TableWithApi from "@/components/Table/TableWithApi"; + +import EffectifTableDetails from "../../effectifs/engine/EffectifsTableDetails"; + +import SIFAeffectifsTableColumnsDefs from "./SIFAEffectifsColumns"; +import SIFAEffectifsFilterPanel from "./SIFAEffectifsFilterPanel"; + +interface EffectifsTableProps { + organisme: Organisme; + organismesEffectifs: any[]; + filters: Record; + pagination: any; + search: string; + sort: SortingState; + onPaginationChange: (pagination: any) => void; + onSearchChange: (search: string) => void; + onFilterChange: (filters: Record) => void; + onSortChange: (sort: SortingState) => void; + total: number; + availableFilters: Record; + resetFilters: () => void; + isFetching: boolean; + canEdit?: boolean; + modeSifa?: boolean; + refetch: (options: { throwOnError: boolean; cancelRefetch: boolean }) => Promise; +} + +const EffectifsTable = ({ + organismesEffectifs, + filters, + pagination, + search, + sort: initialSort, + onPaginationChange, + onSearchChange, + onFilterChange, + onSortChange, + total, + availableFilters, + resetFilters, + isFetching, + canEdit, + modeSifa, + refetch, +}: EffectifsTableProps) => { + const router = useRouter(); + + const [sort, setSort] = useState(initialSort); + const [localSearch, setLocalSearch] = useState(search || ""); + const [showOnlyErrors, setShowOnlyErrors] = useState(false); + + useEffect(() => { + setSort(initialSort); + setLocalSearch(search || ""); + }, [initialSort, search]); + + const handleSearchInputChange = (event: React.ChangeEvent) => { + setLocalSearch(event.target.value); + }; + + const onSearchButtonClick = () => { + executeSearch(); + }; + + const handleKeyDown = (event: React.KeyboardEvent) => { + if (event.key === "Enter") { + executeSearch(); + } + }; + + const executeSearch = () => { + onSearchChange(localSearch); + + router.push( + { + pathname: router.pathname, + query: { ...router.query, search: localSearch }, + }, + undefined, + { shallow: true } + ); + }; + + const handlePaginationChange = (newPagination) => { + onPaginationChange(newPagination); + + router.push( + { + pathname: router.pathname, + query: { + ...router.query, + pageIndex: newPagination.pageIndex, + pageSize: newPagination.pageSize, + }, + }, + undefined, + { shallow: true } + ); + }; + + const handleSortChange = (newSort: SortingState) => { + onSortChange(newSort); + }; + + const filteredEffectifs = showOnlyErrors + ? organismesEffectifs.filter((effectif) => effectif.validation_errors?.length > 0) + : organismesEffectifs; + + return ( + + + + + + + + + + + + + + + { + setShowOnlyErrors(e.target.checked); + }} + /> + Afficher uniquement les données manquantes pour SIFA + + + + + + Vous avez {total} effectifs au total, en contrat au 31 décembre {getSIFADate(new Date()).getUTCFullYear()} + + + []} + enableRowExpansion={true} + sortingState={sort} + onSortingChange={handleSortChange} + onPaginationChange={handlePaginationChange} + isLoading={isFetching} + renderSubComponent={(row: Row) => ( + + )} + /> + + ); +}; + +export default EffectifsTable; diff --git a/ui/modules/mon-espace/effectifs/engine/Effectif.tsx b/ui/modules/mon-espace/effectifs/engine/Effectif.tsx index 30ba67f56..5fd8e4029 100644 --- a/ui/modules/mon-espace/effectifs/engine/Effectif.tsx +++ b/ui/modules/mon-espace/effectifs/engine/Effectif.tsx @@ -1,4 +1,4 @@ -import { Box, Skeleton } from "@chakra-ui/react"; +import { Skeleton } from "@chakra-ui/react"; import React from "react"; import { Statut } from "shared"; @@ -38,9 +38,7 @@ const Effectif = React.memo(function EffectifMemo({ <> {/* @ts-expect-error */} - - - + ); diff --git a/ui/modules/mon-espace/effectifs/engine/EffectifTableContainer.tsx b/ui/modules/mon-espace/effectifs/engine/EffectifTableContainer.tsx deleted file mode 100644 index cfd137518..000000000 --- a/ui/modules/mon-espace/effectifs/engine/EffectifTableContainer.tsx +++ /dev/null @@ -1,55 +0,0 @@ -import { Box, HStack, Text } from "@chakra-ui/react"; -import { UseQueryResult } from "@tanstack/react-query"; -import { useState } from "react"; - -import { DoubleChevrons } from "@/theme/components/icons/DoubleChevrons"; - -import EffectifsTable from "./EffectifsTable"; - -interface EffectifsTableContainerProps { - effectifs: any[]; - modeSifa?: boolean; - canEdit?: boolean; - searchValue?: string; - tableId: string; - formation: any; - refetch: (options: { throwOnError: boolean; cancelRefetch: boolean }) => Promise; -} -const EffectifsTableContainer = ({ - effectifs, - formation, - canEdit, - searchValue, - tableId, - modeSifa, - refetch, - ...props -}: EffectifsTableContainerProps) => { - const [count, setCount] = useState(effectifs.length); - return ( - - {count !== 0 && ( - - - - {formation.libelle_long} - - - [Code diplôme {formation.cfd}] - [Code RNCP {formation.rncp}] - - - )} - setCount(count)} - modeSifa={modeSifa} - refetch={refetch} - /> - - ); -}; - -export default EffectifsTableContainer; diff --git a/ui/modules/mon-espace/effectifs/engine/EffectifsTable.tsx b/ui/modules/mon-espace/effectifs/engine/EffectifsTable.tsx deleted file mode 100644 index cf656ab32..000000000 --- a/ui/modules/mon-espace/effectifs/engine/EffectifsTable.tsx +++ /dev/null @@ -1,467 +0,0 @@ -import { Box, Text, HStack, Button, UnorderedList, ListItem, Flex } from "@chakra-ui/react"; -import { UseQueryResult } from "@tanstack/react-query"; -import { DateTime } from "luxon"; -import React, { useState } from "react"; -import { useRecoilValue } from "recoil"; -import { getStatut } from "shared"; - -import Table from "@/components/Table/Table"; -import { InfoTooltip } from "@/components/Tooltip/InfoTooltip"; -import { AddFill, Alert, SubtractLine, ValidateIcon } from "@/theme/components/icons"; - -import EffectifTableDetails from "./EffectifsTableDetails"; -import { effectifStateSelector } from "./formEngine/atoms"; - -const ShowErrorInCell = ({ item, fieldName, value }) => { - const { validation_errors } = item; - const validation_error = validation_errors.find((e) => e.fieldName === fieldName); - if (validation_error) { - return ( - - - {validation_error.inputValue || "VIDE"} - - - ); - } - return value; -}; - -interface EffectifsTableProps { - organismesEffectifs: any[]; - modeSifa?: boolean; - canEdit?: boolean; - columns?: string[]; - show?: string; - searchValue?: string; - RenderErrorImport?: (data: any) => any; - onCountItemsChange?: (count: number) => any; - tableId: string; - refetch: (options: { throwOnError: boolean; cancelRefetch: boolean }) => Promise; -} - -const EffectifsTable = ({ - organismesEffectifs, - modeSifa = false, - canEdit = false, - columns = ["expander", "annee_scolaire", "statut_courant", "nom", "prenom", "separator", "source", "state"], - show = "normal", - searchValue, - RenderErrorImport = () => {}, - onCountItemsChange = () => {}, - tableId, - refetch, -}: EffectifsTableProps) => { - const [count, setCount] = useState(organismesEffectifs.length); - - return ( - - {count > 0 && {count} apprenant(es)} - { - setCount(count); - onCountItemsChange(count); - }} - columns={{ - ...(columns.includes("annee_scolaire") - ? { - annee_scolaire: { - size: 100, - header: () => ( - <> - Année scolaire{" "} - - - ), - cell: ({ row, getValue }) => { - if (show === "errorInCell") { - return ( - - ); - } - return getValue(); - }, - }, - } - : {}), - ...(columns.includes("cfd") - ? { - cfd: { - size: 120, - header: () => ( - <> - Code Formation Diplôme - - - ), - cell: ({ row, getValue }) => { - if (show === "errorInCell") { - return ( - - ); - } - return getValue(); - }, - }, - } - : {}), - ...(columns.includes("rncp") - ? { - rncp: { - size: 70, - header: () => "Code RNCP", - cell: ({ row, getValue }) => { - if (show === "errorInCell") { - return ( - - ); - } - return getValue(); - }, - }, - } - : {}), - ...(columns.includes("nom") - ? { - nom: { - header: () => ( - - Nom{" "} - - - ), - cell: ({ row, getValue }) => { - if (show === "errorInCell") { - return ( - - ); - } - return getValue(); - }, - }, - } - : {}), - ...(columns.includes("prenom") - ? { - prenom: { - size: 110, - header: () => ( - - Prénom{" "} - - - ), - cell: ({ row, getValue }) => { - if (show === "errorInCell") { - return ( - - ); - } - return getValue(); - }, - }, - } - : {}), - ...(columns.includes("statut_courant") - ? { - statut_courant: { - size: 170, - header: () => "Statut courant apprenant(e)", - cell: ({ row }) => { - const statut = organismesEffectifs[row.id]?.statut; - - if (!statut || !statut.parcours.length) { - return ( - - Aucun statut - - ); - } - const historiqueSorted = statut.parcours.sort((a, b) => { - return new Date(a.date_statut).getTime() - new Date(b.date_statut).getTime(); - }); - const current = [...historiqueSorted].pop(); - - return ( - - - {getStatut(current.valeur)} - - - (depuis {DateTime.fromISO(current.date).setLocale("fr-FR").toFormat("dd/MM/yyyy")}) - - - ); - }, - }, - } - : {}), - ...(columns.includes("separator") - ? { - separator: { - size: 15, - minSize: 15, - header: () => ( - -   - - ), - cell: () => ( - -   - - ), - }, - } - : {}), - ...(columns.includes("source") - ? { - source: { - size: 130, - header: () => ( - - Source - ( - - Ce champ indique la provenance de la donnée. Par exemple, la transmission est réalisée par - un ERP ou via un téléversement de fichier Excel. - - )} - /> - - ), - cell: ({ row }) => { - const { source } = organismesEffectifs[row.id]; - return ( - - {source} - - ); - }, - }, - } - : {}), - ...(columns.includes("action") - ? { - action: { - size: 90, - header: () => ( - - Action - Action à faire} /> - - ), - cell: ({ row }) => { - const { toUpdate } = organismesEffectifs[row.id]; - - return ( - - {toUpdate ? "Mise à jour" : "Nouveau"} - - ); - }, - }, - } - : {}), - ...(columns.includes("error-import") - ? { - errorState: { - size: 120, - header: () => ( - - Erreur(s) sur la donnée - Détails} /> - - ), - cell: ({ row }) => RenderErrorImport(organismesEffectifs[row.id]), - }, - } - : {}), - ...(columns.includes("state") - ? { - state: { - size: 200, - header: () => ( - - État de la donnée - État de la donnée} - contentComponent={() => ( - - {modeSifa ? ( - <> - - Cette colonne indique si les données obligatoires pour l’enquête SIFA sont complètes - ou manquantes. - - - Si manquante(s), veuillez les compléter à la source (sur votre ERP si votre organisme - transmet via API). Les modifications apportées seront visibles dès le lendemain sur - cette page. - - - Note : vous pouvez aussi télécharger le fichier en l’état, mais il - faudra compléter les données manquantes sur ce dernier. - - - ) : ( - - Ce champ indique si les données affichées contiennent des erreurs ou non. - - )} - - )} - /> - - ), - cell: ({ row }) => { - const { id } = organismesEffectifs[row.id]; - // eslint-disable-next-line react-hooks/rules-of-hooks - const { validation_errors, requiredSifa } = useRecoilValue(effectifStateSelector(id)); // Not the best; THIS IS AN EXCEPTION; This should not be reproduce anywhere else - - const MissingSIFA = ({ requiredSifa }) => { - if (!requiredSifa?.length) - return ( - - Complète pour SIFA - - ); - - return ( - - - {" "} - {requiredSifa.length} manquante(s) pour SIFA - "Champ(s) manquant(s) :"} - contentComponent={() => ( - - - {requiredSifa.map((fieldName, i) => ( - {fieldName} - ))} - - - Veuillez le(s) corriger/compléter sur : - - - - - votre outil de gestion (ex : Gestibase, Ypareo) si vous transmettez par API. La - donnée apparaîtra sur le Tableau de bord dans les prochaines 24 heures. - - - - - le Tableau de bord de l’apprentissage si vous avez transmis vos effectifs via - fichier Excel. - - - - - )} - /> - - - ); - }; - - const ValidationsErrorsInfo = ({ validation_errors }) => { - if (!validation_errors?.length) return null; - return ( - - - {" "} - {validation_errors.length} erreur(s) de transmission - - ( - - Champ(s) en erreur(s) : - - {validation_errors.map(({ fieldName }, i) => ( - {fieldName} - ))} - - - )} - /> - - ); - }; - - return ( - - {modeSifa && } - - - ); - }, - }, - } - : {}), - ...(columns.includes("expander") - ? { - expander: { - size: 25, - header: () => " ", - cell: ({ row }) => { - return row.getCanExpand() ? ( - - - - ) : null; - }, - }, - } - : {}), - }} - getRowCanExpand={() => true} - renderSubComponent={({ row }) => { - return ; - }} - /> - - ); -}; - -export default EffectifsTable; diff --git a/ui/modules/mon-espace/effectifs/engine/EffectifsTableDetails.tsx b/ui/modules/mon-espace/effectifs/engine/EffectifsTableDetails.tsx index 4eba1619b..20cd42696 100644 --- a/ui/modules/mon-espace/effectifs/engine/EffectifsTableDetails.tsx +++ b/ui/modules/mon-espace/effectifs/engine/EffectifsTableDetails.tsx @@ -1,4 +1,3 @@ -import { Box } from "@chakra-ui/react"; import { useQueryClient } from "@tanstack/react-query"; import React, { useEffect, useRef } from "react"; import { useSetRecoilState } from "recoil"; @@ -23,14 +22,12 @@ const EffectifTableDetails = ({ row, modeSifa = false, canEdit = false, refetch } return ( - - - + ); }; diff --git a/ui/modules/mon-espace/effectifs/engine/effectifForm/EffectifForm.tsx b/ui/modules/mon-espace/effectifs/engine/effectifForm/EffectifForm.tsx index aed25f3f7..306e396d6 100644 --- a/ui/modules/mon-espace/effectifs/engine/effectifForm/EffectifForm.tsx +++ b/ui/modules/mon-espace/effectifs/engine/effectifForm/EffectifForm.tsx @@ -218,7 +218,7 @@ export const EffectifForm = memo( const historyStatus = sortedParcours.slice(1); return ( - + {!fromDECA && ( )} - setAccordionIndex(expandedIndex)} - reduceMotion - > - - {({ isExpanded }) => ( - - - {parcours.length > 0 ? ( - <> + + setAccordionIndex(expandedIndex)} + reduceMotion + > + + {({ isExpanded }) => ( + + + {parcours.length > 0 ? ( + <> + + Statut actuel + + {getStatut(currentStatus.valeur)} + + + + Date de déclaration du statut + + {new Date(currentStatus.date).toLocaleDateString()} + + + + + + Anciens statuts + + {historyStatus.map((status, idx) => ( + + + {getStatut(status.valeur)} déclaré le {new Date(status.date).toLocaleDateString()} + + + ))} + + + ) : ( Statut actuel - {getStatut(currentStatus.valeur)} + Aucun statut - - Date de déclaration du statut - - {new Date(currentStatus.date).toLocaleDateString()} - - - - - - Anciens statuts - - {historyStatus.map((status, idx) => ( - - - {getStatut(status.valeur)} déclaré le {new Date(status.date).toLocaleDateString()} - - - ))} - - - ) : ( - - Statut actuel - - Aucun statut - - - )} - - - )} - - - {({ isExpanded }) => ( - - - - )} - - - {({ isExpanded }) => ( - - - - )} - - - {({ isExpanded }) => ( - - - - )} - - + )} + + + )} + + + {({ isExpanded }) => ( + + + + )} + + + {({ isExpanded }) => ( + + + + )} + + + {({ isExpanded }) => ( + + + + )} + + + ); } diff --git a/ui/modules/mon-espace/effectifs/engine/effectifForm/blocks/apprenant/EffectifApprenant.tsx b/ui/modules/mon-espace/effectifs/engine/effectifForm/blocks/apprenant/EffectifApprenant.tsx index 6e9e8d0de..cff139112 100644 --- a/ui/modules/mon-espace/effectifs/engine/effectifForm/blocks/apprenant/EffectifApprenant.tsx +++ b/ui/modules/mon-espace/effectifs/engine/effectifForm/blocks/apprenant/EffectifApprenant.tsx @@ -18,7 +18,7 @@ export const EffectifApprenant = memo(({ apprenant, modeSifa }: { apprenant: any if (!organisme) return null; return ( - + diff --git a/ui/modules/mon-espace/effectifs/engine/effectifForm/blocks/contrats/EffectifContrats.tsx b/ui/modules/mon-espace/effectifs/engine/effectifForm/blocks/contrats/EffectifContrats.tsx index e45eaf667..85dc63f80 100644 --- a/ui/modules/mon-espace/effectifs/engine/effectifForm/blocks/contrats/EffectifContrats.tsx +++ b/ui/modules/mon-espace/effectifs/engine/effectifForm/blocks/contrats/EffectifContrats.tsx @@ -9,7 +9,7 @@ export const ApprenantContrats = memo(({ contrats }: { contrats: any[] }) => { <> {contrats?.map((contrat, i) => { return ( - + diff --git a/ui/modules/mon-espace/effectifs/engine/effectifForm/blocks/formation/EffectifFormation.tsx b/ui/modules/mon-espace/effectifs/engine/effectifForm/blocks/formation/EffectifFormation.tsx index 7e338cb6f..94a9ed395 100644 --- a/ui/modules/mon-espace/effectifs/engine/effectifForm/blocks/formation/EffectifFormation.tsx +++ b/ui/modules/mon-espace/effectifs/engine/effectifForm/blocks/formation/EffectifFormation.tsx @@ -6,7 +6,7 @@ import { InputController } from "@/modules/mon-espace/effectifs/engine/formEngin // eslint-disable-next-line react/display-name, no-unused-vars export const EffectifFormation = memo(() => { return ( - + diff --git a/ui/modules/mon-espace/effectifs/engine/effectifsTable/EffectifsColumns.tsx b/ui/modules/mon-espace/effectifs/engine/effectifsTable/EffectifsColumns.tsx new file mode 100644 index 000000000..b098b81a2 --- /dev/null +++ b/ui/modules/mon-espace/effectifs/engine/effectifsTable/EffectifsColumns.tsx @@ -0,0 +1,163 @@ +import { Box, HStack, ListItem, Text, UnorderedList, VStack } from "@chakra-ui/react"; +import { DateTime } from "luxon"; +import { getStatut } from "shared"; + +import { capitalizeWords } from "@/common/utils/stringUtils"; +import { InfoTooltip } from "@/components/Tooltip/InfoTooltip"; + +const effectifsTableColumnsDefs = [ + { + accessorKey: "annee_scolaire", + header: () => ( + <> + Période{" "} + + + ), + cell: ({ row, getValue }) => , + size: 120, + }, + { + accessorKey: "nom", + header: () => ( + <> + Nom{" "} + + + ), + cell: ({ row, getValue }) => , + size: 160, + }, + { + accessorKey: "prenom", + header: () => ( + <> + Prénom{" "} + + + ), + cell: ({ row, getValue }) => ( + + ), + size: 160, + }, + { + accessorKey: "formation", + header: () => "Formation", + cell: ({ row }) => { + return ( + + {row.original?.formation?.libelle_long || "Libellé manquant"} + + CFD : {row.original?.formation?.cfd} - RNCP : {row.original?.formation?.cfd} + + + ); + }, + size: 350, + }, + { + accessorKey: "source", + header: () => ( + <> + Source{" "} + + "Source de la donnée"} + contentComponent={() => ( + + + Ce champ indique la provenance de la donnée. Par exemple, la donnée est transmise par un ERP ou via un + téléversement de fichier Excel, ou encore de plateforme DECA (Dépôt des Contrats d’Alternance). + + + )} + aria-label="Informations sur la répartition des effectifs au national" + /> + + ), + cell: ({ row, getValue }) => ( + + ), + size: 150, + }, + { + accessorKey: "statut_courant", + header: () => ( + <> + Statut actuel{" "} + "Statut actuel"} + contentComponent={() => ( + + Un jeune peut être : + + apprenti en contrat + inscrit sans contrat signé + en rupture de contrat + en fin de formation (diplômé) + abandon (a quitté le CFA) + + + )} + aria-label="Informations sur la répartition des effectifs au national" + /> + + ), + cell: ({ row }) => { + const statut = row.original?.statut; + + if (!statut || !statut.parcours.length) { + return ( + + Aucun statut + + ); + } + + const historiqueSorted = statut.parcours.sort( + (a, b) => new Date(a.date_statut).getTime() - new Date(b.date_statut).getTime() + ); + const current = [...historiqueSorted].pop(); + + return ( + + {getStatut(current.valeur)} + + depuis le {DateTime.fromISO(current.date).setLocale("fr-FR").toFormat("dd/MM/yyyy")} + + + ); + }, + size: 170, + }, +]; + +const ShowErrorInCell = ({ item, fieldName, value }) => { + const { validation_errors } = item; + const validation_error = validation_errors?.find((e) => e.fieldName === fieldName); + if (validation_error) { + return ( + + + {validation_error.inputValue || "VIDE"} + + + ); + } + return value; +}; + +export default effectifsTableColumnsDefs; diff --git a/ui/modules/mon-espace/effectifs/engine/effectifsTable/EffectifsFilterComponent.tsx b/ui/modules/mon-espace/effectifs/engine/effectifsTable/EffectifsFilterComponent.tsx new file mode 100644 index 000000000..582e05082 --- /dev/null +++ b/ui/modules/mon-espace/effectifs/engine/effectifsTable/EffectifsFilterComponent.tsx @@ -0,0 +1,60 @@ +import { Checkbox, CheckboxGroup, Stack } from "@chakra-ui/react"; + +import { capitalizeWords } from "@/common/utils/stringUtils"; +import { FilterButton } from "@/components/FilterButton/FilterButton"; +import SimpleOverlayMenu from "@/modules/dashboard/SimpleOverlayMenu"; + +interface EffectifsFilterComponentProps { + filterKey: string; + displayName: string; + options: string[]; + selectedValues: string[]; + onChange: (selectedValues: string[]) => void; + isOpen: boolean; + setIsOpen: (isOpen: boolean) => void; + sortOrder?: "asc" | "desc"; +} + +const EffectifsFilterComponent: React.FC = ({ + filterKey, + displayName, + options, + selectedValues, + onChange, + isOpen, + setIsOpen, + sortOrder = "asc", +}) => { + const sortedOptions = [...options].sort((a, b) => { + if (sortOrder === "asc") { + return a.localeCompare(b); + } + return b.localeCompare(a); + }); + + return ( +
+ setIsOpen(!isOpen)} + buttonLabel={displayName} + badge={selectedValues.length} + /> + {isOpen && ( + setIsOpen(false)} width="auto" p="3w"> + + + {sortedOptions.map((option, index) => ( + + {capitalizeWords(option)} + + ))} + + + + )} +
+ ); +}; + +export default EffectifsFilterComponent; diff --git a/ui/modules/mon-espace/effectifs/engine/effectifsTable/EffectifsFilterPanel.tsx b/ui/modules/mon-espace/effectifs/engine/effectifsTable/EffectifsFilterPanel.tsx new file mode 100644 index 000000000..5b5312c13 --- /dev/null +++ b/ui/modules/mon-espace/effectifs/engine/effectifsTable/EffectifsFilterPanel.tsx @@ -0,0 +1,93 @@ +import { Button, Stack, Text } from "@chakra-ui/react"; +import { useState } from "react"; + +import EffectifsFilterComponent from "./EffectifsFilterComponent"; + +interface EffectifsFilterPanelProps { + filters: Record; + availableFilters: Record; + onFilterChange: (filters: Record) => void; + resetFilters: () => void; +} + +const EffectifsFilterPanel: React.FC = ({ + filters, + availableFilters, + onFilterChange, + resetFilters, +}) => { + const [openFilter, setOpenFilter] = useState(null); + + const handleCheckboxChange = (filterKey: string, selectedValues: string[]) => { + const updatedFilters = { ...filters, [filterKey]: selectedValues }; + onFilterChange(updatedFilters); + }; + + return ( + + + FILTRER PAR + + + {/* Année scolaire */} + {availableFilters?.annee_scolaire && ( + handleCheckboxChange("annee_scolaire", values)} + isOpen={openFilter === "annee_scolaire"} + setIsOpen={(isOpen) => setOpenFilter(isOpen ? "annee_scolaire" : null)} + sortOrder="desc" + /> + )} + + {/* Source */} + {availableFilters?.source && ( + handleCheckboxChange("source", values)} + isOpen={openFilter === "source"} + setIsOpen={(isOpen) => setOpenFilter(isOpen ? "source" : null)} + /> + )} + + {/* Statut courant */} + {availableFilters?.statut_courant && ( + handleCheckboxChange("statut_courant", values)} + isOpen={openFilter === "statut_courant"} + setIsOpen={(isOpen) => setOpenFilter(isOpen ? "statut_courant" : null)} + /> + )} + + {/* Formation */} + {availableFilters?.formation_libelle_long && ( + handleCheckboxChange("formation_libelle_long", values)} + isOpen={openFilter === "formation_libelle_long"} + setIsOpen={(isOpen) => setOpenFilter(isOpen ? "formation_libelle_long" : null)} + /> + )} + + + + + ); +}; + +export default EffectifsFilterPanel; diff --git a/ui/modules/mon-espace/effectifs/engine/effectifsTable/EffectifsTable.tsx b/ui/modules/mon-espace/effectifs/engine/effectifsTable/EffectifsTable.tsx new file mode 100644 index 000000000..a81c8da09 --- /dev/null +++ b/ui/modules/mon-espace/effectifs/engine/effectifsTable/EffectifsTable.tsx @@ -0,0 +1,219 @@ +import { SearchIcon } from "@chakra-ui/icons"; +import { Box, Button, Divider, HStack, Input, InputGroup, InputRightElement, Switch, Text } from "@chakra-ui/react"; +import { UseQueryResult } from "@tanstack/react-query"; +import { Row, SortingState } from "@tanstack/react-table"; +import { useRouter } from "next/router"; +import { useState, useEffect } from "react"; + +import { effectifsExportColumns } from "@/common/exports"; +import { Organisme } from "@/common/internal/Organisme"; +import { exportDataAsXlsx } from "@/common/utils/exportUtils"; +import DownloadButton from "@/components/buttons/DownloadButton"; +import TableWithApi from "@/components/Table/TableWithApi"; + +import EffectifTableDetails from "../EffectifsTableDetails"; + +import effectifsTableColumnsDefs from "./EffectifsColumns"; +import EffectifsFilterPanel from "./EffectifsFilterPanel"; + +const DISPLAY_DOWNLOAD_BUTTON = false; +interface EffectifsTableProps { + organisme: Organisme; + organismesEffectifs: any[]; + filters: Record; + pagination: any; + search: string; + sort: SortingState; + onPaginationChange: (pagination: any) => void; + onSearchChange: (search: string) => void; + onFilterChange: (filters: Record) => void; + onSortChange: (sort: SortingState) => void; + total: number; + availableFilters: Record; + resetFilters: () => void; + isFetching: boolean; + canEdit?: boolean; + modeSifa?: boolean; + refetch: (options: { throwOnError: boolean; cancelRefetch: boolean }) => Promise; +} + +const EffectifsTable = ({ + organisme, + organismesEffectifs, + filters, + pagination, + search, + sort: initialSort, + onPaginationChange, + onSearchChange, + onFilterChange, + onSortChange, + total, + availableFilters, + resetFilters, + isFetching, + canEdit, + modeSifa, + refetch, +}: EffectifsTableProps) => { + const router = useRouter(); + + const [sort, setSort] = useState(initialSort); + const [localSearch, setLocalSearch] = useState(search || ""); + const [showOnlyErrors, setShowOnlyErrors] = useState(false); + + useEffect(() => { + setSort(initialSort); + setLocalSearch(search || ""); + }, [initialSort, search]); + + const handleSearchInputChange = (event: React.ChangeEvent) => { + setLocalSearch(event.target.value); + }; + + const onSearchButtonClick = () => { + executeSearch(); + }; + + const handleKeyDown = (event: React.KeyboardEvent) => { + if (event.key === "Enter") { + executeSearch(); + } + }; + + const executeSearch = () => { + onSearchChange(localSearch); + + router.push( + { + pathname: router.pathname, + query: { ...router.query, search: localSearch }, + }, + undefined, + { shallow: true } + ); + }; + + const handlePaginationChange = (newPagination) => { + onPaginationChange(newPagination); + + router.push( + { + pathname: router.pathname, + query: { + ...router.query, + pageIndex: newPagination.pageIndex, + pageSize: newPagination.pageSize, + }, + }, + undefined, + { shallow: true } + ); + }; + + const handleSortChange = (newSort: SortingState) => { + onSortChange(newSort); + }; + + const filteredEffectifs = showOnlyErrors + ? organismesEffectifs.filter((effectif) => effectif.validation_errors?.length > 0) + : organismesEffectifs; + + return ( + + + + + + + + + + {DISPLAY_DOWNLOAD_BUTTON && ( + { + exportDataAsXlsx( + `tdb-effectifs.xlsx`, + organismesEffectifs?.map((effectif) => ({ + organisme_uai: organisme.uai, + organisme_siret: organisme.siret, + organisme_nom: organisme.raison_sociale, + organisme_nature: organisme.nature, + apprenant_nom: effectif.nom, + apprenant_prenom: effectif.prenom, + apprenant_date_de_naissance: effectif.date_de_naissance, + apprenant_statut: effectif.statut_courant, + formation_annee: effectif.formation?.annee, + formation_cfd: effectif.formation?.cfd, + formation_libelle_long: effectif.formation?.libelle_long, + formation_niveau: effectif.formation?.niveau, + formation_rncp: effectif.formation?.rncp, + formation_date_debut_formation: effectif.formation?.date_debut_formation, + formation_date_fin_formation: effectif.formation?.date_fin_formation, + })) || [], + effectifsExportColumns + ); + }} + > + Télécharger la liste + + )} + + + + + + { + setShowOnlyErrors(e.target.checked); + }} + /> + Afficher uniquement les données en erreur + + + + + + {total} apprenant(es) trouvé(es) + + + ) => ( + + )} + /> + + ); +}; + +export default EffectifsTable; diff --git a/ui/theme/components/table.js b/ui/theme/components/table.js index 2cc6c3dc0..52a2ab773 100644 --- a/ui/theme/components/table.js +++ b/ui/theme/components/table.js @@ -26,12 +26,17 @@ export const Table = { backgroundColor: "grey.100", }, _hover: { - backgroundColor: "grey.200", + backgroundColor: "#E3E3FD", + }, + "&.table-row-expanded": { + backgroundColor: "#E3E3FD", + _hover: { + backgroundColor: "#E3E3FD", + }, + }, + "&.expanded-row": { + backgroundColor: "white", }, - }, - td: { - paddingY: "2", - paddingX: "3", }, }, },