Construire et déployer une application RH en utilisant Refine

Introduction

Dans ce tutoriel, nous allons créer une application de gestion des ressources humaines avec le Refine Framework et la déployer sur la DigitalOcean App Platform.

À la fin de ce tutoriel, nous aurons une application de gestion des ressources humaines qui comprend :

  • Page de connexion : Permet aux utilisateurs de se connecter en tant que manager ou employé. Les managers ont accès aux pages Congés et Demandes, tandis que les employés n’ont accès qu’à la page Congés.
  • Pages de congés : Permet aux employés de demander, de consulter et d’annuler leurs congés. Les managers peuvent également attribuer de nouveaux congés.
  • Page des demandes : Accessible uniquement aux responsables RH pour approuver ou rejeter les demandes de congé.

Remarque : Vous pouvez obtenir le code source complet de l’application que nous allons créer dans ce tutoriel depuis ce dépôt GitHub

En procédant ainsi, nous utiliserons :

  • API REST : Pour récupérer et mettre à jour les données. Refine dispose de packages de fournisseur de données intégrés et d’API REST, mais vous pouvez également créer les vôtres pour répondre à vos besoins spécifiques. Dans ce guide, nous allons utiliser NestJs CRUD comme notre service backend et le package @refinedev/nestjsx-crud comme notre fournisseur de données.
  • Material UI : Nous l’utiliserons pour les composants UI et le personnaliserons entièrement selon notre propre design. Refine prend en charge Material UI par défaut, mais vous pouvez utiliser n’importe quelle bibliothèque UI que vous préférez.

Une fois que nous aurons construit l’application, nous la mettrons en ligne en utilisant la plateforme d’applications de DigitalOcean qui facilite la configuration, le lancement et la croissance des applications et des sites web statiques. Vous pouvez déployer du code en pointant simplement vers un dépôt GitHub et laisser la plateforme d’applications faire le gros du travail en gérant l’infrastructure, les environnements d’exécution des applications et les dépendances.

Prérequis

Qu’est-ce que Refine ?

Refine est un méta-framework React open source pour construire des applications web B2B complexes, principalement axées sur la gestion des données, comme les outils internes, les panneaux d’administration et les tableaux de bord. Il est conçu en fournissant un ensemble de hooks et de composants pour améliorer le processus de développement avec un meilleur flux de travail pour le développeur.

Il offre des fonctionnalités complètes et prêtes pour la production pour des applications de niveau entreprise afin de simplifier les tâches rémunérées comme la gestion de l’état et des données, l’authentification et le contrôle d’accès. Cela permet aux développeurs de rester concentrés sur le cœur de leur application d’une manière qui est abstraite de nombreux détails d’implémentation écrasants.

Étape 1 — Configuration du projet

Nous utiliserons la commande npm create refine-app pour initialiser le projet de manière interactive.

npm create refine-app@latest

Sélectionnez les options suivantes lorsqu’elles vous sont demandées :

✔ Choose a project template · Vite
✔ What would you like to name your project?: · hr-app
✔ Choose your backend service to connect: · nestjsx-crud
✔ Do you want to use a UI Framework?: · Material UI
✔ Do you want to add example pages?: · No
✔ Do you need any Authentication logic?: · None
✔ Choose a package manager: · npm

Une fois la configuration terminée, accédez au dossier du projet et lancez votre application avec :

npm run dev

Ouvrez http://localhost:5173 dans votre navigateur pour voir l’application.

Préparation du Projet

Maintenant que notre projet est configuré, apportons quelques modifications à la structure du projet et supprimons les fichiers inutiles.

Tout d’abord, installez les dépendances tierces :

  • @mui/x-date-pickers, @mui/x-date-pickers-pro : Ce sont des composants de sélection de date pour Material UI. Nous les utiliserons pour sélectionner la plage de dates des demandes de congé.
  • react-hot-toast : Une bibliothèque de toast minimaliste pour React. Nous l’utiliserons pour afficher des messages de succès et d’erreur.
  • react-infinite-scroll-component : Un composant React pour faciliter le défilement infini. Nous l’utiliserons pour charger plus de demandes de congé à mesure que l’utilisateur fait défiler la page pour voir plus de demandes.
  • dayjs : Une bibliothèque de date légère pour analyser, valider, manipuler et formater des dates.
  • vite-tsconfig-paths : Un plugin Vite qui vous permet d’utiliser des alias de chemin TypeScript dans votre projet Vite.
npm install @mui/x-date-pickers @mui/x-date-pickers-pro dayjs react-hot-toast react-infinite-scroll-component
npm install --save-dev vite-tsconfig-paths

Après avoir installé les dépendances, mettez à jour vite.config.ts et tsconfig.json pour utiliser le plugin vite-tsconfig-paths. Cela permet d’utiliser des alias de chemin TypeScript dans les projets Vite, permettant des importations avec l’alias @.

import react from "@vitejs/plugin-react";
import { defineConfig } from "vite";
import tsconfigPaths from "vite-tsconfig-paths";

// https://vitejs.dev/config/
export default defineConfig({
  plugins: [tsconfigPaths({ root: __dirname }), react()],
});
{
  "compilerOptions": {
    "target": "ESNext",
    "useDefineForClassFields": true,
    "lib": ["DOM", "DOM.Iterable", "ESNext"],
    "allowJs": false,
    "skipLibCheck": true,
    "esModuleInterop": false,
    "allowSyntheticDefaultImports": true,
    "strict": true,
    "forceConsistentCasingInFileNames": true,
    "module": "ESNext",
    "moduleResolution": "bundler",
    "resolveJsonModule": true,
    "isolatedModules": true,
    "noEmit": true,
    "jsx": "react-jsx",
    "baseUrl": "./src",
    "paths": {
      "@/*": ["./*"]
    }
  },
  "include": ["src"],
  "references": [{ "path": "./tsconfig.node.json" }]
}

Ensuite, supprimons les fichiers et dossiers inutiles :

  • src/contexts : Ce dossier contient un seul fichier qui est ColorModeContext. Il gère le mode sombre/claire de l’application. Nous ne l’utiliserons pas dans ce tutoriel.
  • src/components : Ce dossier contient le composant <Header />. Nous utiliserons un composant d’en-tête personnalisé dans ce tutoriel.
rm -rf src/contexts src/components

Après avoir supprimé les fichiers et dossiers, App.tsx génère une erreur que nous corrigerons dans les étapes suivantes.
Tout au long du tutoriel, nous aborderons la programmation des pages et composants principaux. Alors, récupérez les fichiers et dossiers nécessaires depuis le répertoire GitHub. Avec ces fichiers, nous aurons une structure de base pour notre application de gestion des ressources humaines.

  • icônes : Dossier des icônes contenant toutes les icônes de l’application.
  • types :
  • utilitaires:
    • constants.ts: Constantes de l’application.
    • axios.ts: Instance Axios pour les requêtes API, gestion des jetons d’accès, des jetons de rafraîchissement et des erreurs.
    • init-dayjs.ts: Initialise Day.js avec les plugins requis.
  • fournisseurs:
    • access-control: Gère les autorisations utilisateur en utilisant accessControlProvider; contrôle la visibilité de la page Requests en fonction du rôle de l’utilisateur.
    • auth-provider: Gère l’authentification avec authProvider; assure que toutes les pages sont protégées et nécessitent une connexion.
    • notification-provider: Affiche les messages de succès et d’erreur via react-hot-toast.
    • query-client: Client de requête personnalisé pour un contrôle et une personnalisation complets.
    • theme-provider: Gère le thème Material UI.
  • composants:
    • layout: Composants de mise en page.
    • loading-overlay: Affiche un superposition de chargement pendant les récupérations de données.
    • input: Affiche les champs de saisie de formulaire.
    • frame: Composant personnalisé ajoutant des bordures, des titres et des icônes aux sections de la page.
    • modal: Composant de boîte de dialogue modale personnalisée.

Après avoir copié les fichiers et dossiers, la structure des fichiers devrait ressembler à ceci :

└── 📁src
    └── 📁components
        └── 📁frame
        └── 📁input
        └── 📁layout
            └── 📁header
            └── 📁page-header
            └── 📁sider
        └── 📁loading-overlay
        └── 📁modal
    └── 📁icons
    └── 📁providers
        └── 📁access-control
        └── 📁auth-provider
        └── 📁notification-provider
        └── 📁query-client
        └── 📁theme-provider
    └── 📁types
    └── 📁utilities
    └── App.tsx
    └── index.tsx
    └── vite-env.d.ts

Ensuite, mettez à jour le fichier App.tsx pour inclure les fournisseurs et composants nécessaires.

src/App.tsx
import { Refine } from '@refinedev/core'
import { DevtoolsProvider, DevtoolsPanel } from '@refinedev/devtools'
import dataProvider from '@refinedev/nestjsx-crud'
import routerProvider, { UnsavedChangesNotifier, DocumentTitleHandler } from '@refinedev/react-router-v6'
import { BrowserRouter, Routes, Route, Outlet } from 'react-router-dom'
import { Toaster } from 'react-hot-toast'

import { Layout } from '@/components/layout'

import { ThemeProvider } from '@/providers/theme-provider'
import { authProvider } from '@/providers/auth-provider'
import { accessControlProvider } from '@/providers/access-control'
import { useNotificationProvider } from '@/providers/notification-provider'
import { queryClient } from '@/providers/query-client'

import { BASE_URL } from '@/utilities/constants'
import { axiosInstance } from '@/utilities/axios'

import { Role } from './types'

import '@/utilities/init-dayjs'

function App() {
  return (
    <BrowserRouter>
      <ThemeProvider>
        <DevtoolsProvider>
          <Refine
            authProvider={authProvider}
            routerProvider={routerProvider}
            dataProvider={dataProvider(BASE_URL, axiosInstance)}
            notificationProvider={useNotificationProvider}
            resources={[
              {
                name: 'employee',
                meta: {
                  scope: Role.EMPLOYEE,
                },
              },
              {
                name: 'manager',
                meta: {
                  scope: Role.MANAGER,
                },
              },
            ]}
            accessControlProvider={accessControlProvider}
            options={{
              reactQuery: {
                clientConfig: queryClient,
              },
              syncWithLocation: true,
              warnWhenUnsavedChanges: true,
              useNewQueryKeys: true,
            }}>
            <Routes>
              <Route
                element={
                  <Layout>
                    <Outlet />
                  </Layout>
                }>
                <Route index element={<h1>Hello World</h1>} />
              </Route>
            </Routes>
            <UnsavedChangesNotifier />
            <DocumentTitleHandler />
            <Toaster position='bottom-right' reverseOrder={false} />
            <DevtoolsPanel />
          </Refine>
        </DevtoolsProvider>
      </ThemeProvider>
    </BrowserRouter>
  )
}

export default App


Décomposons les changements importants que nous avons apportés au fichier App.tsx :

  • <Refine /> : Le composant principal de @refinedev/core qui enveloppe l’ensemble de l’application pour fournir la récupération de données, la gestion d’état et d’autres fonctionnalités.
  • <DevtoolsProvider /> et <DevtoolsPanel /> : Utilisés à des fins de débogage et de développement.
  • <ThemeProvider />: Applique un thème personnalisé à l’application.
  • Initialisation de Day.js: Pour la manipulation de la date et de l’heure.
  • resources: Un tableau spécifiant les entités de données (employee et manager) que Refine va récupérer. Nous utilisons des ressources parent et enfant pour organiser les données et gérer les autorisations. Chaque ressource a une scope définissant le rôle de l’utilisateur, qui contrôle l’accès à différentes parties de l’application.
  • queryClient: Un client de requête personnalisé pour un contrôle total et une personnalisation de la récupération des données.
  • syncWithLocation: Permet de synchroniser l’état de l’application (filtres, trieurs, pagination, etc.) avec l’URL.
  • warnWhenUnsavedChanges: Affiche un avertissement lorsque l’utilisateur essaie de naviguer loin d’une page avec des modifications non enregistrées.
  • <Layout />: Un composant de mise en page personnalisé qui enveloppe le contenu de l’application. Il contient l’en-tête, la barre latérale et la zone de contenu principal. Nous expliquerons ce composant dans les étapes suivantes.

Maintenant, nous sommes prêts à commencer à construire l’application de gestion des ressources humaines.


Étape 2— Personnalisation et stylisation

Examinez de plus près le theme-provider. Nous avons fortement personnalisé le thème Material UI pour correspondre au design de l’application de gestion des ressources humaines, en créant deux thèmes, un pour les gestionnaires et un pour les employés, afin de les différencier par des couleurs différentes.

De plus, nous avons ajouté Inter comme police personnalisée pour l’application. Pour l’installer, vous devez ajouter la ligne suivante au fichier index.html :

<head>
  <meta charset="utf-8" />
  <link rel="icon" href="/favicon.ico" />

  <^>
  <link <^ />
  <^>
  href="https://fonts.googleapis.com/css2?family=Inter:ital,opsz,wght@0,14..32,100..900;1,14..32,100..900&display=swap"
    rel="stylesheet"   /> <^>

  <meta name="viewport" content="width=device-width, initial-scale=1" />
  <meta name="theme-color" content="#000000" />
  <meta
    name="description"
    content="refine | Build your React-based CRUD applications, without constraints."
  />
  <meta
    data-rh="true"
    property="og:image"
    content="https://refine.dev/img/refine_social.png"
  />
  <meta
    data-rh="true"
    name="twitter:image"
    content="https://refine.dev/img/refine_social.png"
  />
  <title>
    Refine - Build your React-based CRUD applications, without constraints.
  </title>
</head>

Inspection du composant personnalisé <Layout /> Component

Dans l’étape précédente, nous avons ajouté un composant de mise en page personnalisé à l’application. Normalement, nous pourrions utiliser la mise en page par défaut du framework UI, mais nous voulons montrer comment vous pouvez personnaliser.

Le composant de mise en page contient l’en-tête, la barre latérale et la zone de contenu principal. Il utilise <ThemedLayoutV2 /> comme base et l’a personnalisé pour correspondre à la conception de l’application de gestion des ressources humaines.

<Sider />

La barre latérale contient le logo de l’application et les liens de navigation. Sur les appareils mobiles, c’est une barre latérale collapsible qui s’ouvre lorsque l’utilisateur clique sur l’icône du menu. Les liens de navigation sont préparés avec le hook useMenu de Refine et rendus en fonction du rôle de l’utilisateur à l’aide du composant <CanAccess />.

<UserSelect />

Monté sur la barre latérale, il affiche l’avatar et le nom de l’utilisateur connecté. Lorsqu’on clique dessus, il ouvre une popover avec les détails de l’utilisateur et un bouton de déconnexion. Les utilisateurs peuvent basculer entre différents rôles en sélectionnant dans la liste déroulante. Ce composant permet de tester en passant entre les utilisateurs avec différents rôles.

<Header />

Il ne rend rien sur les appareils de bureau. Sur les appareils mobiles, il affiche le logo de l’application et une icône de menu pour ouvrir la barre latérale. L’en-tête est collant et toujours visible en haut de la page.

<PageHeader />

Cela affiche le titre de la page et les boutons de navigation. Le titre de la page est généré automatiquement avec le useResource hook, qui récupère le nom de la ressource depuis le contexte Refine. Cela nous permet de partager le même style et la même mise en page à travers l’application.

Étape 3 — Mise en œuvre de l’authentification et de l’autorisation

Dans cette étape, nous allons mettre en œuvre la logique d’authentification et d’autorisation pour notre application de gestion des ressources humaines. Cela servira d’excellent exemple de contrôle d’accès dans les applications d’entreprise.

Lorsque les utilisateurs se connectent en tant que gestionnaires, ils pourront voir les pages Temps de congé et Demandes. S’ils se connectent en tant qu’employés, ils ne verront que la page Temps de congé. Les gestionnaires peuvent approuver ou refuser les demandes de congé sur la page Demandes.

Les employés peuvent demander un congé et consulter leur historique sur la page Time Off. Pour mettre en œuvre cela, nous utiliserons les fonctionnalités authProvider et accessControlProvider de Refine.

Authentication

