介紹
在本教程中,我們將使用 Refine Framework 建立一個人力資源管理應用程式並將其部署到 DigitalOcean App Platform。
在本教程結束時,我們將擁有一個包含以下功能的人力資源管理應用程式:
- 登入頁面:允許用戶以經理或員工身份登入。經理可以訪問
請假
和請求
頁面,而員工僅能訪問請假
頁面。 - 請假頁面:允許員工請求、查看和取消他們的請假。經理也可以分配新的請假。
- 請求頁面:僅限人力資源經理用於批准或拒絕請假請求。
注意:您可以從這個 GitHub 倉庫 獲取我們在本教程中建立的應用程式的完整源代碼。
在進行這些操作時,我們將使用:
- REST API:用於提取和更新數據。Refine 內置了數據提供者包和 REST API,但您也可以根據具體需求構建自己的 API。在本指南中,我們將使用 NestJs CRUD 作為後端服務,並使用 @refinedev/nestjsx-crud 包作為數據提供者。
- Material UI:我們將使用它來構建 UI 組件,並根據我們自己的設計進行全面自定義。Refine 內置支持 Material UI,但您可以使用任何您喜歡的 UI 庫。
一旦我們構建了應用程序,我們將使用 DigitalOcean 的 App Platform 將其上線,該平台使設置、啟動和擴展應用程序及靜態網站變得容易。您只需指向 GitHub 倉庫即可部署代碼,讓 App Platform 來處理基礎設施、應用運行時和依賴項的繁重工作。
先決條件
- 一個本地的 Node.js 開發環境。您可以參考 如何安裝 Node.js 並創建本地開發環境。
- 一些有關 React 和 TypeScript 的基本知識。您可以參考 如何在 React.js 中編碼 和 在 React 中使用 TypeScript 系列。
- 一個 GitHub 帳戶
- 一個 DigitalOcean 帳戶
什麼是 Refine?
Refine 是一個開源的 React 元框架,用於構建複雜的 B2B 網絡應用程序,主要專注於數據管理的用例,如內部工具、管理面板和儀表板。它通過提供一組鉤子和組件來改善開發流程,為開發者提供更好的工作流程。
它為企業級應用提供功能完善、準備生產的特性,以簡化狀態和數據管理、身份驗證和訪問控制等付費任務。這使得開發者能夠專注於應用的核心,以一種抽象的方式來減少許多繁瑣的實現細節。
步驟 1 — 設置項目
使用npm create refine-app
命令互動式地初始化項目。
npm create refine-app@latest
在提示時選擇以下選項:
✔ 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
設置完成後,轉到項目文件夾並啟動應用程序:
npm run dev
在瀏覽器中打開http://localhost:5173
以查看應用程序。
準備項目
現在我們已經設置了項目,讓我們對項目結構進行一些更改並刪除不必要的文件。
首先,安裝第三方依賴項:
@mui/x-date-pickers
,@mui/x-date-pickers-pro
:這些是Material UI的日期選擇器組件。我們將用它們來選擇請求休假的日期範圍。react-hot-toast
:React的極簡式提示庫。我們將使用它來顯示成功和錯誤消息。react-infinite-scroll-component
:一個讓無限滾動變得簡單的React組件。我們將用它來在用戶向下滾動頁面查看更多請求時加載更多休假請求。dayjs
:一個輕量級的日期庫,用於解析、驗證、操作和格式化日期。vite-tsconfig-paths
:一個Vite插件,允許您在Vite項目中使用TypeScript路徑別名。
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
安裝依賴項後,更新 vite.config.ts
和 tsconfig.json
以使用 vite-tsconfig-paths
插件。這使得在 Vite 項目中啟用 TypeScript 路徑別名,允許使用 @
別名進行導入。
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" }]
}
接下來,讓我們刪除不必要的文件和資料夾:
src/contexts
:這個資料夾包含單個文件ColorModeContext
。它處理應用程序的深色/淺色模式。我們在本教程中不會使用它。src/components
:這個資料夾包含<Header />
組件。我們在本教程中將使用自定義標頭組件。
rm -rf src/contexts src/components
刪除文件和資料夾後,App.tsx
會報錯,我們將在接下來的步驟中修復它。
在整個教程中,我們將涵蓋核心頁面和組件的編碼。因此,從 GitHub 儲存庫 獲取必要的文件和資料夾。擁有這些文件後,我們將擁有 HR 管理應用程序的基本結構。
- icons:圖示資料夾,包含所有應用程序圖示。
- types:
index.ts
:應用程序類型。
- 工具:
constants.ts
: 應用常數.axios.ts
: 用於 API 請求的 Axios 實例,處理訪問令牌、刷新令牌和錯誤.init-dayjs.ts
: 使用所需的插件初始化 Day.js.
- 提供者:
- 組件:
layout
: 佈局組件。loading-overlay
: 在數據獲取期間顯示加載覆蓋層。input
: 渲染表單輸入字段。frame
: 自定義組件,為頁面部分添加邊框、標題和圖標。modal
: 自定義模態對話框組件。
複製檔案和資料夾後,檔案結構應如下所示:
└── 📁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
接下來,更新 App.tsx
檔案以包含必要的提供者和組件。
import { Refine } from '@refinedev/core'
import { DevtoolsProvider, DevtoolsPanel } from '@refinedev/devtools'
import dataProvider from '@refinedev/nestjsx-crud'
import routerProvider, { UnsavedChangesNotifier, DocumentTitleHandler } from '@refinedev/react-router-v6'
import { BrowserRouter, Routes, Route, Outlet } from 'react-router-dom'
import { Toaster } from 'react-hot-toast'
import { Layout } from '@/components/layout'
import { ThemeProvider } from '@/providers/theme-provider'
import { authProvider } from '@/providers/auth-provider'
import { accessControlProvider } from '@/providers/access-control'
import { useNotificationProvider } from '@/providers/notification-provider'
import { queryClient } from '@/providers/query-client'
import { BASE_URL } from '@/utilities/constants'
import { axiosInstance } from '@/utilities/axios'
import { Role } from './types'
import '@/utilities/init-dayjs'
function App() {
return (
<BrowserRouter>
<ThemeProvider>
<DevtoolsProvider>
<Refine
authProvider={authProvider}
routerProvider={routerProvider}
dataProvider={dataProvider(BASE_URL, axiosInstance)}
notificationProvider={useNotificationProvider}
resources={[
{
name: 'employee',
meta: {
scope: Role.EMPLOYEE,
},
},
{
name: 'manager',
meta: {
scope: Role.MANAGER,
},
},
]}
accessControlProvider={accessControlProvider}
options={{
reactQuery: {
clientConfig: queryClient,
},
syncWithLocation: true,
warnWhenUnsavedChanges: true,
useNewQueryKeys: true,
}}>
<Routes>
<Route
element={
<Layout>
<Outlet />
</Layout>
}>
<Route index element={<h1>Hello World</h1>} />
</Route>
</Routes>
<UnsavedChangesNotifier />
<DocumentTitleHandler />
<Toaster position='bottom-right' reverseOrder={false} />
<DevtoolsPanel />
</Refine>
</DevtoolsProvider>
</ThemeProvider>
</BrowserRouter>
)
}
export default App
讓我們來分析一下對 App.tsx
檔案所做的重要更改:
<Refine />
: 來自@refinedev/core
的核心組件,包裹整個應用程序以提供資料獲取、狀態管理和其他功能。<DevtoolsProvider />
和<DevtoolsPanel />
: 用於調試和開發目的。<ThemeProvider />
: 在應用程式中應用自訂主題。- 初始化 Day.js: 用於日期和時間操作。
- 資源: 一個數組,指定 Refine 將獲取的數據實體(
employee
和manager
)。我們使用父資源和子資源來組織數據和管理權限。每個資源都有一個scope
定義用戶角色,控制對應用程式不同部分的訪問。 - queryClient: 一個自訂查詢客戶端,提供對數據獲取的完全控制和自訂。
- syncWithLocation: 啟用應用狀態(過濾器、排序器、分頁等)與 URL 的同步。
- warnWhenUnsavedChanges: 當用戶嘗試導航離開有未保存更改的頁面時顯示警告。
<Layout />
: 一個自訂的佈局組件,用於包裹應用內容。它包含標題、側邊欄和主要內容區域。我們將在接下來的步驟中解釋這個組件。
現在,我們已經準備好開始構建人力資源管理應用程序。
步驟 2—自訂和樣式設計
仔細查看 theme-provider
。我們對 Material UI 主題進行了大量自訂,以匹配人力資源管理應用的設計,創建了兩個主題,一個用於經理,另一個用於員工,以不同顏色區分它們。
此外,我們還為應用添加了 Inter 作為自訂字體。要安裝,您需要將以下行添加到 index.html
文件中:
<head>
<meta charset="utf-8" />
<link rel="icon" href="/favicon.ico" />
<^>
<link <^ />
<^>
href="https://fonts.googleapis.com/css2?family=Inter:ital,opsz,wght@0,14..32,100..900;1,14..32,100..900&display=swap"
rel="stylesheet" /> <^>
<meta name="viewport" content="width=device-width, initial-scale=1" />
<meta name="theme-color" content="#000000" />
<meta
name="description"
content="refine | Build your React-based CRUD applications, without constraints."
/>
<meta
data-rh="true"
property="og:image"
content="https://refine.dev/img/refine_social.png"
/>
<meta
data-rh="true"
name="twitter:image"
content="https://refine.dev/img/refine_social.png"
/>
<title>
Refine - Build your React-based CRUD applications, without constraints.
</title>
</head>
檢查自訂 <Layout />
元件
在前一步中,我們向應用程式添加了一個自訂佈局元件。通常,我們可以使用 UI 框架的預設佈局,但我們想展示如何進行自訂。
佈局元件包含標頭、側邊欄和主要內容區域。它使用 <ThemedLayoutV2 />
作為基礎,並進行自訂以符合人力資源管理應用程式的設計。
<Sider />
側邊欄包含應用程式標誌和導航連結。在移動設備上,這是一個可折疊的側邊欄,當用戶點擊菜單圖標時會打開。導航連結使用 useMenu
鉤子從 Refine 準備,並根據用戶的角色透過 <CanAccess />
元件進行渲染。
<UserSelect />
安裝在側邊欄上,顯示已登錄用戶的頭像和名字。點擊時,它會打開一個彈出窗口,顯示用戶詳細信息和登出按鈕。用戶可以通過從下拉選單中選擇來切換不同的角色。此元件允許通過切換不同角色的用戶來進行測試。
<Header />
在桌面設備上不顯示任何內容。在移動設備上,它顯示應用程式標誌和一個菜單圖標以打開側邊欄。標題是固定的,始終顯示在頁面的頂部。
<PageHeader />
這顯示了頁面標題和導航按鈕。頁面標題是使用useResource
鉤子自動生成的,該鉤子從Refine上下文中提取資源名稱。這讓我們能夠在整個應用程序中共享相同的樣式和佈局。
步驟3 — 實施身份驗證和授權
在此步驟中,我們將為我們的HR管理應用程序實施身份驗證和授權邏輯。這將作為企業應用程序中訪問控制的絕佳示例。
當用戶以經理身份登錄時,他們將能夠看到Time Off
和Requests
頁面。如果他們以員工身份登錄,則只會看到Time Off
頁面。經理可以在Requests
頁面上批准或拒絕請假請求。
員工可以在 請假
頁面上請求休假並查看其歷史紀錄。為了實現這一點,我們將使用 Refine 的 authProvider
和 accessControlProvider
功能。
身份驗證
在 Refine 中,身份驗證由 authProvider
處理。它允許您為您的應用定義身份驗證邏輯。在前一步中,我們已經從 GitHub 倉庫複製了 authProvider
並將其作為屬性傳遞給 <Refine />
組件。我們將使用以下鉤子和組件來根據用戶是否登錄來控制我們應用的行為。
useLogin
: 一個提供mutate
函數以登錄用戶的鉤子。useLogout
: 一個提供mutate
函數以登出用戶的鉤子。useIsAuthenticated
: 一個返回布林值的 Hook,指示用戶是否已通過身份驗證。<Authenticated />
: 一個僅在用戶已通過身份驗證的情況下渲染其子元素的組件。
Authorization
在 Refine 中,授權由 accessControlProvider
處理。它允許您定義用戶角色和權限,並根據用戶的角色控制對應用程序不同部分的訪問。在前一步中,我們已經從 GitHub 儲存庫複製了 accessControlProvider
並將其作為屬性傳遞給 <Refine />
組件。讓我們仔細看看 accessControlProvider
的工作原理。
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 (!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,
};
}
// 用戶只能在其角色與資源範圍匹配時訪問資源
return {
can: user.role === scope,
};
},
};
在我們的應用程序中,我們有兩個角色:MANAGER
和 EMPLOYEE
。
管理員可以訪問 Requests
頁面,而員工僅能訪問 Time Off
頁面。accessControlProvider
會檢查用戶的角色和資源範圍,以確定用戶是否可以訪問該資源。如果用戶的角色與資源範圍匹配,他們可以訪問該資源。否則,他們將被拒絕訪問。我們將使用 useCan
鉤子和 <CanAccess />
組件來根據用戶的角色控制我們應用程序的行為。
設置登錄頁面
在前一步中,我們將 authProvider
添加到 <Refine />
組件中。authProvider
負責處理身份驗證。
首先,我們需要獲取圖像。我們將這些圖像用作登錄頁面的背景圖像。在 public
文件夾中創建一個名為 images
的新文件夾,並從 GitHub 存儲庫 獲取圖像。
在獲取圖像後,讓我們在 src/pages/login
資料夾中創建一個名為 index.tsx
的新文件,並添加以下代碼:
import { useState } from "react";
import { useLogin } from "@refinedev/core";
import {
Avatar,
Box,
Button,
Divider,
MenuItem,
Select,
Typography,
} from "@mui/material";
import { HrLogo } from "@/icons";
export const PageLogin = () => {
const [selectedEmail, setSelectedEmail] = useState<string>(
mockUsers.managers[0].email,
);
const { mutate: login } = useLogin();
return (
<Box
sx={{
position: "relative",
background:
"linear-gradient(180deg, #7DE8CD 0%, #C6ECD9 24.5%, #5CD6D6 100%)",
display: "flex",
flexDirection: "column",
justifyContent: "center",
alignItems: "center",
height: "100dvh",
}}
>
<Box
sx={{
zIndex: 2,
background: "white",
width: "328px",
padding: "24px",
borderRadius: "36px",
display: "flex",
flexDirection: "column",
gap: "24px",
}}
>
<Box
sx={{
display: "flex",
flexDirection: "column",
alignItems: "center",
gap: "16px",
}}
>
<HrLogo />
<Typography variant="body1" fontWeight={600}>
Welcome to RefineHR
</Typography>
</Box>
<Divider />
<Box sx={{ display: "flex", flexDirection: "column", gap: "8px" }}>
<Typography variant="caption" color="text.secondary">
Select user
</Typography>
<Select
size="small"
value={selectedEmail}
sx={{
height: "40px",
borderRadius: "12px",
"& .MuiOutlinedInput-notchedOutline": {
borderWidth: "1px !important",
borderColor: (theme) => `${theme.palette.divider} !important`,
},
}}
MenuProps={{
sx: {
"& .MuiList-root": {
paddingBottom: "0px",
},
"& .MuiPaper-root": {
border: (theme) => `1px solid ${theme.palette.divider}`,
borderRadius: "12px",
boxShadow: "none",
},
},
}}
>
<Typography
variant="caption"
textTransform="uppercase"
color="text.secondary"
sx={{
paddingLeft: "12px",
paddingBottom: "8px",
display: "block",
}}
>
Managers
</Typography>
{mockUsers.managers.map((user) => (
<MenuItem
key={user.email}
value={user.email}
onClick={() => setSelectedEmail(user.email)}
>
<Box sx={{ display: "flex", alignItems: "center", gap: "8px" }}>
<Avatar
src={user.avatarUrl}
alt={`${user.firstName} ${user.lastName}`}
sx={{ width: "24px", height: "24px" }}
/>
<Typography
noWrap
variant="caption"
sx={{ display: "flex", alignItems: "center" }}
>
{`${user.firstName} ${user.lastName}`}
</Typography>
</Box>
</MenuItem>
))}
<Divider />
<Typography
variant="caption"
textTransform="uppercase"
color="text.secondary"
sx={{
paddingLeft: "12px",
paddingBottom: "8px",
display: "block",
}}
>
Employees
</Typography>
{mockUsers.employees.map((user) => (
<MenuItem
key={user.email}
value={user.email}
onClick={() => setSelectedEmail(user.email)}
>
<Box sx={{ display: "flex", alignItems: "center", gap: "8px" }}>
<Avatar
src={user.avatarUrl}
alt={`${user.firstName} ${user.lastName}`}
sx={{ width: "24px", height: "24px" }}
/>
<Typography
noWrap
variant="caption"
sx={{ display: "flex", alignItems: "center" }}
>
{`${user.firstName} ${user.lastName}`}
</Typography>
</Box>
</MenuItem>
))}
</Select>
</Box>
<Button
variant="contained"
sx={{
borderRadius: "12px",
height: "40px",
width: "100%",
color: "white",
backgroundColor: (theme) => theme.palette.grey[900],
}}
onClick={() => {
login({ email: selectedEmail });
}}
>
Sign in
</Button>
</Box>
<Box
sx={{
zIndex: 1,
width: {
xs: "240px",
sm: "370px",
md: "556px",
},
height: {
xs: "352px",
sm: "554px",
md: "816px",
},
position: "absolute",
left: "0px",
bottom: "0px",
}}
>
<img
src="/images/login-left.png"
alt="flowers"
width="100%"
height="100%"
/>
</Box>
<Box
sx={{
zIndex: 1,
width: {
xs: "320px",
sm: "480px",
md: "596px",
},
height: {
xs: "312px",
sm: "472px",
md: "584px",
},
position: "absolute",
right: "0px",
top: "0px",
}}
>
<img
src="/images/login-right.png"
alt="flowers"
width="100%"
height="100%"
/>
</Box>
</Box>
);
};
const mockUsers = {
managers: [
{
email: "[email protected]",
firstName: "Michael",
lastName: "Scott",
avatarUrl:
"https://refine-hr-example.s3.eu-west-1.amazonaws.com/avatars/Michael-Scott.png",
},
{
avatarUrl:
"https://refine-hr-example.s3.eu-west-1.amazonaws.com/avatars/Jim-Halpert.png",
firstName: "Jim",
lastName: "Halpert",
email: "[email protected]",
},
{
avatarUrl:
"https://refine-hr-example.s3.eu-west-1.amazonaws.com/avatars/Toby-Flenderson.png",
firstName: "Toby",
lastName: "Flenderson",
email: "[email protected]",
},
],
employees: [
{
avatarUrl:
"https://refine-hr-example.s3.eu-west-1.amazonaws.com/avatars/Pam-Beesly.png",
firstName: "Pam",
lastName: "Beesly",
email: "[email protected]",
},
{
avatarUrl:
"https://refine-hr-example.s3.eu-west-1.amazonaws.com/avatars/Andy-Bernard.png",
firstName: "Andy",
lastName: "Bernard",
email: "[email protected]",
},
{
avatarUrl:
"https://refine-hr-example.s3.eu-west-1.amazonaws.com/avatars/Ryan-Howard.png",
firstName: "Ryan",
lastName: "Howard",
email: "[email protected]",
},
],
};
為了簡化身份驗證過程,我們創建了一個 mockUsers
對象,裡面有兩個數組:managers
和 employees
。每個數組包含預定義的用戶對象。當用戶從下拉菜單中選擇一個電子郵件並單擊 登入
按鈕時,將調用 login
函數並傳遞所選的電子郵件。login
函數是由 Refine 的 useLogin
鉤子提供的變更函數。它調用 authProvider.login
,並傳遞所選的電子郵件。
接下來,讓我們導入 <PageLogin />
組件,並更新帶有突出變更的 App.tsx
文件。
import { Authenticated, Refine } from "@refinedev/core";
import { DevtoolsProvider, DevtoolsPanel } from "@refinedev/devtools";
import { ErrorComponent } from "@refinedev/mui";
import dataProvider from "@refinedev/nestjsx-crud";
import routerProvider, {
UnsavedChangesNotifier,
DocumentTitleHandler,
} from "@refinedev/react-router-v6";
import {
BrowserRouter,
Routes,
Route,
Outlet,
Navigate,
} from "react-router-dom";
import { Toaster } from "react-hot-toast";
import { PageLogin } from "@/pages/login";
import { Layout } from "@/components/layout";
import { ThemeProvider } from "@/providers/theme-provider";
import { authProvider } from "@/providers/auth-provider";
import { accessControlProvider } from "@/providers/access-control";
import { useNotificationProvider } from "@/providers/notification-provider";
import { queryClient } from "@/providers/query-client";
import { BASE_URL } from "@/utilities/constants";
import { axiosInstance } from "@/utilities/axios";
import { Role } from './types'
import "@/utilities/init-dayjs";
function App() {
return (
<BrowserRouter>
<ThemeProvider>
<DevtoolsProvider>
<Refine
authProvider={authProvider}
routerProvider={routerProvider}
dataProvider={dataProvider(BASE_URL, axiosInstance)}
notificationProvider={useNotificationProvider}
resources={[
{
name: 'employee',
meta: {
scope: Role.EMPLOYEE,
},
},
{
name: 'manager',
meta: {
scope: Role.MANAGER,
},
},
]}
accessControlProvider={accessControlProvider}
options={{
reactQuery: {
clientConfig: queryClient,
},
syncWithLocation: true,
warnWhenUnsavedChanges: true,
useNewQueryKeys: true,
}}
>
<Routes>
<Route
element={
<Authenticated
key="authenticated-routes"
redirectOnFail="/login"
>
<Layout>
<Outlet />
</Layout>
</Authenticated>
}
>
<Route index element={<h1>Hello World</h1>} />
</Route>
<Route
element={
<Authenticated key="auth-pages" fallback={<Outlet />}>
<Navigate to="/" />
</Authenticated>
}
>
<Route path="/login" element={<PageLogin />} />
</Route>
<Route
element={
<Authenticated key="catch-all">
<Layout>
<Outlet />
</Layout>
</Authenticated>
}
>
<Route path="*" element={<ErrorComponent />} />
</Route>
</Routes>
<UnsavedChangesNotifier />
<DocumentTitleHandler />
<Toaster position="bottom-right" reverseOrder={false} />
<DevtoolsPanel />
</Refine>
</DevtoolsProvider>
</ThemeProvider>
</BrowserRouter>
);
}
export default App;
在更新的 App.tsx
文件中,我們添加了來自 Refine 的 <Authenticated />
組件。此組件用於保護需要身份驗證的路由。它需要一個 key
屬性來唯一標識組件,一個 fallback
屬性來在用戶未經身份驗證時渲染,以及一個 redirectOnFail
屬性來在身份驗證失敗時將用戶重定向到指定路由。在內部,它調用 authProvider.check
方法來檢查用戶是否已通過身份驗證。
讓我們仔細看看 key="auth-pages"
上的內容。
<Route
element={
<Authenticated key="auth-pages" fallback={<Outlet />}>
<Navigate to="/" />
</Authenticated>
}
>
<Route path="/login" element={<PageLogin />} />
</Route>
<Authenticated />
元件包裹著”/login”路由以檢查使用者的驗證狀態。
fallback={<Outlet />}
:如果使用者未經驗證,則渲染嵌套路由(即顯示<PageLogin />
元件)。- 子元件(
<Navigate to="/" />
):如果使用者已經驗證,則將其重新導向到首頁(/
)。
讓我們仔細查看我們在key="catch-all"
上的內容。
<Route
element={
<Authenticated key="catch-all">
<Layout>
<Outlet />
</Layout>
</Authenticated>
}
>
<Route path="*" element={<ErrorComponent />} />
</Route>
<Authenticated />
元件包裹著path="*"
路由以檢查使用者的驗證狀態。這個路由是一個全匹配路由,當使用者經過驗證時渲染<ErrorComponent />
。這使我們能夠在使用者嘗試訪問不存在的路由時顯示404頁面。
現在,當您運行應用程序並導航到http://localhost:5173/login
,您應該看到帶有下拉選擇使用者的登錄頁面。
目前,“/”頁面未執行任何操作。在接下來的步驟中,我們將實現Time Off
和Requests
頁面。
第4步 — 建立請假頁面
建立請假列表頁面
在這一步驟中,我們將建立請假
頁面。員工可以請求休假並查看他們的休假歷史。經理也可以查看他們的歷史,但不是請求休假,而是可以直接分配給自己。我們將使用Refine的accessControlProvider
、<CanAccess />
元件和useCan
hook 來實現這一點。

