Developer Center

Building and Deploying an HR App Using Refine

Published on November 25, 2024
authorauthor

Alican Erdurmaz and Oliver Mensah

Building and Deploying an HR App Using Refine

Introduction

In this tutorial, we will build an HR Management application with Refine Framework and deploy it to the DigitalOcean App Platform.

At the end of this tutorial, we’ll have a HR management application that includes:

  • Login page: Allows users to log in as either a manager or an employee. Managers have access to the Time Off and Requests pages, while employees only have access to the Time Off page.
  • Time Off pages: Allows employees to request, view, and cancel their time off. Also managers can assign new time offs.
  • Requests page: Accessible only to HR managers for approving or rejecting time off requests.

Note: You can get the complete source code of the app we’ll build in this tutorial from this GitHub repository

While doing these, we’ll use the:

  • Rest API: To fetch and update the data. Refine has built-in data provider packages and REST APIs, but you can also build your own to suit your specific requirements. In this guide, we’re going to use NestJs CRUD as our backend service and the @refinedev/nestjsx-crud package as our data provider.
  • Material UI: We will use it for UI components and fully customize it according to our own design. Refine has built-in support for Material UI, but you can use any UI library you like.

Once we’ve build the app, we’ll put it online using DigitalOcean’s App Platform which makes it easy to set up, launch, and grow apps and static websites. You can deploy code by simply pointing to a GitHub repository and let the App Platform do the heavy lifting of managing the infrastructure, app runtimes, and dependencies.

Prerequisites

What is Refine?

Refine is an open source React meta-framework for building complex B2B web applications, mainly data management focused use cases like internal tools, admin panels, and dashboards. It’s designed by providing a set of hooks and components to improve the development process with a better workflow for the developer.

It provides feature-complete, production-ready features for enterprise-level apps to simplify paid tasks like state and data management, authentication, and access control. This enables developers to remain focused on the core of their application in a way that is abstracted from many overwhelming implementation details.

Step 1 — Setting Up the Project

We’ll use the npm create refine-app command to interactively initialize the project.

npm create refine-app@latest

Select the following options when prompted:

✔ 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

Once the setup is complete, navigate to the project folder and start your app with:

npm run dev

Open http://localhost:5173 in your browser to see the app.

Welcome Page

Preparing the Project

Now that we have our project set up, let’s make some changes to the project structure and remove the unnecessary files.

First, install the 3rd party dependencies:

  • @mui/x-date-pickers, @mui/x-date-pickers-pro: These are date picker components for Material UI. We will use them to select the date range for the time off requests.
  • react-hot-toast: A minimalistic toast library for React. We will use it to show success and error messages.
  • react-infinite-scroll-component: A React component to make infinite scroll easy. We will use it to load more time off requests as the user scrolls down the page to view more requests.
  • dayjs: A lightweight date library for parsing, validating, manipulating, and formatting dates.
  • vite-tsconfig-paths: A Vite plugin that allows you to use TypeScript path aliases in your Vite project.
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

After installing dependencies, update vite.config.ts and tsconfig.json to use the vite-tsconfig-paths plugin. This enables TypeScript path aliases in Vite projects, allowing imports with the @ 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" }]
}

Next, let’s remove the unnecessary files and folders:

  • src/contexts: This folder contains single file which is ColorModeContext. It’s handles dark/light mode for the app. We won’t be using it in this tutorial.
  • src/components: This folder contains the <Header /> component. We will use a custom header component in this tutorial.
rm -rf src/contexts src/components

After removing the files and folders, App.tsx gives an error which we will fix in the next steps. Throughout the tutorial, we’ll cover coding the core pages and components. So, get the necessary files and folders from the GitHub repository. With these files, we will have a basic structure for our HR Management application.

  • icons: Icons folder containing all app icons.
  • types:
  • utilities:
    • constants.ts: App constants.
    • axios.ts: Axios instance for API requests, handling access tokens, refresh tokens, and errors.
    • init-dayjs.ts: Initializes Day.js with required plugins.
  • providers:
    • access-control: Manages user permissions using accessControlProvider; controls visibility of the Requests page based on user role.
    • auth-provider: Manages authentication with authProvider; ensures all pages are protected and require login.
    • notification-provider: Displays success and error messages via react-hot-toast.
    • query-client: Custom query client for full control and customization.
    • theme-provider: Manages the Material UI theme.
  • components:
    • layout: Layout components.
    • loading-overlay: Shows a loading overlay during data fetches.
    • input: Renders form input fields.
    • frame: Custom component adding borders, titles, and icons to page sections.
    • modal: Custom modal dialog component.