Dans Refine, l’authentification est gérée par le authProvider. Cela vous permet de définir la logique d’authentification pour votre application. À l’étape précédente, nous avons déjà copié le authProvider du dépôt GitHub et l’avons donné au composant <Refine /> en tant que prop. Nous utiliserons les hooks et composants suivants pour contrôler le comportement de notre application en fonction de la connexion de l’utilisateur ou non.

  • useLogin: Un hook qui fournit une fonction mutate pour connecter l’utilisateur.
  • useLogout: Un hook qui fournit une fonction mutate pour déconnecter l’utilisateur.
  • useIsAuthenticated: Un hook qui renvoie un booléen indiquant si l’utilisateur est authentifié.
  • <Authenticated />: Un composant qui affiche ses enfants uniquement si l’utilisateur est authentifié.

Autorisation

Dans Refine, l’autorisation est gérée par le accessControlProvider. Il vous permet de définir les rôles et les permissions des utilisateurs, et de contrôler l’accès à différentes parties de l’application en fonction du rôle de l’utilisateur. À l’étape précédente, nous avons déjà copié le accessControlProvider depuis le dépôt GitHub et l’avons donné au composant <Refine /> en tant que prop. Prenons un peu plus de temps pour examiner le accessControlProvider et voir comment il fonctionne.

src/providers/access-control/index.ts

import type { AccessControlBindings } from "@refinedev/core";
import { Role } from "@/types";

export const accessControlProvider: AccessControlBindings = {
  options: {
    queryOptions: {
      keepPreviousData: true,
    },
    buttons: {
      hideIfUnauthorized: true,
    },
  },
  can: async ({ params, action }) => {
    const user = JSON.parse(localStorage.getItem("user") || "{}");
    if (!user) return { can: false };

    const scope = params?.resource?.meta?.scope;
    // si la ressource n'a pas de portée, elle n'est pas accessible
    if (!scope) return { can: false };

    if (user.role === Role.MANAGER) {
      return {
        can: true,
      };
    }

    if (action === "manager") {
      return {
        can: user.role === Role.MANAGER,
      };
    }

    if (action === "employee") {
      return {
        can: user.role === Role.EMPLOYEE,
      };
    }

    // les utilisateurs ne peuvent accéder aux ressources que si leur rôle correspond à la portée de la ressource
    return {
      can: user.role === scope,
    };
  },
};


Dans notre application, nous avons deux rôles : MANAGER et EMPLOYEE.

Les gestionnaires ont accès à la page Demandes, tandis que les employés n’ont accès qu’à la page Temps libre. Le accessControlProvider vérifie le rôle de l’utilisateur et l’étendue de la ressource pour déterminer si l’utilisateur peut y accéder. Si le rôle de l’utilisateur correspond à l’étendue de la ressource, il peut y accéder. Sinon, l’accès lui est refusé. Nous utiliserons le hook useCan et le composant <CanAccess /> pour contrôler le comportement de notre application en fonction du rôle de l’utilisateur.

Configuration de la page de connexion

Lors de l’étape précédente, nous avons ajouté le authProvider au composant <Refine />. Le authProvider est responsable de la gestion de l’authentification.

Tout d’abord, nous devons obtenir des images. Nous utiliserons ces images comme images de fond pour la page de connexion. Créez un nouveau dossier appelé images dans le dossier public et obtenez les images à partir du dépôt GitHub.

Après avoir obtenu les images, créons un nouveau fichier appelé index.tsx dans le dossier src/pages/login et ajoutons le code suivant :

src/pages/login/index.tsx
import { useState } from "react";
import { useLogin } from "@refinedev/core";
import {
  Avatar,
  Box,
  Button,
  Divider,
  MenuItem,
  Select,
  Typography,
} from "@mui/material";
import { HrLogo } from "@/icons";

export const PageLogin = () => {
  const [selectedEmail, setSelectedEmail] = useState<string>(
    mockUsers.managers[0].email,
  );

  const { mutate: login } = useLogin();

  return (
    <Box
      sx={{
        position: "relative",
        background:
          "linear-gradient(180deg, #7DE8CD 0%, #C6ECD9 24.5%, #5CD6D6 100%)",
        display: "flex",
        flexDirection: "column",
        justifyContent: "center",
        alignItems: "center",
        height: "100dvh",
      }}
    >
      <Box
        sx={{
          zIndex: 2,
          background: "white",
          width: "328px",
          padding: "24px",
          borderRadius: "36px",
          display: "flex",
          flexDirection: "column",
          gap: "24px",
        }}
      >
        <Box
          sx={{
            display: "flex",
            flexDirection: "column",
            alignItems: "center",
            gap: "16px",
          }}
        >
          <HrLogo />
          <Typography variant="body1" fontWeight={600}>
            Welcome to RefineHR
          </Typography>
        </Box>

        <Divider />

        <Box sx={{ display: "flex", flexDirection: "column", gap: "8px" }}>
          <Typography variant="caption" color="text.secondary">
            Select user
          </Typography>
          <Select
            size="small"
            value={selectedEmail}
            sx={{
              height: "40px",
              borderRadius: "12px",

              "& .MuiOutlinedInput-notchedOutline": {
                borderWidth: "1px !important",
                borderColor: (theme) => `${theme.palette.divider} !important`,
              },
            }}
            MenuProps={{
              sx: {
                "& .MuiList-root": {
                  paddingBottom: "0px",
                },

                "& .MuiPaper-root": {
                  border: (theme) => `1px solid ${theme.palette.divider}`,
                  borderRadius: "12px",
                  boxShadow: "none",
                },
              },
            }}
          >
            <Typography
              variant="caption"
              textTransform="uppercase"
              color="text.secondary"
              sx={{
                paddingLeft: "12px",
                paddingBottom: "8px",
                display: "block",
              }}
            >
              Managers
            </Typography>
            {mockUsers.managers.map((user) => (
              <MenuItem
                key={user.email}
                value={user.email}
                onClick={() => setSelectedEmail(user.email)}
              >
                <Box sx={{ display: "flex", alignItems: "center", gap: "8px" }}>
                  <Avatar
                    src={user.avatarUrl}
                    alt={`${user.firstName} ${user.lastName}`}
                    sx={{ width: "24px", height: "24px" }}
                  />
                  <Typography
                    noWrap
                    variant="caption"
                    sx={{ display: "flex", alignItems: "center" }}
                  >
                    {`${user.firstName} ${user.lastName}`}
                  </Typography>
                </Box>
              </MenuItem>
            ))}

            <Divider />

            <Typography
              variant="caption"
              textTransform="uppercase"
              color="text.secondary"
              sx={{
                paddingLeft: "12px",
                paddingBottom: "8px",
                display: "block",
              }}
            >
              Employees
            </Typography>
            {mockUsers.employees.map((user) => (
              <MenuItem
                key={user.email}
                value={user.email}
                onClick={() => setSelectedEmail(user.email)}
              >
                <Box sx={{ display: "flex", alignItems: "center", gap: "8px" }}>
                  <Avatar
                    src={user.avatarUrl}
                    alt={`${user.firstName} ${user.lastName}`}
                    sx={{ width: "24px", height: "24px" }}
                  />
                  <Typography
                    noWrap
                    variant="caption"
                    sx={{ display: "flex", alignItems: "center" }}
                  >
                    {`${user.firstName} ${user.lastName}`}
                  </Typography>
                </Box>
              </MenuItem>
            ))}
          </Select>
        </Box>

        <Button
          variant="contained"
          sx={{
            borderRadius: "12px",
            height: "40px",
            width: "100%",
            color: "white",
            backgroundColor: (theme) => theme.palette.grey[900],
          }}
          onClick={() => {
            login({ email: selectedEmail });
          }}
        >
          Sign in
        </Button>
      </Box>

      <Box
        sx={{
          zIndex: 1,
          width: {
            xs: "240px",
            sm: "370px",
            md: "556px",
          },
          height: {
            xs: "352px",
            sm: "554px",
            md: "816px",
          },
          position: "absolute",
          left: "0px",
          bottom: "0px",
        }}
      >
        <img
          src="/images/login-left.png"
          alt="flowers"
          width="100%"
          height="100%"
        />
      </Box>
      <Box
        sx={{
          zIndex: 1,
          width: {
            xs: "320px",
            sm: "480px",
            md: "596px",
          },
          height: {
            xs: "312px",
            sm: "472px",
            md: "584px",
          },
          position: "absolute",
          right: "0px",
          top: "0px",
        }}
      >
        <img
          src="/images/login-right.png"
          alt="flowers"
          width="100%"
          height="100%"
        />
      </Box>
    </Box>
  );
};

const mockUsers = {
  managers: [
    {
      email: "[email protected]",
      firstName: "Michael",
      lastName: "Scott",
      avatarUrl:
        "https://refine-hr-example.s3.eu-west-1.amazonaws.com/avatars/Michael-Scott.png",
    },
    {
      avatarUrl:
        "https://refine-hr-example.s3.eu-west-1.amazonaws.com/avatars/Jim-Halpert.png",
      firstName: "Jim",
      lastName: "Halpert",
      email: "[email protected]",
    },
    {
      avatarUrl:
        "https://refine-hr-example.s3.eu-west-1.amazonaws.com/avatars/Toby-Flenderson.png",
      firstName: "Toby",
      lastName: "Flenderson",
      email: "[email protected]",
    },
  ],
  employees: [
    {
      avatarUrl:
        "https://refine-hr-example.s3.eu-west-1.amazonaws.com/avatars/Pam-Beesly.png",
      firstName: "Pam",
      lastName: "Beesly",
      email: "[email protected]",
    },
    {
      avatarUrl:
        "https://refine-hr-example.s3.eu-west-1.amazonaws.com/avatars/Andy-Bernard.png",
      firstName: "Andy",
      lastName: "Bernard",
      email: "[email protected]",
    },
    {
      avatarUrl:
        "https://refine-hr-example.s3.eu-west-1.amazonaws.com/avatars/Ryan-Howard.png",
      firstName: "Ryan",
      lastName: "Howard",
      email: "[email protected]",
    },
  ],
};

Pour simplifier le processus d’authentification, nous avons créé un objet mockUsers avec deux tableaux : managers et employees. Chaque tableau contient des objets utilisateurs prédéfinis. Lorsqu’un utilisateur sélectionne un email dans le menu déroulant et clique sur le bouton Sign in, la fonction login est appelée avec l’email sélectionné. La fonction login est une fonction de mutation fournie par le hook useLogin de Refine. Elle appelle authProvider.login avec l’email sélectionné.

Ensuite, importons le composant <PageLogin /> et mettons à jour le fichier App.tsx avec les changements mis en évidence.

src/App.tsx
import { Authenticated, Refine } from "@refinedev/core";
import { DevtoolsProvider, DevtoolsPanel } from "@refinedev/devtools";
import { ErrorComponent } from "@refinedev/mui";
import dataProvider from "@refinedev/nestjsx-crud";
import routerProvider, {
  UnsavedChangesNotifier,
  DocumentTitleHandler,
} from "@refinedev/react-router-v6";
import {
  BrowserRouter,
  Routes,
  Route,
  Outlet,
  Navigate,
} from "react-router-dom";
import { Toaster } from "react-hot-toast";

import { PageLogin } from "@/pages/login";

import { Layout } from "@/components/layout";

import { ThemeProvider } from "@/providers/theme-provider";
import { authProvider } from "@/providers/auth-provider";
import { accessControlProvider } from "@/providers/access-control";
import { useNotificationProvider } from "@/providers/notification-provider";
import { queryClient } from "@/providers/query-client";

import { BASE_URL } from "@/utilities/constants";
import { axiosInstance } from "@/utilities/axios";

import { Role } from './types'

import "@/utilities/init-dayjs";

function App() {
  return (
    <BrowserRouter>
      <ThemeProvider>
        <DevtoolsProvider>
          <Refine
            authProvider={authProvider}
            routerProvider={routerProvider}
            dataProvider={dataProvider(BASE_URL, axiosInstance)}
            notificationProvider={useNotificationProvider}
            resources={[
              {
                name: 'employee',
                meta: {
                  scope: Role.EMPLOYEE,
                },
              },
              {
                name: 'manager',
                meta: {
                  scope: Role.MANAGER,
                },
              },
            ]}
            accessControlProvider={accessControlProvider}
            options={{
              reactQuery: {
                clientConfig: queryClient,
              },
              syncWithLocation: true,
              warnWhenUnsavedChanges: true,
              useNewQueryKeys: true,
            }}
          >
            <Routes>
              <Route
                element={
                  <Authenticated
                    key="authenticated-routes"
                    redirectOnFail="/login"
                  >
                    <Layout>
                      <Outlet />
                    </Layout>
                  </Authenticated>
                }
              >
                <Route index element={<h1>Hello World</h1>} />
              </Route>

              <Route
                element={
                  <Authenticated key="auth-pages" fallback={<Outlet />}>
                    <Navigate to="/" />
                  </Authenticated>
                }
              >
                <Route path="/login" element={<PageLogin />} />
              </Route>

              <Route
                element={
                  <Authenticated key="catch-all">
                    <Layout>
                      <Outlet />
                    </Layout>
                  </Authenticated>
                }
              >
                <Route path="*" element={<ErrorComponent />} />
              </Route>
            </Routes>

            <UnsavedChangesNotifier />
            <DocumentTitleHandler />
            <Toaster position="bottom-right" reverseOrder={false} />
            <DevtoolsPanel />
          </Refine>
        </DevtoolsProvider>
      </ThemeProvider>
    </BrowserRouter>
  );
}

export default App;

Dans le fichier App.tsx mis à jour, nous avons ajouté le composant <Authenticated /> de Refine. Ce composant est utilisé pour protéger les routes nécessitant une authentification. Il prend une prop key pour identifier de manière unique le composant, une prop fallback à rendre lorsque l’utilisateur n’est pas authentifié, et une prop redirectOnFail pour rediriger l’utilisateur vers la route spécifiée lorsque l’authentification échoue. En interne, il appelle la méthode authProvider.check pour vérifier si l’utilisateur est authentifié.

Regardons de plus près ce que nous avons sur key="auth-pages"

<Route
  element={
    <Authenticated key="auth-pages" fallback={<Outlet />}>
      <Navigate to="/" />
    </Authenticated>
  }
>
  <Route path="/login" element={<PageLogin />} />
</Route>

<Authenticated /> le composant s’enveloppe dans la route “/login” pour vérifier l’état d’authentification de l’utilisateur.

  • fallback={<Outlet />} : Si l’utilisateur n’est pas authentifié, rendre la route imbriquée (c’est-à-dire afficher le composant <PageLogin />).
  • Enfants (<Navigate to="/" />) : Si l’utilisateur est authentifié, le rediriger vers la page d’accueil (/).

Examinons de plus près ce que nous avons sur key="catch-all"

<Route
  element={
    <Authenticated key="catch-all">
      <Layout>
        <Outlet />
      </Layout>
    </Authenticated>
  }
>
  <Route path="*" element={<ErrorComponent />} />
</Route>

<Authenticated /> le composant s’enveloppe dans la route path="*" pour vérifier l’état d’authentification de l’utilisateur. Cette route est une route de capture qui rend le <ErrorComponent /> lorsque l’utilisateur est authentifié. Elle nous permet d’afficher une page 404 lorsque l’utilisateur essaie d’accéder à une route inexistante.

Maintenant, lorsque vous exécutez l’application et naviguez vers http://localhost:5173/login, vous devriez voir la page de connexion avec le menu déroulant pour sélectionner l’utilisateur.

Pour l’instant, la page “/” ne fait rien. Dans les étapes suivantes, nous allons implémenter les pages Time Off et Requests.

Étape 4 — Création d’une page Time Off

Création de la page de liste des congés

Dans cette étape, nous allons construire la page Congés. Les employés peuvent demander des congés et voir leur historique de congés. Les managers peuvent également consulter leur historique, mais au lieu de demander des congés, ils peuvent les attribuer directement à eux-mêmes. Nous allons faire fonctionner cela en utilisant le accessControlProvider de Refine, le composant <CanAccess /> et le hook useCan.

<PageEmployeeTimeOffsList />

Avant de commencer à construire la page de congés, nous devons créer quelques composants pour afficher l’historique des congés, les demandes de congés à venir et les statistiques des congés utilisés. À la fin de cette étape, nous utiliserons ces composants pour construire la page de congés.

Création du composant <TimeOffList /> pour afficher l’historique des congés