在我們開始建立請假頁面之前,我們需要創建一些組件來顯示休假歷史、即將到來的休假請求以及使用的休假統計數據。在這一步的末尾,我們將使用這些組件來構建請假頁面。
建立<TimeOffList />
組件來顯示休假歷史
在src/components
文件夾中創建一個名為time-offs
的新文件夾。在time-offs
文件夾內,創建一個名為list.tsx
的新文件,並添加以下代碼:
import { useState } from "react";
import {
type CrudFilters,
type CrudSort,
useDelete,
useGetIdentity,
useInfiniteList,
} from "@refinedev/core";
import {
Box,
Button,
CircularProgress,
IconButton,
Popover,
Typography,
} from "@mui/material";
import InfiniteScroll from "react-infinite-scroll-component";
import dayjs from "dayjs";
import { DateField } from "@refinedev/mui";
import { Frame } from "@/components/frame";
import { LoadingOverlay } from "@/components/loading-overlay";
import { red } from "@/providers/theme-provider/colors";
import {
AnnualLeaveIcon,
CasualLeaveIcon,
DeleteIcon,
NoTimeOffIcon,
SickLeaveIcon,
ThreeDotsIcon,
PopoverTipIcon,
} from "@/icons";
import { type Employee, TimeOffStatus, type TimeOff } from "@/types";
const variantMap = {
Annual: {
label: "Annual Leave",
iconColor: "primary.700",
iconBgColor: "primary.50",
icon: <AnnualLeaveIcon width={16} height={16} />,
},
Sick: {
label: "Sick Leave",
iconColor: "#C2410C",
iconBgColor: "#FFF7ED",
icon: <SickLeaveIcon width={16} height={16} />,
},
Casual: {
label: "Casual Leave",
iconColor: "grey.700",
iconBgColor: "grey.50",
icon: <CasualLeaveIcon width={16} height={16} />,
},
} as const;
type Props = {
type: "upcoming" | "history" | "inReview";
};
export const TimeOffList = (props: Props) => {
const { data: employee } = useGetIdentity<Employee>();
const { data, isLoading, hasNextPage, fetchNextPage } =
useInfiniteList<TimeOff>({
resource: "time-offs",
sorters: sorters[props.type],
filters: [
...filters[props.type],
{
field: "employeeId",
operator: "eq",
value: employee?.id,
},
],
queryOptions: {
enabled: !!employee?.id,
},
});
const timeOffHistory = data?.pages.flatMap((page) => page.data) || [];
const hasData = isLoading || timeOffHistory.length !== 0;
if (props.type === "inReview" && !hasData) {
return null;
}
return (
<Frame
sx={(theme) => ({
maxHeight: "362px",
paddingBottom: 0,
position: "relative",
"&::after": {
pointerEvents: "none",
content: '""',
position: "absolute",
bottom: 0,
left: "24px",
right: "24px",
width: "80%",
height: "32px",
background:
"linear-gradient(180deg, rgba(255, 255, 255, 0), #FFFFFF)",
},
display: "flex",
flexDirection: "column",
})}
sxChildren={{
paddingRight: 0,
paddingLeft: 0,
flex: 1,
overflow: "hidden",
}}
title={title[props.type]}
>
<LoadingOverlay loading={isLoading} sx={{ height: "100%" }}>
{!hasData ? (
<Box
sx={{
display: "flex",
flexDirection: "column",
alignItems: "center",
justifyContent: "center",
gap: "24px",
height: "180px",
}}
>
<NoTimeOffIcon />
<Typography variant="body2" color="text.secondary">
{props.type === "history"
? "No time off used yet."
: "No upcoming time offs scheduled."}
</Typography>
</Box>
) : (
<Box
id="scrollableDiv-timeOffHistory"
sx={(theme) => ({
maxHeight: "312px",
height: "auto",
[theme.breakpoints.up("lg")]: {
height: "312px",
},
overflow: "auto",
paddingLeft: "12px",
paddingRight: "12px",
})}
>
<InfiniteScroll
dataLength={timeOffHistory.length}
next={() => fetchNextPage()}
hasMore={hasNextPage || false}
endMessage={
!isLoading &&
hasData && (
<Box
sx={{
pt: timeOffHistory.length > 3 ? "40px" : "16px",
}}
/>
)
}
scrollableTarget="scrollableDiv-timeOffHistory"
loader={
<Box
sx={{
display: "flex",
alignItems: "center",
justifyContent: "center",
width: "100%",
height: "100px",
}}
>
<CircularProgress size={24} />
</Box>
}
>
<Box
sx={{
display: "flex",
flexDirection: "column",
gap: "12px",
}}
>
{timeOffHistory.map((timeOff) => {
return (
<ListItem
timeOff={timeOff}
key={timeOff.id}
type={props.type}
/>
);
})}
</Box>
</InfiniteScroll>
</Box>
)}
</LoadingOverlay>
</Frame>
);
};
const ListItem = ({
timeOff,
type,
}: { timeOff: TimeOff; type: Props["type"] }) => {
const { mutateAsync: timeOffCancel } = useDelete<TimeOff>();
const [anchorEl, setAnchorEl] = useState<HTMLButtonElement | null>(null);
const [hovered, setHovered] = useState(false);
const diffrenceOfDays =
dayjs(timeOff.endsAt).diff(dayjs(timeOff.startsAt), "day") + 1;
const isSameDay = dayjs(timeOff.startsAt).isSame(
dayjs(timeOff.endsAt),
"day",
);
return (
<Box
key={timeOff.id}
onMouseEnter={() => setHovered(true)}
onMouseLeave={() => setHovered(false)}
sx={{
display: "flex",
alignItems: "center",
gap: "16px",
height: "64px",
paddingLeft: "12px",
paddingRight: "12px",
borderRadius: "64px",
backgroundColor: hovered ? "grey.50" : "transparent",
transition: "background-color 0.2s",
}}
>
<Box
sx={{
display: "flex",
alignItems: "center",
justifyContent: "center",
color: variantMap[timeOff.timeOffType].iconColor,
backgroundColor: variantMap[timeOff.timeOffType].iconBgColor,
width: "40px",
height: "40px",
borderRadius: "100%",
}}
>
{variantMap[timeOff.timeOffType].icon}
</Box>
<Box
sx={{
display: "flex",
flexDirection: "column",
gap: "4px",
}}
>
<Box
sx={{
display: "flex",
alignItems: "center",
gap: "4px",
}}
>
{isSameDay ? (
<DateField
value={timeOff.startsAt}
color="text.secondary"
variant="caption"
format="MMMM DD"
/>
) : (
<>
<DateField
value={timeOff.startsAt}
color="text.secondary"
variant="caption"
format="MMMM DD"
/>
<Typography variant="caption" color="text.secondary">
-
</Typography>
<DateField
value={timeOff.endsAt}
color="text.secondary"
variant="caption"
format="MMMM DD"
/>
</>
)}
</Box>
<Typography variant="body2">
<span
style={{
fontWeight: 500,
}}
>
{diffrenceOfDays} {diffrenceOfDays > 1 ? "days" : "day"} of{" "}
</span>
{variantMap[timeOff.timeOffType].label}
</Typography>
</Box>
{hovered && (type === "inReview" || type === "upcoming") && (
<IconButton
onClick={(e) => setAnchorEl(e.currentTarget)}
sx={{
display: "flex",
alignItems: "center",
justifyContent: "center",
width: "40px",
height: "40px",
marginLeft: "auto",
backgroundColor: "white",
borderRadius: "100%",
color: "grey.400",
border: (theme) => `1px solid ${theme.palette.grey[400]}`,
flexShrink: 0,
}}
>
<ThreeDotsIcon />
</IconButton>
)}
<Popover
id={timeOff.id.toString()}
open={Boolean(anchorEl)}
anchorEl={anchorEl}
onClose={() => {
setAnchorEl(null);
setHovered(false);
}}
anchorOrigin={{
vertical: "bottom",
horizontal: "center",
}}
transformOrigin={{
vertical: "top",
horizontal: "center",
}}
sx={{
"& .MuiPaper-root": {
overflow: "visible",
borderRadius: "12px",
border: (theme) => `1px solid ${theme.palette.grey[400]}`,
boxShadow: "0px 0px 0px 4px rgba(222, 229, 237, 0.25)",
},
}}
>
<Button
variant="text"
onClick={async () => {
await timeOffCancel({
resource: "time-offs",
id: timeOff.id,
invalidates: ["all"],
successNotification: () => {
return {
type: "success",
message: "Time off request cancelled successfully.",
};
},
});
}}
sx={{
position: "relative",
width: "200px",
height: "56px",
paddingLeft: "16px",
color: red[900],
display: "flex",
gap: "12px",
justifyContent: "flex-start",
"&:hover": {
backgroundColor: "transparent",
},
}}
>
<DeleteIcon />
<Typography variant="body2">Cancel Request</Typography>
<Box
sx={{
width: "40px",
height: "16px",
position: "absolute",
top: "-2px",
left: "calc(50% - 1px)",
transform: "translate(-50%, -50%)",
}}
>
<PopoverTipIcon />
</Box>
</Button>
</Popover>
</Box>
);
};
const today = dayjs().toISOString();
const title: Record<Props["type"], string> = {
history: "Time Off History",
upcoming: "Upcoming Time Off",
inReview: "In Review",
};
const filters: Record<Props["type"], CrudFilters> = {
history: [
{
field: "status",
operator: "eq",
value: TimeOffStatus.APPROVED,
},
{
field: "endsAt",
operator: "lt",
value: today,
},
],
upcoming: [
{
field: "status",
operator: "eq",
value: TimeOffStatus.APPROVED,
},
{
field: "endsAt",
operator: "gte",
value: today,
},
],
inReview: [
{
field: "status",
operator: "eq",
value: TimeOffStatus.PENDING,
},
],
};
const sorters: Record<Props["type"], CrudSort[]> = {
history: [{ field: "startsAt", order: "desc" }],
upcoming: [{ field: "endsAt", order: "asc" }],
inReview: [{ field: "startsAt", order: "asc" }],
};
list.tsx
文件很長,但其中大部分處理樣式和UI呈現。