After copying the files and folders, file structure should look like this:

└── 📁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

Next, update the App.tsx file to include the necessary providers and components.

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


home page

Let’s break down the important changes we made to the App.tsx file:

  • <Refine />: The core component from @refinedev/core that wraps the entire application to provide data fetching, state management, and other features.
  • <DevtoolsProvider /> and <DevtoolsPanel />: Used for debugging and development purposes.
  • <ThemeProvider />: Applies custom theming across the app.
  • Initializing Day.js: For date and time manipulation.
  • resources: An array specifying the data entities (employee and manager) that Refine will fetch. We use parent and child resources to organize data and manage permissions. Each resource has a scope defining the user role, which controls access to different parts of the app.
  • queryClient: A custom query client for full control and customization of the data fetching.
  • syncWithLocation: Enables syncing the app state(filters, sorters, pagination etc.) with the URL.
  • warnWhenUnsavedChanges: Shows a warning when the user tries to navigate away from a page with unsaved changes.
  • <Layout />: A custom layout component that wraps the app content. It contains the header, sidebar, and main content area. We will explain this component in the next steps.

Now, we are ready to start building the HR Management application.


Step 2— Customization and styling

Take a closer look at the theme-provider. We’ve heavily customized the Material UI theme to match the HR Management app’s design, creating two themes one for managers and one for employees to differentiate them with different colors.

Also, we’ve added Inter as a custom font for the app. To install you need to add the following line to the index.html file:

<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>

Inspecting Custom <Layout /> Component

In the previous step we added a custom layout component to the app. Normally, we could use the default layout of the UI framework but we want to show how you can make customization.

The layout component contains the header, sidebar, and main content area. It uses <ThemedLayoutV2 /> as a base and customized it to match the HR Management app’s design.

<Sider />

The sidebar contains the app logo and navigation links. On the mobile devices It’s a collapsible sidebar that opens when the user clicks the menu icon. Navigation links prepared with useMenu hook from Refine and rendered based on the user’s role with help of <CanAccess /> component.

<UserSelect />

Mounted on the sidebar, shows the logged in user’s avatar and name. When clicked, it opens a popover with user details and a logout button. Users can switch between different roles by selecting from the dropdown. This component allows testing by switching between users with different roles.

<Header />

It renders nothing on desktop devices. On mobile devices, it shows the app logo and a menu icon to open the sidebar. The header is sticky and always visible at the top of the page.

<PageHeader />

It shows the page title and navigation buttons.Page title automatically generated with useResource hook, which fetches the resource name from the Refine context. It allow us the share the same styling and layout across the app.

Step 3 — Implementing the Authentication & Authorization

In this step, we will implement the authentication and authorization logic for our HR Management application. This will serve as a great example of access control in enterprise applications.

When users log in as a manager, they will be able to see the Time Off and Requests pages. If they log in as an employee, they will only see the Time Off page. Managers can approve or decline time off requests on the Requests page.

Employees can request time off and view their history on the Time Off page. To implement this, we will authProvider and accessControlProvider features of Refine.

Authentication

In Refine, authentication is handled by the authProvider. It allows you to define the authentication logic for your app. In the previous step, we already copied the authProvider from the GitHub repository and give it to the <Refine /> component as a prop. We will use following hooks and components to control behaviour of our app based on user is logged in or not.

  • useLogin: A hook that provides a mutate function to log in the user.
  • useLogout: A hook that provides a mutate function to log out the user.
  • useIsAuthenticated: A hook that returns a boolean indicating whether the user is authenticated.
  • <Authenticated />: A component that renders its children only if the user is authenticated.

Authorization