Créez un nouveau dossier appelé time-offs dans le dossier src/components. À l’intérieur du dossier time-offs, créez un nouveau fichier appelé list.tsx et ajoutez le code suivant :

src/components/time-offs/list.tsx
import { useState } from "react";
import {
  type CrudFilters,
  type CrudSort,
  useDelete,
  useGetIdentity,
  useInfiniteList,
} from "@refinedev/core";
import {
  Box,
  Button,
  CircularProgress,
  IconButton,
  Popover,
  Typography,
} from "@mui/material";
import InfiniteScroll from "react-infinite-scroll-component";
import dayjs from "dayjs";
import { DateField } from "@refinedev/mui";
import { Frame } from "@/components/frame";
import { LoadingOverlay } from "@/components/loading-overlay";
import { red } from "@/providers/theme-provider/colors";
import {
  AnnualLeaveIcon,
  CasualLeaveIcon,
  DeleteIcon,
  NoTimeOffIcon,
  SickLeaveIcon,
  ThreeDotsIcon,
  PopoverTipIcon,
} from "@/icons";
import { type Employee, TimeOffStatus, type TimeOff } from "@/types";

const variantMap = {
  Annual: {
    label: "Annual Leave",
    iconColor: "primary.700",
    iconBgColor: "primary.50",
    icon: <AnnualLeaveIcon width={16} height={16} />,
  },
  Sick: {
    label: "Sick Leave",
    iconColor: "#C2410C",
    iconBgColor: "#FFF7ED",
    icon: <SickLeaveIcon width={16} height={16} />,
  },
  Casual: {
    label: "Casual Leave",
    iconColor: "grey.700",
    iconBgColor: "grey.50",
    icon: <CasualLeaveIcon width={16} height={16} />,
  },
} as const;

type Props = {
  type: "upcoming" | "history" | "inReview";
};

export const TimeOffList = (props: Props) => {
  const { data: employee } = useGetIdentity<Employee>();

  const { data, isLoading, hasNextPage, fetchNextPage } =
    useInfiniteList<TimeOff>({
      resource: "time-offs",
      sorters: sorters[props.type],
      filters: [
        ...filters[props.type],
        {
          field: "employeeId",
          operator: "eq",
          value: employee?.id,
        },
      ],
      queryOptions: {
        enabled: !!employee?.id,
      },
    });

  const timeOffHistory = data?.pages.flatMap((page) => page.data) || [];
  const hasData = isLoading || timeOffHistory.length !== 0;

  if (props.type === "inReview" && !hasData) {
    return null;
  }

  return (
    <Frame
      sx={(theme) => ({
        maxHeight: "362px",
        paddingBottom: 0,
        position: "relative",
        "&::after": {
          pointerEvents: "none",
          content: '""',
          position: "absolute",
          bottom: 0,
          left: "24px",
          right: "24px",
          width: "80%",
          height: "32px",
          background:
            "linear-gradient(180deg, rgba(255, 255, 255, 0), #FFFFFF)",
        },
        display: "flex",
        flexDirection: "column",
      })}
      sxChildren={{
        paddingRight: 0,
        paddingLeft: 0,
        flex: 1,
        overflow: "hidden",
      }}
      title={title[props.type]}
    >
      <LoadingOverlay loading={isLoading} sx={{ height: "100%" }}>
        {!hasData ? (
          <Box
            sx={{
              display: "flex",
              flexDirection: "column",
              alignItems: "center",
              justifyContent: "center",
              gap: "24px",
              height: "180px",
            }}
          >
            <NoTimeOffIcon />
            <Typography variant="body2" color="text.secondary">
              {props.type === "history"
                ? "No time off used yet."
                : "No upcoming time offs scheduled."}
            </Typography>
          </Box>
        ) : (
          <Box
            id="scrollableDiv-timeOffHistory"
            sx={(theme) => ({
              maxHeight: "312px",
              height: "auto",
              [theme.breakpoints.up("lg")]: {
                height: "312px",
              },
              overflow: "auto",
              paddingLeft: "12px",
              paddingRight: "12px",
            })}
          >
            <InfiniteScroll
              dataLength={timeOffHistory.length}
              next={() => fetchNextPage()}
              hasMore={hasNextPage || false}
              endMessage={
                !isLoading &&
                hasData && (
                  <Box
                    sx={{
                      pt: timeOffHistory.length > 3 ? "40px" : "16px",
                    }}
                  />
                )
              }
              scrollableTarget="scrollableDiv-timeOffHistory"
              loader={
                <Box
                  sx={{
                    display: "flex",
                    alignItems: "center",
                    justifyContent: "center",
                    width: "100%",
                    height: "100px",
                  }}
                >
                  <CircularProgress size={24} />
                </Box>
              }
            >
              <Box
                sx={{
                  display: "flex",
                  flexDirection: "column",
                  gap: "12px",
                }}
              >
                {timeOffHistory.map((timeOff) => {
                  return (
                    <ListItem
                      timeOff={timeOff}
                      key={timeOff.id}
                      type={props.type}
                    />
                  );
                })}
              </Box>
            </InfiniteScroll>
          </Box>
        )}
      </LoadingOverlay>
    </Frame>
  );
};

const ListItem = ({
  timeOff,
  type,
}: { timeOff: TimeOff; type: Props["type"] }) => {
  const { mutateAsync: timeOffCancel } = useDelete<TimeOff>();

  const [anchorEl, setAnchorEl] = useState<HTMLButtonElement | null>(null);
  const [hovered, setHovered] = useState(false);

  const diffrenceOfDays =
    dayjs(timeOff.endsAt).diff(dayjs(timeOff.startsAt), "day") + 1;

  const isSameDay = dayjs(timeOff.startsAt).isSame(
    dayjs(timeOff.endsAt),
    "day",
  );

  return (
    <Box
      key={timeOff.id}
      onMouseEnter={() => setHovered(true)}
      onMouseLeave={() => setHovered(false)}
      sx={{
        display: "flex",
        alignItems: "center",
        gap: "16px",
        height: "64px",
        paddingLeft: "12px",
        paddingRight: "12px",
        borderRadius: "64px",
        backgroundColor: hovered ? "grey.50" : "transparent",
        transition: "background-color 0.2s",
      }}
    >
      <Box
        sx={{
          display: "flex",
          alignItems: "center",
          justifyContent: "center",
          color: variantMap[timeOff.timeOffType].iconColor,
          backgroundColor: variantMap[timeOff.timeOffType].iconBgColor,
          width: "40px",
          height: "40px",
          borderRadius: "100%",
        }}
      >
        {variantMap[timeOff.timeOffType].icon}
      </Box>
      <Box
        sx={{
          display: "flex",
          flexDirection: "column",
          gap: "4px",
        }}
      >
        <Box
          sx={{
            display: "flex",
            alignItems: "center",
            gap: "4px",
          }}
        >
          {isSameDay ? (
            <DateField
              value={timeOff.startsAt}
              color="text.secondary"
              variant="caption"
              format="MMMM DD"
            />
          ) : (
            <>
              <DateField
                value={timeOff.startsAt}
                color="text.secondary"
                variant="caption"
                format="MMMM DD"
              />
              <Typography variant="caption" color="text.secondary">
                -
              </Typography>
              <DateField
                value={timeOff.endsAt}
                color="text.secondary"
                variant="caption"
                format="MMMM DD"
              />
            </>
          )}
        </Box>
        <Typography variant="body2">
          <span
            style={{
              fontWeight: 500,
            }}
          >
            {diffrenceOfDays} {diffrenceOfDays > 1 ? "days" : "day"} of{" "}
          </span>
          {variantMap[timeOff.timeOffType].label}
        </Typography>
      </Box>

      {hovered && (type === "inReview" || type === "upcoming") && (
        <IconButton
          onClick={(e) => setAnchorEl(e.currentTarget)}
          sx={{
            display: "flex",
            alignItems: "center",
            justifyContent: "center",
            width: "40px",
            height: "40px",
            marginLeft: "auto",
            backgroundColor: "white",
            borderRadius: "100%",
            color: "grey.400",
            border: (theme) => `1px solid ${theme.palette.grey[400]}`,
            flexShrink: 0,
          }}
        >
          <ThreeDotsIcon />
        </IconButton>
      )}

      <Popover
        id={timeOff.id.toString()}
        open={Boolean(anchorEl)}
        anchorEl={anchorEl}
        onClose={() => {
          setAnchorEl(null);
          setHovered(false);
        }}
        anchorOrigin={{
          vertical: "bottom",
          horizontal: "center",
        }}
        transformOrigin={{
          vertical: "top",
          horizontal: "center",
        }}
        sx={{
          "& .MuiPaper-root": {
            overflow: "visible",
            borderRadius: "12px",
            border: (theme) => `1px solid ${theme.palette.grey[400]}`,
            boxShadow: "0px 0px 0px 4px rgba(222, 229, 237, 0.25)",
          },
        }}
      >
        <Button
          variant="text"
          onClick={async () => {
            await timeOffCancel({
              resource: "time-offs",
              id: timeOff.id,
              invalidates: ["all"],
              successNotification: () => {
                return {
                  type: "success",
                  message: "Time off request cancelled successfully.",
                };
              },
            });
          }}
          sx={{
            position: "relative",
            width: "200px",
            height: "56px",
            paddingLeft: "16px",
            color: red[900],
            display: "flex",
            gap: "12px",
            justifyContent: "flex-start",
            "&:hover": {
              backgroundColor: "transparent",
            },
          }}
        >
          <DeleteIcon />
          <Typography variant="body2">Cancel Request</Typography>

          <Box
            sx={{
              width: "40px",
              height: "16px",
              position: "absolute",
              top: "-2px",
              left: "calc(50% - 1px)",
              transform: "translate(-50%, -50%)",
            }}
          >
            <PopoverTipIcon />
          </Box>
        </Button>
      </Popover>
    </Box>
  );
};

const today = dayjs().toISOString();

const title: Record<Props["type"], string> = {
  history: "Time Off History",
  upcoming: "Upcoming Time Off",
  inReview: "In Review",
};

const filters: Record<Props["type"], CrudFilters> = {
  history: [
    {
      field: "status",
      operator: "eq",
      value: TimeOffStatus.APPROVED,
    },
    {
      field: "endsAt",
      operator: "lt",
      value: today,
    },
  ],
  upcoming: [
    {
      field: "status",
      operator: "eq",
      value: TimeOffStatus.APPROVED,
    },
    {
      field: "endsAt",
      operator: "gte",
      value: today,
    },
  ],
  inReview: [
    {
      field: "status",
      operator: "eq",
      value: TimeOffStatus.PENDING,
    },
  ],
};

const sorters: Record<Props["type"], CrudSort[]> = {
  history: [{ field: "startsAt", order: "desc" }],
  upcoming: [{ field: "endsAt", order: "asc" }],
  inReview: [{ field: "startsAt", order: "asc" }],
};


Le fichier list.tsx est long, mais la plupart de son contenu concerne le style et la présentation de l’interface utilisateur.

<TimeOffList />

Nous utiliserons ce composant <TimeOffList /> dans trois contextes différents :

  <TimeOffList type="inReview" />
  <TimeOffList type="upcoming" />
  <TimeOffList type="history" />

La prop type détermine quel type de liste de congés afficher :

  • inReview : Affiche les demandes de congé en attente d’approbation.
  • upcoming : Affiche les congés à venir qui ont été approuvés mais qui ne se sont pas encore produits.
  • history : Liste les congés qui ont été approuvés et qui ont déjà eu lieu.

À l’intérieur du composant, nous créerons des filtres et des trieurs basés sur la prop type. Nous utiliserons ces filtres et trieurs pour récupérer les données de congé depuis l’API.

Décomposons les parties clés du composant :

1. Obtenir l’utilisateur actuel
const { data: employee } = useGetIdentity<Employee>();
  • useGetIdentity<Employee>() : Récupère les informations de l’utilisateur actuel.
    • Nous utilisons l’ID de l’employé pour filtrer les congés afin que chaque utilisateur ne voie que ses demandes.
2. Récupération des données de congé avec défilement infini
const { data, isLoading, hasNextPage, fetchNextPage } =
  useInfiniteList <
  TimeOff >
  {
    resource: "time-offs",
    sorters: sorters[props.type],
    filters: [
      ...filters[props.type],
      { field: "employeeId", operator: "eq", value: employee?.id },
    ],
    queryOptions: { enabled: !!employee?.id },
  };

// ...

<InfiniteScroll
  dataLength={timeOffHistory.length}
  next={() => fetchNextPage()}
  hasMore={hasNextPage || false}
  // ... autres props
>
  {/* Rendre les éléments de la liste ici */}
</InfiniteScroll>;
  • useInfiniteList<TimeOff>(): Récupère les données de congés avec défilement infini.

    • resource: Spécifie le point de terminaison de l’API.
    • sorters et filters: Ajustés en fonction de type pour récupérer les bonnes données.
    • Filtre employeeId: Assure que seuls les congés de l’utilisateur actuel sont récupérés.
    • queryOptions.enabled: Exécute la requête uniquement lorsque les données de l’employé sont disponibles.
  • <InfiniteScroll />: Permet de charger plus de données au fur et à mesure que l’utilisateur fait défiler vers le bas.

    • next: Fonction pour récupérer la page suivante de données.
    • hasMore: Indique si d’autres données sont disponibles.
3. Annuler une demande de congé
const { mutateAsync: timeOffCancel } = useDelete<TimeOff>();

// À l'intérieur du composant ListItem
await timeOffCancel({
  resource: "time-offs",
  id: timeOff.id,
  invalidates: ["all"],
  successNotification: () => ({
    type: "success",
    message: "Time off request cancelled successfully.",
  }),
});
  • useDelete: Fournit la fonction timeOffCancel pour supprimer une demande de congé.
    • Utilisé lorsqu’un utilisateur annule son congé.
    • Affiche un message de succès une fois l’opération terminée.
4. Affichage des dates avec <DateField />
<DateField
  value={timeOff.startsAt}
  color="text.secondary"
  variant="caption"
  format="MMMM DD"
/>
  • <DateField />: Formate et affiche les dates de manière conviviale.
    • valeur: La date à afficher.
    • format: Spécifie le format de la date (par exemple, “5 janvier”).
5. Création de filtres et trieurs basés sur le type

Filtres:

const filters: Record<Props["type"], CrudFilters> = {
  history: [
    {
      field: "status",
      operator: "eq",
      value: TimeOffStatus.APPROVED,
    },
    {
      field: "endsAt",
      operator: "lt",
      value: today,
    },
  ],
  // ... autres types
};
  • Définit les critères pour récupérer les congés en fonction du statut et des dates.
    • historique: Récupère les congés approuvés qui sont déjà terminés.
    • à_venir: Récupère les congés approuvés qui sont à venir.

Trieurs:

const sorters: Record<Props["type"], CrudSort[]> = {
  history: [{ field: "startsAt", order: "desc" }],
  // ... autres types
};
  • Détermine l’ordre des données récupérées.
    • historique: Trie par date de début par ordre décroissant.

Construction du composant <TimeOffLeaveCards /> pour afficher les statistiques des congés utilisés.

Créez un nouveau fichier appelé leave-cards.tsx dans le dossier src/components/time-offs et ajoutez le code suivant :

src/components/time-offs/leave-cards.tsx

import { useGetIdentity, useList } from "@refinedev/core";
import { Box, Grid, Skeleton, Typography } from "@mui/material";
import { AnnualLeaveIcon, CasualLeaveIcon, SickLeaveIcon } from "@/icons";
import {
  type Employee,
  TimeOffStatus,
  TimeOffType,
  type TimeOff,
} from "@/types";

type Props = {
  employeeId?: number;
};