我們將在三個不同的上下文中使用這個 <TimeOffList />
元件:
<TimeOffList type="inReview" />
<TimeOffList type="upcoming" />
<TimeOffList type="history" />
type
屬性決定顯示哪種類型的請假清單:
inReview
: 顯示待批准的請假請求。upcoming
: 顯示已批准但尚未發生的即將到來的請假。history
: 列出已批准且已經發生的請假。
在這個元件內,我們將根據 type
屬性創建過濾器和排序器。我們將使用這些過濾器和排序器從 API 獲取請假數據。
讓我們拆解元件的關鍵部分:
1. 獲取當前用戶
const { data: employee } = useGetIdentity<Employee>();
useGetIdentity<Employee>()
: 獲取當前用戶的資訊。我們使用員工的 ID 來過濾請假,以便每個用戶僅能看到他們的請求。
2. 使用無限滾動獲取請假數據
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}
// ... 其他屬性
>
{/* 在這裡渲染列表項目 */}
</InfiniteScroll>;
-
useInfiniteList<TimeOff>()
: 獲取具有無限滾動的請假數據。resource
: 指定API端點。sorters
和filters
: 根據type
調整以獲取正確的數據。employeeId
過濾器: 確保僅獲取當前用戶的請假紀錄。queryOptions.enabled
: 僅在員工數據可用時執行查詢。
-
<InfiniteScroll />
: 允許在使用者向下滾動時載入更多數據。next
: 擷取下一頁數據的功能。hasMore
: 指示是否有更多數據可用。
3. 取消請假請求
const { mutateAsync: timeOffCancel } = useDelete<TimeOff>();
// 在 ListItem 組件內部
await timeOffCancel({
resource: "time-offs",
id: timeOff.id,
invalidates: ["all"],
successNotification: () => ({
type: "success",
message: "Time off request cancelled successfully.",
}),
});
useDelete
: 提供timeOffCancel
函數來刪除請假請求。- 在使用者取消請假時使用。
- 完成後顯示成功消息。
4. 使用 <DateField />
顯示日期
<DateField
value={timeOff.startsAt}
color="text.secondary"
variant="caption"
format="MMMM DD"
/>
<DateField />
:以用户友好的方式格式化和显示日期。value
:要显示的日期。format
:指定日期格式(例如,“一月05日”)。
5. 基于type
创建过滤器和排序器
过滤器:
const filters: Record<Props["type"], CrudFilters> = {
history: [
{
field: "status",
operator: "eq",
value: TimeOffStatus.APPROVED,
},
{
field: "endsAt",
operator: "lt",
value: today,
},
],
// ... 其他类型
};
- 根据状态和日期定义获取休假的条件。
history
:获取已经结束的已批准休假。upcoming
:获取即将到来的已批准休假。
排序器:
const sorters: Record<Props["type"], CrudSort[]> = {
history: [{ field: "startsAt", order: "desc" }],
// ... 其他类型
};
- 确定获取数据的顺序。
history
:按照开始日期降序排序。
构建<TimeOffLeaveCards />
组件以显示已使用的休假统计信息。
在 src/components/time-offs
資料夾中創建一個名為 leave-cards.tsx
的新檔案,並添加以下代碼:
import { useGetIdentity, useList } from "@refinedev/core";
import { Box, Grid, Skeleton, Typography } from "@mui/material";
import { AnnualLeaveIcon, CasualLeaveIcon, SickLeaveIcon } from "@/icons";
import {
type Employee,
TimeOffStatus,
TimeOffType,
type TimeOff,
} from "@/types";
type Props = {
employeeId?: number;
};
export const TimeOffLeaveCards = (props: Props) => {
const { data: employee, isLoading: isLoadingEmployee } =
useGetIdentity<Employee>({
queryOptions: {
enabled: !props.employeeId,
},
});
const { data: timeOffsSick, isLoading: isLoadingTimeOffsSick } =
useList<TimeOff>({
resource: "time-offs",
// 我們只需要病假的總天數,因此可以將 pageSize 設置為 1 以減少負載
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",
// 我們只需要病假的總天數,因此可以將 pageSize 設置為 1 以減少負載
pagination: { pageSize: 1 },
filters: [
{
field: "status",
operator: "eq",
value: TimeOffStatus.APPROVED,
},
{
field: "timeOffType",
operator: "eq",
value: TimeOffType.CASUAL,
},
{
field: "employeeId",
operator: "eq",
value: employee?.id,
},
],
queryOptions: {
enabled: !!employee?.id,
},
});
const loading =
isLoadingEmployee || isLoadingTimeOffsSick || isLoadingTimeOffsCasual;
return (
<Grid container spacing="24px">
<Grid item xs={12} sm={4}>
<Card
loading={loading}
type="annual"
value={employee?.availableAnnualLeaveDays || 0}
/>
</Grid>
<Grid item xs={12} sm={4}>
<Card loading={loading} type="sick" value={timeOffsSick?.total || 0} />
</Grid>
<Grid item xs={12} sm={4}>
<Card
loading={loading}
type="casual"
value={timeOffsCasual?.total || 0}
/>
</Grid>
</Grid>
);
};
const variantMap = {
annual: {
label: "Annual Leave",
description: "Days available",
bgColor: "primary.50",
titleColor: "primary.900",
descriptionColor: "primary.700",
iconColor: "primary.700",
icon: <AnnualLeaveIcon />,
},
sick: {
label: "Sick Leave",
description: "Days used",
bgColor: "#FFF7ED",
titleColor: "#7C2D12",
descriptionColor: "#C2410C",
iconColor: "#C2410C",
icon: <SickLeaveIcon />,
},
casual: {
label: "Casual Leave",
description: "Days used",
bgColor: "grey.50",
titleColor: "grey.900",
descriptionColor: "grey.700",
iconColor: "grey.700",
icon: <CasualLeaveIcon />,
},
};
const Card = (props: {
type: "annual" | "sick" | "casual";
value: number;
loading?: boolean;
}) => {
return (
<Box
sx={{
backgroundColor: variantMap[props.type].bgColor,
padding: "24px",
borderRadius: "12px",
}}
>
<Box
sx={{
display: "flex",
alignItems: "center",
justifyContent: "space-between",
}}
>
<Typography
variant="h6"
sx={{
color: variantMap[props.type].titleColor,
fontSize: "16px",
fontWeight: 500,
lineHeight: "24px",
}}
>
{variantMap[props.type].label}
</Typography>
<Box
sx={{
color: variantMap[props.type].iconColor,
}}
>
{variantMap[props.type].icon}
</Box>
</Box>
<Box sx={{ marginTop: "8px", display: "flex", flexDirection: "column" }}>
{props.loading ? (
<Box
sx={{
width: "40%",
height: "32px",
display: "flex",
alignItems: "center",
justifyContent: "center",
}}
>
<Skeleton
variant="rounded"
sx={{
width: "100%",
height: "20px",
}}
/>
</Box>
) : (
<Typography
variant="caption"
sx={{
color: variantMap[props.type].descriptionColor,
fontSize: "24px",
lineHeight: "32px",
fontWeight: 600,
}}
>
{props.value}
</Typography>
)}
<Typography
variant="body1"
sx={{
color: variantMap[props.type].descriptionColor,
fontSize: "12px",
lineHeight: "16px",
}}
>
{variantMap[props.type].description}
</Typography>
</Box>
</Box>
);
};