In Refine, authorization is handled by the accessControlProvider. It allows you to define user roles and permissions, and control access to different parts of the app based on the user’s role. In the previous step, we already copied the accessControlProvider from the GitHub repository and give it to the <Refine /> component as a prop. Let’s take a closer look at the accessControlProvider to see how it works.

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;
    // if the resource does not have a scope, it is not 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,
      };
    }

    // users can only access resources if their role matches the resource scope
    return {
      can: user.role === scope,
    };
  },
};


In our app, we have two roles: MANAGER and EMPLOYEE.

Managers have access to the Requests page, while employees only have access to the Time Off page. The accessControlProvider checks the user’s role and the resource scope to determine if the user can access the resource. If the user’s role matches the resource scope, they can access the resource. Otherwise, they are denied access. We will use useCan hook and <CanAccess /> component to control behaviour of our app based on user’s role.

Setting Up the Login Page

In the previous step, we added the authProvider to the <Refine /> component. The authProvider is responsible for handling authentication.

First, we need to get images. We will use these images as background images for the login page. Create a new folder called images in the public folder and get the images from the GitHub repository.

After getting the images, let’s create a new file called index.tsx in the src/pages/login folder and add the following code:

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: "michael.scott@dundermifflin.com",
      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: "jim.halpert@dundermifflin.com",
    },
    {
      avatarUrl:
        "https://refine-hr-example.s3.eu-west-1.amazonaws.com/avatars/Toby-Flenderson.png",
      firstName: "Toby",
      lastName: "Flenderson",
      email: "toby.flenderson@dundermifflin.com",
    },
  ],
  employees: [
    {
      avatarUrl:
        "https://refine-hr-example.s3.eu-west-1.amazonaws.com/avatars/Pam-Beesly.png",
      firstName: "Pam",
      lastName: "Beesly",
      email: "pam.beesly@dundermifflin.com",
    },
    {
      avatarUrl:
        "https://refine-hr-example.s3.eu-west-1.amazonaws.com/avatars/Andy-Bernard.png",
      firstName: "Andy",
      lastName: "Bernard",
      email: "andy.bernard@dundermifflin.com",
    },
    {
      avatarUrl:
        "https://refine-hr-example.s3.eu-west-1.amazonaws.com/avatars/Ryan-Howard.png",
      firstName: "Ryan",
      lastName: "Howard",
      email: "ryan.howard@dundermifflin.com",
    },
  ],
};

To simplify the authentication process, we’ve created a mockUsers object with two arrays: managers and employees. Each array contains predefined user objects. When a user selects an email from the dropdown and clicks the Sign in button, the login function is called with the selected email. The login function is a mutation function provided by the useLogin hook from Refine. It’s calls authProvider.login with the selected email.

Next, let’s import the <PageLogin /> component and update the App.tsx file with highlighted changes.

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;

In the updated App.tsx file, we’ve added the <Authenticated /> component from Refine. This component is used to protect routes that require authentication. It takes a key prop to uniquely identify the component, a fallback prop to render when the user is not authenticated, and a redirectOnFail prop to redirect the user to the specified route when authentication fails. Under the hood it calls authProvider.check method to check if the user is authenticated.

Let’s closer look what we’ve on key="auth-pages"

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

<Authenticated /> component wraps to “/login” route to check the user’s authentication status.

  • fallback={<Outlet />}: If the user is not authenticated, render the nested route (i.e., show the <PageLogin /> component).
  • Children (<Navigate to="/" />): If the user is authenticated, redirect them to the home page (/).

Let’s closer look what we’ve on key="catch-all"

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

<Authenticated /> component wraps to path="*" route to check the user’s authentication status. This route is a catch-all route that renders the <ErrorComponent /> when the user is authenticated. It allows us to show a 404 page when the user tries to access a non-existent route.

Now, when you run the app and navigate to http://localhost:5173/login, you should see the login page with the dropdown to select the user.

login

Right now, “/” page is doing nothing. In the next steps we will implement the Time Off and Requests pages.

Step 4 — Building a Time Off Page

Building Time Off List Page

In this step, we’ll build the Time Off page. Employees can request time off and see their time off history. Managers can also view their history, but instead of requesting time off, they can assign it to themselves directly. We’ll make this work using Refine’s accessControlProvider, the <CanAccess /> component, and the useCan hook.