export const TimeOffLeaveCards = (props: Props) => {
  const { data: employee, isLoading: isLoadingEmployee } =
    useGetIdentity<Employee>({
      queryOptions: {
        enabled: !props.employeeId,
      },
    });

  const { data: timeOffsSick, isLoading: isLoadingTimeOffsSick } =
    useList<TimeOff>({
      resource: "time-offs",
      // nous avons seulement besoin du nombre total de congés maladies, donc nous pouvons définir pageSize sur 1 pour réduire la charge
      pagination: { pageSize: 1 },
      filters: [
        {
          field: "status",
          operator: "eq",
          value: TimeOffStatus.APPROVED,
        },
        {
          field: "timeOffType",
          operator: "eq",
          value: TimeOffType.SICK,
        },
        {
          field: "employeeId",
          operator: "eq",
          value: employee?.id,
        },
      ],
      queryOptions: {
        enabled: !!employee?.id,
      },
    });

  const { data: timeOffsCasual, isLoading: isLoadingTimeOffsCasual } =
    useList<TimeOff>({
      resource: "time-offs",
      // nous avons seulement besoin du nombre total de congés maladies, donc nous pouvons définir pageSize sur 1 pour réduire la charge
      pagination: { pageSize: 1 },
      filters: [
        {
          field: "status",
          operator: "eq",
          value: TimeOffStatus.APPROVED,
        },
        {
          field: "timeOffType",
          operator: "eq",
          value: TimeOffType.CASUAL,
        },
        {
          field: "employeeId",
          operator: "eq",
          value: employee?.id,
        },
      ],
      queryOptions: {
        enabled: !!employee?.id,
      },
    });

  const loading =
    isLoadingEmployee || isLoadingTimeOffsSick || isLoadingTimeOffsCasual;

  return (
    <Grid container spacing="24px">
      <Grid item xs={12} sm={4}>
        <Card
          loading={loading}
          type="annual"
          value={employee?.availableAnnualLeaveDays || 0}
        />
      </Grid>
      <Grid item xs={12} sm={4}>
        <Card loading={loading} type="sick" value={timeOffsSick?.total || 0} />
      </Grid>
      <Grid item xs={12} sm={4}>
        <Card
          loading={loading}
          type="casual"
          value={timeOffsCasual?.total || 0}
        />
      </Grid>
    </Grid>
  );
};

const variantMap = {
  annual: {
    label: "Annual Leave",
    description: "Days available",
    bgColor: "primary.50",
    titleColor: "primary.900",
    descriptionColor: "primary.700",
    iconColor: "primary.700",
    icon: <AnnualLeaveIcon />,
  },
  sick: {
    label: "Sick Leave",
    description: "Days used",
    bgColor: "#FFF7ED",
    titleColor: "#7C2D12",
    descriptionColor: "#C2410C",
    iconColor: "#C2410C",
    icon: <SickLeaveIcon />,
  },
  casual: {
    label: "Casual Leave",
    description: "Days used",
    bgColor: "grey.50",
    titleColor: "grey.900",
    descriptionColor: "grey.700",
    iconColor: "grey.700",
    icon: <CasualLeaveIcon />,
  },
};

const Card = (props: {
  type: "annual" | "sick" | "casual";
  value: number;
  loading?: boolean;
}) => {
  return (
    <Box
      sx={{
        backgroundColor: variantMap[props.type].bgColor,
        padding: "24px",
        borderRadius: "12px",
      }}
    >
      <Box
        sx={{
          display: "flex",
          alignItems: "center",
          justifyContent: "space-between",
        }}
      >
        <Typography
          variant="h6"
          sx={{
            color: variantMap[props.type].titleColor,
            fontSize: "16px",
            fontWeight: 500,
            lineHeight: "24px",
          }}
        >
          {variantMap[props.type].label}
        </Typography>
        <Box
          sx={{
            color: variantMap[props.type].iconColor,
          }}
        >
          {variantMap[props.type].icon}
        </Box>
      </Box>

      <Box sx={{ marginTop: "8px", display: "flex", flexDirection: "column" }}>
        {props.loading ? (
          <Box
            sx={{
              width: "40%",
              height: "32px",
              display: "flex",
              alignItems: "center",
              justifyContent: "center",
            }}
          >
            <Skeleton
              variant="rounded"
              sx={{
                width: "100%",
                height: "20px",
              }}
            />
          </Box>
        ) : (
          <Typography
            variant="caption"
            sx={{
              color: variantMap[props.type].descriptionColor,
              fontSize: "24px",
              lineHeight: "32px",
              fontWeight: 600,
            }}
          >
            {props.value}
          </Typography>
        )}
        <Typography
          variant="body1"
          sx={{
            color: variantMap[props.type].descriptionColor,
            fontSize: "12px",
            lineHeight: "16px",
          }}
        >
          {variantMap[props.type].description}
        </Typography>
      </Box>
    </Box>
  );
};


<TimeOffLeaveCards />

Le composant <TimeOffLeaveCards /> affiche des statistiques sur les congés d’un employé. Il montre trois cartes pour le congé annuel, le congé maladie et le congé occasionnel, indiquant combien de jours sont disponibles ou utilisés.

Décomposons les parties clés du composant :

1. Récupération des données
  • Données de l’employé : Utilise useGetIdentity pour obtenir les informations de l’employé actuel, comme le nombre de jours de congé annuel disponibles.
  • Comptes de congés : Utilise useList pour récupérer le nombre total de jours de congé maladie et de congé occasionnel utilisés par l’employé. Il définit pageSize sur 1 car nous avons seulement besoin du compte total, pas de tous les détails.
2. Affichage des cartes
  • Le composant rend trois composants de carte, un pour chaque type de congé.
  • Chaque carte montre :
    • Le type de congé (par exemple, Congé Annuel).
    • Le nombre de jours disponibles ou utilisés.
    • Une icône représentant le type de congé.
3. Gestion des états de chargement
  • Si les données sont encore en cours de chargement, un espace réservé squelette s’affiche à la place des chiffres réels.
  • La prop loading est passée aux cartes pour gérer cet état.
4. Le composant de carte
  • Reçoit les props type, valeur et loading.
  • Utilise un variantMap pour obtenir les libellés, couleurs et icônes corrects en fonction du type de congé.
  • Affiche les informations de congé avec le style approprié.

Construction de <PageEmployeeTimeOffsList />

Maintenant que nous avons les composants pour la liste des congés et l’affichage des cartes de congé, créons le nouveau fichier dans le dossier src/pages/employee/time-offs/ appelé list.tsx et ajoutons le code suivant :

src/pages/time-off.tsx
import { CanAccess, useCan } from "@refinedev/core";
import { CreateButton } from "@refinedev/mui";
import { Box, Grid } from "@mui/material";
import { PageHeader } from "@/components/layout/page-header";
import { TimeOffList } from "@/components/time-offs/list";
import { TimeOffLeaveCards } from "@/components/time-offs/leave-cards";
import { TimeOffIcon } from "@/icons";
import { ThemeProvider } from "@/providers/theme-provider";
import { Role } from "@/types";

export const PageEmployeeTimeOffsList = () => {
  const { data: useCanData } = useCan({
    action: "manager",
    params: {
      resource: {
        name: "time-offs",
        meta: {
          scope: "manager",
        },
      },
    },
  });
  const isManager = useCanData?.can;

  return (
    <ThemeProvider role={isManager ? Role.MANAGER : Role.EMPLOYEE}>
      <Box>
        <PageHeader
          title="Time Off"
          rightSlot={
            <CreateButton
              size="large"
              variant="contained"
              startIcon={<TimeOffIcon />}
            >
              <CanAccess action="manager" fallback="Request Time Off">
                Assign Time Off
              </CanAccess>
            </CreateButton>
          }
        />

        <TimeOffLeaveCards />

        <Grid
          container
          spacing="24px"
          sx={{
            marginTop: "24px",
          }}
        >
          <Grid item xs={12} md={6}>
            <Box
              sx={{
                display: "flex",
                flexDirection: "column",
                gap: "24px",
              }}
            >
              <TimeOffList type="inReview" />
              <TimeOffList type="upcoming" />
            </Box>
          </Grid>
          <Grid item xs={12} md={6}>
            <TimeOffList type="history" />
          </Grid>
        </Grid>
      </Box>
    </ThemeProvider>
  );
};

<PageEmployeeTimeOffsList /> est le composant principal de la page des congés, nous utiliserons ce composant pour afficher les listes de congés et les cartes de congé lorsque les utilisateurs naviguent vers l’itinéraire /employee/time-offs.

<PageEmployeeTimeOffsList />

Décomposons les parties clés du composant :

1. Vérification des rôles de l’utilisateur
  • Utilise le crochet useCan pour déterminer si l’utilisateur actuel est un gestionnaire.
  • Définit isManager sur true si l’utilisateur a les autorisations de gestionnaire.
2. Application du thème en fonction du rôle
  • Enveloppe le contenu à l’intérieur d’un <ThemeProvider />.
  • Le thème change en fonction que l’utilisateur soit un gestionnaire ou un employé.
3. En-tête de page avec bouton conditionnel
  • Rend un <PageHeader /> avec le titre « Congés ».
  • Inclut un <CreateButton /> qui change en fonction du rôle de l’utilisateur :
    • Si l’utilisateur est un gestionnaire, le bouton affiche « Assigner des congés ».
    • Si l’utilisateur n’est pas un gestionnaire, il affiche « Demander des congés ».
  • Cela est géré à l’aide du composant <CanAccess />, qui vérifie les permissions.
4. Affichage des statistiques de congés
  • Inclut le composant <TimeOffLeaveCards /> pour afficher les soldes et l’utilisation des congés.
  • Cela fournit un résumé des congés annuels, maladie et occasionnels.
5. Liste des demandes de congés
  • Utilise une mise en page <Grid /> pour organiser le contenu.
  • Sur le côté gauche (md={6}), il affiche :
    • TimeOffList avec type="inReview" : Affiche les demandes de congés en attente.
    • TimeOffList avec type="upcoming" : Affiche les congés approuvés à venir.
  • Sur le côté droit (md={6}), il affiche:
    • TimeOffList avec type="historique": Affiche les congés passés qui ont déjà eu lieu.

Ajout de la route « /employee/time-offs »

Nous sommes prêts à rendre le composant <PageEmployeeTimeOffsList /> sur la route /employee/time-offs. Mettons à jour le fichier App.tsx pour inclure cette route:

src/App.tsx
import { Authenticated, ErrorComponent, Refine } from '@refinedev/core'
import { DevtoolsProvider, DevtoolsPanel } from '@refinedev/devtools'
import dataProvider from '@refinedev/nestjsx-crud'
import routerProvider, {
  UnsavedChangesNotifier,
  DocumentTitleHandler,
  NavigateToResource,
} from '@refinedev/react-router-v6'
import { BrowserRouter, Routes, Route, Outlet } from 'react-router-dom'
import { Toaster } from 'react-hot-toast'

import { PageEmployeeTimeOffsList } from '@/pages/employee/time-offs/list'
import { PageLogin } from '@/pages/login'

import { Layout } from '@/components/layout'

import { ThemeProvider } from '@/providers/theme-provider'
import { authProvider } from '@/providers/auth-provider'
import { accessControlProvider } from '@/providers/access-control'
import { useNotificationProvider } from '@/providers/notification-provider'
import { queryClient } from '@/providers/query-client'

import { BASE_URL } from '@/utilities/constants'
import { axiosInstance } from '@/utilities/axios'

import { TimeOffIcon } from '@/icons'

import { Role } from '@/types'

import '@/utilities/init-dayjs'

function App() {
  return (
    <BrowserRouter>
      <ThemeProvider>
        <DevtoolsProvider>
          <Refine
            authProvider={authProvider}
            routerProvider={routerProvider}
            dataProvider={dataProvider(BASE_URL, axiosInstance)}
            notificationProvider={useNotificationProvider}
            resources={[
              {
                name: 'employee',
                meta: {
                  scope: Role.EMPLOYEE,
                },
              },
              {
                name: 'manager',
                meta: {
                  scope: Role.MANAGER,
                },
              },
              {
                name: 'time-offs',
                list: '/employee/time-offs',
                meta: {
                  parent: 'employee',
                  scope: Role.EMPLOYEE,
                  label: 'Time Off',
                  icon: <TimeOffIcon />,
                },
              },
            ]}
            accessControlProvider={accessControlProvider}
            options={{
              reactQuery: {
                clientConfig: queryClient,
              },
              syncWithLocation: true,
              warnWhenUnsavedChanges: true,
              useNewQueryKeys: true,
            }}>
            <Routes>
              <Route
                element={
                  <Authenticated key='authenticated-routes' redirectOnFail='/login'>
                    <Outlet />
                  </Authenticated>
                }>
                <Route index element={<NavigateToResource resource='time-offs' />} />

                <Route
                  path='employee'
                  element={
                    <ThemeProvider role={Role.EMPLOYEE}>
                      <Layout>
                        <Outlet />
                      </Layout>
                    </ThemeProvider>
                  }>
                  <Route path='time-offs' element={<Outlet />}>
                    <Route index element={<PageEmployeeTimeOffsList />} />
                  </Route>
                </Route>
              </Route>

              <Route
                element={
                  <Authenticated key='auth-pages' fallback={<Outlet />}>
                    <NavigateToResource resource='time-offs' />
                  </Authenticated>
                }>
                <Route path='/login' element={<PageLogin />} />
              </Route>

              <Route
                element={
                  <Authenticated key="catch-all">
                    <Layout>
                      <Outlet />
                    </Layout>
                  </Authenticated>
                }
              >
                <Route path="*" element={<ErrorComponent />} />
              </Route>
            </Routes>

            <UnsavedChangesNotifier />
            <DocumentTitleHandler />
            <Toaster position='bottom-right' reverseOrder={false} />
            <DevtoolsPanel />
          </Refine>
        </DevtoolsProvider>
      </ThemeProvider>
    </BrowserRouter>
  )
}

export default App

Démontons les éléments clés du fichier App.tsx mis à jour:

1. Définition de la ressource Congés
{
  name: 'time-offs',
  list: '/employee/time-offs',
  meta: {
    parent: 'employee',
    scope: Role.EMPLOYEE,
    label: 'Time Off',
    icon: <TimeOffIcon />,
  },
}

Nous avons ajouté une nouvelle ressource pour les congés en tant qu’enfant de la ressource employé. Cela indique que les congés sont liés aux employés et sont accessibles par les employés.

  • name: 'congés': C’est l’identifiant de la ressource, utilisé en interne par Refine.
  • list: '/employee/time-offs': Spécifie la route qui affiche la vue liste de la ressource.
  • meta: Un objet contenant des métadonnées supplémentaires sur la ressource.
    • parent: 'employee': Regroupe cette ressource sous le champ employee, qui peut être utilisé pour organiser les ressources dans l’interface utilisateur (comme dans un menu latéral) ou pour le contrôle d’accès.
    • scope: Role.EMPLOYEE: Indique que cette ressource est accessible aux utilisateurs ayant le rôle EMPLOYEE. Nous l’utilisons dans le accessControlProvider pour gérer les autorisations.
    • label: 'Time Off': Le nom d’affichage de la ressource dans l’interface utilisateur.
    • icon: <TimeOffIcon />: Associe le TimeOffIcon à cette ressource pour une identification visuelle.
2. Redirection vers la ressource “time-offs” lorsque les utilisateurs naviguent vers la route /
<Route index element={<NavigateToResource resource="time-offs" />} />

Nous utilisons le <NavigateToResource /> composant pour rediriger les utilisateurs vers la ressource time-offs lorsqu’ils naviguent vers la route /. Cela garantit que les utilisateurs voient la liste des congés par défaut.

3. Redirection vers la ressource « time-offs » lorsque les utilisateurs sont authentifiés
<Route
  element={
    <Authenticated key="auth-pages" fallback={<Outlet />}>
      <NavigateToResource resource="time-offs" />
    </Authenticated>
  }
>
  <Route path="/login" element={<PageLogin />} />
</Route>

Lorsque les utilisateurs sont authentifiés, nous les redirigeons vers la ressource time-offs. S’ils ne sont pas authentifiés, ils voient la page de connexion.

4. Ajout de la Route /employee/time-offs
<Route
  path="employee"
  element={
    <ThemeProvider role={Role.EMPLOYEE}>
      <Layout>
        <Outlet />
      </Layout>
    </ThemeProvider>
  }
>
  <Route path="time-offs" element={<Outlet />}>
    <Route index element={<PageEmployeeTimeOffsList />} />
  </Route>
</Route>

Nous organisons les pages des employés en utilisant des routes imbriquées. Tout d’abord, nous créons une route principale avec path='employee' qui enveloppe le contenu dans un thème et une mise en page spécifiques aux employés. À l’intérieur de cette route, nous ajoutons path='time-offs', qui affiche le composant PageEmployeeTimeOffsList. Cette structure regroupe toutes les fonctionnalités des employés sous un seul chemin et maintient le style cohérent.