<TimeOffLeaveCards />
組件顯示有關員工休假的統計信息。它顯示三張卡片,分別為年假、病假和事假,指示可用或已使用的天數。
讓我們來分解組件的關鍵部分:
1. 獲取數據
- 員工數據: 使用
useGetIdentity
獲取當前員工的信息,例如可用的年假天數。 - 休假計數: 使用
useList
獲取員工已使用的病假和事假的總天數。它將pageSize
設置為 1,因為我們只需要總計,而不是所有的詳細信息。
2. 顯示卡片
- 該組件渲染三個卡片組件,每種休假類型對應一張卡片。
- 每張卡片顯示:
- 休假類型(例如:年假)。
- 可用或已使用的天數。
- 表示休假類型的圖標。
3. 處理加載狀態
- 如果數據仍在加載中,則會顯示一個骨架占位符,而不是實際數字。
- 將
loading
屬性傳遞給卡片,以管理此狀態。
4. 卡片組件
- 作為屬性接收
type
、value
和loading
。 - 使用
variantMap
來根據請假類型獲取正確的標籤、顏色和圖示。 - 以適當的樣式顯示請假信息。
構建 <PageEmployeeTimeOffsList />
現在我們已經有了用於列出休假和顯示請假卡片的組件,讓我們在 src/pages/employee/time-offs/
文件夾中創建名為 list.tsx
的新文件,並添加以下代碼:
import { CanAccess, useCan } from "@refinedev/core";
import { CreateButton } from "@refinedev/mui";
import { Box, Grid } from "@mui/material";
import { PageHeader } from "@/components/layout/page-header";
import { TimeOffList } from "@/components/time-offs/list";
import { TimeOffLeaveCards } from "@/components/time-offs/leave-cards";
import { TimeOffIcon } from "@/icons";
import { ThemeProvider } from "@/providers/theme-provider";
import { Role } from "@/types";
export const PageEmployeeTimeOffsList = () => {
const { data: useCanData } = useCan({
action: "manager",
params: {
resource: {
name: "time-offs",
meta: {
scope: "manager",
},
},
},
});
const isManager = useCanData?.can;
return (
<ThemeProvider role={isManager ? Role.MANAGER : Role.EMPLOYEE}>
<Box>
<PageHeader
title="Time Off"
rightSlot={
<CreateButton
size="large"
variant="contained"
startIcon={<TimeOffIcon />}
>
<CanAccess action="manager" fallback="Request Time Off">
Assign Time Off
</CanAccess>
</CreateButton>
}
/>
<TimeOffLeaveCards />
<Grid
container
spacing="24px"
sx={{
marginTop: "24px",
}}
>
<Grid item xs={12} md={6}>
<Box
sx={{
display: "flex",
flexDirection: "column",
gap: "24px",
}}
>
<TimeOffList type="inReview" />
<TimeOffList type="upcoming" />
</Box>
</Grid>
<Grid item xs={12} md={6}>
<TimeOffList type="history" />
</Grid>
</Grid>
</Box>
</ThemeProvider>
);
};
<PageEmployeeTimeOffsList />
是休假頁面的主要組件,我們將使用此組件在用戶導航至 /employee/time-offs
路由時顯示休假列表和請假卡片。

讓我們分解組件的關鍵部分:
1. 檢查用戶角色
- 使用
useCan
鉤子來確定當前用戶是否是經理。 - 如果用戶有管理權限,則將
isManager
設置為true
。
2. 根據角色應用主題
- 將內容包裹在
<ThemeProvider />
中。 - 主題根據用戶是經理還是員工而更改。
3. 具有條件按鈕的頁面標題
- 使用標題“Time Off”呈現
<PageHeader />
。 - 包含一個根據用戶角色更改的
<CreateButton />
:- 如果用戶是經理,按鈕上顯示“指派休假”。
- 如果用戶不是經理,則按鈕上顯示“請求休假”。
- 這是使用
<CanAccess />
組件來處理權限檢查。
4. 顯示請假統計
- 包括
<TimeOffLeaveCards />
組件以顯示請假結餘和使用情況。 - 這提供了年假、病假和事假的摘要。
5. 列出請假申請
- 使用
<Grid />
布局來組織內容。 - 在左側(
md={6}
)顯示:TimeOffList
withtype="inReview"
: 顯示待審核的請假申請。TimeOffList
withtype="upcoming"
: 顯示即將批准的請假。
- 在右側 (
md={6}
),顯示:TimeOffList
使用type="history"
:顯示已經發生的過往請假。
添加 “/employee/time-offs” 路由
我們準備在 /employee/time-offs
路由上渲染 <PageEmployeeTimeOffsList />
組件。讓我們更新 App.tsx
文件以包含此路由:
import { Authenticated, ErrorComponent, Refine } from '@refinedev/core'
import { DevtoolsProvider, DevtoolsPanel } from '@refinedev/devtools'
import dataProvider from '@refinedev/nestjsx-crud'
import routerProvider, {
UnsavedChangesNotifier,
DocumentTitleHandler,
NavigateToResource,
} from '@refinedev/react-router-v6'
import { BrowserRouter, Routes, Route, Outlet } from 'react-router-dom'
import { Toaster } from 'react-hot-toast'
import { PageEmployeeTimeOffsList } from '@/pages/employee/time-offs/list'
import { PageLogin } from '@/pages/login'
import { Layout } from '@/components/layout'
import { ThemeProvider } from '@/providers/theme-provider'
import { authProvider } from '@/providers/auth-provider'
import { accessControlProvider } from '@/providers/access-control'
import { useNotificationProvider } from '@/providers/notification-provider'
import { queryClient } from '@/providers/query-client'
import { BASE_URL } from '@/utilities/constants'
import { axiosInstance } from '@/utilities/axios'
import { TimeOffIcon } from '@/icons'
import { Role } from '@/types'
import '@/utilities/init-dayjs'
function App() {
return (
<BrowserRouter>
<ThemeProvider>
<DevtoolsProvider>
<Refine
authProvider={authProvider}
routerProvider={routerProvider}
dataProvider={dataProvider(BASE_URL, axiosInstance)}
notificationProvider={useNotificationProvider}
resources={[
{
name: 'employee',
meta: {
scope: Role.EMPLOYEE,
},
},
{
name: 'manager',
meta: {
scope: Role.MANAGER,
},
},
{
name: 'time-offs',
list: '/employee/time-offs',
meta: {
parent: 'employee',
scope: Role.EMPLOYEE,
label: 'Time Off',
icon: <TimeOffIcon />,
},
},
]}
accessControlProvider={accessControlProvider}
options={{
reactQuery: {
clientConfig: queryClient,
},
syncWithLocation: true,
warnWhenUnsavedChanges: true,
useNewQueryKeys: true,
}}>
<Routes>
<Route
element={
<Authenticated key='authenticated-routes' redirectOnFail='/login'>
<Outlet />
</Authenticated>
}>
<Route index element={<NavigateToResource resource='time-offs' />} />
<Route
path='employee'
element={
<ThemeProvider role={Role.EMPLOYEE}>
<Layout>
<Outlet />
</Layout>
</ThemeProvider>
}>
<Route path='time-offs' element={<Outlet />}>
<Route index element={<PageEmployeeTimeOffsList />} />
</Route>
</Route>
</Route>
<Route
element={
<Authenticated key='auth-pages' fallback={<Outlet />}>
<NavigateToResource resource='time-offs' />
</Authenticated>
}>
<Route path='/login' element={<PageLogin />} />
</Route>
<Route
element={
<Authenticated key="catch-all">
<Layout>
<Outlet />
</Layout>
</Authenticated>
}
>
<Route path="*" element={<ErrorComponent />} />
</Route>
</Routes>
<UnsavedChangesNotifier />
<DocumentTitleHandler />
<Toaster position='bottom-right' reverseOrder={false} />
<DevtoolsPanel />
</Refine>
</DevtoolsProvider>
</ThemeProvider>
</BrowserRouter>
)
}
export default App
讓我們拆解更新後的 App.tsx
文件的關鍵部分:
1. 定義請假資源
{
name: 'time-offs',
list: '/employee/time-offs',
meta: {
parent: 'employee',
scope: Role.EMPLOYEE,
label: 'Time Off',
icon: <TimeOffIcon />,
},
}
我們為請假添加了一個新的 資源,作為 employee
資源 的子項。這表示請假與員工相關,並且員工可以訪問。
name: 'time-offs'
:這是資源的標識符,由 Refine 內部使用。list: '/employee/time-offs'
:指定顯示資源列表視圖的路由。meta
: 一個包含有關資源的附加元數據的對象。parent: 'employee'
: 將此資源歸類於employee
範疇,可用於在 UI 中組織資源(如在側邊菜單中)或進行訪問控制。scope: Role.EMPLOYEE
: 表示此資源可供擁有EMPLOYEE
角色的用戶訪問。我們在accessControlProvider
中使用此功能來管理權限。label: 'Time Off'
: 此資源在 UI 中的顯示名稱。icon: <TimeOffIcon />
: 將TimeOffIcon
與此資源關聯以進行視覺識別。
2. 當用戶導航到/
路由時,重定向到“time-offs”資源。
<Route index element={<NavigateToResource resource="time-offs" />} />
我們使用 <NavigateToResource />
元件來將用戶重定向到 time-offs
資源,當他們導航到 /
路由時。這確保用戶默認看到的是請假列表。
3. 當用戶已驗證時重定向到“time-offs”資源
<Route
element={
<Authenticated key="auth-pages" fallback={<Outlet />}>
<NavigateToResource resource="time-offs" />
</Authenticated>
}
>
<Route path="/login" element={<PageLogin />} />
</Route>
當用戶已驗證時,我們將他們重定向到 time-offs
資源。如果他們未經驗證,則會看到登錄頁面。
4. 添加 /employee/time-offs
路由
<Route
path="employee"
element={
<ThemeProvider role={Role.EMPLOYEE}>
<Layout>
<Outlet />
</Layout>
</ThemeProvider>
}
>
<Route path="time-offs" element={<Outlet />}>
<Route index element={<PageEmployeeTimeOffsList />} />
</Route>
</Route>
我們使用嵌套路由來組織員工頁面。首先,我們創建一個主路由,路徑為 path='employee'
,該路由包裹內容在員工特定的主題和佈局中。在這個路由內,我們添加 path='time-offs'
,該路由顯示 PageEmployeeTimeOffsList
元件。這種結構將所有員工功能分組在一個路徑下,並保持樣式一致。
在添加這些更改後,您可以導航到 /employee/time-offs
路由以查看請假列表頁面運行狀況。