time offs list page
<PageEmployeeTimeOffsList />

Before we start building the time off page, we need to create couple of components to show time off history, upcoming time off requests and, statistics of used time offs. At the end of this step, we will use these components to build the time off page.

Building <TimeOffList /> component to show time off history

Create a new folder called time-offs in the src/components folder. Inside the time-offs folder, create a new file called list.tsx and add the following code:

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" }],
};


The list.tsx file is lengthy, but most of it deals with styling and UI presentation.

time offs list
<TimeOffList />

We’ll use this <TimeOffList /> component in three different contexts:

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

The type prop determines which kind of time-off list to display:

  • inReview: Shows time-off requests that are pending approval.
  • upcoming: Displays upcoming time-offs that have been approved but not yet occurred.
  • history: Lists time-offs that have been approved and have already taken place.

Inside the component, we will create filters and sorters based on the type prop. We will use these filters and sorters to fetch the time off data from the API.

Let’s break down the key parts of the component:

1. Getting the Current User
const { data: employee } = useGetIdentity<Employee>();
  • useGetIdentity<Employee>(): Fetches the current user’s information.
    • We use the employee’s ID to filter time-offs so each user sees only their requests.
2. Fetching Time-Off Data with Infinite Scrolling
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}
  // ... other props
>
  {/* Render the list items here */}
</InfiniteScroll>;
  • useInfiniteList<TimeOff>(): Fetches time-off data with infinite scrolling.

    • resource: Specifies the API endpoint.
    • sorters and filters: Adjusted based on type to fetch the right data.
    • employeeId filter: Ensures only the current user’s time-offs are fetched.
    • queryOptions.enabled: Runs the query only when the employee data is available.
  • <InfiniteScroll />: Allows loading more data as the user scrolls down.

    • next: Function to fetch the next page of data.
    • hasMore: Indicates if more data is available.
3. Canceling a Time-Off Request
const { mutateAsync: timeOffCancel } = useDelete<TimeOff>();

// Inside the ListItem component
await timeOffCancel({
  resource: "time-offs",
  id: timeOff.id,
  invalidates: ["all"],
  successNotification: () => ({
    type: "success",
    message: "Time off request cancelled successfully.",
  }),
});
  • useDelete: Provides the timeOffCancel function to delete a time-off request.
    • Used when a user cancels their time-off.
    • Shows a success message upon completion.
4. Displaying Dates with <DateField />
<DateField
  value={timeOff.startsAt}
  color="text.secondary"
  variant="caption"
  format="MMMM DD"
/>
  • <DateField />: Formats and displays dates in a user-friendly way.
    • value: The date to display.
    • format: Specifies the date format (e.g., “January 05”).
5. Creating Filters and Sorters based on type

Filters:

const filters: Record<Props["type"], CrudFilters> = {
  history: [
    {
      field: "status",
      operator: "eq",
      value: TimeOffStatus.APPROVED,
    },
    {
      field: "endsAt",
      operator: "lt",
      value: today,
    },
  ],
  // ... other types
};
  • Defines criteria for fetching time-offs based on status and dates.
    • history: Fetches approved time-offs that have already ended.
    • upcoming: Fetches approved time-offs that are upcoming.

Sorters:

const sorters: Record<Props["type"], CrudSort[]> = {
  history: [{ field: "startsAt", order: "desc" }],
  // ... other types
};
  • Determines the order of fetched data.
    • history: Sorts by start date in descending order.

Building <TimeOffLeaveCards /> component to display statistics of used time offs

Create a new file called leave-cards.tsx in the src/components/time-offs folder and add the following code:

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",
      // we only need total number of sick leaves, so we can set pageSize to 1 to reduce the load
      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",
      // we only need total number of sick leaves, so we can set pageSize to 1 to reduce the load
      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>
  );
};


used time-offs stats
<TimeOffLeaveCards />

The <TimeOffLeaveCards /> component displays statistics about an employee’s time off. It shows three cards for Annual Leave, Sick Leave, and Casual Leave, indicating how many days are available or used.