Après avoir ajouté ces modifications, vous pouvez naviguer vers la route /employee/time-offs pour voir la page de la liste des congés en action.

/employee/time-offs

En ce moment, la page de la liste des congés est fonctionnelle, mais elle manque de la capacité de créer de nouvelles demandes de congé. Ajoutons la capacité de créer de nouvelles demandes de congé.

Construction de la page de création de congés

Nous allons créer une nouvelle page pour demander ou attribuer des congés. Cette page comportera un formulaire où les utilisateurs pourront spécifier le type de congé, les dates de début et de fin, ainsi que des notes supplémentaires.

Avant de commencer, nous devons créer de nouveaux composants à utiliser dans le formulaire :

Création du composant <TimeOffFormSummary />

Créez un nouveau fichier appelé form-summary.tsx dans le dossier src/components/time-offs/ et ajoutez le code suivant :

src/components/time-offs/form-summary.tsx

import { Box, Divider, Typography } from "@mui/material";

type Props = {
  availableAnnualDays: number;
  requestedDays: number;
};

export const TimeOffFormSummary = (props: Props) => {
  const remainingDays = props.availableAnnualDays - props.requestedDays;

  return (
    <Box
      sx={{
        display: "flex",
        flexDirection: "column",
        alignItems: "flex-end",
        gap: "16px",
        whiteSpace: "nowrap",
      }}
    >
      <Box
        sx={{
          display: "flex",
          gap: "16px",
        }}
      >
        <Typography variant="body2" color="text.secondary">
          Available Annual Leave Days:
        </Typography>
        <Typography variant="body2">{props.availableAnnualDays}</Typography>
      </Box>

      <Box
        sx={{
          display: "flex",
          gap: "16px",
        }}
      >
        <Typography variant="body2" color="text.secondary">
          Requested Days:
        </Typography>
        <Typography variant="body2">{props.requestedDays}</Typography>
      </Box>

      <Divider
        sx={{
          width: "100%",
        }}
      />
      <Box
        sx={{
          display: "flex",
          gap: "16px",
          height: "40px",
        }}
      >
        <Typography variant="body2" color="text.secondary">
          Remaining Days:
        </Typography>
        <Typography variant="body2" fontWeight={500}>
          {remainingDays}
        </Typography>
      </Box>
    </Box>
  );
};

<TimeOffFormSummary />

Le composant <TimeOffFormSummary /> affiche un résumé de la demande de congé. Il montre le nombre de jours de congé annuel disponibles, le nombre de jours demandés et les jours restants. Nous utiliserons ce composant dans le formulaire de congé pour fournir aux utilisateurs un aperçu clair de leur demande.

Création du composant <PageEmployeeTimeOffsCreate />

Créez un nouveau fichier appelé create.tsx dans le dossier src/pages/employee/time-offs/ et ajoutez le code suivant :

src/pages/time-offs/create.tsx
import { useCan, useGetIdentity, type HttpError } from "@refinedev/core";
import { useForm } from "@refinedev/react-hook-form";
import { Controller } from "react-hook-form";
import type { DateRange } from "@mui/x-date-pickers-pro/models";
import { Box, Button, MenuItem, Select, Typography } from "@mui/material";
import dayjs from "dayjs";
import { PageHeader } from "@/components/layout/page-header";
import { InputText } from "@/components/input/text";
import { LoadingOverlay } from "@/components/loading-overlay";
import { InputDateStartsEnds } from "@/components/input/date-starts-ends";
import { TimeOffFormSummary } from "@/components/time-offs/form-summary";
import { ThemeProvider } from "@/providers/theme-provider";
import {
  type Employee,
  type TimeOff,
  TimeOffType,
  TimeOffStatus,
  Role,
} from "@/types";
import { CheckRectangleIcon } from "@/icons";

type FormValues = Omit<TimeOff, "id" | "notes"> & {
  notes: string;
  dates: DateRange<dayjs.Dayjs>;
};

export const PageEmployeeTimeOffsCreate = () => {
  const { data: useCanData } = useCan({
    action: "manager",
    params: {
      resource: {
        name: "time-offs",
        meta: {
          scope: "manager",
        },
      },
    },
  });
  const isManager = useCanData?.can;

  const { data: employee } =
    useGetIdentity<Employee>();

  const {
    refineCore: { formLoading, onFinish },
    ...formMethods
  } = useForm<TimeOff, HttpError, FormValues>({
    defaultValues: {
      timeOffType: TimeOffType.ANNUAL,
      notes: "",
      dates: [null, null],
    },
    refineCoreProps: {
      successNotification: () => {
        return {
          message: isManager
            ? "Time off assigned"
            : "Your time off request is submitted for review.",
          type: "success",
        };
      },
    },
  });
  const { control, handleSubmit, formState, watch } = formMethods;

  const onFinishHandler = async (values: FormValues) => {
    const payload: FormValues = {
      ...values,
      startsAt: dayjs(values.dates[0]).format("YYYY-MM-DD"),
      endsAt: dayjs(values.dates[1]).format("YYYY-MM-DD"),
      ...(isManager && {
        status: TimeOffStatus.APPROVED,
      }),
    };
    await onFinish(payload);
  };

  const timeOffType = watch("timeOffType");
  const selectedDays = watch("dates");
  const startsAt = selectedDays[0];
  const endsAt = selectedDays[1];
  const availableAnnualDays = employee?.availableAnnualLeaveDays ?? 0;
  const requestedDays =
    startsAt && endsAt ? endsAt.diff(startsAt, "day") + 1 : 0;

  return (
    <ThemeProvider role={isManager ? Role.MANAGER : Role.EMPLOYEE}>
      <LoadingOverlay loading={formLoading}>
        <Box>
          <PageHeader
            title={isManager ? "Assign Time Off" : "Request Time Off"}
            showListButton
            showDivider
          />

          <Box
            component="form"
            onSubmit={handleSubmit(onFinishHandler)}
            sx={{
              display: "flex",
              flexDirection: "column",
              gap: "24px",
              marginTop: "24px",
            }}
          >
            <Box>
              <Typography
                variant="body2"
                sx={{
                  mb: "8px",
                }}
              >
                Time Off Type
              </Typography>
              <Controller
                name="timeOffType"
                control={control}
                render={({ field }) => (
                  <Select
                    {...field}
                    size="small"
                    sx={{
                      minWidth: "240px",
                      height: "40px",
                      "& .MuiSelect-select": {
                        paddingBlock: "10px",
                      },
                    }}
                  >
                    <MenuItem value={TimeOffType.ANNUAL}>Annual Leave</MenuItem>
                    <MenuItem value={TimeOffType.CASUAL}>Casual Leave</MenuItem>
                    <MenuItem value={TimeOffType.SICK}>Sick Leave</MenuItem>
                  </Select>
                )}
              />
            </Box>

            <Box>
              <Typography
                variant="body2"
                sx={{
                  mb: "16px",
                }}
              >
                Requested Dates
              </Typography>
              <Controller
                name="dates"
                control={control}
                rules={{
                  validate: (value) => {
                    if (!value[0] || !value[1]) {
                      return "Please select both start and end dates";
                    }

                    return true;
                  },
                }}
                render={({ field }) => {
                  return (
                    <Box
                      sx={{
                        display: "grid",
                        gridTemplateColumns: () => {
                          return {
                            sm: "1fr",
                            lg: "628px 1fr",
                          };
                        },
                        gap: "40px",
                      }}
                    >
                      <InputDateStartsEnds
                        {...field}
                        error={formState.errors.dates?.message}
                        availableAnnualDays={availableAnnualDays}
                        requestedDays={requestedDays}
                      />
                      {timeOffType === TimeOffType.ANNUAL && (
                        <Box
                          sx={{
                            display: "flex",
                            maxWidth: "628px",
                            alignItems: () => {
                              return {
                                lg: "flex-end",
                              };
                            },
                            justifyContent: () => {
                              return {
                                xs: "flex-end",
                                lg: "flex-start",
                              };
                            },
                          }}
                        >
                          <TimeOffFormSummary
                            availableAnnualDays={availableAnnualDays}
                            requestedDays={requestedDays}
                          />
                        </Box>
                      )}
                    </Box>
                  );
                }}
              />
            </Box>

            <Box
              sx={{
                maxWidth: "628px",
              }}
            >
              <Controller
                name="notes"
                control={control}
                render={({ field, fieldState }) => {
                  return (
                    <InputText
                      {...field}
                      label="Notes"
                      error={fieldState.error?.message}
                      placeholder="Place enter your notes"
                      multiline
                      rows={3}
                    />
                  );
                }}
              />
            </Box>

            <Button
              variant="contained"
              size="large"
              type="submit"
              startIcon={isManager ? <CheckRectangleIcon /> : undefined}
            >
              {isManager ? "Assign" : "Send Request"}
            </Button>
          </Box>
        </Box>
      </LoadingOverlay>
    </ThemeProvider>
  );
};

<PageEmployeeTimeOffsCreate />

Le composant <PageEmployeeTimeOffsCreate /> affiche un formulaire pour créer de nouvelles demandes de congé dans une application de gestion des ressources humaines. Les employés et les managers peuvent l’utiliser pour demander ou attribuer des congés. Le formulaire comprend des options pour sélectionner le type de congé, choisir les dates de début et de fin, ajouter des notes et il montre un résumé du congé demandé.

Décomposons les principales parties du composant :

1. Vérification du rôle de l’utilisateur

const { data: useCanData } = useCan({
  action: "manager",
  params: {
    resource: {
      name: "time-offs",
      meta: {
        scope: "manager",
      },
    },
  },
});
const isManager = useCanData?.can;

Avec le useCan hook, nous vérifions si l’utilisateur actuel a des permissions de manager. Cela détermine si l’utilisateur peut attribuer des congés ou seulement les demander. Nous traiterons la soumission du formulaire différemment dans onFinishHandler en fonction du rôle de l’utilisateur.

2. État du formulaire et soumission


 const {
  refineCore: { formLoading, onFinish },
  ...formMethods
} = useForm<TimeOff, HttpError, FormValues>({
  defaultValues: {
    timeOffType: TimeOffType.ANNUAL,
    notes: "",
    dates: [null, null],
  },
  refineCoreProps: {
    successNotification: () => {
      return {
        message: isManager
          ? "Time off assigned"
          : "Your time off request is submitted for review.",
        type: "success",
      };
    },
  },
});
const { control, handleSubmit, formState, watch } = formMethods;

  const onFinishHandler = async (values: FormValues) => {
    const payload: FormValues = {
      ...values,
      startsAt: dayjs(values.dates[0]).format("YYYY-MM-DD"),
      endsAt: dayjs(values.dates[1]).format("YYYY-MM-DD"),
      ...(isManager && {
        status: TimeOffStatus.APPROVED,
      }),
    };
    await onFinish(payload);
  };

useForm initialise le formulaire avec des valeurs par défaut et définit les notifications de succès en fonction du rôle de l’utilisateur. La fonction onFinishHandler traite les données du formulaire avant de les soumettre. Pour les managers, le statut est immédiatement défini sur APPROVED, tandis que les demandes des employés sont soumises pour examen.

3. Styles

<ThemeProvider role={isManager ? Role.MANAGER : Role.EMPLOYEE}>
  {/* ... */}
</ThemeProvider>

<Button
  variant="contained"
  size="large"
  type="submit"
  startIcon={isManager ? <CheckRectangleIcon /> : undefined}
>
  {isManager ? "Assign" : "Send Request"}
</Button>

Dans notre design, la couleur principale change en fonction du rôle de l’utilisateur. Nous utilisons le <ThemeProvider /> pour appliquer le bon thème en conséquence. Le texte et l’icône du bouton de soumission changent également selon que l’utilisateur est un manager ou un employé.

4. Ajout de la route “/employee/time-offs/create”

Nous devons ajouter la nouvelle route pour la page de création de congés. Mettons à jour le fichier App.tsx pour inclure cette route :

src/App.tsx
import { Authenticated, ErrorComponent, Refine } from '@refinedev/core'
import { DevtoolsProvider, DevtoolsPanel } from '@refinedev/devtools'
import dataProvider from '@refinedev/nestjsx-crud'
import routerProvider, {
  UnsavedChangesNotifier,
  DocumentTitleHandler,
  NavigateToResource,
} from '@refinedev/react-router-v6'
import { BrowserRouter, Routes, Route, Outlet } from 'react-router-dom'
import { Toaster } from 'react-hot-toast'

import { PageEmployeeTimeOffsList } from '@/pages/employee/time-offs/list'
import { PageEmployeeTimeOffsCreate } from '@/pages/employee/time-offs/create'
import { PageLogin } from '@/pages/login'

import { Layout } from '@/components/layout'

import { ThemeProvider } from '@/providers/theme-provider'
import { authProvider } from '@/providers/auth-provider'
import { accessControlProvider } from '@/providers/access-control'
import { useNotificationProvider } from '@/providers/notification-provider'
import { queryClient } from '@/providers/query-client'

import { BASE_URL } from '@/utilities/constants'
import { axiosInstance } from '@/utilities/axios'

import { TimeOffIcon } from '@/icons'

import { Role } from '@/types'

import '@/utilities/init-dayjs'

function App() {
  return (
    <BrowserRouter>
      <ThemeProvider>
        <DevtoolsProvider>
          <Refine
            authProvider={authProvider}
            routerProvider={routerProvider}
            dataProvider={dataProvider(BASE_URL, axiosInstance)}
            notificationProvider={useNotificationProvider}
            resources={[
              {
                name: 'employee',
                meta: {
                  scope: Role.EMPLOYEE,
                  order: 2,
                },
              },
              {
                name: 'manager',
                meta: {
                  scope: Role.MANAGER,
                  order: 1,
                },
              },
              {
                name: 'time-offs',
                list: '/employee/time-offs',
                create: '/employee/time-offs/new',
                meta: {
                  parent: 'employee',
                  scope: Role.EMPLOYEE,
                  label: 'Time Off',
                  icon: <TimeOffIcon />,
                },
              },
            ]}
            accessControlProvider={accessControlProvider}
            options={{
              reactQuery: {
                clientConfig: queryClient,
              },
              syncWithLocation: true,
              warnWhenUnsavedChanges: true,
              useNewQueryKeys: true,
            }}>
            <Routes>
              <Route
                element={
                  <Authenticated key='authenticated-routes' redirectOnFail='/login'>
                    <Outlet />
                  </Authenticated>
                }>
                <Route index element={<NavigateToResource resource='time-offs' />} />

                <Route
                  path='employee'
                  element={
                    <ThemeProvider role={Role.EMPLOYEE}>
                      <Layout>
                        <Outlet />
                      </Layout>
                    </ThemeProvider>
                  }>
                  <Route path='time-offs' element={<Outlet />}>
                    <Route index element={<PageEmployeeTimeOffsList />} />
                    <Route path='new' element={<PageEmployeeTimeOffsCreate />} />
                  </Route>
                </Route>
              </Route>

              <Route
                element={
                  <Authenticated key='auth-pages' fallback={<Outlet />}>
                    <NavigateToResource resource='time-offs' />
                  </Authenticated>
                }>
                <Route path='/login' element={<PageLogin />} />
              </Route>

              <Route
                element={
                  <Authenticated key='catch-all'>
                    <Layout>
                      <Outlet />
                    </Layout>
                  </Authenticated>
                }>
                <Route path='*' element={<ErrorComponent />} />
              </Route>
            </Routes>

            <UnsavedChangesNotifier />
            <DocumentTitleHandler />
            <Toaster position='bottom-right' reverseOrder={false} />
            <DevtoolsPanel />
          </Refine>
        </DevtoolsProvider>
      </ThemeProvider>
    </BrowserRouter>
  )
}

export default App


Après avoir ajouté ces changements, vous pouvez naviguer vers la route /employee/time-offs/create ou cliquer sur le bouton “Attribuer des congés” sur la page de la liste des congés pour accéder au formulaire de création de congés.

/employee/time-offs/create

Étape 5 — Construction de la page de gestion des demandes de congé