現在,請假列表頁面是功能性的,但缺乏創建新請假請求的能力。讓我們添加創建新請假請求的能力。
建立請假創建頁面
我們將創建一個新的頁面,用於請求或分配休假。這個頁面將包含一個表單,使用者可以指定休假的類型、開始和結束日期,以及任何附加註解。
在我們開始之前,我們需要創建新的組件來用於表單:
構建 <TimeOffFormSummary />
組件
在 src/components/time-offs/
資料夾中創建一個名為 form-summary.tsx
的新文件,並添加以下代碼:
import { Box, Divider, Typography } from "@mui/material";
type Props = {
availableAnnualDays: number;
requestedDays: number;
};
export const TimeOffFormSummary = (props: Props) => {
const remainingDays = props.availableAnnualDays - props.requestedDays;
return (
<Box
sx={{
display: "flex",
flexDirection: "column",
alignItems: "flex-end",
gap: "16px",
whiteSpace: "nowrap",
}}
>
<Box
sx={{
display: "flex",
gap: "16px",
}}
>
<Typography variant="body2" color="text.secondary">
Available Annual Leave Days:
</Typography>
<Typography variant="body2">{props.availableAnnualDays}</Typography>
</Box>
<Box
sx={{
display: "flex",
gap: "16px",
}}
>
<Typography variant="body2" color="text.secondary">
Requested Days:
</Typography>
<Typography variant="body2">{props.requestedDays}</Typography>
</Box>
<Divider
sx={{
width: "100%",
}}
/>
<Box
sx={{
display: "flex",
gap: "16px",
height: "40px",
}}
>
<Typography variant="body2" color="text.secondary">
Remaining Days:
</Typography>
<Typography variant="body2" fontWeight={500}>
{remainingDays}
</Typography>
</Box>
</Box>
);
};

<TimeOffFormSummary />
組件顯示休假請求的摘要。它顯示可用的年度假期天數、請求的天數以及剩餘的天數。我們將在休假表單中使用此組件,以便為使用者提供其請求的清晰概覽。
構建 <PageEmployeeTimeOffsCreate />
組件
在 src/pages/employee/time-offs/
資料夾中創建一個名為 create.tsx
的新文件,並添加以下代碼:
import { useCan, useGetIdentity, type HttpError } from "@refinedev/core";
import { useForm } from "@refinedev/react-hook-form";
import { Controller } from "react-hook-form";
import type { DateRange } from "@mui/x-date-pickers-pro/models";
import { Box, Button, MenuItem, Select, Typography } from "@mui/material";
import dayjs from "dayjs";
import { PageHeader } from "@/components/layout/page-header";
import { InputText } from "@/components/input/text";
import { LoadingOverlay } from "@/components/loading-overlay";
import { InputDateStartsEnds } from "@/components/input/date-starts-ends";
import { TimeOffFormSummary } from "@/components/time-offs/form-summary";
import { ThemeProvider } from "@/providers/theme-provider";
import {
type Employee,
type TimeOff,
TimeOffType,
TimeOffStatus,
Role,
} from "@/types";
import { CheckRectangleIcon } from "@/icons";
type FormValues = Omit<TimeOff, "id" | "notes"> & {
notes: string;
dates: DateRange<dayjs.Dayjs>;
};
export const PageEmployeeTimeOffsCreate = () => {
const { data: useCanData } = useCan({
action: "manager",
params: {
resource: {
name: "time-offs",
meta: {
scope: "manager",
},
},
},
});
const isManager = useCanData?.can;
const { data: employee } =
useGetIdentity<Employee>();
const {
refineCore: { formLoading, onFinish },
...formMethods
} = useForm<TimeOff, HttpError, FormValues>({
defaultValues: {
timeOffType: TimeOffType.ANNUAL,
notes: "",
dates: [null, null],
},
refineCoreProps: {
successNotification: () => {
return {
message: isManager
? "Time off assigned"
: "Your time off request is submitted for review.",
type: "success",
};
},
},
});
const { control, handleSubmit, formState, watch } = formMethods;
const onFinishHandler = async (values: FormValues) => {
const payload: FormValues = {
...values,
startsAt: dayjs(values.dates[0]).format("YYYY-MM-DD"),
endsAt: dayjs(values.dates[1]).format("YYYY-MM-DD"),
...(isManager && {
status: TimeOffStatus.APPROVED,
}),
};
await onFinish(payload);
};
const timeOffType = watch("timeOffType");
const selectedDays = watch("dates");
const startsAt = selectedDays[0];
const endsAt = selectedDays[1];
const availableAnnualDays = employee?.availableAnnualLeaveDays ?? 0;
const requestedDays =
startsAt && endsAt ? endsAt.diff(startsAt, "day") + 1 : 0;
return (
<ThemeProvider role={isManager ? Role.MANAGER : Role.EMPLOYEE}>
<LoadingOverlay loading={formLoading}>
<Box>
<PageHeader
title={isManager ? "Assign Time Off" : "Request Time Off"}
showListButton
showDivider
/>
<Box
component="form"
onSubmit={handleSubmit(onFinishHandler)}
sx={{
display: "flex",
flexDirection: "column",
gap: "24px",
marginTop: "24px",
}}
>
<Box>
<Typography
variant="body2"
sx={{
mb: "8px",
}}
>
Time Off Type
</Typography>
<Controller
name="timeOffType"
control={control}
render={({ field }) => (
<Select
{...field}
size="small"
sx={{
minWidth: "240px",
height: "40px",
"& .MuiSelect-select": {
paddingBlock: "10px",
},
}}
>
<MenuItem value={TimeOffType.ANNUAL}>Annual Leave</MenuItem>
<MenuItem value={TimeOffType.CASUAL}>Casual Leave</MenuItem>
<MenuItem value={TimeOffType.SICK}>Sick Leave</MenuItem>
</Select>
)}
/>
</Box>
<Box>
<Typography
variant="body2"
sx={{
mb: "16px",
}}
>
Requested Dates
</Typography>
<Controller
name="dates"
control={control}
rules={{
validate: (value) => {
if (!value[0] || !value[1]) {
return "Please select both start and end dates";
}
return true;
},
}}
render={({ field }) => {
return (
<Box
sx={{
display: "grid",
gridTemplateColumns: () => {
return {
sm: "1fr",
lg: "628px 1fr",
};
},
gap: "40px",
}}
>
<InputDateStartsEnds
{...field}
error={formState.errors.dates?.message}
availableAnnualDays={availableAnnualDays}
requestedDays={requestedDays}
/>
{timeOffType === TimeOffType.ANNUAL && (
<Box
sx={{
display: "flex",
maxWidth: "628px",
alignItems: () => {
return {
lg: "flex-end",
};
},
justifyContent: () => {
return {
xs: "flex-end",
lg: "flex-start",
};
},
}}
>
<TimeOffFormSummary
availableAnnualDays={availableAnnualDays}
requestedDays={requestedDays}
/>
</Box>
)}
</Box>
);
}}
/>
</Box>
<Box
sx={{
maxWidth: "628px",
}}
>
<Controller
name="notes"
control={control}
render={({ field, fieldState }) => {
return (
<InputText
{...field}
label="Notes"
error={fieldState.error?.message}
placeholder="Place enter your notes"
multiline
rows={3}
/>
);
}}
/>
</Box>
<Button
variant="contained"
size="large"
type="submit"
startIcon={isManager ? <CheckRectangleIcon /> : undefined}
>
{isManager ? "Assign" : "Send Request"}
</Button>
</Box>
</Box>
</LoadingOverlay>
</ThemeProvider>
);
};

<PageEmployeeTimeOffsCreate />
組件顯示一個用於在HR管理應用中創建新的休假請求的表單。員工和經理都可以使用它來請求或分配休假。該表單包括選擇休假類型的選項、選擇開始和結束日期、添加註解,並顯示請求的休假摘要。
讓我們分解組件的關鍵部分:
1. 檢查用戶角色
const { data: useCanData } = useCan({
action: "manager",
params: {
resource: {
name: "time-offs",
meta: {
scope: "manager",
},
},
},
});
const isManager = useCanData?.can;
使用 useCan
鉤子,我們檢查當前用戶是否擁有管理員權限。這決定了用戶是否可以指派休假或僅僅請求休假。我們將根據用戶的角色在 onFinishHandler
中以不同方式處理表單提交。
2. 表單狀態與提交
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
用預設值初始化表單,並根據用戶的角色設置成功通知。onFinishHandler
函數在提交之前處理表單數據。對於管理員,狀態會立即設置為 APPROVED
,而員工的請求則提交以進行審核。
3. 樣式
<ThemeProvider role={isManager ? Role.MANAGER : Role.EMPLOYEE}>
{/* ... */}
</ThemeProvider>
<Button
variant="contained"
size="large"
type="submit"
startIcon={isManager ? <CheckRectangleIcon /> : undefined}
>
{isManager ? "Assign" : "Send Request"}
</Button>
在我們的設計中,主要顏色根據用戶的角色而變化。我們使用 <ThemeProvider />
來相應地應用正確的主題。提交按鈕的文本和圖標也會根據用戶是管理員還是員工而改變。
4. 添加 “/employee/time-offs/create” 路由
我們需要為創建休假頁面添加新的路由。讓我們更新 App.tsx
文件以包含此路由:
import { Authenticated, ErrorComponent, Refine } from '@refinedev/core'
import { DevtoolsProvider, DevtoolsPanel } from '@refinedev/devtools'
import dataProvider from '@refinedev/nestjsx-crud'
import routerProvider, {
UnsavedChangesNotifier,
DocumentTitleHandler,
NavigateToResource,
} from '@refinedev/react-router-v6'
import { BrowserRouter, Routes, Route, Outlet } from 'react-router-dom'
import { Toaster } from 'react-hot-toast'
import { PageEmployeeTimeOffsList } from '@/pages/employee/time-offs/list'
import { PageEmployeeTimeOffsCreate } from '@/pages/employee/time-offs/create'
import { PageLogin } from '@/pages/login'
import { Layout } from '@/components/layout'
import { ThemeProvider } from '@/providers/theme-provider'
import { authProvider } from '@/providers/auth-provider'
import { accessControlProvider } from '@/providers/access-control'
import { useNotificationProvider } from '@/providers/notification-provider'
import { queryClient } from '@/providers/query-client'
import { BASE_URL } from '@/utilities/constants'
import { axiosInstance } from '@/utilities/axios'
import { TimeOffIcon } from '@/icons'
import { Role } from '@/types'
import '@/utilities/init-dayjs'
function App() {
return (
<BrowserRouter>
<ThemeProvider>
<DevtoolsProvider>
<Refine
authProvider={authProvider}
routerProvider={routerProvider}
dataProvider={dataProvider(BASE_URL, axiosInstance)}
notificationProvider={useNotificationProvider}
resources={[
{
name: 'employee',
meta: {
scope: Role.EMPLOYEE,
order: 2,
},
},
{
name: 'manager',
meta: {
scope: Role.MANAGER,
order: 1,
},
},
{
name: 'time-offs',
list: '/employee/time-offs',
create: '/employee/time-offs/new',
meta: {
parent: 'employee',
scope: Role.EMPLOYEE,
label: 'Time Off',
icon: <TimeOffIcon />,
},
},
]}
accessControlProvider={accessControlProvider}
options={{
reactQuery: {
clientConfig: queryClient,
},
syncWithLocation: true,
warnWhenUnsavedChanges: true,
useNewQueryKeys: true,
}}>
<Routes>
<Route
element={
<Authenticated key='authenticated-routes' redirectOnFail='/login'>
<Outlet />
</Authenticated>
}>
<Route index element={<NavigateToResource resource='time-offs' />} />
<Route
path='employee'
element={
<ThemeProvider role={Role.EMPLOYEE}>
<Layout>
<Outlet />
</Layout>
</ThemeProvider>
}>
<Route path='time-offs' element={<Outlet />}>
<Route index element={<PageEmployeeTimeOffsList />} />
<Route path='new' element={<PageEmployeeTimeOffsCreate />} />
</Route>
</Route>
</Route>
<Route
element={
<Authenticated key='auth-pages' fallback={<Outlet />}>
<NavigateToResource resource='time-offs' />
</Authenticated>
}>
<Route path='/login' element={<PageLogin />} />
</Route>
<Route
element={
<Authenticated key='catch-all'>
<Layout>
<Outlet />
</Layout>
</Authenticated>
}>
<Route path='*' element={<ErrorComponent />} />
</Route>
</Routes>
<UnsavedChangesNotifier />
<DocumentTitleHandler />
<Toaster position='bottom-right' reverseOrder={false} />
<DevtoolsPanel />
</Refine>
</DevtoolsProvider>
</ThemeProvider>
</BrowserRouter>
)
}
export default App
添加這些更改後,您可以導航到 /employee/time-offs/create
路由,或在休假列表頁面上單擊 “指派休假” 按鈕以訪問創建休假表單。