Let’s break down the key parts of the component:

1. Fetching Data
  • Employee Data: Uses useGetIdentity to get the current employee’s information, like available annual leave days.
  • Time Off Counts: Uses useList to fetch the total number of sick and casual leave days used by the employee. It sets pageSize to 1 because we only need the total count, not all the details.
2. Displaying the Cards
  • The component renders three card components, one for each leave type.
  • Each card shows:
    • The type of leave (e.g., Annual Leave).
    • The number of days available or used.
    • An icon representing the leave type.
3. Handling Loading States
  • If data is still loading, it shows a skeleton placeholder instead of the actual numbers.
  • The loading prop is passed to the cards to manage this state.
4. The Card Component
  • Receives type, value, and loading as props.
  • Uses a variantMap to get the correct labels, colors, and icons based on the leave type.
  • Displays the leave information with appropriate styling.

Building <PageEmployeeTimeOffsList />

Now that we have the components for listing time offs and showing leave cards, let’s create the new file in the src/pages/employee/time-offs/ folder called list.tsx and add the following code:

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 /> is the main component for the time off page, we will use this component to display the time off lists and leave cards when users navigate to the /employee/time-offs route.

time offs list page
<PageEmployeeTimeOffsList />

Let’s break down the key parts of the component:

1. Checking User Roles
  • Uses the useCan hook to determine if the current user is a manager.
  • Sets isManager to true if the user has manager permissions.
2. Applying Theme Based on Role
  • Wraps the content inside a <ThemeProvider />.
  • The theme changes based on whether the user is a manager or an employee.
3. Page Header with Conditional Button
  • Renders a <PageHeader /> with the title “Time Off”.
  • Includes a <CreateButton /> that changes based on the user’s role:
    • If the user is a manager, the button says “Assign Time Off”.
    • If the user is not a manager, it says “Request Time Off”.
  • This is handled using the <CanAccess /> component, which checks permissions.
4. Displaying Leave Statistics
  • Includes the <TimeOffLeaveCards /> component to show leave balances and usage.
  • This provides a summary of annual, sick, and casual leave.
5. Listing Time Off Requests
  • Uses a <Grid /> layout to organize the content.
  • On the left side (md={6}), it displays:
    • TimeOffList with type="inReview": Shows pending time-off requests.
    • TimeOffList with type="upcoming": Shows upcoming approved time offs.
  • On the right side (md={6}), it displays:
    • TimeOffList with type="history": Shows past time offs that have already occurred.

Adding the “/employee/time-offs” Route

We are ready to render the <PageEmployeeTimeOffsList /> component on the /employee/time-offs route. Let’s update the App.tsx file to include this 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

Let’s break down the key parts of the updated App.tsx file:

1. Defining the Time Off Resource
{
  name: 'time-offs',
  list: '/employee/time-offs',
  meta: {
    parent: 'employee',
    scope: Role.EMPLOYEE,
    label: 'Time Off',
    icon: <TimeOffIcon />,
  },
}

We added a new resource for time-offs as a child of the employee resource. This indicates that time-offs are related to employees and are accessible by employees.

  • name: 'time-offs': This is the identifier for the resource, used internally by Refine.
  • list: '/employee/time-offs': Specifies the route that displays the list view of the resource.
  • meta: An object containing additional metadata about the resource.
    • parent: 'employee': Groups this resource under the employee scope, which can be used for organizing resources in the UI (like in a sidebar menu) or for access control.
    • scope: Role.EMPLOYEE: Indicates that this resource is accessible to users with the EMPLOYEE role. We this in the accessControlProvider to manage permissions.
    • label: 'Time Off': The display name for the resource in the UI.
    • icon: <TimeOffIcon />: Associates the TimeOffIcon with this resource for visual identification.
2. Redirecting to the “time-offs” resource when users navigate to the / route
<Route index element={<NavigateToResource resource="time-offs" />} />

We use the <NavigateToResource /> component to redirect users to the time-offs resource when they navigate to the / route. This ensures that users see the time-off list by default.

3. Redirecting to the “time-offs” resource when users are authenticated
<Route
  element={
    <Authenticated key="auth-pages" fallback={<Outlet />}>
      <NavigateToResource resource="time-offs" />
    </Authenticated>
  }