Dans cette étape, nous allons créer une nouvelle page pour gérer les demandes de congé. Cette page permettra aux gestionnaires de consulter et d’approuver ou de rejeter les demandes de congé soumises par les employés.

/manager/requests

Construction de la page de liste des demandes de congé

Nous allons créer une nouvelle page pour gérer les demandes de congé. Cette page inclura une liste des demandes de congé, affichant des détails tels que le nom de l’employé, le type de congé, les dates demandées et le statut actuel.

Avant de commencer, nous devons créer de nouveaux composants à utiliser dans la liste :

Construction du composant <RequestsList />

Créez un nouveau fichier appelé list.tsx dans le dossier src/components/requests/ et ajoutez le code suivant :

src/components/requests/list.tsx
import type { ReactNode } from "react";
import InfiniteScroll from "react-infinite-scroll-component";
import {
  Box,
  Button,
  CircularProgress,
  Skeleton,
  Typography,
} from "@mui/material";

type Props = {
  dataLength: number;
  hasMore: boolean;
  scrollableTarget: string;
  loading: boolean;
  noDataText: string;
  noDataIcon: ReactNode;
  children: ReactNode;
  next: () => void;
};

export const RequestsList = (props: Props) => {
  const hasData = props.dataLength > 0 || props.loading;
  if (!hasData) {
    return (
      <Box
        sx={{
          display: "flex",
          alignItems: "center",
          justifyContent: "center",
          flexDirection: "column",
          gap: "24px",
        }}
      >
        {props.noDataIcon}
        <Typography variant="body2" color="text.secondary">
          {props.noDataText || "No data."}
        </Typography>
      </Box>
    );
  }

  return (
    <Box
      sx={{
        position: "relative",
      }}
    >
      <Box
        id={props.scrollableTarget}
        sx={(theme) => ({
          maxHeight: "600px",
          [theme.breakpoints.up("lg")]: {
            height: "600px",
          },
          overflow: "auto",
          ...((props.dataLength > 6 || props.loading) && {
            "&::after": {
              pointerEvents: "none",
              content: '""',
              zIndex: 1,
              position: "absolute",
              bottom: "0",
              left: "12px",
              right: "12px",
              width: "calc(100% - 24px)",
              height: "60px",
              background:
                "linear-gradient(180deg, rgba(255, 255, 255, 0), #FFFFFF)",
            },
          }),
        })}
      >
        <InfiniteScroll
          dataLength={props.dataLength}
          hasMore={props.hasMore}
          next={props.next}
          scrollableTarget={props.scrollableTarget}
          endMessage={
            !props.loading &&
            props.dataLength > 6 && (
              <Box
                sx={{
                  pt: "40px",
                }}
              />
            )
          }
          loader={
            <Box
              sx={{
                display: "flex",
                alignItems: "center",
                justifyContent: "center",
                width: "100%",
                height: "100px",
              }}
            >
              <CircularProgress size={24} />
            </Box>
          }
        >
          <Box
            sx={{
              display: "flex",
              flexDirection: "column",
            }}
          >
            {props.loading ? <SkeletonList /> : props.children}
          </Box>
        </InfiniteScroll>
      </Box>
    </Box>
  );
};

const SkeletonList = () => {
  return (
    <>
      {[...Array(6)].map((_, index) => (
        <Box
          key={index}
          sx={(theme) => ({
            paddingRight: "24px",
            paddingLeft: "24px",
            display: "flex",
            flexDirection: "column",
            justifyContent: "flex-end",
            gap: "12px",
            paddingTop: "12px",
            paddingBottom: "4px",

            [theme.breakpoints.up("sm")]: {
              paddingTop: "20px",
              paddingBottom: "12px",
            },

            "& .MuiSkeleton-rectangular": {
              borderRadius: "2px",
            },
          })}
        >
          <Skeleton variant="rectangular" width="64px" height="12px" />
          <Box
            sx={{
              display: "flex",
              alignItems: "center",
              gap: "24px",
            }}
          >
            <Skeleton
              variant="circular"
              width={48}
              height={48}
              sx={{
                flexShrink: 0,
              }}
            />
            <Box
              sx={(theme) => ({
                height: "auto",
                width: "100%",
                [theme.breakpoints.up("md")]: {
                  height: "48px",
                },
                display: "flex",
                flex: 1,
                flexDirection: "column",
                justifyContent: "center",
                gap: "8px",
              })}
            >
              <Skeleton
                variant="rectangular"
                sx={(theme) => ({
                  width: "100%",
                  [theme.breakpoints.up("sm")]: {
                    width: "120px",
                  },
                })}
                height="16px"
              />
              <Skeleton
                variant="rectangular"
                sx={(theme) => ({
                  width: "100%",
                  [theme.breakpoints.up("sm")]: {
                    width: "230px",
                  },
                })}
                height="12px"
              />
            </Box>
            <Button
              size="small"
              color="inherit"
              sx={(theme) => ({
                display: "none",
                [theme.breakpoints.up("sm")]: {
                  display: "block",
                },

                alignSelf: "flex-start",
                flexShrink: 0,
                marginLeft: "auto",
              })}
            >
              View Request
            </Button>
          </Box>
        </Box>
      ))}
    </>
  );
};

Le composant <RequestsList /> affiche une liste de demandes de congé avec un défilement infini. Il inclut un indicateur de chargement, des espaces réservés squelette et un message en l’absence de données. Ce composant est conçu pour gérer efficacement de grands ensembles de données et offrir une expérience utilisateur fluide.

Création du composant <RequestsListItem />

Créez un nouveau fichier appelé list-item.tsx dans le dossier src/components/requests/ et ajoutez le code suivant :

src/components/requests/list-item.tsx
import { Box, Typography, Avatar, Button } from "@mui/material";
import type { ReactNode } from "react";

type Props = {
  date: string;
  avatarURL: string;
  title: string;
  descriptionIcon?: ReactNode;
  description: string;
  onClick?: () => void;
  showTimeSince?: boolean;
};

export const RequestsListItem = ({
  date,
  avatarURL,
  title,
  descriptionIcon,
  description,
  onClick,
  showTimeSince,
}: Props) => {
  return (
    <Box
      role="button"
      onClick={onClick}
      sx={(theme) => ({
        cursor: "pointer",
        paddingRight: "24px",
        paddingLeft: "24px",

        paddingTop: "4px",
        paddingBottom: "4px",
        [theme.breakpoints.up("sm")]: {
          paddingTop: "12px",
          paddingBottom: "12px",
        },

        "&:hover": {
          backgroundColor: theme.palette.action.hover,
        },
      })}
    >
      {showTimeSince && (
        <Box
          sx={{
            marginBottom: "8px",
          }}
        >
          <Typography variant="caption" color="textSecondary">
            {date}
          </Typography>
        </Box>
      )}
      <Box
        sx={{
          display: "flex",
        }}
      >
        <Avatar
          src={avatarURL}
          alt={title}
          sx={{ width: "48px", height: "48px" }}
        />
        <Box
          sx={(theme) => ({
            height: "auto",
            [theme.breakpoints.up("md")]: {
              height: "48px",
            },
            width: "100%",
            display: "flex",
            flexWrap: "wrap",
            justifyContent: "space-between",
            gap: "4px",
            marginLeft: "16px",
          })}
        >
          <Box>
            <Typography variant="body2" fontWeight={500} lineHeight="24px">
              {title}
            </Typography>
            <Box
              sx={{
                display: "flex",
                alignItems: "center",
                gap: "8px",
                minWidth: "260px",
              }}
            >
              {descriptionIcon}
              <Typography variant="caption" color="textSecondary">
                {description}
              </Typography>
            </Box>
          </Box>

          {onClick && (
            <Button
              size="small"
              color="inherit"
              onClick={onClick}
              sx={{
                alignSelf: "flex-start",
                flexShrink: 0,
                marginLeft: "auto",
              }}
            >
              View Request
            </Button>
          )}
        </Box>
      </Box>
    </Box>
  );
};

Le composant <RequestsListItem /> affiche une seule demande de congé dans la liste. Il inclut l’avatar de l’employé, son nom, une description, et un bouton pour voir les détails de la demande. Ce composant est réutilisable et peut être utilisé pour rendre chaque élément de la liste des demandes de congé.

Création du composant <PageManagerRequestsList />

Créez un nouveau fichier appelé list.tsx dans le dossier src/pages/manager/requests/ et ajoutez le code suivant :

import type { PropsWithChildren } from "react";
import { useGo, useInfiniteList } from "@refinedev/core";
import { Box, Typography } from "@mui/material";
import dayjs from "dayjs";
import { Frame } from "@/components/frame";
import { PageHeader } from "@/components/layout/page-header";
import { RequestsListItem } from "@/components/requests/list-item";
import { RequestsList } from "@/components/requests/list";
import { indigo } from "@/providers/theme-provider/colors";
import { TimeOffIcon, RequestTypeIcon, NoTimeOffIcon } from "@/icons";
import { TimeOffStatus, type Employee, type TimeOff } from "@/types";

export const PageManagerRequestsList = ({ children }: PropsWithChildren) => {
  return (
    <>
      <Box>
        <PageHeader title="Awaiting Requests" />
        <TimeOffsList />
      </Box>
      {children}
    </>
  );
};

const TimeOffsList = () => {
  const go = useGo();

  const {
    data: timeOffsData,
    isLoading: timeOffsLoading,
    fetchNextPage: timeOffsFetchNextPage,
    hasNextPage: timeOffsHasNextPage,
  } = useInfiniteList<
    TimeOff & {
      employee: Employee;
    }
  >({
    resource: "time-offs",
    filters: [
      { field: "status", operator: "eq", value: TimeOffStatus.PENDING },
    ],
    sorters: [{ field: "createdAt", order: "desc" }],
    meta: {
      join: ["employee"],
    },
  });

  const timeOffs = timeOffsData?.pages.flatMap((page) => page.data) || [];
  const totalCount = timeOffsData?.pages[0].total;

  return (
    <Frame
      title="Time off Requests"
      titleSuffix={
        !!totalCount &&
        totalCount > 0 && (
          <Box
            sx={{
              padding: "4px",
              display: "flex",
              alignItems: "center",
              justifyContent: "center",
              minWidth: "24px",
              height: "24px",
              borderRadius: "4px",
              backgroundColor: indigo[100],
            }}
          >
            <Typography
              variant="caption"
              sx={{
                color: indigo[500],
                fontSize: "12px",
                lineHeight: "16px",
              }}
            >
              {totalCount}
            </Typography>
          </Box>
        )
      }
      icon={<TimeOffIcon width={24} height={24} />}
      sx={{
        flex: 1,
        paddingBottom: "0px",
      }}
      sxChildren={{
        padding: 0,
      }}
    >
      <RequestsList
        loading={timeOffsLoading}
        dataLength={timeOffs.length}
        hasMore={timeOffsHasNextPage || false}
        next={timeOffsFetchNextPage}
        scrollableTarget="scrollableDiv-timeOffs"
        noDataText="No time off requests right now."
        noDataIcon={<NoTimeOffIcon />}
      >
        {timeOffs.map((timeOff) => {
          const date = dayjs(timeOff.createdAt).fromNow();
          const fullName = `${timeOff.employee.firstName} ${timeOff.employee.lastName}`;
          const avatarURL = timeOff.employee.avatarUrl;
          const requestedDay =
            dayjs(timeOff.endsAt).diff(dayjs(timeOff.startsAt), "day") + 1;
          const description = `Requested ${requestedDay} ${
            requestedDay > 1 ? "days" : "day"
          } of time  ${timeOff.timeOffType.toLowerCase()} leave.`;

          return (
            <RequestsListItem
              key={timeOff.id}
              date={date}
              avatarURL={avatarURL}
              title={fullName}
              showTimeSince
              descriptionIcon={<RequestTypeIcon type={timeOff.timeOffType} />}
              description={description}
              onClick={() => {
                go({
                  type: "replace",
                  to: {
                    resource: "requests",
                    id: timeOff.id,
                    action: "edit",
                  },
                });
              }}
            />
          );
        })}
      </RequestsList>
    </Frame>
  );
};

Le composant <PageManagerRequestsList /> affiche les demandes de congé en attente que les managers doivent approuver. Il montre des détails comme le nom de l’employé, le type de congé, les dates demandées et combien de temps s’est écoulé depuis la demande. Les managers peuvent cliquer sur une demande pour voir plus de détails. Il utilise <RequestsList /> et <RequestsListItem /> pour rendre la liste.

Ce composant accepte également children comme prop. Ensuite, nous allons implémenter une route modale utilisant <Outlet /> pour afficher les détails de la demande, rendant la route /manager/requests/:id à l’intérieur du composant.

Ajout de la route “/manager/requests”

Nous devons ajouter le nouveau chemin pour la page de gestion des demandes de congé. Mettons à jour le fichier App.tsx pour inclure ce chemin :

src/App.tsx
import { Authenticated, ErrorComponent, Refine } from '@refinedev/core'
import { DevtoolsProvider, DevtoolsPanel } from '@refinedev/devtools'
import dataProvider from '@refinedev/nestjsx-crud'
import routerProvider, {
  UnsavedChangesNotifier,
  DocumentTitleHandler,
  NavigateToResource,
} from '@refinedev/react-router-v6'
import { BrowserRouter, Routes, Route, Outlet } from 'react-router-dom'
import { Toaster } from 'react-hot-toast'

import { PageEmployeeTimeOffsList } from '@/pages/employee/time-offs/list'
import { PageEmployeeTimeOffsCreate } from '@/pages/employee/time-offs/create'
import { PageLogin } from '@/pages/login'

import { Layout } from '@/components/layout'

import { ThemeProvider } from '@/providers/theme-provider'
import { authProvider } from '@/providers/auth-provider'
import { accessControlProvider } from '@/providers/access-control'
import { useNotificationProvider } from '@/providers/notification-provider'
import { queryClient } from '@/providers/query-client'

import { BASE_URL } from '@/utilities/constants'
import { axiosInstance } from '@/utilities/axios'

import { RequestsIcon, TimeOffIcon } from '@/icons'

import { Role } from '@/types'

import '@/utilities/init-dayjs'
import { PageManagerRequestsList } from './pages/manager/requests/list'

function App() {
  return (
    <BrowserRouter>
      <ThemeProvider>
        <DevtoolsProvider>
          <Refine
            authProvider={authProvider}
            routerProvider={routerProvider}
            dataProvider={dataProvider(BASE_URL, axiosInstance)}
            notificationProvider={useNotificationProvider}
            resources={[
              {
                name: 'employee',
                meta: {
                  scope: Role.EMPLOYEE,
                  order: 2,
                },
              },
              {
                name: 'manager',
                meta: {
                  scope: Role.MANAGER,
                  order: 1,
                },
              },
              {
                name: 'time-offs',
                list: '/employee/time-offs',
                create: '/employee/time-offs/new',
                meta: {
                  parent: 'employee',
                  scope: Role.EMPLOYEE,
                  label: 'Time Off',
                  icon: <TimeOffIcon />,
                },
              },
              {
                name: 'time-offs',
                list: '/manager/requests',
                identifier: 'requests',
                meta: {
                  parent: 'manager',
                  scope: Role.MANAGER,
                  label: 'Requests',
                  icon: <RequestsIcon />,
                },
              },
            ]}
            accessControlProvider={accessControlProvider}
            options={{
              reactQuery: {
                clientConfig: queryClient,
              },
              syncWithLocation: true,
              warnWhenUnsavedChanges: true,
              useNewQueryKeys: true,
            }}>
            <Routes>
              <Route
                element={
                  <Authenticated key='authenticated-routes' redirectOnFail='/login'>
                    <Outlet />
                  </Authenticated>
                }>
                <Route index element={<NavigateToResource resource='time-offs' />} />

                <Route
                  path='employee'
                  element={
                    <ThemeProvider role={Role.EMPLOYEE}>
                      <Layout>
                        <Outlet />
                      </Layout>
                    </ThemeProvider>
                  }>
                  <Route path='time-offs' element={<Outlet />}>
                    <Route index element={<PageEmployeeTimeOffsList />} />
                    <Route path='new' element={<PageEmployeeTimeOffsCreate />} />
                  </Route>
                </Route>
              </Route>

              <Route
                path='manager'
                element={
                  <ThemeProvider role={Role.MANAGER}>
                    <Layout>
                      <Outlet />
                    </Layout>
                  </ThemeProvider>
                }>
                <Route path='requests' element={<Outlet />}>
                  <Route index element={<PageManagerRequestsList />} />
                </Route>
              </Route>

              <Route
                element={
                  <Authenticated key='auth-pages' fallback={<Outlet />}>
                    <NavigateToResource resource='time-offs' />
                  </Authenticated>
                }>
                <Route path='/login' element={<PageLogin />} />
              </Route>

              <Route
                element={
                  <Authenticated key='catch-all'>
                    <Layout>
                      <Outlet />
                    </Layout>
                  </Authenticated>
                }>
                <Route path='*' element={<ErrorComponent />} />
              </Route>
            </Routes>

            <UnsavedChangesNotifier />
            <DocumentTitleHandler />
            <Toaster position='bottom-right' reverseOrder={false} />
            <DevtoolsPanel />
          </Refine>
        </DevtoolsProvider>
      </ThemeProvider>
    </BrowserRouter>
  )
}