步驟5 — 建立休假申請管理頁面
在這個步驟中,我們將建立一個新頁面來管理休假申請。這個頁面將允許管理人員審核並批准或拒絕員工提交的休假申請。

建立休假申請清單頁面
我們將建立一個新頁面來管理休假申請。這個頁面將包含一個休假申請清單,顯示員工姓名、休假類型、申請日期和目前狀態等詳細資訊。
在開始之前,我們需要創建新的組件來在清單中使用:
建立 <RequestsList />
組件
在 src/components/requests/
文件夾中創建一個名為 list.tsx
的新文件,並添加以下代碼:
import type { ReactNode } from "react";
import InfiniteScroll from "react-infinite-scroll-component";
import {
Box,
Button,
CircularProgress,
Skeleton,
Typography,
} from "@mui/material";
type Props = {
dataLength: number;
hasMore: boolean;
scrollableTarget: string;
loading: boolean;
noDataText: string;
noDataIcon: ReactNode;
children: ReactNode;
next: () => void;
};
export const RequestsList = (props: Props) => {
const hasData = props.dataLength > 0 || props.loading;
if (!hasData) {
return (
<Box
sx={{
display: "flex",
alignItems: "center",
justifyContent: "center",
flexDirection: "column",
gap: "24px",
}}
>
{props.noDataIcon}
<Typography variant="body2" color="text.secondary">
{props.noDataText || "No data."}
</Typography>
</Box>
);
}
return (
<Box
sx={{
position: "relative",
}}
>
<Box
id={props.scrollableTarget}
sx={(theme) => ({
maxHeight: "600px",
[theme.breakpoints.up("lg")]: {
height: "600px",
},
overflow: "auto",
...((props.dataLength > 6 || props.loading) && {
"&::after": {
pointerEvents: "none",
content: '""',
zIndex: 1,
position: "absolute",
bottom: "0",
left: "12px",
right: "12px",
width: "calc(100% - 24px)",
height: "60px",
background:
"linear-gradient(180deg, rgba(255, 255, 255, 0), #FFFFFF)",
},
}),
})}
>
<InfiniteScroll
dataLength={props.dataLength}
hasMore={props.hasMore}
next={props.next}
scrollableTarget={props.scrollableTarget}
endMessage={
!props.loading &&
props.dataLength > 6 && (
<Box
sx={{
pt: "40px",
}}
/>
)
}
loader={
<Box
sx={{
display: "flex",
alignItems: "center",
justifyContent: "center",
width: "100%",
height: "100px",
}}
>
<CircularProgress size={24} />
</Box>
}
>
<Box
sx={{
display: "flex",
flexDirection: "column",
}}
>
{props.loading ? <SkeletonList /> : props.children}
</Box>
</InfiniteScroll>
</Box>
</Box>
);
};
const SkeletonList = () => {
return (
<>
{[...Array(6)].map((_, index) => (
<Box
key={index}
sx={(theme) => ({
paddingRight: "24px",
paddingLeft: "24px",
display: "flex",
flexDirection: "column",
justifyContent: "flex-end",
gap: "12px",
paddingTop: "12px",
paddingBottom: "4px",
[theme.breakpoints.up("sm")]: {
paddingTop: "20px",
paddingBottom: "12px",
},
"& .MuiSkeleton-rectangular": {
borderRadius: "2px",
},
})}
>
<Skeleton variant="rectangular" width="64px" height="12px" />
<Box
sx={{
display: "flex",
alignItems: "center",
gap: "24px",
}}
>
<Skeleton
variant="circular"
width={48}
height={48}
sx={{
flexShrink: 0,
}}
/>
<Box
sx={(theme) => ({
height: "auto",
width: "100%",
[theme.breakpoints.up("md")]: {
height: "48px",
},
display: "flex",
flex: 1,
flexDirection: "column",
justifyContent: "center",
gap: "8px",
})}
>
<Skeleton
variant="rectangular"
sx={(theme) => ({
width: "100%",
[theme.breakpoints.up("sm")]: {
width: "120px",
},
})}
height="16px"
/>
<Skeleton
variant="rectangular"
sx={(theme) => ({
width: "100%",
[theme.breakpoints.up("sm")]: {
width: "230px",
},
})}
height="12px"
/>
</Box>
<Button
size="small"
color="inherit"
sx={(theme) => ({
display: "none",
[theme.breakpoints.up("sm")]: {
display: "block",
},
alignSelf: "flex-start",
flexShrink: 0,
marginLeft: "auto",
})}
>
View Request
</Button>
</Box>
</Box>
))}
</>
);
};
<RequestsList />
組件顯示了一個帶有無窮滾動的休假申請清單。它包括了加載指示器、骨架占位符和在沒有數據時的提示訊息。該組件旨在高效處理大型資料集並提供流暢的用戶體驗。
建立 <RequestsListItem />
元件
在 src/components/requests/
資料夾中建立一個名為 list-item.tsx
的新檔案,並添加以下代碼:
import { Box, Typography, Avatar, Button } from "@mui/material";
import type { ReactNode } from "react";
type Props = {
date: string;
avatarURL: string;
title: string;
descriptionIcon?: ReactNode;
description: string;
onClick?: () => void;
showTimeSince?: boolean;
};
export const RequestsListItem = ({
date,
avatarURL,
title,
descriptionIcon,
description,
onClick,
showTimeSince,
}: Props) => {
return (
<Box
role="button"
onClick={onClick}
sx={(theme) => ({
cursor: "pointer",
paddingRight: "24px",
paddingLeft: "24px",
paddingTop: "4px",
paddingBottom: "4px",
[theme.breakpoints.up("sm")]: {
paddingTop: "12px",
paddingBottom: "12px",
},
"&:hover": {
backgroundColor: theme.palette.action.hover,
},
})}
>
{showTimeSince && (
<Box
sx={{
marginBottom: "8px",
}}
>
<Typography variant="caption" color="textSecondary">
{date}
</Typography>
</Box>
)}
<Box
sx={{
display: "flex",
}}
>
<Avatar
src={avatarURL}
alt={title}
sx={{ width: "48px", height: "48px" }}
/>
<Box
sx={(theme) => ({
height: "auto",
[theme.breakpoints.up("md")]: {
height: "48px",
},
width: "100%",
display: "flex",
flexWrap: "wrap",
justifyContent: "space-between",
gap: "4px",
marginLeft: "16px",
})}
>
<Box>
<Typography variant="body2" fontWeight={500} lineHeight="24px">
{title}
</Typography>
<Box
sx={{
display: "flex",
alignItems: "center",
gap: "8px",
minWidth: "260px",
}}
>
{descriptionIcon}
<Typography variant="caption" color="textSecondary">
{description}
</Typography>
</Box>
</Box>
{onClick && (
<Button
size="small"
color="inherit"
onClick={onClick}
sx={{
alignSelf: "flex-start",
flexShrink: 0,
marginLeft: "auto",
}}
>
View Request
</Button>
)}
</Box>
</Box>
</Box>
);
};
<RequestsListItem />
元件在列表中顯示單個的請假請求。它包括員工的頭像、姓名、描述,以及查看請求詳情的按鈕。這個元件是可重用的,可以用來渲染請假請求列表中的每個項目。
建立 <PageManagerRequestsList />
元件
在 src/pages/manager/requests/
資料夾中建立一個名為 list.tsx
的新檔案,並添加以下代碼:
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>
);
};
<PageManagerRequestsList />
元件顯示經理需要批准的待處理請假請求。它顯示員工的姓名、請假類型、請求的日期以及請求發出多久之前的詳情。經理可以點擊請求以查看更多詳情。它使用 <RequestsList />
和 <RequestsListItem />
來渲染列表。
這個元件也接受 children
作為道具。接下來,我們將實現一個 使用 <Outlet />
的模式路由 來顯示請求詳情,並在元件內部渲染 /manager/requests/:id
路由。
添加 “/manager/requests” 路由
我們需要為請假請求管理頁面添加新的路由。讓我們更新 App.tsx
文件以包含這個路由:
import { Authenticated, ErrorComponent, Refine } from '@refinedev/core'
import { DevtoolsProvider, DevtoolsPanel } from '@refinedev/devtools'
import dataProvider from '@refinedev/nestjsx-crud'
import routerProvider, {
UnsavedChangesNotifier,
DocumentTitleHandler,
NavigateToResource,
} from '@refinedev/react-router-v6'
import { BrowserRouter, Routes, Route, Outlet } from 'react-router-dom'
import { Toaster } from 'react-hot-toast'
import { PageEmployeeTimeOffsList } from '@/pages/employee/time-offs/list'
import { PageEmployeeTimeOffsCreate } from '@/pages/employee/time-offs/create'
import { PageLogin } from '@/pages/login'
import { Layout } from '@/components/layout'
import { ThemeProvider } from '@/providers/theme-provider'
import { authProvider } from '@/providers/auth-provider'
import { accessControlProvider } from '@/providers/access-control'
import { useNotificationProvider } from '@/providers/notification-provider'
import { queryClient } from '@/providers/query-client'
import { BASE_URL } from '@/utilities/constants'
import { axiosInstance } from '@/utilities/axios'
import { RequestsIcon, TimeOffIcon } from '@/icons'
import { Role } from '@/types'
import '@/utilities/init-dayjs'
import { PageManagerRequestsList } from './pages/manager/requests/list'
function App() {
return (
<BrowserRouter>
<ThemeProvider>
<DevtoolsProvider>
<Refine
authProvider={authProvider}
routerProvider={routerProvider}
dataProvider={dataProvider(BASE_URL, axiosInstance)}
notificationProvider={useNotificationProvider}
resources={[
{
name: 'employee',
meta: {
scope: Role.EMPLOYEE,
order: 2,
},
},
{
name: 'manager',
meta: {
scope: Role.MANAGER,
order: 1,
},
},
{
name: 'time-offs',
list: '/employee/time-offs',
create: '/employee/time-offs/new',
meta: {
parent: 'employee',
scope: Role.EMPLOYEE,
label: 'Time Off',
icon: <TimeOffIcon />,
},
},
{
name: 'time-offs',
list: '/manager/requests',
identifier: 'requests',
meta: {
parent: 'manager',
scope: Role.MANAGER,
label: 'Requests',
icon: <RequestsIcon />,
},
},
]}
accessControlProvider={accessControlProvider}
options={{
reactQuery: {
clientConfig: queryClient,
},
syncWithLocation: true,
warnWhenUnsavedChanges: true,
useNewQueryKeys: true,
}}>
<Routes>
<Route
element={
<Authenticated key='authenticated-routes' redirectOnFail='/login'>
<Outlet />
</Authenticated>
}>
<Route index element={<NavigateToResource resource='time-offs' />} />
<Route
path='employee'
element={
<ThemeProvider role={Role.EMPLOYEE}>
<Layout>
<Outlet />
</Layout>
</ThemeProvider>
}>
<Route path='time-offs' element={<Outlet />}>
<Route index element={<PageEmployeeTimeOffsList />} />
<Route path='new' element={<PageEmployeeTimeOffsCreate />} />
</Route>
</Route>
</Route>
<Route
path='manager'
element={
<ThemeProvider role={Role.MANAGER}>
<Layout>
<Outlet />
</Layout>
</ThemeProvider>
}>
<Route path='requests' element={<Outlet />}>
<Route index element={<PageManagerRequestsList />} />
</Route>
</Route>
<Route
element={
<Authenticated key='auth-pages' fallback={<Outlet />}>
<NavigateToResource resource='time-offs' />
</Authenticated>
}>
<Route path='/login' element={<PageLogin />} />
</Route>
<Route
element={
<Authenticated key='catch-all'>
<Layout>
<Outlet />
</Layout>
</Authenticated>
}>
<Route path='*' element={<ErrorComponent />} />
</Route>
</Routes>
<UnsavedChangesNotifier />
<DocumentTitleHandler />
<Toaster position='bottom-right' reverseOrder={false} />
<DevtoolsPanel />
</Refine>
</DevtoolsProvider>
</ThemeProvider>
</BrowserRouter>
)
}
export default App
添加這些更改後,您可以導航到 /manager/requests
路由查看請假請求管理頁面的運行效果