>
  <Route path="/login" element={<PageLogin />} />
</Route>

When users are authenticated, we redirect them to the time-offs resource. If they are not authenticated, they see the login page.

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

We organize employee pages using nested routes. First, we create a main route with path='employee' that wraps content in an employee-specific theme and layout. Inside this route, we add path='time-offs', which displays the PageEmployeeTimeOffsList component. This structure groups all employee features under one path and keeps the styling consistent.

After adding these changes, you can navigate to the /employee/time-offs route to see the time offs list page in action.

time offs page
/employee/time-offs

Right now, the time offs list page is functional, but it lacks the ability to create new time off requests. Let’s add the ability to create new time off requests.

Building Time Off Create Page

We will create a new page for requesting or assigning time off. This page will include a form where users can specify the type of time off, start and end dates, and any additional notes.

Before we start, we need to create new components to use in the form:

Building <TimeOffFormSummary /> Component

Create a new file called form-summary.tsx in the src/components/time-offs/ folder and add the following code:

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>
  );
};

requested time off summary
<TimeOffFormSummary />

The <TimeOffFormSummary /> component displays a summary of the time off request. It shows the available annual leave days, the number of days requested, and the remaining days. We will use this component in the time off form to provide users with a clear overview of their request.

Building <PageEmployeeTimeOffsCreate /> Component

Create a new file called create.tsx in the src/pages/employee/time-offs/ folder and add the following code:

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>
  );
};

request time offs
<PageEmployeeTimeOffsCreate />

The <PageEmployeeTimeOffsCreate /> component displays a form for creating new time-off requests in an HR management app. Both employees and managers can use it to request or assign time off. The form includes options to select the type of time off, pick start and end dates, add notes, and it shows a summary of the requested time off.

Let’s break down the key parts of the component:

1. Checking User Role

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

With the useCan hook, we check if the current user has manager permissions. This determines whether the user can assign time off or only request it. We will handle the form submission differently on onFinishHandler based on the user’s role.

2. Form State and Submission


 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 initializes the form with default values and sets success notifications based on the user’s role. The onFinishHandler function processes the form data before submitting it. For managers, the status is set to APPROVED immediately, while employees’ requests are submitted for review.

3. Styling

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

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

In our design, the primary color changes based on the user’s role. We use the <ThemeProvider /> to apply the correct theme accordingly. The submit button’s text and icon also change depending on whether the user is a manager or an employee.

4. Adding the “/employee/time-offs/create” Route

We need to add the new route for the create time off page. Let’s update the App.tsx file to include this 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


After adding these changes, you can navigate to the /employee/time-offs/create route or click the “Assign Time Off” button on the time offs list page to access the create time off form.

create time off page
/employee/time-offs/create

Step 5 — Building Time Off Requests Management Page

In this step, we will create a new page to manage time off requests. This page will allow managers to review and approve or reject time off requests submitted by employees.

requests page
/manager/requests

Building Time Off Requests List Page

We will create a new page for managing time off requests. This page will include a list of time off requests, showing details like the employee’s name, the type of time off, the requested dates, and the current status.

Before we start, we need to create new components to use in the list:

Building <RequestsList /> Component

Create a new file called list.tsx in the src/components/requests/ folder and add the following code:

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>
      ))}
    </>
  );
};

The <RequestsList /> component displays a list of time off requests with infinite scrolling. It includes a loading indicator, skeleton placeholders, and a message when there is no data. This component is designed to handle large datasets efficiently and provide a smooth user experience.

Building <RequestsListItem /> Component

Create a new file called list-item.tsx in the src/components/requests/ folder and add the following code:

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>
  );
};

The <RequestsListItem /> component displays a single time off request in the list. It includes the employee’s avatar, name, description, and a button to view the request details. This component is reusable and can be used to render each item in the time off requests list.

Building <PageManagerRequestsList /> Component

Create a new file called list.tsx in the src/pages/manager/requests/ folder and add the following code:

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>
  );
};