export default App

Après avoir apporté ces modifications, vous pouvez naviguer vers le chemin /manager/requests pour voir la page de gestion des demandes de congé en action

/manager/requests

Création de la page des détails de la demande de congé

Dans cette étape, nous allons créer une nouvelle page pour afficher les détails d’une demande de congé. Cette page affichera le nom de l’employé, le type de congé, les dates demandées et l’état actuel. Les responsables peuvent approuver ou rejeter la demande depuis cette page.

Création du composant <TimeOffRequestModal />

Tout d’abord, créez un fichier appelé use-get-employee-time-off-usage dans le dossier src/hooks/ et ajoutez le code suivant :

src/hooks/use-get-employee-time-off-usage.ts
import { useList } from "@refinedev/core";
import { type TimeOff, TimeOffStatus, TimeOffType } from "@/types";
import { useMemo } from "react";
import dayjs from "dayjs";

export const useGetEmployeeTimeOffUsage = ({
  employeeId,
}: { employeeId?: number }) => {
  const query = useList<TimeOff>({
    resource: "time-offs",
    pagination: { pageSize: 999 },
    filters: [
      {
        field: "status",
        operator: "eq",
        value: TimeOffStatus.APPROVED,
      },
      {
        field: "employeeId",
        operator: "eq",
        value: employeeId,
      },
    ],
    queryOptions: {
      enabled: !!employeeId,
    },
  });
  const data = query?.data?.data;

  const { sick, casual, annual, sickCount, casualCount, annualCount } =
    useMemo(() => {
      const sick: TimeOff[] = [];
      const casual: TimeOff[] = [];
      const annual: TimeOff[] = [];
      let sickCount = 0;
      let casualCount = 0;
      let annualCount = 0;

      data?.forEach((timeOff) => {
        const duration =
          dayjs(timeOff.endsAt).diff(dayjs(timeOff.startsAt), "days") + 1;

        if (timeOff.timeOffType === TimeOffType.SICK) {
          sick.push(timeOff);
          sickCount += duration;
        } else if (timeOff.timeOffType === TimeOffType.CASUAL) {
          casual.push(timeOff);
          casualCount += duration;
        } else if (timeOff.timeOffType === TimeOffType.ANNUAL) {
          annual.push(timeOff);
          annualCount += duration;
        }
      });

      return {
        sick,
        casual,
        annual,
        sickCount,
        casualCount,
        annualCount,
      };
    }, [data]);

  return {
    query,
    sick,
    casual,
    annual,
    sickCount,
    casualCount,
    annualCount,
  };
};

Nous utiliserons le hook useGetEmployeeTimeOffUsage pour calculer le nombre total de jours qu’un employé a pris pour chaque type de congé. Cette information sera affichée dans la page des détails de la demande de congé.

Après cela, créez un nouveau fichier appelé time-off-request-modal.tsx dans le dossier src/components/requests/ et ajoutez le code suivant :

src/components/requests/time-off-request-modal.tsx
import type { ReactNode } from "react";
import { useInvalidate, useList, useUpdate } from "@refinedev/core";
import {
  Avatar,
  Box,
  Button,
  Divider,
  Tooltip,
  Typography,
} from "@mui/material";
import dayjs from "dayjs";
import { Modal } from "@/components/modal";
import {
  TimeOffStatus,
  TimeOffType,
  type Employee,
  type TimeOff,
} from "@/types";
import { RequestTypeIcon, ThumbsDownIcon, ThumbsUpIcon } from "@/icons";
import { useGetEmployeeTimeOffUsage } from "@/hooks/use-get-employee-time-off-usage";

type Props = {
  open: boolean;
  onClose: () => void;
  loading: boolean;
  onSuccess?: () => void;
  timeOff:
    | (TimeOff & {
        employee: Employee;
      })
    | null
    | undefined;
};

export const TimeOffRequestModal = ({
  open,
  timeOff,
  loading: loadingFromProps,
  onClose,
  onSuccess,
}: Props) => {
  const employeeUsedTimeOffs = useGetEmployeeTimeOffUsage({
    employeeId: timeOff?.employee.id,
  });

  const invalidate = useInvalidate();

  const { mutateAsync } = useUpdate<TimeOff>();

  const employee = timeOff?.employee;
  const duration =
    dayjs(timeOff?.endsAt).diff(dayjs(timeOff?.startsAt), "days") + 1;
  const remainingAnnualLeaveDays =
    (employee?.availableAnnualLeaveDays ?? 0) - duration;

  const { data: timeOffsData, isLoading: timeOffsLoading } = useList<
    TimeOff & { employee: Employee }
  >({
    resource: "time-offs",
    pagination: {
      pageSize: 999,
    },
    filters: [
      {
        field: "status",
        operator: "eq",
        value: TimeOffStatus.APPROVED,
      },
      {
        operator: "and",
        value: [
          {
            field: "startsAt",
            operator: "lte",
            value: timeOff?.endsAt,
          },
          {
            field: "endsAt",
            operator: "gte",
            value: timeOff?.startsAt,
          },
        ],
      },
    ],
    queryOptions: {
      enabled: !!timeOff,
    },
    meta: {
      join: ["employee"],
    },
  });
  const whoIsOutList = timeOffsData?.data || [];

  const handleSubmit = async (status: TimeOffStatus) => {
    await mutateAsync({
      resource: "time-offs",
      id: timeOff?.id,
      invalidates: ["resourceAll"],
      values: {
        status,
      },
    });

    onSuccess?.();
    invalidate({
      resource: "employees",
      invalidates: ["all"],
    });
  };

  const loading = timeOffsLoading || loadingFromProps;

  return (
    <Modal
      open={open}
      title="Time Off Request"
      loading={loading}
      sx={{
        maxWidth: "520px",
      }}
      onClose={onClose}
      footer={
        <>
          <Divider />
          <Box
            sx={{
              display: "flex",
              alignItems: "center",
              justifyContent: "space-between",
              gap: "8px",
              padding: "24px",
            }}
          >
            <Button
              sx={{
                backgroundColor: (theme) => theme.palette.error.light,
              }}
              startIcon={<ThumbsDownIcon />}
              onClick={() => handleSubmit(TimeOffStatus.REJECTED)}
            >
              Decline
            </Button>
            <Button
              sx={{
                backgroundColor: (theme) => theme.palette.success.light,
              }}
              onClick={() => handleSubmit(TimeOffStatus.APPROVED)}
              startIcon={<ThumbsUpIcon />}
            >
              Accept
            </Button>
          </Box>
        </>
      }
    >
      <Box
        sx={{
          display: "flex",
          alignItems: "center",
          padding: "24px",
          backgroundColor: (theme) => theme.palette.grey[50],
          borderBottom: (theme) => `1px solid ${theme.palette.divider}`,
        }}
      >
        <Avatar
          src={employee?.avatarUrl}
          alt={employee?.firstName}
          sx={{
            width: "80px",
            height: "80px",
            marginRight: "24px",
          }}
        />
        <Box
          sx={{
            display: "flex",
            flexDirection: "column",
          }}
        >
          <Typography
            variant="h2"
            fontSize="18px"
            lineHeight="28px"
            fontWeight="500"
          >
            {employee?.firstName} {employee?.lastName}
          </Typography>
          <Typography variant="caption">{employee?.jobTitle}</Typography>
          <Typography variant="caption">{employee?.role}</Typography>
        </Box>
      </Box>

      <Box
        sx={{
          padding: "24px",
        }}
      >
        <InfoRow
          loading={loading}
          label="Request Type"
          value={
            <Box
              component="span"
              sx={{
                display: "flex",
                alignItems: "center",
                gap: "8px",
              }}
            >
              <RequestTypeIcon type={timeOff?.timeOffType} />
              <Typography variant="body2" component="span">
                {timeOff?.timeOffType} Leave
              </Typography>
            </Box>
          }
        />
        <Divider />
        <InfoRow
          loading={loading}
          label="Duration"
          value={`${duration > 1 ? `${duration} days` : `${duration} day`}`}
        />
        <Divider />
        <InfoRow
          loading={loading}
          label={
            {
              [TimeOffType.ANNUAL]: "Remaining Annual Leave Days",
              [TimeOffType.SICK]: "Previously Used Sick Leave Days",
              [TimeOffType.CASUAL]: "Previously Used Casual Leave Days",
            }[timeOff?.timeOffType ?? TimeOffType.ANNUAL]
          }
          value={
            {
              [TimeOffType.ANNUAL]: remainingAnnualLeaveDays,
              [TimeOffType.SICK]: employeeUsedTimeOffs.sickCount,
              [TimeOffType.CASUAL]: employeeUsedTimeOffs.casualCount,
            }[timeOff?.timeOffType ?? TimeOffType.ANNUAL]
          }
        />
        <Divider />
        <InfoRow
          loading={loading}
          label="Start Date"
          value={dayjs(timeOff?.startsAt).format("MMMM DD")}
        />
        <Divider />
        <InfoRow
          loading={loading}
          label="End Date"
          value={dayjs(timeOff?.endsAt).format("MMMM DD")}
        />

        <Divider />
        <Box
          sx={{
            display: "flex",
            flexDirection: "column",
            gap: "8px",
            paddingY: "24px",
          }}
        >
          <Typography variant="body2" fontWeight={600}>
            Notes
          </Typography>
          <Typography
            variant="body2"
            sx={{
              height: "20px",
              fontStyle: timeOff?.notes ? "normal" : "italic",
            }}
          >
            {!loading && (timeOff?.notes || "No notes provided.")}
          </Typography>
        </Box>

        <Divider />
        <Box
          sx={{
            display: "flex",
            flexDirection: "column",
            gap: "8px",
            paddingY: "24px",
          }}
        >
          <Typography variant="body2" fontWeight={600}>
            Who's out between these days?
          </Typography>
          <Box
            sx={{
              display: "flex",
              alignItems: "center",
              flexWrap: "wrap",
              gap: "8px",
            }}
          >
            {whoIsOutList.length ? (
              whoIsOutList.map((whoIsOut) => (
                <Tooltip
                  key={whoIsOut.id}
                  sx={{
                    "& .MuiTooltip-tooltip": {
                      background: "red",
                    },
                  }}
                  title={
                    <Box
                      sx={{
                        display: "flex",
                        flexDirection: "column",
                        gap: "2px",
                      }}
                    >
                      <Typography variant="body2">
                        {whoIsOut.employee.firstName}{" "}
                        {whoIsOut.employee.lastName}
                      </Typography>
                      <Typography variant="caption">
                        {whoIsOut.timeOffType} Leave
                      </Typography>
                      <Typography variant="caption">
                        {dayjs(whoIsOut.startsAt).format("MMMM DD")} -{" "}
                        {dayjs(whoIsOut.endsAt).format("MMMM DD")}
                      </Typography>
                    </Box>
                  }
                  placement="top"
                >
                  <Avatar
                    src={whoIsOut.employee.avatarUrl}
                    alt={whoIsOut.employee.firstName}
                    sx={{
                      width: "32px",
                      height: "32px",
                    }}
                  />
                </Tooltip>
              ))
            ) : (
              <Typography
                variant="body2"
                sx={{
                  height: "32px",
                  fontStyle: "italic",
                }}
              >
                {loading ? "" : "No one is out between these days."}
              </Typography>
            )}
          </Box>
        </Box>
      </Box>
    </Modal>
  );
};

const InfoRow = ({
  label,
  value,
  loading,
}: { label: ReactNode; value: ReactNode; loading: boolean }) => {
  return (
    <Box
      sx={{
        display: "flex",
        justifyContent: "space-between",
        paddingY: "24px",
        height: "72px",
      }}
    >
      <Typography variant="body2">{label}</Typography>
      <Typography variant="body2">{loading ? "" : value}</Typography>
    </Box>
  );
};

Décomposons le composant <TimeOffRequestModal /> :

1. Récupération de l’utilisation des congés de l’employé

Le crochet useGetEmployeeTimeOffUsage est utilisé pour récupérer l’utilisation des congés de l’employé. Ce crochet calcule les jours de congé annuel restants et les jours de congé maladie et décontracté précédemment utilisés en fonction de l’historique des congés de l’employé.

2. Récupération des congés approuvés qui se chevauchent
filters: [
  {
    field: "status",
    operator: "eq",
    value: TimeOffStatus.APPROVED,
  },
  {
    operator: "and",
    value: [
      {
        field: "startsAt",
        operator: "lte",
        value: timeOff?.endsAt,
      },
      {
        field: "endsAt",
        operator: "gte",
        value: timeOff?.startsAt,
      },
    ],
  },
];

Le crochet useList avec les filtres ci-dessus récupère tous les congés approuvés qui se chevauchent avec la demande de congé actuelle. Cette liste est utilisée pour afficher les employés absents entre les dates demandées.

3. Gestion de l’approbation/du rejet de la demande de congé

La fonction handleSubmit est appelée lorsque le responsable approuve ou rejette la demande de congé.

const invalidate = useInvalidate();

// ...

const handleSubmit = async (status: TimeOffStatus) => {
  await mutateAsync({
    resource: "time-offs",
    id: timeOff?.id,
    invalidates: ["resourceAll"],
    values: {
      status,
    },
  });

  onSuccess?.();
  invalidate({
    resource: "employees",
    invalidates: ["all"],
  });
};

Refine invalide automatiquement le cache des ressources après la mutation de la ressource (les congés dans ce cas).
Étant donné que l’utilisation des congés de l’employé est calculée en fonction de l’historique des congés, nous invalidons également le cache de ressources des employés pour mettre à jour l’utilisation des congés de l’employé.

Ajout de la route « /manager/requests/:id »

Dans cette étape, nous allons créer une nouvelle route pour afficher la page de détails de la demande de congé, où les responsables peuvent approuver ou rejeter les demandes.

Créons un nouveau fichier appelé edit.tsx dans le dossier src/pages/manager/requests/time-offs/ et ajoutons le code suivant:

src/pages/manager/requests/time-offs/edit.tsx
import { useGo, useShow } from "@refinedev/core";
import { TimeOffRequestModal } from "@/components/requests/time-off-request-modal";
import type { Employee, TimeOff } from "@/types";

export const PageManagerRequestsTimeOffsEdit = () => {
  const go = useGo();

  const { query: timeOffRequestQuery } = useShow<
    TimeOff & { employee: Employee }
  >({
    meta: {
      join: ["employee"],
    },
  });

  const loading = timeOffRequestQuery.isLoading;

  return (
    <TimeOffRequestModal
      open
      loading={loading}
      timeOff={timeOffRequestQuery?.data?.data}
      onClose={() =>
        go({
          to: {
            resource: "requests",
            action: "list",
          },
        })
      }
      onSuccess={() => {
        go({
          to: {
            resource: "requests",
            action: "list",
          },
        });
      }}
    />
  );
};

Maintenant, nous devons ajouter la nouvelle route pour afficher la page de détails de la demande de congé. Mettons à jour le fichier App.tsx pour inclure cette route:

src/App.tsx
import { Authenticated, ErrorComponent, Refine } from '@refinedev/core'
import { DevtoolsProvider, DevtoolsPanel } from '@refinedev/devtools'
import dataProvider from '@refinedev/nestjsx-crud'
import routerProvider, {
  UnsavedChangesNotifier,
  DocumentTitleHandler,
  NavigateToResource,
} from '@refinedev/react-router-v6'
import { BrowserRouter, Routes, Route, Outlet } from 'react-router-dom'
import { Toaster } from 'react-hot-toast'

