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:
Time Off
and Requests
pages, while employees only have access to the Time Off
page.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:
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.
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.
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.
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.
index.ts
: App types.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.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.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.
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
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.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.<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.
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>
<Layout />
ComponentIn 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.
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.
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.
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:
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.
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).<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.
Right now, “/” page is doing nothing. In the next steps we will implement the Time Off
and Requests
pages.
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.
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.
<TimeOffList />
component to show time off historyCreate 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:
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.
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:
const { data: employee } = useGetIdentity<Employee>();
useGetIdentity<Employee>()
: Fetches the current user’s information.
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.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.
<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”).type
Filters:
const filters: Record<Props["type"], CrudFilters> = {
history: [
{
field: "status",
operator: "eq",
value: TimeOffStatus.APPROVED,
},
{
field: "endsAt",
operator: "lt",
value: today,
},
],
// ... other types
};
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
};
history
: Sorts by start date in descending order.<TimeOffLeaveCards />
component to display statistics of used time offsCreate a new file called leave-cards.tsx
in the src/components/time-offs
folder and add the following code:
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>
);
};
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:
useGetIdentity
to get the current employee’s information, like available annual leave days.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.loading
prop is passed to the cards to manage this state.type
, value
, and loading
as props.variantMap
to get the correct labels, colors, and icons based on the leave type.<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:
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.
Let’s break down the key parts of the component:
useCan
hook to determine if the current user is a manager.isManager
to true
if the user has manager permissions.<ThemeProvider />
.<PageHeader />
with the title “Time Off”.<CreateButton />
that changes based on the user’s role:
<CanAccess />
component, which checks permissions.<TimeOffLeaveCards />
component to show leave balances and usage.<Grid />
layout to organize the content.md={6}
), it displays:
TimeOffList
with type="inReview"
: Shows pending time-off requests.TimeOffList
with type="upcoming"
: Shows upcoming approved time offs.md={6}
), it displays:
TimeOffList
with type="history"
: Shows past time offs that have already occurred.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:
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:
{
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./
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.
<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.
/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.
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.
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:
<TimeOffFormSummary />
ComponentCreate a new file called form-summary.tsx
in the src/components/time-offs/
folder and add the following code:
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>
);
};
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.
<PageEmployeeTimeOffsCreate />
ComponentCreate a new file called create.tsx
in the src/pages/employee/time-offs/
folder and add the following code:
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>
);
};
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:
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.
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.
<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.
We need to add the new route for the create time off page. Let’s update the App.tsx
file to include this route:
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.
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.
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:
<RequestsList />
ComponentCreate a new file called list.tsx
in the src/components/requests/
folder and add the following code:
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.
<RequestsListItem />
ComponentCreate a new file called list-item.tsx
in the src/components/requests/
folder and add the following code:
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.
<PageManagerRequestsList />
ComponentCreate 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.
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:
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
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.
<TimeOffRequestModal />
ComponentFirst, create a file called use-get-employee-time-off-usage
in the src/hooks/
folder and add the following code:
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:
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:
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.
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.
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.
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:
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:
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.
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:
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.
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.
Log in to your GitHub account and create a new repository named refine-hr
. You can make the repository public or private:
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.
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:
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.
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
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.
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!