The <PageManagerRequestsList /> component displays pending time-off requests that managers need to approve. It shows details like the employee’s name, leave type, requested dates, and how long ago the request was made. Managers can click on a request to see more details. It uses <RequestsList /> and <RequestsListItem /> to render the list.

This component also accepts children as a prop. Next, we’ll implement a modal route using <Outlet /> to display request details, rendering the /manager/requests/:id route inside the component.

Adding the “/manager/requests” Route

We need to add the new route for the time off requests management page. Let’s update the App.tsx file to include this 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 { 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

After adding these changes, you can navigate to the /manager/requests route to see the time off requests management page in action

requests page
/manager/requests

Building Time Off Request Details Page

In this step, we will create a new page to display the details of a time off request. This page will show the employee’s name, the type of time off, the requested dates, and the current status. Managers can approve or reject the request from this page.

Building <TimeOffRequestModal /> Component

First, create a file called use-get-employee-time-off-usage in the src/hooks/ folder and add the following code:

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,
  };
};

We will use the useGetEmployeeTimeOffUsage hook to calculate the total number of days an employee has taken for each type of time off. This information will be displayed in the time off request details page.

After that, create a new file called time-off-request-modal.tsx in the src/components/requests/ folder and add the following code:

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>
  );
};

Let’s break down the <TimeOffRequestModal /> component:

1. Fetching Employee Time-Off Usage

The useGetEmployeeTimeOffUsage hook is used to fetch the employee’s time-off usage. This hook calculates the remaining annual leave days and the previously used sick and casual leave days based on the employee’s time-off history.

2. Fetching Overlapping Approved Time-Offs
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,
      },
    ],
  },
];

The useList hook with the above filters fetches all approved time-offs that overlap with the current time-off request. This list is used to display the employees who are out between the requested dates.

3. Handling Time-Off Request Approval/Rejection

The handleSubmit function is called when the manager approves or rejects the time-off request.

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 automatically invalidates the resource cache after resource it’s mutated (time-offs in this case). Since the employee’s time-off usage is calculated based on the time-off history, we also invalidate the employees resource cache to update the employee’s time-off usage.

Adding the “/manager/requests/:id” Route

In this step, we’ll create a new route to display the time-off request details page, where managers can approve or reject requests.

Let’s create a new file called edit.tsx in the src/pages/manager/requests/time-offs/ folder and add the following code:

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",
          },
        });
      }}
    />
  );
};

Now we need to add the new route to render the time-off request details page. Let’s update the App.tsx file to include this 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



Let’s take a closer look at the changes:

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

The code above sets up a nested route structure where a modal is displayed when navigating to a specific child route. The <PageManagerRequestsTimeOffsEdit /> component is a modal and rendered as a child of the <PageManagerRequestsList /> component. This structure allows us to display the modal on top of the list page while keeping the list page visible in the background.

When you navigate to the /manager/requests/:id/edit route or click on a time-off request in the list, the time-off request details page will be displayed as a modal on top of the list page.

requests edit page
/manager/requests/:id/edit

Step 6 — Implementing Authorization and Access Control

Authorization is a critical component in enterprise-level applications, playing a key role in both security and operational efficiency. It ensures that only authorized users can access specific resources, safeguarding sensitive data and functionalities. Refine’s authorization system provides the necessary infrastructure to protect your resources and ensure that users interact with your application in a secure and controlled manner. In this step, we will implement authorization and access control for the time-off requests management feature. We will restrict access to the /manager/requests and /manager/requests/:id/edit routes to managers only with help of <CanAccess /> component.

Right now, when you log in as an employee, you can’t see Requests page link in the sidebar but you can still access the /manager/requests route by typing the URL in the browser. We will add a guard to prevent unauthorized access to these routes.

Let’s update the App.tsx file to include the authorization checks:

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

In the code above, we added the <CanAccess /> component to the “/manager” route. This component checks if the user has the “manager” role before rendering the child routes. If the user doesn’t have the “manager” role, they will be redirected to the time-off list page for employees.

Now, when you log in as an employee and try to access the /manager/requests route, you will be redirected to the time-off list page for employees.

Step 7 — Deploying to DigitalOcean App platform