建立請假請求詳情頁面
在這一步中,我們將創建一個新頁面來顯示請假請求的詳細信息。這個頁面將顯示員工的姓名、請假類型、請求的日期以及當前狀態。管理者可以在這個頁面上批准或拒絕請求。
建立 <TimeOffRequestModal />
組件
首先,在 src/hooks/
文件夾中創建一個名為 use-get-employee-time-off-usage
的文件,並添加以下代碼:
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,
};
};
我們將使用 useGetEmployeeTimeOffUsage
hook 來計算每類請假中員工已請的總天數。這些信息將顯示在請假請求詳細頁面中。
之後,在 src/components/requests/
文件夾中創建一個名為 time-off-request-modal.tsx
的新文件,並添加以下代碼:
import type { ReactNode } from "react";
import { useInvalidate, useList, useUpdate } from "@refinedev/core";
import {
Avatar,
Box,
Button,
Divider,
Tooltip,
Typography,
} from "@mui/material";
import dayjs from "dayjs";
import { Modal } from "@/components/modal";
import {
TimeOffStatus,
TimeOffType,
type Employee,
type TimeOff,
} from "@/types";
import { RequestTypeIcon, ThumbsDownIcon, ThumbsUpIcon } from "@/icons";
import { useGetEmployeeTimeOffUsage } from "@/hooks/use-get-employee-time-off-usage";
type Props = {
open: boolean;
onClose: () => void;
loading: boolean;
onSuccess?: () => void;
timeOff:
| (TimeOff & {
employee: Employee;
})
| null
| undefined;
};
export const TimeOffRequestModal = ({
open,
timeOff,
loading: loadingFromProps,
onClose,
onSuccess,
}: Props) => {
const employeeUsedTimeOffs = useGetEmployeeTimeOffUsage({
employeeId: timeOff?.employee.id,
});
const invalidate = useInvalidate();
const { mutateAsync } = useUpdate<TimeOff>();
const employee = timeOff?.employee;
const duration =
dayjs(timeOff?.endsAt).diff(dayjs(timeOff?.startsAt), "days") + 1;
const remainingAnnualLeaveDays =
(employee?.availableAnnualLeaveDays ?? 0) - duration;
const { data: timeOffsData, isLoading: timeOffsLoading } = useList<
TimeOff & { employee: Employee }
>({
resource: "time-offs",
pagination: {
pageSize: 999,
},
filters: [
{
field: "status",
operator: "eq",
value: TimeOffStatus.APPROVED,
},
{
operator: "and",
value: [
{
field: "startsAt",
operator: "lte",
value: timeOff?.endsAt,
},
{
field: "endsAt",
operator: "gte",
value: timeOff?.startsAt,
},
],
},
],
queryOptions: {
enabled: !!timeOff,
},
meta: {
join: ["employee"],
},
});
const whoIsOutList = timeOffsData?.data || [];
const handleSubmit = async (status: TimeOffStatus) => {
await mutateAsync({
resource: "time-offs",
id: timeOff?.id,
invalidates: ["resourceAll"],
values: {
status,
},
});
onSuccess?.();
invalidate({
resource: "employees",
invalidates: ["all"],
});
};
const loading = timeOffsLoading || loadingFromProps;
return (
<Modal
open={open}
title="Time Off Request"
loading={loading}
sx={{
maxWidth: "520px",
}}
onClose={onClose}
footer={
<>
<Divider />
<Box
sx={{
display: "flex",
alignItems: "center",
justifyContent: "space-between",
gap: "8px",
padding: "24px",
}}
>
<Button
sx={{
backgroundColor: (theme) => theme.palette.error.light,
}}
startIcon={<ThumbsDownIcon />}
onClick={() => handleSubmit(TimeOffStatus.REJECTED)}
>
Decline
</Button>
<Button
sx={{
backgroundColor: (theme) => theme.palette.success.light,
}}
onClick={() => handleSubmit(TimeOffStatus.APPROVED)}
startIcon={<ThumbsUpIcon />}
>
Accept
</Button>
</Box>
</>
}
>
<Box
sx={{
display: "flex",
alignItems: "center",
padding: "24px",
backgroundColor: (theme) => theme.palette.grey[50],
borderBottom: (theme) => `1px solid ${theme.palette.divider}`,
}}
>
<Avatar
src={employee?.avatarUrl}
alt={employee?.firstName}
sx={{
width: "80px",
height: "80px",
marginRight: "24px",
}}
/>
<Box
sx={{
display: "flex",
flexDirection: "column",
}}
>
<Typography
variant="h2"
fontSize="18px"
lineHeight="28px"
fontWeight="500"
>
{employee?.firstName} {employee?.lastName}
</Typography>
<Typography variant="caption">{employee?.jobTitle}</Typography>
<Typography variant="caption">{employee?.role}</Typography>
</Box>
</Box>
<Box
sx={{
padding: "24px",
}}
>
<InfoRow
loading={loading}
label="Request Type"
value={
<Box
component="span"
sx={{
display: "flex",
alignItems: "center",
gap: "8px",
}}
>
<RequestTypeIcon type={timeOff?.timeOffType} />
<Typography variant="body2" component="span">
{timeOff?.timeOffType} Leave
</Typography>
</Box>
}
/>
<Divider />
<InfoRow
loading={loading}
label="Duration"
value={`${duration > 1 ? `${duration} days` : `${duration} day`}`}
/>
<Divider />
<InfoRow
loading={loading}
label={
{
[TimeOffType.ANNUAL]: "Remaining Annual Leave Days",
[TimeOffType.SICK]: "Previously Used Sick Leave Days",
[TimeOffType.CASUAL]: "Previously Used Casual Leave Days",
}[timeOff?.timeOffType ?? TimeOffType.ANNUAL]
}
value={
{
[TimeOffType.ANNUAL]: remainingAnnualLeaveDays,
[TimeOffType.SICK]: employeeUsedTimeOffs.sickCount,
[TimeOffType.CASUAL]: employeeUsedTimeOffs.casualCount,
}[timeOff?.timeOffType ?? TimeOffType.ANNUAL]
}
/>
<Divider />
<InfoRow
loading={loading}
label="Start Date"
value={dayjs(timeOff?.startsAt).format("MMMM DD")}
/>
<Divider />
<InfoRow
loading={loading}
label="End Date"
value={dayjs(timeOff?.endsAt).format("MMMM DD")}
/>
<Divider />
<Box
sx={{
display: "flex",
flexDirection: "column",
gap: "8px",
paddingY: "24px",
}}
>
<Typography variant="body2" fontWeight={600}>
Notes
</Typography>
<Typography
variant="body2"
sx={{
height: "20px",
fontStyle: timeOff?.notes ? "normal" : "italic",
}}
>
{!loading && (timeOff?.notes || "No notes provided.")}
</Typography>
</Box>
<Divider />
<Box
sx={{
display: "flex",
flexDirection: "column",
gap: "8px",
paddingY: "24px",
}}
>
<Typography variant="body2" fontWeight={600}>
Who's out between these days?
</Typography>
<Box
sx={{
display: "flex",
alignItems: "center",
flexWrap: "wrap",
gap: "8px",
}}
>
{whoIsOutList.length ? (
whoIsOutList.map((whoIsOut) => (
<Tooltip
key={whoIsOut.id}
sx={{
"& .MuiTooltip-tooltip": {
background: "red",
},
}}
title={
<Box
sx={{
display: "flex",
flexDirection: "column",
gap: "2px",
}}
>
<Typography variant="body2">
{whoIsOut.employee.firstName}{" "}
{whoIsOut.employee.lastName}
</Typography>
<Typography variant="caption">
{whoIsOut.timeOffType} Leave
</Typography>
<Typography variant="caption">
{dayjs(whoIsOut.startsAt).format("MMMM DD")} -{" "}
{dayjs(whoIsOut.endsAt).format("MMMM DD")}
</Typography>
</Box>
}
placement="top"
>
<Avatar
src={whoIsOut.employee.avatarUrl}
alt={whoIsOut.employee.firstName}
sx={{
width: "32px",
height: "32px",
}}
/>
</Tooltip>
))
) : (
<Typography
variant="body2"
sx={{
height: "32px",
fontStyle: "italic",
}}
>
{loading ? "" : "No one is out between these days."}
</Typography>
)}
</Box>
</Box>
</Box>
</Modal>
);
};
const InfoRow = ({
label,
value,
loading,
}: { label: ReactNode; value: ReactNode; loading: boolean }) => {
return (
<Box
sx={{
display: "flex",
justifyContent: "space-between",
paddingY: "24px",
height: "72px",
}}
>
<Typography variant="body2">{label}</Typography>
<Typography variant="body2">{loading ? "" : value}</Typography>
</Box>
);
};
讓我們來解析 <TimeOffRequestModal />
組件:
1. 獲取員工請假使用情況
useGetEmployeeTimeOffUsage
鉤子用於獲取員工的請假使用情況。此鉤子根據員工的請假歷史計算剩餘的年假天數以及之前使用的病假和事假天數。
2. 獲取重疊的已批准請假
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,
},
],
},
];
使用上述過濾器的 useList
鉤子獲取所有與當前請假請求重疊的已批准請假。此列表用於顯示在請求日期之間缺席的員工。
3. 處理請假請求的批准/拒絕
當經理批准或拒絕請假請求時,會調用 handleSubmit
函數。
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 會在資源被修改後自動使資源快取失效(在這種情況下是 time-offs
)。由於員工的請假使用情況是根據請假歷史計算的,因此我們還會使 employees
資源快取失效,以更新員工的請假使用情況。
新增 “/manager/requests/:id” 路由
在這一步中,我們將創建一個新路由,以顯示請假請求詳細頁面,經理可以在此批准或拒絕請求。
讓我們在 src/pages/manager/requests/time-offs/
文件夾中創建一個名為 edit.tsx
的新文件,並添加以下代碼:
import { useGo, useShow } from "@refinedev/core";
import { TimeOffRequestModal } from "@/components/requests/time-off-request-modal";
import type { Employee, TimeOff } from "@/types";
export const PageManagerRequestsTimeOffsEdit = () => {
const go = useGo();
const { query: timeOffRequestQuery } = useShow<
TimeOff & { employee: Employee }
>({
meta: {
join: ["employee"],
},
});
const loading = timeOffRequestQuery.isLoading;
return (
<TimeOffRequestModal
open
loading={loading}
timeOff={timeOffRequestQuery?.data?.data}
onClose={() =>
go({
to: {
resource: "requests",
action: "list",
},
})
}
onSuccess={() => {
go({
to: {
resource: "requests",
action: "list",
},
});
}}
/>
);
};
現在我們需要添加新路由以渲染請假請求詳細頁面。讓我們更新 App.tsx
文件以包含此路由:
import { Authenticated, ErrorComponent, Refine } from '@refinedev/core'
import { DevtoolsProvider, DevtoolsPanel } from '@refinedev/devtools'
import dataProvider from '@refinedev/nestjsx-crud'
import routerProvider, {
UnsavedChangesNotifier,
DocumentTitleHandler,
NavigateToResource,
} from '@refinedev/react-router-v6'
import { BrowserRouter, Routes, Route, Outlet } from 'react-router-dom'
import { Toaster } from 'react-hot-toast'
import { PageEmployeeTimeOffsList } from '@/pages/employee/time-offs/list'
import { PageEmployeeTimeOffsCreate } from '@/pages/employee/time-offs/create'
import { PageManagerRequestsList } from '@/pages/manager/requests/list'
import { PageManagerRequestsTimeOffsEdit } from '@/pages/manager/requests/time-offs/edit'
import { PageLogin } from '@/pages/login'
import { Layout } from '@/components/layout'
import { ThemeProvider } from '@/providers/theme-provider'
import { authProvider } from '@/providers/auth-provider'
import { accessControlProvider } from '@/providers/access-control'
import { useNotificationProvider } from '@/providers/notification-provider'
import { queryClient } from '@/providers/query-client'
import { BASE_URL } from '@/utilities/constants'
import { axiosInstance } from '@/utilities/axios'
import { RequestsIcon, TimeOffIcon } from '@/icons'
import { Role } from '@/types'
import '@/utilities/init-dayjs'
function App() {
return (
<BrowserRouter>
<ThemeProvider>
<DevtoolsProvider>
<Refine
authProvider={authProvider}
routerProvider={routerProvider}
dataProvider={dataProvider(BASE_URL, axiosInstance)}
notificationProvider={useNotificationProvider}
resources={[
{
name: 'employee',
meta: {
scope: Role.EMPLOYEE,
order: 2,
},
},
{
name: 'manager',
meta: {
scope: Role.MANAGER,
order: 1,
},
},
{
name: 'time-offs',
list: '/employee/time-offs',
create: '/employee/time-offs/new',
meta: {
parent: 'employee',
scope: Role.EMPLOYEE,
label: 'Time Off',
icon: <TimeOffIcon />,
},
},
{
name: 'time-offs',
list: '/manager/requests',
edit: '/manager/requests/:id/edit',
identifier: 'requests',
meta: {
parent: 'manager',
scope: Role.MANAGER,
label: 'Requests',
icon: <RequestsIcon />,
},
},
]}
accessControlProvider={accessControlProvider}
options={{
reactQuery: {
clientConfig: queryClient,
},
syncWithLocation: true,
warnWhenUnsavedChanges: true,
useNewQueryKeys: true,
}}>
<Routes>
<Route
element={
<Authenticated key='authenticated-routes' redirectOnFail='/login'>
<Outlet />
</Authenticated>
}>
<Route index element={<NavigateToResource resource='time-offs' />} />
<Route
path='employee'
element={
<ThemeProvider role={Role.EMPLOYEE}>
<Layout>
<Outlet />
</Layout>
</ThemeProvider>
}>
<Route path='time-offs' element={<Outlet />}>
<Route index element={<PageEmployeeTimeOffsList />} />
<Route path='new' element={<PageEmployeeTimeOffsCreate />} />
</Route>
</Route>
</Route>
<Route
path='manager'
element={
<ThemeProvider role={Role.MANAGER}>
<Layout>
<Outlet />
</Layout>
</ThemeProvider>
}>
<Route
path='requests'
element={
<PageManagerRequestsList>
<Outlet />
</PageManagerRequestsList>
}>
<Route path=':id/edit' element={<PageManagerRequestsTimeOffsEdit />} />
</Route>
</Route>
<Route
element={
<Authenticated key='auth-pages' fallback={<Outlet />}>
<NavigateToResource resource='time-offs' />
</Authenticated>
}>
<Route path='/login' element={<PageLogin />} />
</Route>
<Route
element={
<Authenticated key='catch-all'>
<Layout>
<Outlet />
</Layout>
</Authenticated>
}>
<Route path='*' element={<ErrorComponent />} />
</Route>
</Routes>
<UnsavedChangesNotifier />
<DocumentTitleHandler />
<Toaster position='bottom-right' reverseOrder={false} />
<DevtoolsPanel />
</Refine>
</DevtoolsProvider>
</ThemeProvider>
</BrowserRouter>
)
}
export default App
讓我們仔細看看這些變更:
<Route
path="requests"
element={
<PageManagerRequestsList>
<Outlet />
</PageManagerRequestsList>
}
>
<Route path=":id/edit" element={<PageManagerRequestsTimeOffsEdit />} />
</Route>
上述代碼設置了一個嵌套的路由結構,當導航到特定的子路由時,將顯示一個模態視窗。<PageManagerRequestsTimeOffsEdit />
組件是一個模態視窗,作為 <PageManagerRequestsList />
組件的子組件渲染。這種結構使我們能夠在保持列表頁面可見的情況下,在列表頁面上方顯示模態視窗。
當您導航到 /manager/requests/:id/edit
路由或點擊列表中的請假請求時,請假請求詳細頁面將作為模態視窗顯示在列表頁面上方。