import { PageEmployeeTimeOffsList } from '@/pages/employee/time-offs/list'
import { PageEmployeeTimeOffsCreate } from '@/pages/employee/time-offs/create'
import { PageManagerRequestsList } from '@/pages/manager/requests/list'
import { PageManagerRequestsTimeOffsEdit } from '@/pages/manager/requests/time-offs/edit'
import { PageLogin } from '@/pages/login'

import { Layout } from '@/components/layout'

import { ThemeProvider } from '@/providers/theme-provider'
import { authProvider } from '@/providers/auth-provider'
import { accessControlProvider } from '@/providers/access-control'
import { useNotificationProvider } from '@/providers/notification-provider'
import { queryClient } from '@/providers/query-client'

import { BASE_URL } from '@/utilities/constants'
import { axiosInstance } from '@/utilities/axios'

import { RequestsIcon, TimeOffIcon } from '@/icons'

import { Role } from '@/types'

import '@/utilities/init-dayjs'

function App() {
  return (
    <BrowserRouter>
      <ThemeProvider>
        <DevtoolsProvider>
          <Refine
            authProvider={authProvider}
            routerProvider={routerProvider}
            dataProvider={dataProvider(BASE_URL, axiosInstance)}
            notificationProvider={useNotificationProvider}
            resources={[
              {
                name: 'employee',
                meta: {
                  scope: Role.EMPLOYEE,
                  order: 2,
                },
              },
              {
                name: 'manager',
                meta: {
                  scope: Role.MANAGER,
                  order: 1,
                },
              },
              {
                name: 'time-offs',
                list: '/employee/time-offs',
                create: '/employee/time-offs/new',
                meta: {
                  parent: 'employee',
                  scope: Role.EMPLOYEE,
                  label: 'Time Off',
                  icon: <TimeOffIcon />,
                },
              },
              {
                name: 'time-offs',
                list: '/manager/requests',
                edit: '/manager/requests/:id/edit',
                identifier: 'requests',
                meta: {
                  parent: 'manager',
                  scope: Role.MANAGER,
                  label: 'Requests',
                  icon: <RequestsIcon />,
                },
              },
            ]}
            accessControlProvider={accessControlProvider}
            options={{
              reactQuery: {
                clientConfig: queryClient,
              },
              syncWithLocation: true,
              warnWhenUnsavedChanges: true,
              useNewQueryKeys: true,
            }}>
            <Routes>
              <Route
                element={
                  <Authenticated key='authenticated-routes' redirectOnFail='/login'>
                    <Outlet />
                  </Authenticated>
                }>
                <Route index element={<NavigateToResource resource='time-offs' />} />

                <Route
                  path='employee'
                  element={
                    <ThemeProvider role={Role.EMPLOYEE}>
                      <Layout>
                        <Outlet />
                      </Layout>
                    </ThemeProvider>
                  }>
                  <Route path='time-offs' element={<Outlet />}>
                    <Route index element={<PageEmployeeTimeOffsList />} />
                    <Route path='new' element={<PageEmployeeTimeOffsCreate />} />
                  </Route>
                </Route>
              </Route>

              <Route
                path='manager'
                element={
                  <ThemeProvider role={Role.MANAGER}>
                    <Layout>
                      <Outlet />
                    </Layout>
                  </ThemeProvider>
                }>
                <Route
                  path='requests'
                  element={
                    <PageManagerRequestsList>
                      <Outlet />
                    </PageManagerRequestsList>
                  }>
                  <Route path=':id/edit' element={<PageManagerRequestsTimeOffsEdit />} />
                </Route>
              </Route>

              <Route
                element={
                  <Authenticated key='auth-pages' fallback={<Outlet />}>
                    <NavigateToResource resource='time-offs' />
                  </Authenticated>
                }>
                <Route path='/login' element={<PageLogin />} />
              </Route>

              <Route
                element={
                  <Authenticated key='catch-all'>
                    <Layout>
                      <Outlet />
                    </Layout>
                  </Authenticated>
                }>
                <Route path='*' element={<ErrorComponent />} />
              </Route>
            </Routes>

            <UnsavedChangesNotifier />
            <DocumentTitleHandler />
            <Toaster position='bottom-right' reverseOrder={false} />
            <DevtoolsPanel />
          </Refine>
        </DevtoolsProvider>
      </ThemeProvider>
    </BrowserRouter>
  )
}

export default App



Jetons un coup d’œil de plus près aux changements:

<Route
  path="requests"
  element={
    <PageManagerRequestsList>
      <Outlet />
    </PageManagerRequestsList>
  }
>
  <Route path=":id/edit" element={<PageManagerRequestsTimeOffsEdit />} />
</Route>

Le code ci-dessus met en place une structure de routes imbriquées où un modal est affiché lors de la navigation vers une route enfant spécifique. Le composant <PageManagerRequestsTimeOffsEdit /> est un modal et est rendu en tant qu’enfant du composant <PageManagerRequestsList />. Cette structure nous permet d’afficher le modal au-dessus de la page de liste tout en gardant la page de liste visible en arrière-plan.

Lorsque vous naviguez vers la route /manager/requests/:id/edit ou cliquez sur une demande de congé dans la liste, la page de détails de la demande de congé sera affichée comme un modal au-dessus de la page de liste.

/manager/requests/:id/edit

Étape 6 — Mise en œuvre de l’autorisation et du contrôle d’accès

L’autorisation est un élément critique dans les applications de niveau entreprise, jouant un rôle clé à la fois en matière de sécurité et d’efficacité opérationnelle. Elle garantit que seuls les utilisateurs autorisés peuvent accéder à des ressources spécifiques, protégeant ainsi les données sensibles et les fonctionnalités. Le système d’autorisation de Refine fournit l’infrastructure nécessaire pour protéger vos ressources et garantir que les utilisateurs interagissent avec votre application de manière sécurisée et contrôlée. Dans cette étape, nous mettrons en place l’autorisation et le contrôle d’accès pour la fonction de gestion des demandes d’absence. Nous restreindrons l’accès aux routes /manager/requests et /manager/requests/:id/edit aux seuls managers à l’aide du composant <CanAccess />.

Actuellement, lorsque vous vous connectez en tant qu’employé, vous ne pouvez pas voir le lien de la page Demandes dans la barre latérale, mais vous pouvez toujours accéder à la route /manager/requests en tapant l’URL dans le navigateur. Nous ajouterons une protection pour empêcher l’accès non autorisé à ces routes.

Mettons à jour le fichier App.tsx pour inclure les vérifications d’autorisation:

src/App.tsx
import { Authenticated, CanAccess, ErrorComponent, Refine } from '@refinedev/core'
import { DevtoolsProvider, DevtoolsPanel } from '@refinedev/devtools'
import dataProvider from '@refinedev/nestjsx-crud'
import routerProvider, {
  UnsavedChangesNotifier,
  DocumentTitleHandler,
  NavigateToResource,
} from '@refinedev/react-router-v6'
import { BrowserRouter, Routes, Route, Outlet } from 'react-router-dom'
import { Toaster } from 'react-hot-toast'

import { PageEmployeeTimeOffsList } from '@/pages/employee/time-offs/list'
import { PageEmployeeTimeOffsCreate } from '@/pages/employee/time-offs/create'
import { PageManagerRequestsList } from '@/pages/manager/requests/list'
import { PageManagerRequestsTimeOffsEdit } from '@/pages/manager/requests/time-offs/edit'
import { PageLogin } from '@/pages/login'

import { Layout } from '@/components/layout'

import { ThemeProvider } from '@/providers/theme-provider'
import { authProvider } from '@/providers/auth-provider'
import { accessControlProvider } from '@/providers/access-control'
import { useNotificationProvider } from '@/providers/notification-provider'
import { queryClient } from '@/providers/query-client'

import { BASE_URL } from '@/utilities/constants'
import { axiosInstance } from '@/utilities/axios'

import { RequestsIcon, TimeOffIcon } from '@/icons'

import { Role } from '@/types'

import '@/utilities/init-dayjs'

function App() {
  return (
    <BrowserRouter>
      <ThemeProvider>
        <DevtoolsProvider>
          <Refine
            authProvider={authProvider}
            routerProvider={routerProvider}
            dataProvider={dataProvider(BASE_URL, axiosInstance)}
            notificationProvider={useNotificationProvider}
            resources={[
              {
                name: 'employee',
                meta: {
                  scope: Role.EMPLOYEE,
                  order: 2,
                },
              },
              {
                name: 'manager',
                meta: {
                  scope: Role.MANAGER,
                  order: 1,
                },
              },
              {
                name: 'time-offs',
                list: '/employee/time-offs',
                create: '/employee/time-offs/new',
                meta: {
                  parent: 'employee',
                  scope: Role.EMPLOYEE,
                  label: 'Time Off',
                  icon: <TimeOffIcon />,
                },
              },
              {
                name: 'time-offs',
                list: '/manager/requests',
                edit: '/manager/requests/:id/edit',
                identifier: 'requests',
                meta: {
                  parent: 'manager',
                  scope: Role.MANAGER,
                  label: 'Requests',
                  icon: <RequestsIcon />,
                },
              },
            ]}
            accessControlProvider={accessControlProvider}
            options={{
              reactQuery: {
                clientConfig: queryClient,
              },
              syncWithLocation: true,
              warnWhenUnsavedChanges: true,
              useNewQueryKeys: true,
            }}>
            <Routes>
              <Route
                element={
                  <Authenticated key='authenticated-routes' redirectOnFail='/login'>
                    <Outlet />
                  </Authenticated>
                }>
                <Route index element={<NavigateToResource resource='time-offs' />} />

                <Route
                  path='employee'
                  element={
                    <ThemeProvider role={Role.EMPLOYEE}>
                      <Layout>
                        <Outlet />
                      </Layout>
                    </ThemeProvider>
                  }>
                  <Route path='time-offs' element={<Outlet />}>
                    <Route index element={<PageEmployeeTimeOffsList />} />
                    <Route path='new' element={<PageEmployeeTimeOffsCreate />} />
                  </Route>
                </Route>
              </Route>

              <Route
                path='manager'
                element={
                  <ThemeProvider role={Role.MANAGER}>
                    <Layout>
                      <CanAccess action='manager' fallback={<NavigateToResource resource='time-offs' />}>
                        <Outlet />
                      </CanAccess>
                    </Layout>
                  </ThemeProvider>
                }>
                <Route
                  path='requests'
                  element={
                    <PageManagerRequestsList>
                      <Outlet />
                    </PageManagerRequestsList>
                  }>
                  <Route path=':id/edit' element={<PageManagerRequestsTimeOffsEdit />} />
                </Route>
              </Route>

              <Route
                element={
                  <Authenticated key='auth-pages' fallback={<Outlet />}>
                    <NavigateToResource resource='time-offs' />
                  </Authenticated>
                }>
                <Route path='/login' element={<PageLogin />} />
              </Route>

              <Route
                element={
                  <Authenticated key='catch-all'>
                    <Layout>
                      <Outlet />
                    </Layout>
                  </Authenticated>
                }>
                <Route path='*' element={<ErrorComponent />} />
              </Route>
            </Routes>

            <UnsavedChangesNotifier />
            <DocumentTitleHandler />
            <Toaster position='bottom-right' reverseOrder={false} />
            <DevtoolsPanel />
          </Refine>
        </DevtoolsProvider>
      </ThemeProvider>
    </BrowserRouter>
  )
}

export default App

Dans le code ci-dessus, nous avons ajouté le composant <CanAccess /> à la route « /manager ». Ce composant vérifie si l’utilisateur a le rôle de « manager » avant de rendre les routes enfants. Si l’utilisateur n’a pas le rôle de « manager », il sera redirigé vers la page de liste des congés pour les employés.

Maintenant, lorsque vous vous connectez en tant qu’employé et que vous essayez d’accéder à la route /manager/requests, vous serez redirigé vers la page de liste des congés pour les employés.

Étape 7 — Déploiement sur la plateforme d’application DigitalOcean

Dans cette étape, nous allons déployer l’application sur la plateforme d’application DigitalOcean. Pour ce faire, nous hébergerons le code source sur GitHub et connecterons le dépôt GitHub à la plateforme d’application.

Pousser le code sur GitHub

Connectez-vous à votre compte GitHub et créez un nouveau dépôt nommé refine-hr. Vous pouvez rendre le dépôt public ou privé :

Après avoir créé le dépôt, naviguez dans le répertoire du projet et exécutez la commande suivante pour initialiser un nouveau dépôt Git :

git init

Ensuite, ajoutez tous les fichiers au dépôt Git avec cette commande :

git add .

Puis, validez les fichiers avec cette commande :

git commit -m "Initial commit"

Ensuite, ajoutez le dépôt GitHub comme dépôt distant avec cette commande :

git remote add origin <your-github-repository-url>

Ensuite, spécifiez que vous souhaitez pousser votre code vers la branche main avec cette commande :

git branch -M main

Enfin, poussez le code vers le dépôt GitHub avec cette commande :

git push -u origin main

Lorsque vous y êtes invité, entrez vos identifiants GitHub pour pousser votre code.

Vous recevrez un message de succès après que le code a été poussé vers le dépôt GitHub.

Dans cette section, vous avez poussé votre projet sur GitHub afin de pouvoir y accéder en utilisant les Applications DigitalOcean. L’étape suivante consiste à créer une nouvelle Application DigitalOcean en utilisant votre projet et à configurer le déploiement automatique.

Déploiement sur la plateforme d’applications DigitalOcean

Dans ce processus, vous prendriez une application React et la prépareriez pour le déploiement via la plateforme d’applications de DigitalOcean. Vous lieriez votre dépôt GitHub à DigitalOcean, configureriez comment l’application sera construite, puis créeriez un déploiement initial d’un projet. Après le déploiement du projet, les modifications supplémentaires que vous apportez seront automatiquement reconstruites et mises à jour.

À la fin de cette étape, vous aurez votre application déployée sur DigitalOcean avec une livraison continue prévue.

Connectez-vous à votre compte DigitalOcean et accédez à la page Applications. Cliquez sur le bouton Créer une application :

Si vous n’avez pas connecté votre compte GitHub à DigitalOcean, vous serez invité à le faire. Cliquez sur le bouton Connecter à GitHub. Une nouvelle fenêtre s’ouvrira, vous demandant d’autoriser DigitalOcean à accéder à votre compte GitHub.

Après avoir autorisé DigitalOcean, vous serez redirigé vers la page des applications DigitalOcean. La prochaine étape consiste à sélectionner votre dépôt GitHub. Après avoir sélectionné votre dépôt, vous serez invité à choisir une branche à déployer. Sélectionnez la branche main et cliquez sur le bouton Suivant.

Ensuite, vous verrez les étapes de configuration de votre application. Dans ce tutoriel, vous pouvez cliquer sur le bouton Suivant pour passer les étapes de configuration. Cependant, vous pouvez également configurer votre application comme vous le souhaitez.

Attendez que la construction soit terminée. Après la fin de la construction, appuyez sur Application en direct pour accéder à votre projet dans le navigateur. Il sera identique au projet testé localement, mais il sera accessible sur le web avec une URL sécurisée. De plus, vous pouvez suivre ce tutoriel disponible sur le site de la communauté DigitalOcean pour apprendre comment déployer des applications basées sur React sur la plateforme d’application.

Remarque : En cas d’échec du déploiement de la construction, vous pouvez configurer votre commande de construction sur DigitalOcean pour utiliser npm install --production=false && npm run build && npm prune --production au lieu de npm run build

Conclusion

Dans ce tutoriel, nous avons construit une application de gestion des ressources humaines en utilisant Refine depuis le début et nous nous sommes familiarisés avec la façon de créer une application CRUD entièrement fonctionnelle.

De plus, nous allons démontrer comment déployer votre application sur la DigitalOcean App Platform.

Si vous souhaitez en savoir plus sur Refine, vous pouvez consulter la documentation et si vous avez des questions ou des retours, vous pouvez rejoindre le serveur Discord de Refine.

Source:
https://www.digitalocean.com/community/developer-center/building-and-deploying-an-hr-app-using-refine