In this step, we’ll deploy the application to the DigitalOcean App Platform. To do that, we’ll host the source code on GitHub and connect the GitHub repository to the App Platform.

Pushing the Code to GitHub

Log in to your GitHub account and create a new repository named refine-hr. You can make the repository public or private:

Create an new repository

After creating the repository, navigate to the project directory and run the following command to initialize a new Git repository:

git init

Next, add all the files to the Git repository with this command:

git add .

Then, commit the files with this command:

git commit -m "Initial commit"

Next, add the GitHub repository as a remote repository with this command:

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

Next, specify that you want to push your code to the main branch with this command:

git branch -M main

Finally, push the code to the GitHub repository with this command:

git push -u origin main

When prompted, enter your GitHub credentials to push your code.

You’ll receive a success message after the code is pushed to the GitHub repository.

In this section, you pushed your project to GitHub so that you can access it using DigitalOcean Apps. The next step is to create a new DigitalOcean App using your project and set up automatic deployment.

Deploying to DigitalOcean App Platform

During this, you would take a React application and prepare it for deployment via DigitalOcean’s App Platform. You would link your GitHub repository to DigitalOcean, configure how the app will build, and then create an initial deployment of a project. After the project is deployed, additional changes you make will be automatically rebuilt and updated.

By the end of this step, you will have your application deployed on DigitalOcean with continuous delivery catered for.

Log in to your DigitalOcean account and navigate to the Apps page. Click the Create App button:

Digital Ocean create a new app

If you haven’t connected your GitHub account to DigitalOcean, you’ll be prompted to do so. Click the Connect to GitHub button. A new window will open, asking you to authorize DigitalOcean to access your GitHub account.

After you authorize DigitalOcean, you’ll be redirected back to the DigitalOcean Apps page. The next step is to select your GitHub repository. After you select your repository, you’ll be prompted to select a branch to deploy. Select the main branch and click the Next button.

DigitalOcean select repository

After that, you’ll see the configuration steps for your application. In this tutorial, you can click the Next button to skip the configuration steps. However, you can also configure your application as you wish.

Wait for the build to complete. After the build is complete, press Live App to access your project in the browser. It will be the same as the project you tested locally, but this will be live on the web with a secure URL. Also, you can follow this tutorial available on DigitalOcean community site to learn how to deploy react based applications to App Platform.

Note: In case you build fails to deploy successfully, you can configure your build command on DigitalOcean to use npm install --production=false && npm run build && npm prune --production instead of npm run build

DigitalOcean live preview

Conclusion

In this tutorial, we built a HR Management application using Refine from scratch and got familiar with how to build a fully-functional CRUD app.

Also, we’ll demonstrate how to deploy your application to the DigitalOcean App Platform.

If you want to learn more about Refine, you can check out the documentation and if you have any questions or feedback, you can join the Refine Discord Server.

Thanks for learning with the DigitalOcean Community. Check out our offerings for compute, storage, networking, and managed databases.

Learn more about our products

About the authors
Default avatar
Alican Erdurmaz

author


Default avatar
Oliver Mensah

author


Still looking for an answer?

Ask a questionSearch for more help

Was this helpful?
 
Leave a comment


This textbox defaults to using Markdown to format your answer.

You can type !ref in this text area to quickly search our full set of tutorials, documentation & marketplace offerings and insert the link!

Try DigitalOcean for free

Click below to sign up and get $200 of credit to try our products over 60 days!

Sign up

Join the Tech Talk
Success! Thank you! Please check your email for further details.

Please complete your information!

Become a contributor for community

Get paid to write technical tutorials and select a tech-focused charity to receive a matching donation.

DigitalOcean Documentation

Full documentation for every DigitalOcean product.

Resources for startups and SMBs

The Wave has everything you need to know about building a business, from raising funding to marketing your product.

Get our newsletter

Stay up to date by signing up for DigitalOcean’s Infrastructure as a Newsletter.

New accounts only. By submitting your email you agree to our Privacy Policy

The developer cloud

Scale up as you grow — whether you're running one virtual machine or ten thousand.

Get started for free

Sign up and get $200 in credit for your first 60 days with DigitalOcean.*

*This promotional offer applies to new accounts only.