第 6 步 — 實施授權和訪問控制
授權是企業級應用程序中的關鍵組件,在安全性和運營效率方面發揮著重要作用。它確保只有經授權的用戶可以訪問特定資源,從而保護敏感數據和功能。Refine的授權系統提供必要的基礎設施,以保護您的資源並確保用戶以安全和受控的方式與應用程序交互。在這一步驟中,我們將為請假申請管理功能實施授權和訪問控制。我們將通過<CanAccess />
組件僅允許經理訪問/manager/requests
和/manager/requests/:id/edit
路由。
當您以員工身分登錄時,您將無法在側邊欄中看到Requests
頁面鏈接,但仍可通過在瀏覽器中輸入URL來訪問/manager/requests
路由。我們將添加防護措斷以防止未經授權訪問這些路由。
讓我們更新App.tsx
文件以包含授權檢查:
import { Authenticated, CanAccess, ErrorComponent, Refine } from '@refinedev/core'
import { DevtoolsProvider, DevtoolsPanel } from '@refinedev/devtools'
import dataProvider from '@refinedev/nestjsx-crud'
import routerProvider, {
UnsavedChangesNotifier,
DocumentTitleHandler,
NavigateToResource,
} from '@refinedev/react-router-v6'
import { BrowserRouter, Routes, Route, Outlet } from 'react-router-dom'
import { Toaster } from 'react-hot-toast'
import { PageEmployeeTimeOffsList } from '@/pages/employee/time-offs/list'
import { PageEmployeeTimeOffsCreate } from '@/pages/employee/time-offs/create'
import { PageManagerRequestsList } from '@/pages/manager/requests/list'
import { PageManagerRequestsTimeOffsEdit } from '@/pages/manager/requests/time-offs/edit'
import { PageLogin } from '@/pages/login'
import { Layout } from '@/components/layout'
import { ThemeProvider } from '@/providers/theme-provider'
import { authProvider } from '@/providers/auth-provider'
import { accessControlProvider } from '@/providers/access-control'
import { useNotificationProvider } from '@/providers/notification-provider'
import { queryClient } from '@/providers/query-client'
import { BASE_URL } from '@/utilities/constants'
import { axiosInstance } from '@/utilities/axios'
import { RequestsIcon, TimeOffIcon } from '@/icons'
import { Role } from '@/types'
import '@/utilities/init-dayjs'
function App() {
return (
<BrowserRouter>
<ThemeProvider>
<DevtoolsProvider>
<Refine
authProvider={authProvider}
routerProvider={routerProvider}
dataProvider={dataProvider(BASE_URL, axiosInstance)}
notificationProvider={useNotificationProvider}
resources={[
{
name: 'employee',
meta: {
scope: Role.EMPLOYEE,
order: 2,
},
},
{
name: 'manager',
meta: {
scope: Role.MANAGER,
order: 1,
},
},
{
name: 'time-offs',
list: '/employee/time-offs',
create: '/employee/time-offs/new',
meta: {
parent: 'employee',
scope: Role.EMPLOYEE,
label: 'Time Off',
icon: <TimeOffIcon />,
},
},
{
name: 'time-offs',
list: '/manager/requests',
edit: '/manager/requests/:id/edit',
identifier: 'requests',
meta: {
parent: 'manager',
scope: Role.MANAGER,
label: 'Requests',
icon: <RequestsIcon />,
},
},
]}
accessControlProvider={accessControlProvider}
options={{
reactQuery: {
clientConfig: queryClient,
},
syncWithLocation: true,
warnWhenUnsavedChanges: true,
useNewQueryKeys: true,
}}>
<Routes>
<Route
element={
<Authenticated key='authenticated-routes' redirectOnFail='/login'>
<Outlet />
</Authenticated>
}>
<Route index element={<NavigateToResource resource='time-offs' />} />
<Route
path='employee'
element={
<ThemeProvider role={Role.EMPLOYEE}>
<Layout>
<Outlet />
</Layout>
</ThemeProvider>
}>
<Route path='time-offs' element={<Outlet />}>
<Route index element={<PageEmployeeTimeOffsList />} />
<Route path='new' element={<PageEmployeeTimeOffsCreate />} />
</Route>
</Route>
</Route>
<Route
path='manager'
element={
<ThemeProvider role={Role.MANAGER}>
<Layout>
<CanAccess action='manager' fallback={<NavigateToResource resource='time-offs' />}>
<Outlet />
</CanAccess>
</Layout>
</ThemeProvider>
}>
<Route
path='requests'
element={
<PageManagerRequestsList>
<Outlet />
</PageManagerRequestsList>
}>
<Route path=':id/edit' element={<PageManagerRequestsTimeOffsEdit />} />
</Route>
</Route>
<Route
element={
<Authenticated key='auth-pages' fallback={<Outlet />}>
<NavigateToResource resource='time-offs' />
</Authenticated>
}>
<Route path='/login' element={<PageLogin />} />
</Route>
<Route
element={
<Authenticated key='catch-all'>
<Layout>
<Outlet />
</Layout>
</Authenticated>
}>
<Route path='*' element={<ErrorComponent />} />
</Route>
</Routes>
<UnsavedChangesNotifier />
<DocumentTitleHandler />
<Toaster position='bottom-right' reverseOrder={false} />
<DevtoolsPanel />
</Refine>
</DevtoolsProvider>
</ThemeProvider>
</BrowserRouter>
)
}
export default App
在以上代碼中,我們將<CanAccess />
組件添加到“/manager”路由。該組件在渲染子路由之前檢查用戶是否具有“manager”角色。如果用戶沒有“manager”角色,將被重定向到員工的請假列表頁面。
現在,當您以員工身份登錄並嘗試訪問 /manager/requests
路徑時,您將被重定向到員工的請假列表頁面。
第 7 步 — 部署到 DigitalOcean 應用平台
在這一步中,我們將應用程序部署到 DigitalOcean 應用平台。為此,我們將在 GitHub 上托管源代碼,並將 GitHub 倉庫連接到應用平台。
將代碼推送到 GitHub
登錄到您的 GitHub 帳戶,然後 創建一個名為 refine-hr
的新倉庫。您可以將倉庫設置為公開或私有:
創建倉庫後,導航到項目目錄並運行以下命令以初始化新的 Git 倉庫:
git init
接下來,使用此命令將所有文件添加到 Git 倉庫:
git add .
然後,使用此命令提交文件:
git commit -m "Initial commit"
接下來,使用此命令將 GitHub 倉庫添加為遠端倉庫:
git remote add origin <your-github-repository-url>
接下來,使用此命令指定您要將代碼推送到 main
分支:
git branch -M main
最後,使用以下命令將代碼推送到 GitHub 存儲庫:
git push -u origin main
在提示時,輸入您的 GitHub 憑證以推送代碼。
代碼推送到 GitHub 存儲庫後,您將收到成功消息。
在此部分,您將專案推送到 GitHub,以便您可以通過 DigitalOcean Apps 訪問它。下一步是使用您的專案創建一個新的 DigitalOcean App,並設置自動部署。
部署到 DigitalOcean App 平台
在此過程中,您將對 React 應用程序進行準備,以便通過 DigitalOcean 的 App 平台進行部署。您將將您的 GitHub 存儲庫連接到 DigitalOcean,配置應用程序的構建方式,然後創建項目的初始部署。項目部署後,您所做的任何額外更改都將自動重建和更新。
完成此步驟時,您將在 DigitalOcean 上部署您的應用程序,並為持續交付做好準備。
登錄您的 DigitalOcean 帳戶,並導航到 Apps 頁面。點擊 創建 App 按鈕:
如果您尚未將您的 GitHub 帳戶連接到 DigitalOcean,系統將提示您這樣做。點擊 連接到 GitHub 按鈕。將會打開一個新窗口,要求您授權 DigitalOcean 訪問您的 GitHub 帳戶。
授權 DigitalOcean 後,您將被重定向回 DigitalOcean 應用程序頁面。下一步是選擇您的 GitHub 倉庫。在選擇您的倉庫後,系統將提示您選擇要部署的分支。選擇 main
分支並點擊 下一步 按鈕。
之後,您將看到應用程序的配置步驟。在本教程中,您可以點擊 下一步 按鈕來跳過配置步驟。不過,您也可以根據自己的需求配置應用程序。
等待構建完成。構建完成後,按 實時應用程序 以在瀏覽器中訪問您的項目。這將與您本地測試的項目相同,但這將在網絡上以安全的 URL 實時運行。此外,您可以參考 這個教程,該教程可在 DigitalOcean 社區網站上找到,了解如何將基於 React 的應用程序部署到 App Platform。
注意:如果您的構建未能成功部署,您可以在 DigitalOcean 上配置您的構建命令,使用 npm install --production=false && npm run build && npm prune --production
來替代 npm run build
。
結論
在本教程中,我們從零開始構建了一個人力資源管理應用程序,並熟悉了如何構建一個功能完整的 CRUD 應用程序。
此外,我們還將演示如何將您的應用程序部署到DigitalOcean 應用平台。
如果您想了解更多有關 Refine 的信息,可以查看文檔,如果您有任何問題或反饋,可以加入Refine Discord 伺服器。