このチュートリアルでは、Refine Framework を使用してHR管理アプリケーションを構築し、DigitalOcean App Platform にデプロイします。
このチュートリアルの最後には、次の機能を含むHR管理アプリケーションが完成します:
ログインページ :ユーザーがマネージャーまたは従業員としてログインできるようにします。マネージャーは休暇
およびリクエスト
ページにアクセスでき、従業員は休暇
ページのみアクセス可能です。
休暇ページ :従業員が休暇のリクエスト、確認、およびキャンセルを行えるようにします。また、マネージャーは新しい休暇を割り当てることができます。
リクエストページ :HRマネージャーのみが休暇リクエストを承認または拒否できるページです。
これらを行いながら、私たちは次のものを使用します:
Rest API : データの取得および更新のためのもの。Refineには組み込みのデータプロバイダパッケージとREST APIがありますが、独自のものを作成して特定の要件に合わせることもできます。このガイドでは、バックエンドサービスとしてNestJs CRUD を使用し、データプロバイダとして@refinedev/nestjsx-crud パッケージを使用します。
Material UI : UIコンポーネントに使用し、独自のデザインに合わせて完全にカスタマイズします。RefineにはMaterial UIの組み込みサポートがありますが、好きなUIライブラリを使用できます。
アプリを構築したら、DigitalOceanのApp Platform を使用してオンラインに公開します。これにより、GitHubリポジトリを指定するだけでコードをデプロイし、App Platformがインフラストラクチャ、アプリのランタイム、依存関係の管理作業を請け負います。
Refine は、複雑なB2Bウェブアプリケーションを構築するためのオープンソースのReactメタフレームワークで、主に内部ツール、管理パネル、ダッシュボードのようなデータ管理に焦点を当てたユースケースを対象としています。開発者のためにより良いワークフローを提供するために、フックとコンポーネントのセットを提供するよう設計されています。
エンタープライズレベルのアプリ向けに完全な機能を備えた、商用準備完了の機能を提供し、状態やデータ管理、認証、アクセス制御などの有料タスクを簡素化します。これにより、開発者は多くの圧倒的な実装の詳細から抽象化された形で、アプリケーションのコアに集中することができます。
npm create refine-app
コマンドを使用して、プロジェクトを対話的に初期化します。
プロンプトが表示されたときに、以下のオプションを選択します:
セットアップが完了したら、プロジェクトフォルダに移動して次のコマンドでアプリを起動します:
アプリを確認するには、ブラウザで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プロジェクトでTypeScriptパスエイリアスを使用できるViteプラグインです。
依存関係をインストールした後、vite.config.ts
とtsconfig.json
を更新してvite-tsconfig-paths
プラグインを使用します。これにより、ViteプロジェクトでTypeScriptのパスエイリアスが有効になり、@
エイリアスを使ったインポートが可能になります。
次に、不要なファイルとフォルダーを削除しましょう:
src/contexts
:このフォルダーにはColorModeContext
という単一ファイルが含まれています。アプリのダーク/ライトモードを扱います。このチュートリアルでは使用しません。
src/components
:このフォルダーには<Header />
コンポーネントが含まれています。このチュートリアルではカスタムヘッダーコンポーネントを使用します。
ファイルとフォルダーを削除した後、App.tsx
にエラーが表示されますが、次のステップで修正します。チュートリアル全体を通して、コアページとコンポーネントのコーディングを行います。したがって、GitHubリポジトリ から必要なファイルとフォルダーを取得してください。これらのファイルを使用して、HR管理アプリケーションの基本構造を作成します。
icons :アイコンフォルダー にはすべてのアプリアイコンが含まれています。
types :
ユーティリティ :
プロバイダー :
コンポーネント :
ファイルとフォルダーをコピーした後、ファイル構造は次のようになります:
└── 📁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
ファイルを更新します。
src/App.tsx
では、App.tsx
ファイルに加えた重要な変更を見ていきましょう:
さて、HR管理アプリケーションの構築を始める準備が整いました。
theme-provider
を詳しく見てみましょう。HR管理アプリのデザインに合わせるために、Material UIテーマを大幅にカスタマイズしました。マネージャー用と従業員用の2つのテーマを作成し、異なる色で区別しています。
また、アプリ用にInterというカスタムフォントを追加しました。インストールするには、index.html
ファイルに以下の行を追加する必要があります:
前のステップでは、アプリにカスタムレイアウトコンポーネントを追加しました。通常、UIフレームワークのデフォルトレイアウトを使用できますが、カスタマイズの方法を示したいと思います。
レイアウトコンポーネントには、ヘッダー、サイドバー、メインコンテンツエリアが含まれています。これは<ThemedLayoutV2 />
をベースにして、HR管理アプリのデザインに合わせてカスタマイズされています。
サイドバーにはアプリのロゴとナビゲーションリンクが含まれています。モバイルデバイスでは、ユーザーがメニューアイコンをクリックすると開く折りたたみ式サイドバーです。ナビゲーションリンクは、RefineのuseMenu
フックを使用して準備され、ユーザーの役割に基づいて<CanAccess />
コンポーネントの助けを借りてレンダリングされます。
サイドバーにマウントされており、ログイン中のユーザーのアバターと名前を表示します。クリックすると、ユーザーの詳細とログアウトボタンが含まれたポップオーバーが開きます。ユーザーはドロップダウンから選択することで、異なる役割間を切り替えることができます。このコンポーネントは、異なる役割を持つユーザー間を切り替えることでテストを可能にします。
デスクトップデバイスでは何もレンダリングされません。モバイルデバイスでは、アプリのロゴとサイドバーを開くためのメニューアイコンが表示されます。ヘッダーは固定されており、ページの上部に常に表示されます。
これはページタイトルとナビゲーションボタンを表示します。ページタイトルは自動的に useResource
フックを使用して生成され、Refineコンテキストからリソース名を取得します。これにより、アプリ全体で同じスタイルとレイアウトを共有できます。
このステップでは、HR管理アプリケーションの認証および承認ロジックを実装します。これは、エンタープライズアプリケーションにおけるアクセス制御の優れた例となります。
ユーザーがマネージャーとしてログインすると、Time Off
および Requests
ページを見ることができます。従業員としてログインした場合は、Time Off
ページのみが表示されます。マネージャーは Requests
ページで時間外リクエストを承認または拒否できます。
従業員は有給休暇
ページで休暇をリクエストし、履歴を表示することができます。これを実装するには、RefineのauthProvider
とaccessControlProvider
機能を使用します。
認証
Refineでは、認証はauthProvider
によって処理されます。これにより、アプリの認証ロジックを定義できます。前のステップでは、GitHubリポジトリからauthProvider
をすでにコピーし、<Refine />
コンポーネントにプロップとして渡しました。次のフックとコンポーネントを使用して、ユーザーがログインしているかどうかに基づいてアプリの動作を制御します。
Authorization
Refineでは、認証はaccessControlProvider
によって処理されます。これにより、ユーザーの役割と権限を定義し、ユーザーの役割に基づいてアプリのさまざまな部分へのアクセスを制御することができます。前のステップで、私たちはすでにGitHubリポジトリからaccessControlProvider
をコピーし、<Refine />
コンポーネントにプロップとして渡しました。accessControlProvider
がどのように機能するかを詳しく見てみましょう。
src/providers/access-control/index.ts
私たちのアプリには、MANAGER
とEMPLOYEE
の2つの役割があります。
マネージャーはRequests
ページにアクセスできますが、従業員はTime Off
ページにのみアクセスできます。 accessControlProvider
はユーザーの役割とリソースのスコープをチェックして、ユーザーがリソースにアクセスできるかどうかを決定します。 ユーザーの役割がリソースのスコープと一致する場合、リソースにアクセスできます。 それ以外の場合、アクセスが拒否されます。 私たちは、ユーザーの役割に基づいてアプリの動作を制御するためにuseCan
フックと<CanAccess />
コンポーネントを使用します。
前のステップで、authProvider
を<Refine />
コンポーネントに追加しました。 authProvider
は認証を扱う責任があります。
まず、画像を取得する必要があります。 これらの画像をログインページの背景画像として使用します。 public
フォルダーにimages
という新しいフォルダーを作成し、GitHubリポジトリ から画像を取得してください。
画像を取得した後、src/pages/login
フォルダーにindex.tsx
という新しいファイルを作成し、以下のコードを追加しましょう:
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
の2つの配列を用意しました。各配列には予め定義されたユーザーオブジェクトが含まれています。ユーザーがドロップダウンからメールアドレスを選択し、Sign in
ボタンをクリックすると、選択されたメールアドレスを持つlogin
関数が呼び出されます。login
関数は、RefineのuseLogin
フックによって提供されるミューテーション関数です。これは選択されたメールアドレスを使ってauthProvider.login
を呼び出します。
次に、<PageLogin />
コンポーネントをインポートし、App.tsx
ファイルをハイライトされた変更で更新しましょう。
src/App.tsx
更新されたApp.tsx
ファイルでは、Refineから<Authenticated />
コンポーネントを追加しました。このコンポーネントは、認証が必要なルートを保護するために使用されます。key
プロップを使用してコンポーネントを一意に識別し、ユーザーが認証されていない場合にレンダリングするfallback
プロップ、および認証に失敗した場合に指定されたルートにユーザーをリダイレクトするredirectOnFail
プロップを受け取ります。内部では、ユーザーが認証されているか確認するためにauthProvider.check
メソッドを呼び出します。
では、key="auth-pages"
についてもう少し詳しく見てみましょう。
<Authenticated />
コンポーネントは、“/login” ルートをラップして、ユーザーの認証状態を確認します。
fallback={<Outlet />}
: ユーザーが 認証されていない 場合、ネストされたルートをレンダリングします(つまり、<PageLogin />
コンポーネントを表示します)。
子要素 (<Navigate to="/" />
) : ユーザーが 認証されている 場合、ホームページ (/
) にリダイレクトします。
では、key="catch-all"
で何があるかを詳しく見てみましょう。
<Authenticated />
コンポーネントは path="*"
ルートをラップして、ユーザーの認証状態を確認します。このルートは、ユーザーが認証されている場合に <ErrorComponent />
をレンダリングするキャッチオールルートです。ユーザーが存在しないルートにアクセスしようとしたときに404ページを表示することを可能にします。
今、アプリを実行して http://localhost:5173/login
に移動すると、ユーザーを選択するためのドロップダウン付きのログインページが表示されるはずです。
現在、“/” ページは何もしていません。次のステップでは Time Off
と Requests
ページを実装します。
このステップでは、タイムオフ
ページを構築します。従業員はタイムオフをリクエストし、自分のタイムオフ履歴を確認できます。マネージャーも履歴を表示できますが、タイムオフをリクエストする代わりに、自分に直接割り当てることができます。この機能は、RefineのaccessControlProvider
、<CanAccess />
コンポーネント、およびuseCan
フックを使用して実現します。
<PageEmployeeTimeOffsList />
タイムオフページを構築する前に、タイムオフ履歴、今後のタイムオフリクエスト、および使用されたタイムオフの統計を表示するためのいくつかのコンポーネントを作成する必要があります。このステップの最後には、これらのコンポーネントを使用してタイムオフページを構築します。
<TimeOffList />
コンポーネントを構築してタイムオフ履歴を表示する
まず、src/components
フォルダー内にtime-offs
という新しいフォルダーを作成します。time-offs
フォルダー内に、list.tsx
という新しいファイルを作成し、次のコードを追加します:
src/components/time-offs/list.tsx
import { useState } from "react" ;
import {
type CrudFilters ,
type CrudSort ,
useDelete,
useGetIdentity,
useInfiniteList,
} from "@refinedev/core" ;
import {
Box ,
Button ,
CircularProgress ,
IconButton ,
Popover ,
Typography ,
} from "@mui/material" ;
import InfiniteScroll from "react-infinite-scroll-component" ;
import dayjs from "dayjs" ;
import { DateField } from "@refinedev/mui" ;
import { Frame } from "@/components/frame" ;
import { LoadingOverlay } from "@/components/loading-overlay" ;
import { red } from "@/providers/theme-provider/colors" ;
import {
AnnualLeaveIcon ,
CasualLeaveIcon ,
DeleteIcon ,
NoTimeOffIcon ,
SickLeaveIcon ,
ThreeDotsIcon ,
PopoverTipIcon ,
} from "@/icons" ;
import { type Employee , TimeOffStatus , type TimeOff } from "@/types" ;
const variantMap = {
Annual : {
label : "Annual Leave" ,
iconColor : "primary.700" ,
iconBgColor : "primary.50" ,
icon : < AnnualLeaveIcon width = { 16 } height = { 16 } /> ,
} ,
Sick : {
label : "Sick Leave" ,
iconColor : "#C2410C" ,
iconBgColor : "#FFF7ED" ,
icon : < SickLeaveIcon width = { 16 } height = { 16 } /> ,
} ,
Casual : {
label : "Casual Leave" ,
iconColor : "grey.700" ,
iconBgColor : "grey.50" ,
icon : < CasualLeaveIcon width = { 16 } height = { 16 } /> ,
} ,
} as const ;
type Props = {
type : "upcoming" | "history" | "inReview" ;
} ;
export const TimeOffList = ( props : Props ) => {
const { data : employee } = useGetIdentity< Employee > ();
const { data, isLoading, hasNextPage, fetchNextPage } =
useInfiniteList < TimeOff > ( {
resource : "time-offs" ,
sorters : sorters[ props. type ] ,
filters : [
... filters[ props. type ] ,
{
field : "employeeId" ,
operator : "eq" ,
value : employee?. id,
} ,
] ,
queryOptions : {
enabled : ! ! employee?. id,
} ,
} );
const timeOffHistory = data?.pages.flatMap((page) => page.data) || [];
const hasData = isLoading || timeOffHistory.length !== 0;
if (props.type === "inReview" && !hasData) {
return null ;
}
return (
< Frame
sx = { ( theme ) => ( {
maxHeight : "362px" ,
paddingBottom : 0 ,
position : "relative" ,
"&::after" : {
pointerEvents : "none" ,
content : '""' ,
position : "absolute" ,
bottom : 0 ,
left : "24px" ,
right : "24px" ,
width : "80%" ,
height : "32px" ,
background :
"linear-gradient(180deg, rgba(255, 255, 255, 0), #FFFFFF)" ,
} ,
display : "flex" ,
flexDirection : "column" ,
} ) }
sxChildren = { {
paddingRight : 0 ,
paddingLeft : 0 ,
flex : 1 ,
overflow : "hidden" ,
} }
title = { title[ props. type ] }
>
< LoadingOverlay loading = { isLoading} sx = { { height : "100%" } } >
{ ! hasData ? (
< Box
sx = { {
display : "flex" ,
flexDirection : "column" ,
alignItems : "center" ,
justifyContent : "center" ,
gap : "24px" ,
height : "180px" ,
} }
>
< NoTimeOffIcon />
< Typography variant = " body2" color = " text.secondary" >
{ props. type === "history"
? "No time off used yet."
: "No upcoming time offs scheduled." }
</ Typography >
</ Box >
) : (
< Box
id = " scrollableDiv-timeOffHistory"
sx = { ( theme ) => ( {
maxHeight : "312px" ,
height : "auto" ,
[ theme. breakpoints . up ( "lg" ) ] : {
height : "312px" ,
} ,
overflow : "auto" ,
paddingLeft : "12px" ,
paddingRight : "12px" ,
} ) }
>
< InfiniteScroll
dataLength = { timeOffHistory. length }
next = { ( ) => fetchNextPage ( ) }
hasMore = { hasNextPage || false }
endMessage = {
! isLoading &&
hasData && (
< Box
sx = { {
pt : timeOffHistory. length > 3 ? "40px" : "16px" ,
} }
/>
)
}
scrollableTarget = " scrollableDiv-timeOffHistory"
loader = {
< Box
sx = { {
display : "flex" ,
alignItems : "center" ,
justifyContent : "center" ,
width : "100%" ,
height : "100px" ,
} }
>
< CircularProgress size = { 24 } />
</ Box >
}
>
< Box
sx = { {
display : "flex" ,
flexDirection : "column" ,
gap : "12px" ,
} }
>
{ timeOffHistory. map ( ( timeOff ) => {
return (
< ListItem
timeOff = { timeOff}
key = { timeOff. id }
type = { props. type }
/>
) ;
} ) }
</ Box >
</ InfiniteScroll >
</ Box >
) }
</ LoadingOverlay >
</ Frame >
);
};
const ListItem = ( {
timeOff,
type,
} : { timeOff : TimeOff ; type: Props [ "type" ] } ) => {
const { mutateAsync : timeOffCancel } = useDelete< TimeOff > ();
const [anchorEl, setAnchorEl] = useState<HTMLButtonElement | null>(null);
const [hovered, setHovered] = useState(false);
const diffrenceOfDays =
dayjs(timeOff.endsAt).diff(dayjs(timeOff.startsAt), "day") + 1;
const isSameDay = dayjs(timeOff.startsAt).isSame(
dayjs(timeOff.endsAt),
"day",
);
return (
< Box
key = { timeOff. id }
onMouseEnter = { ( ) => setHovered ( true ) }
onMouseLeave = { ( ) => setHovered ( false ) }
sx = { {
display : "flex" ,
alignItems : "center" ,
gap : "16px" ,
height : "64px" ,
paddingLeft : "12px" ,
paddingRight : "12px" ,
borderRadius : "64px" ,
backgroundColor : hovered ? "grey.50" : "transparent" ,
transition : "background-color 0.2s" ,
} }
>
< Box
sx = { {
display : "flex" ,
alignItems : "center" ,
justifyContent : "center" ,
color : variantMap[ timeOff. timeOffType ] . iconColor ,
backgroundColor : variantMap[ timeOff. timeOffType ] . iconBgColor ,
width : "40px" ,
height : "40px" ,
borderRadius : "100%" ,
} }
>
{ variantMap[ timeOff. timeOffType ] . icon }
</ Box >
< Box
sx = { {
display : "flex" ,
flexDirection : "column" ,
gap : "4px" ,
} }
>
< Box
sx = { {
display : "flex" ,
alignItems : "center" ,
gap : "4px" ,
} }
>
{ isSameDay ? (
< DateField
value = { timeOff. startsAt }
color = " text.secondary"
variant = " caption"
format = " MMMM DD"
/>
) : (
< >
< DateField
value = { timeOff. startsAt }
color = " text.secondary"
variant = " caption"
format = " MMMM DD"
/>
< Typography variant = " caption" color = " text.secondary" >
-
</ Typography >
< DateField
value = { timeOff. endsAt }
color = " text.secondary"
variant = " caption"
format = " MMMM DD"
/>
</ >
) }
</ Box >
< Typography variant = " body2" >
< span
style = { {
fontWeight : 500 ,
} }
>
{ diffrenceOfDays} { diffrenceOfDays > 1 ? "days" : "day" } of { " " }
</ span>
{ variantMap[ timeOff. timeOffType ] . label }
</ Typography >
</ Box >
{ hovered && ( type === "inReview" || type === "upcoming" ) && (
< IconButton
onClick = { ( e ) => setAnchorEl ( e. currentTarget ) }
sx = { {
display : "flex" ,
alignItems : "center" ,
justifyContent : "center" ,
width : "40px" ,
height : "40px" ,
marginLeft : "auto" ,
backgroundColor : "white" ,
borderRadius : "100%" ,
color : "grey.400" ,
border : ( theme ) => ` 1px solid ${ theme. palette . grey [ 400 ] } ` ,
flexShrink : 0 ,
} }
>
< ThreeDotsIcon />
</ IconButton >
) }
<Popover
id= { timeOff. id . toString ( ) }
open= { Boolean ( anchorEl) }
anchorEl= { anchorEl}
onClose= { ( ) => {
setAnchorEl ( null ) ;
setHovered ( false ) ;
} }
anchorOrigin= { {
vertical : "bottom" ,
horizontal : "center" ,
} }
transformOrigin= { {
vertical : "top" ,
horizontal : "center" ,
} }
sx= { {
"& .MuiPaper-root" : {
overflow : "visible" ,
borderRadius : "12px" ,
border : ( theme ) => ` 1px solid ${ theme. palette . grey [ 400 ] } ` ,
boxShadow : "0px 0px 0px 4px rgba(222, 229, 237, 0.25)" ,
} ,
} }
>
<Button
variant="text"
onClick= { async ( ) => {
await timeOffCancel ( {
resource : "time-offs" ,
id : timeOff. id ,
invalidates : [ "all" ] ,
successNotification : ( ) => {
return {
type : "success" ,
message : "Time off request cancelled successfully." ,
} ;
} ,
} ) ;
} }
sx= { {
position : "relative" ,
width : "200px" ,
height : "56px" ,
paddingLeft : "16px" ,
color : red[ 900 ] ,
display : "flex" ,
gap : "12px" ,
justifyContent : "flex-start" ,
"&:hover" : {
backgroundColor : "transparent" ,
} ,
} }
>
< DeleteIcon />
< Typography variant = " body2" > Cancel Request </ Typography >
< Box
sx = { {
width : "40px" ,
height : "16px" ,
position : "absolute" ,
top : "-2px" ,
left : "calc(50% - 1px)" ,
transform : "translate(-50%, -50%)" ,
} }
>
< PopoverTipIcon />
</ Box >
</ Button >
</ Popover >
</ Box >
);
};
const today = dayjs().toISOString();
const title: Record<Props["type"], string> = {
history : "Time Off History" ,
upcoming : "Upcoming Time Off" ,
inReview : "In Review" ,
} ;
const filters: Record<Props["type"], CrudFilters> = {
history : [
{
field : "status" ,
operator : "eq" ,
value : TimeOffStatus . APPROVED ,
} ,
{
field : "endsAt" ,
operator : "lt" ,
value : today,
} ,
] ,
upcoming : [
{
field : "status" ,
operator : "eq" ,
value : TimeOffStatus . APPROVED ,
} ,
{
field : "endsAt" ,
operator : "gte" ,
value : today,
} ,
] ,
inReview : [
{
field : "status" ,
operator : "eq" ,
value : TimeOffStatus . PENDING ,
} ,
] ,
} ;
const sorters: Record<Props["type"], CrudSort[]> = {
history : [ { field : "startsAt" , order : "desc" } ] ,
upcoming : [ { field : "endsAt" , order : "asc" } ] ,
inReview : [ { field : "startsAt" , order : "asc" } ] ,
} ;
list.tsx
ファイルは長いですが、そのほとんどはスタイリングとUIプレゼンテーションに関連しています。
<TimeOffList />
この<TimeOffList />
コンポーネントを三つの異なるコンテキストで使用します:
type
プロパティは、どの種類の休暇リストを表示するかを決定します:
inReview
:承認待ちの休暇リクエストを表示します。
upcoming
:承認済みだがまだ発生していない今後の休暇を表示します。
history
:承認され、すでに行われた休暇をリストします。
コンポーネント内では、type
プロパティに基づいてフィルターとソーターを作成します。これらのフィルターとソーターを使用して、APIから休暇データを取得します。
コンポーネントの主要な部分を分解してみましょう:
1. 現在のユーザーの取得
3. 休暇申請の取り消し
useDelete
: 休暇申請を削除するtimeOffCancel
関数を提供します。
ユーザーが休暇申請を取り消したときに使用します。
完了時に成功メッセージを表示します。
<DateField />
: 日付をユーザーフレンドリーな方法でフォーマットして表示します。
value
: 表示する日付。
format
: 日付のフォーマットを指定します(例:「1月05日」)。
5. type
に基づくフィルターとソーターの作成
フィルター:
ステータスと日付に基づいて休暇を取得する基準を定義します。
history
: 既に終了した承認済みの休暇を取得します。
upcoming
: 今後の承認済みの休暇を取得します。
ソーター:
<TimeOffLeaveCards />
コンポーネントを構築して、使用済みの休暇の統計を表示します。
leave-cards.tsx
という新しいファイルをsrc/components/time-offs
フォルダーに作成し、以下のコードを追加します:
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" ,
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" ,
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 />
<TimeOffLeaveCards />
コンポーネントは、従業員の休暇に関する統計を表示します。年次休暇、病気休暇、カジュアル休暇の3つのカードを表示し、利用可能または使用された日数を示します。
コンポーネントの重要な部分を分解してみましょう:
1. データの取得
2. カードの表示
コンポーネントは、各休暇タイプ用に1つずつ、3つのカードコンポーネントをレンダリングします。
各カードは以下を表示します:
休暇の種類(例:年次休暇)。
利用可能または使用された日数。
休暇の種類を表すアイコン。
3. ローディング状態の処理
データがまだ読み込み中の場合、実際の数値の代わりにスケルトンプレースホルダーが表示されます。
この状態を管理するためにloading
プロップがカードに渡されます。
4. カードコンポーネント
type
、value
、およびloading
をプロップとして受け取ります。
variantMap
を使用して、休暇の種類に基づいて正しいラベル、色、アイコンを取得します。
適切なスタイルで休暇情報を表示します。
<PageEmployeeTimeOffsList />
の構築
時間外リストや休暇カードを表示するためのコンポーネントが揃ったので、src/pages/employee/time-offs/
フォルダにlist.tsx
という新しいファイルを作成し、以下のコードを追加します。
src/pages/time-off.tsx
<PageEmployeeTimeOffsList />
は、時間外ページのメインコンポーネントであり、このコンポーネントを使用してユーザーが/employee/time-offs
ルートに移動したときに時間外リストと休暇カードを表示します。
<PageEmployeeTimeOffsList />
コンポーネントの主要な部分を解説します:
1. ユーザーの役割の確認
useCan
フックを使用して現在のユーザーがマネージャーかどうかを判断します。
ユーザーがマネージャー権限を持っている場合、isManager
をtrue
に設定します。
2. 役割に基づいたテーマの適用
コンテンツを<ThemeProvider />
内にラップします。
テーマは、ユーザーがマネージャーか従業員かによって変わります。
タイトル「Time Off」を持つ<PageHeader />
をレンダリングします。
<CreateButton />
がユーザーの役割に応じて変わります:
ユーザーがマネージャーであれば、ボタンには「Assign Time Off」と表示されます。
ユーザーがマネージャーでない場合は、「Request Time Off」と表示されます。
これは、権限をチェックする<CanAccess />
コンポーネントを使用して処理されます。
4. 休暇統計の表示
<TimeOffLeaveCards />
コンポーネントを含み、休暇の残高と使用状況を表示します。
これにより、年次、病気、カジュアル休暇の概要が提供されます。
5. 休暇リクエストのリスト表示
<Grid />
レイアウトを使用してコンテンツを整理します。
左側(md={6}
)には次のものが表示されます:
TimeOffList
(type="inReview"
): 保留中の休暇リクエストを表示します。
TimeOffList
(type="upcoming"
): 今後の承認済み休暇を表示します。
右側 (md={6}
) には次のものが表示されます:
TimeOffList
with type="history"
: すでに発生した過去の休暇を表示します。
“/employee/time-offs” ルートの追加
私たちは <PageEmployeeTimeOffsList />
コンポーネントを /employee/time-offs
ルートでレンダリングする準備ができました。このルートを含めるために App.tsx
ファイルを更新しましょう:
src/App.tsx
更新された App.tsx
ファイルの主要な部分を分解してみましょう:
1. 休暇リソースの定義
私たちは 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」リソースにリダイレクトします。
<NavigateToResource />
コンポーネントを使用して、ユーザーが/
ルートに移動するとtime-offs
リソースにリダイレクトします。これにより、ユーザーがデフォルトで休暇リストを表示できます。
3. ユーザーが認証されている場合に「time-offs」リソースにリダイレクトする
ユーザーが認証されている場合、time-offs
リソースにリダイレクトします。認証されていない場合はログインページが表示されます。
4. /employee/time-offs
ルートを追加する
従業員ページをネストされたルートを使用して整理します。まず、従業員固有のテーマとレイアウトでコンテンツをラップするpath='employee'
を持つメインルートを作成します。このルートの内部に、path='time-offs'
を追加し、PageEmployeeTimeOffsList
コンポーネントを表示します。この構造により、すべての従業員機能が1つのパスの下にグループ化され、スタイリングが一貫性を保ちます。
これらの変更を加えた後、/employee/time-offs
ルートに移動して、アクションを起こすための休暇リストページを表示できます。
/employee/time-offs
現時点では、休暇リストページは機能していますが、新しい休暇リクエストを作成する機能がありません。新しい休暇リクエストを作成する機能を追加しましょう。
新しい休暇申請または割り当てページを作成します。 このページには、ユーザーが休暇の種類、開始日と終了日、および追加のメモを指定できるフォームが含まれます。
始める前に、フォームで使用する新しいコンポーネントを作成する必要があります:
src/components/time-offs/
フォルダーにform-summary.tsx
という新しいファイルを作成し、次のコードを追加してください。
src/components/time-offs/form-summary.tsx
<TimeOffFormSummary />
<TimeOffFormSummary />
コンポーネントは、休暇申請の概要を表示します。 利用可能な有給休暇日数、リクエストされた日数、残りの日数が表示されます。このコンポーネントを使用して、ユーザーにリクエストの明確な概要を提供します。
<PageEmployeeTimeOffsCreate />
コンポーネントを作成
src/pages/employee/time-offs/
フォルダーにcreate.tsx
という新しいファイルを作成し、次のコードを追加してください。
src/pages/time-offs/create.tsx
import { useCan, useGetIdentity, type HttpError } from "@refinedev/core" ;
import { useForm } from "@refinedev/react-hook-form" ;
import { Controller } from "react-hook-form" ;
import type { DateRange } from "@mui/x-date-pickers-pro/models" ;
import { Box , Button , MenuItem , Select , Typography } from "@mui/material" ;
import dayjs from "dayjs" ;
import { PageHeader } from "@/components/layout/page-header" ;
import { InputText } from "@/components/input/text" ;
import { LoadingOverlay } from "@/components/loading-overlay" ;
import { InputDateStartsEnds } from "@/components/input/date-starts-ends" ;
import { TimeOffFormSummary } from "@/components/time-offs/form-summary" ;
import { ThemeProvider } from "@/providers/theme-provider" ;
import {
type Employee ,
type TimeOff ,
TimeOffType ,
TimeOffStatus ,
Role ,
} from "@/types" ;
import { CheckRectangleIcon } from "@/icons" ;
type FormValues = Omit < TimeOff , "id" | "notes" > & {
notes : string;
dates : DateRange < dayjs.Dayjs> ;
};
export const PageEmployeeTimeOffsCreate = () => {
const { data : useCanData } = useCan ( {
action : "manager" ,
params : {
resource : {
name : "time-offs" ,
meta : {
scope : "manager" ,
} ,
} ,
} ,
} ) ;
const isManager = useCanData?. can;
const { data : employee } =
useGetIdentity< Employee > ();
const {
refineCore : { formLoading, onFinish } ,
... formMethods
} = useForm<TimeOff, HttpError, FormValues>( {
defaultValues : {
timeOffType : TimeOffType . ANNUAL ,
notes : "" ,
dates : [ null , null ] ,
} ,
refineCoreProps : {
successNotification : ( ) => {
return {
message : isManager
? "Time off assigned"
: "Your time off request is submitted for review." ,
type : "success" ,
} ;
} ,
} ,
} );
const { control, handleSubmit, formState, watch } = formMethods;
const onFinishHandler = async (values: FormValues) => {
const payload : FormValues = {
... values,
startsAt : dayjs ( values. dates [ 0 ] ) . format ( "YYYY-MM-DD" ) ,
endsAt : dayjs ( values. dates [ 1 ] ) . format ( "YYYY-MM-DD" ) ,
... ( isManager && {
status : TimeOffStatus . APPROVED ,
} ) ,
} ;
await onFinish ( payload) ;
} ;
const timeOffType = watch("timeOffType");
const selectedDays = watch("dates");
const startsAt = selectedDays[0];
const endsAt = selectedDays[1];
const availableAnnualDays = employee?.availableAnnualLeaveDays ?? 0;
const requestedDays =
startsAt && endsAt ? endsAt.diff(startsAt, "day") + 1 : 0;
return (
< ThemeProvider role = { isManager ? Role . MANAGER : Role . EMPLOYEE } >
< LoadingOverlay loading = { formLoading} >
< Box >
< PageHeader
title = { isManager ? "Assign Time Off" : "Request Time Off" }
showListButton
showDivider
/>
< Box
component = " form"
onSubmit = { handleSubmit ( onFinishHandler) }
sx = { {
display : "flex" ,
flexDirection : "column" ,
gap : "24px" ,
marginTop : "24px" ,
} }
>
< Box >
< Typography
variant = " body2"
sx = { {
mb : "8px" ,
} }
>
Time Off Type
</ Typography >
<Controller
name="timeOffType"
control= { control}
render= { ( { field } ) => (
< Select
{ ... field}
size = " small"
sx = { {
minWidth : "240px" ,
height : "40px" ,
"& .MuiSelect-select" : {
paddingBlock : "10px" ,
} ,
} }
>
< MenuItem value = { TimeOffType . ANNUAL } > Annual Leave </ MenuItem >
< MenuItem value = { TimeOffType . CASUAL } > Casual Leave </ MenuItem >
< MenuItem value = { TimeOffType . SICK } > Sick Leave </ MenuItem >
</ Select >
) }
/>
</ Box >
< Box >
< Typography
variant = " body2"
sx = { {
mb : "16px" ,
} }
>
Requested Dates
</ Typography >
<Controller
name="dates"
control= { control}
rules= { {
validate : ( value ) => {
if ( ! value[ 0 ] || ! value[ 1 ] ) {
return "Please select both start and end dates" ;
}
return true ;
} ,
} }
render= { ( { field } ) => {
return (
< Box
sx= { {
display : "grid" ,
gridTemplateColumns : ( ) => {
return {
sm : "1fr" ,
lg : "628px 1fr" ,
} ;
} ,
gap : "40px" ,
} }
>
< InputDateStartsEnds
{ ... field}
error = { formState. errors . dates ?. message}
availableAnnualDays = { availableAnnualDays}
requestedDays = { requestedDays}
/>
{ timeOffType === TimeOffType . ANNUAL && (
< Box
sx= { {
display : "flex" ,
maxWidth : "628px" ,
alignItems : ( ) => {
return {
lg : "flex-end" ,
} ;
} ,
justifyContent : ( ) => {
return {
xs : "flex-end" ,
lg : "flex-start" ,
} ;
} ,
} }
>
< TimeOffFormSummary
availableAnnualDays = { availableAnnualDays}
requestedDays = { requestedDays}
/>
</ Box >
)}
</ Box >
);
}}
/>
</ Box >
< Box
sx = { {
maxWidth : "628px" ,
} }
>
< Controller
name = " notes"
control = { control}
render = { ( { field, fieldState } ) => {
return (
< InputText
{ ... field}
label = " Notes"
error = { fieldState. error ?. message}
placeholder = " Place enter your notes"
multiline
rows = { 3 }
/>
) ;
} }
/>
</ Box >
< Button
variant = " contained"
size = " large"
type = " submit"
startIcon = { isManager ? < CheckRectangleIcon /> : undefined }
>
{ isManager ? "Assign" : "Send Request" }
</ Button >
</ Box >
</ Box >
</ LoadingOverlay >
</ ThemeProvider >
);
};
<PageEmployeeTimeOffsCreate />
<PageEmployeeTimeOffsCreate />
コンポーネントは、HR管理アプリ内で新しい休暇リクエストを作成するためのフォームを表示します。従業員とマネージャーの両方が使用して、休暇の申請または割り当てを行うことができます。フォームには、休暇の種類を選択するオプション、開始日と終了日の選択、メモの追加が含まれ、リクエストされた休暇の概要が表示されます。
コンポーネントの主な部分を分解してみましょう:
1. ユーザーの役割をチェック
useCan
フックを使用して、現在のユーザーが管理者権限を持っているかどうかを確認します。これにより、ユーザーが休暇を割り当てることができるか、またはそれを要求することしかできないかが決まります。ユーザーの役割に基づいて、onFinishHandler
でフォームの送信を異なる方法で処理します。
useForm
は、デフォルト値でフォームを初期化し、ユーザーの役割に基づいて成功通知を設定します。onFinishHandler
関数は、送信前にフォームデータを処理します。管理者の場合、ステータスはすぐにAPPROVED
に設定され、従業員のリクエストは審査のために提出されます。
3. スタイリング
私たちのデザインでは、ユーザーの役割に応じて主要な色が変わります。<ThemeProvider />
を使用して、正しいテーマを適用します。送信ボタンのテキストとアイコンも、ユーザーが管理者か従業員かによって変わります。
4. “/employee/time-offs/create” ルートの追加
休暇作成ページのために新しいルートを追加する必要があります。App.tsx
ファイルを更新してこのルートを含めましょう:
src/App.tsx
これらの変更を加えた後、/employee/time-offs/create
ルートに移動するか、休暇リストページの「休暇を割り当てる」ボタンをクリックして休暇作成フォームにアクセスできます。
/employee/time-offs/create
このステップでは、休暇申請を管理する新しいページを作成します。このページでは、マネージャーが従業員が提出した休暇申請を確認し、承認または拒否することができます。
/manager/requests
休暇申請を管理するための新しいページを作成します。このページには、従業員の名前、休暇の種類、申請された日付、および現在のステータスなどの詳細が表示されます。
始める前に、リストで使用する新しいコンポーネントを作成する必要があります:
<RequestsList />
コンポーネントの作成
list.tsx
という新しいファイルをsrc/components/requests/
フォルダに作成し、以下のコードを追加します:
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
という新しいファイルを作成し、以下のコードを追加します:
src/components/requests/list-item.tsx
<RequestsListItem />
コンポーネントは、リスト内の単一の休暇リクエストを表示します。従業員のアバター、名前、説明、およびリクエストの詳細を表示するボタンが含まれています。このコンポーネントは再利用可能で、休暇リクエストのリスト内の各項目をレンダリングするために使用できます。
コンポーネント <PageManagerRequestsList />
の構築
src/pages/manager/requests/
フォルダーに list.tsx
という新しいファイルを作成し、以下のコードを追加します:
<PageManagerRequestsList />
コンポーネントは、マネージャーが承認する必要がある保留中の休暇リクエストを表示します。従業員の名前、休暇の種類、リクエストされた日付、およびリクエストが行われた時期などの詳細が表示されます。マネージャーはリクエストをクリックして詳細を見ることができます。このコンポーネントは <RequestsList />
と <RequestsListItem />
を使用してリストをレンダリングします。
このコンポーネントはまた、プロップとして children
を受け取ります。次に、リクエストの詳細を表示するために <Outlet />
を使用したモーダルルートを実装します。 コンポーネント内で /manager/requests/:id
ルートをレンダリングします。
“/manager/requests” ルートの追加
新しい休暇リクエスト管理ページのルートを追加する必要があります。このルートを含めるためにApp.tsx
ファイルを更新しましょう。
src/App.tsx
import { Authenticated , ErrorComponent , Refine } from '@refinedev/core'
import { DevtoolsProvider , DevtoolsPanel } from '@refinedev/devtools'
import dataProvider from '@refinedev/nestjsx-crud'
import routerProvider, {
UnsavedChangesNotifier ,
DocumentTitleHandler ,
NavigateToResource ,
} from '@refinedev/react-router-v6'
import { BrowserRouter , Routes , Route , Outlet } from 'react-router-dom'
import { Toaster } from 'react-hot-toast'
import { PageEmployeeTimeOffsList } from '@/pages/employee/time-offs/list'
import { PageEmployeeTimeOffsCreate } from '@/pages/employee/time-offs/create'
import { PageLogin } from '@/pages/login'
import { Layout } from '@/components/layout'
import { ThemeProvider } from '@/providers/theme-provider'
import { authProvider } from '@/providers/auth-provider'
import { accessControlProvider } from '@/providers/access-control'
import { useNotificationProvider } from '@/providers/notification-provider'
import { queryClient } from '@/providers/query-client'
import { BASE_URL } from '@/utilities/constants'
import { axiosInstance } from '@/utilities/axios'
import { RequestsIcon , TimeOffIcon } from '@/icons'
import { Role } from '@/types'
import '@/utilities/init-dayjs'
import { PageManagerRequestsList } from './pages/manager/requests/list'
function App ( ) {
return (
< BrowserRouter >
< ThemeProvider >
< DevtoolsProvider >
< Refine
authProvider = { authProvider}
routerProvider = { routerProvider}
dataProvider = { dataProvider ( BASE_URL , axiosInstance) }
notificationProvider = { useNotificationProvider}
resources = { [
{
name : 'employee' ,
meta : {
scope : Role . EMPLOYEE ,
order : 2 ,
} ,
} ,
{
name : 'manager' ,
meta : {
scope : Role . MANAGER ,
order : 1 ,
} ,
} ,
{
name : 'time-offs' ,
list : '/employee/time-offs' ,
create : '/employee/time-offs/new' ,
meta : {
parent : 'employee' ,
scope : Role . EMPLOYEE ,
label : 'Time Off' ,
icon : < TimeOffIcon /> ,
} ,
} ,
{
name : 'time-offs' ,
list : '/manager/requests' ,
identifier : 'requests' ,
meta : {
parent : 'manager' ,
scope : Role . MANAGER ,
label : 'Requests' ,
icon : < RequestsIcon /> ,
} ,
} ,
] }
accessControlProvider = { accessControlProvider}
options = { {
reactQuery : {
clientConfig : queryClient,
} ,
syncWithLocation : true ,
warnWhenUnsavedChanges : true ,
useNewQueryKeys : true ,
} } >
< Routes >
< Route
element = {
< Authenticated key = ' authenticated-routes' redirectOnFail = ' /login' >
< Outlet />
</ Authenticated >
} >
< Route index element = { < NavigateToResource resource = ' time-offs' /> } />
< Route
path = ' employee'
element = {
< ThemeProvider role = { Role . EMPLOYEE } >
< Layout >
< Outlet />
</ Layout >
</ ThemeProvider >
} >
< Route path = ' time-offs' element = { < Outlet /> } >
< Route index element = { < PageEmployeeTimeOffsList /> } />
< Route path = ' new' element = { < PageEmployeeTimeOffsCreate /> } />
</ Route >
</ Route >
</ Route >
< Route
path = ' manager'
element = {
< ThemeProvider role = { Role . MANAGER } >
< Layout >
< Outlet />
</ Layout >
</ ThemeProvider >
} >
< Route path = ' requests' element = { < Outlet /> } >
< Route index element = { < PageManagerRequestsList /> } />
</ Route >
</ Route >
< Route
element = {
< Authenticated key = ' auth-pages' fallback = { < Outlet /> } >
< NavigateToResource resource = ' time-offs' />
</ Authenticated >
} >
< Route path = ' /login' element = { < PageLogin /> } />
</ Route >
< Route
element = {
< Authenticated key = ' catch-all' >
< Layout >
< Outlet />
</ Layout >
</ Authenticated >
} >
< Route path = ' *' element = { < ErrorComponent /> } />
</ Route >
</ Routes >
< UnsavedChangesNotifier />
< DocumentTitleHandler />
< Toaster position = ' bottom-right' reverseOrder = { false } />
< DevtoolsPanel />
</ Refine >
</ DevtoolsProvider >
</ ThemeProvider >
</ BrowserRouter >
)
}
export default App
これらの変更を追加した後、/manager/requests
ルートに移動して、休暇リクエスト管理ページを確認できます。
/manager/requests
このステップでは、休暇リクエストの詳細を表示する新しいページを作成します。このページには従業員の名前、休暇の種類、リクエストされた日付、および現在の状態が表示されます。マネージャーはこのページからリクエストを承認または拒否することができます。
<TimeOffRequestModal />
コンポーネントを構築
まず、src/hooks/
フォルダにuse-get-employee-time-off-usage
というファイルを作成し、以下のコードを追加します。
src/hooks/use-get-employee-time-off-usage.ts
useGetEmployeeTimeOffUsage
フックを使用して、従業員が各種類の休暇を取得した日数の合計を計算します。この情報は休暇リクエスト詳細ページに表示されます。
その後、src/components/requests/
フォルダにtime-off-request-modal.tsx
という新しいファイルを作成し、以下のコードを追加します。
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. 重複した承認された休暇の取得
上記のフィルタを使用したuseList
フックは、現在の休暇リクエストと重複するすべての承認された休暇を取得します。このリストは、リクエストされた日付の間に外出中の従業員を表示するために使用されます。
3. 休暇リクエストの承認/拒否の処理
マネージャが休暇リクエストを承認または拒否するときにhandleSubmit
関数が呼び出されます。
Refineは、リソースが変異した後にリソースキャッシュを自動的に無効にします(この場合はtime-offs
)。
従業員の休暇利用状況が休暇履歴に基づいて計算されるため、従業員の休暇利用状況を更新するためにemployees
リソースキャッシュも無効にします。
「/manager/requests/:id」ルートの追加
このステップでは、マネージャが承認または拒否を行うことができる休暇リクエストの詳細ページを表示する新しいルートを作成します。
次に、src/pages/manager/requests/time-offs/
フォルダにedit.tsx
という新しいファイルを作成し、以下のコードを追加します:
src/pages/manager/requests/time-offs/edit.tsx
次に、休暇リクエストの詳細ページをレンダリングするための新しいルートを追加する必要があります。このルートを含めるためにApp.tsx
ファイルを更新しましょう:
src/App.tsx
import { Authenticated , ErrorComponent , Refine } from '@refinedev/core'
import { DevtoolsProvider , DevtoolsPanel } from '@refinedev/devtools'
import dataProvider from '@refinedev/nestjsx-crud'
import routerProvider, {
UnsavedChangesNotifier ,
DocumentTitleHandler ,
NavigateToResource ,
} from '@refinedev/react-router-v6'
import { BrowserRouter , Routes , Route , Outlet } from 'react-router-dom'
import { Toaster } from 'react-hot-toast'
import { PageEmployeeTimeOffsList } from '@/pages/employee/time-offs/list'
import { PageEmployeeTimeOffsCreate } from '@/pages/employee/time-offs/create'
import { PageManagerRequestsList } from '@/pages/manager/requests/list'
import { PageManagerRequestsTimeOffsEdit } from '@/pages/manager/requests/time-offs/edit'
import { PageLogin } from '@/pages/login'
import { Layout } from '@/components/layout'
import { ThemeProvider } from '@/providers/theme-provider'
import { authProvider } from '@/providers/auth-provider'
import { accessControlProvider } from '@/providers/access-control'
import { useNotificationProvider } from '@/providers/notification-provider'
import { queryClient } from '@/providers/query-client'
import { BASE_URL } from '@/utilities/constants'
import { axiosInstance } from '@/utilities/axios'
import { RequestsIcon , TimeOffIcon } from '@/icons'
import { Role } from '@/types'
import '@/utilities/init-dayjs'
function App ( ) {
return (
< BrowserRouter >
< ThemeProvider >
< DevtoolsProvider >
< Refine
authProvider = { authProvider}
routerProvider = { routerProvider}
dataProvider = { dataProvider ( BASE_URL , axiosInstance) }
notificationProvider = { useNotificationProvider}
resources = { [
{
name : 'employee' ,
meta : {
scope : Role . EMPLOYEE ,
order : 2 ,
} ,
} ,
{
name : 'manager' ,
meta : {
scope : Role . MANAGER ,
order : 1 ,
} ,
} ,
{
name : 'time-offs' ,
list : '/employee/time-offs' ,
create : '/employee/time-offs/new' ,
meta : {
parent : 'employee' ,
scope : Role . EMPLOYEE ,
label : 'Time Off' ,
icon : < TimeOffIcon /> ,
} ,
} ,
{
name : 'time-offs' ,
list : '/manager/requests' ,
edit : '/manager/requests/:id/edit' ,
identifier : 'requests' ,
meta : {
parent : 'manager' ,
scope : Role . MANAGER ,
label : 'Requests' ,
icon : < RequestsIcon /> ,
} ,
} ,
] }
accessControlProvider = { accessControlProvider}
options = { {
reactQuery : {
clientConfig : queryClient,
} ,
syncWithLocation : true ,
warnWhenUnsavedChanges : true ,
useNewQueryKeys : true ,
} } >
< Routes >
< Route
element = {
< Authenticated key = ' authenticated-routes' redirectOnFail = ' /login' >
< Outlet />
</ Authenticated >
} >
< Route index element = { < NavigateToResource resource = ' time-offs' /> } />
< Route
path = ' employee'
element = {
< ThemeProvider role = { Role . EMPLOYEE } >
< Layout >
< Outlet />
</ Layout >
</ ThemeProvider >
} >
< Route path = ' time-offs' element = { < Outlet /> } >
< Route index element = { < PageEmployeeTimeOffsList /> } />
< Route path = ' new' element = { < PageEmployeeTimeOffsCreate /> } />
</ Route >
</ Route >
</ Route >
< Route
path = ' manager'
element = {
< ThemeProvider role = { Role . MANAGER } >
< Layout >
< Outlet />
</ Layout >
</ ThemeProvider >
} >
< Route
path = ' requests'
element = {
< PageManagerRequestsList >
< Outlet />
</ PageManagerRequestsList >
} >
< Route path = ' :id/edit' element = { < PageManagerRequestsTimeOffsEdit /> } />
</ Route >
</ Route >
< Route
element = {
< Authenticated key = ' auth-pages' fallback = { < Outlet /> } >
< NavigateToResource resource = ' time-offs' />
</ Authenticated >
} >
< Route path = ' /login' element = { < PageLogin /> } />
</ Route >
< Route
element = {
< Authenticated key = ' catch-all' >
< Layout >
< Outlet />
</ Layout >
</ Authenticated >
} >
< Route path = ' *' element = { < ErrorComponent /> } />
</ Route >
</ Routes >
< UnsavedChangesNotifier />
< DocumentTitleHandler />
< Toaster position = ' bottom-right' reverseOrder = { false } />
< DevtoolsPanel />
</ Refine >
</ DevtoolsProvider >
</ ThemeProvider >
</ BrowserRouter >
)
}
export default App
変更点を詳しく見てみましょう:
上記のコードは、特定の子ルートに移動するとモーダルが表示される入れ子のルート構造を設定します。<PageManagerRequestsTimeOffsEdit />
コンポーネントはモーダルであり、<PageManagerRequestsList />
コンポーネントの子としてレンダリングされます。この構造により、リストページが背景に表示されている状態で、リストページの上にモーダルを表示することができます。
/manager/requests/:id/edit
ルートに移動するか、リスト内の休暇リクエストをクリックすると、休暇リクエストの詳細ページがリストページの上にモーダルとして表示されます。
/manager/requests/:id/edit
承認は、企業レベルのアプリケーションにおいて重要な要素であり、セキュリティと運用効率の両方において重要な役割を果たします。承認は、認可されたユーザーのみが特定のリソースにアクセスできることを保証し、機密データや機能を保護します。Refineの承認システム は、リソースを保護し、ユーザーがアプリケーションと安全かつ制御された方法で対話できるようにするために必要なインフラストラクチャを提供します。このステップでは、休暇リクエスト管理機能のために承認とアクセス制御を実装します。<CanAccess />
コンポーネントの助けを借りて、/manager/requests
および/manager/requests/:id/edit
ルートへのアクセスをマネージャーのみに制限します。
現在、従業員としてログインすると、サイドバーにRequests
ページリンクが表示されませんが、ブラウザにURLを入力することで/manager/requests
ルートにはアクセスできます。これらのルートへの不正アクセスを防ぐためにガードを追加します。
承認チェックを含めるためにApp.tsx
ファイルを更新しましょう:
src/App.tsx
import { Authenticated , CanAccess , ErrorComponent , Refine } from '@refinedev/core'
import { DevtoolsProvider , DevtoolsPanel } from '@refinedev/devtools'
import dataProvider from '@refinedev/nestjsx-crud'
import routerProvider, {
UnsavedChangesNotifier ,
DocumentTitleHandler ,
NavigateToResource ,
} from '@refinedev/react-router-v6'
import { BrowserRouter , Routes , Route , Outlet } from 'react-router-dom'
import { Toaster } from 'react-hot-toast'
import { PageEmployeeTimeOffsList } from '@/pages/employee/time-offs/list'
import { PageEmployeeTimeOffsCreate } from '@/pages/employee/time-offs/create'
import { PageManagerRequestsList } from '@/pages/manager/requests/list'
import { PageManagerRequestsTimeOffsEdit } from '@/pages/manager/requests/time-offs/edit'
import { PageLogin } from '@/pages/login'
import { Layout } from '@/components/layout'
import { ThemeProvider } from '@/providers/theme-provider'
import { authProvider } from '@/providers/auth-provider'
import { accessControlProvider } from '@/providers/access-control'
import { useNotificationProvider } from '@/providers/notification-provider'
import { queryClient } from '@/providers/query-client'
import { BASE_URL } from '@/utilities/constants'
import { axiosInstance } from '@/utilities/axios'
import { RequestsIcon , TimeOffIcon } from '@/icons'
import { Role } from '@/types'
import '@/utilities/init-dayjs'
function App ( ) {
return (
< BrowserRouter >
< ThemeProvider >
< DevtoolsProvider >
< Refine
authProvider = { authProvider}
routerProvider = { routerProvider}
dataProvider = { dataProvider ( BASE_URL , axiosInstance) }
notificationProvider = { useNotificationProvider}
resources = { [
{
name : 'employee' ,
meta : {
scope : Role . EMPLOYEE ,
order : 2 ,
} ,
} ,
{
name : 'manager' ,
meta : {
scope : Role . MANAGER ,
order : 1 ,
} ,
} ,
{
name : 'time-offs' ,
list : '/employee/time-offs' ,
create : '/employee/time-offs/new' ,
meta : {
parent : 'employee' ,
scope : Role . EMPLOYEE ,
label : 'Time Off' ,
icon : < TimeOffIcon /> ,
} ,
} ,
{
name : 'time-offs' ,
list : '/manager/requests' ,
edit : '/manager/requests/:id/edit' ,
identifier : 'requests' ,
meta : {
parent : 'manager' ,
scope : Role . MANAGER ,
label : 'Requests' ,
icon : < RequestsIcon /> ,
} ,
} ,
] }
accessControlProvider = { accessControlProvider}
options = { {
reactQuery : {
clientConfig : queryClient,
} ,
syncWithLocation : true ,
warnWhenUnsavedChanges : true ,
useNewQueryKeys : true ,
} } >
< Routes >
< Route
element = {
< Authenticated key = ' authenticated-routes' redirectOnFail = ' /login' >
< Outlet />
</ Authenticated >
} >
< Route index element = { < NavigateToResource resource = ' time-offs' /> } />
< Route
path = ' employee'
element = {
< ThemeProvider role = { Role . EMPLOYEE } >
< Layout >
< Outlet />
</ Layout >
</ ThemeProvider >
} >
< Route path = ' time-offs' element = { < Outlet /> } >
< Route index element = { < PageEmployeeTimeOffsList /> } />
< Route path = ' new' element = { < PageEmployeeTimeOffsCreate /> } />
</ Route >
</ Route >
</ Route >
< Route
path = ' manager'
element = {
< ThemeProvider role = { Role . MANAGER } >
< Layout >
< CanAccess action = ' manager' fallback = { < NavigateToResource resource = ' time-offs' /> } >
< Outlet />
</ CanAccess >
</ Layout >
</ ThemeProvider >
} >
< Route
path = ' requests'
element = {
< PageManagerRequestsList >
< Outlet />
</ PageManagerRequestsList >
} >
< Route path = ' :id/edit' element = { < PageManagerRequestsTimeOffsEdit /> } />
</ Route >
</ Route >
< Route
element = {
< Authenticated key = ' auth-pages' fallback = { < Outlet /> } >
< NavigateToResource resource = ' time-offs' />
</ Authenticated >
} >
< Route path = ' /login' element = { < PageLogin /> } />
</ Route >
< Route
element = {
< Authenticated key = ' catch-all' >
< Layout >
< Outlet />
</ Layout >
</ Authenticated >
} >
< Route path = ' *' element = { < ErrorComponent /> } />
</ Route >
</ Routes >
< UnsavedChangesNotifier />
< DocumentTitleHandler />
< Toaster position = ' bottom-right' reverseOrder = { false } />
< DevtoolsPanel />
</ Refine >
</ DevtoolsProvider >
</ ThemeProvider >
</ BrowserRouter >
)
}
export default App
上記のコードでは、<CanAccess />
コンポーネントを「/manager」ルートに追加しました。このコンポーネントは、ユーザーが子ルートをレンダリングする前に「マネージャー」ロールを持っているかどうかを確認します。ユーザーが「マネージャー」ロールを持っていない場合、彼らは従業員のための休暇リストページにリダイレクトされます。
従業員としてログインし、/manager/requests
ルートにアクセスしようとすると、従業員用の休暇リストページにリダイレクトされます。
このステップでは、アプリケーションをDigitalOcean Appプラットフォームにデプロイします。これを行うために、ソースコードをGitHubにホストし、GitHubリポジトリをAppプラットフォームに接続します。
GitHubアカウントにログインし、refine-hr
という名前の新しいリポジトリ を作成します。リポジトリを公開または非公開に設定できます:
リポジトリを作成したら、プロジェクトディレクトリに移動して、次のコマンドを実行して新しいGitリポジトリを初期化します:
次に、次のコマンドですべてのファイルをGitリポジトリに追加します:
その後、次のコマンドでファイルをコミットします:
次に、次のコマンドでGitHubリポジトリをリモートリポジトリとして追加します:
次に、次のコマンドでコードをmain
ブランチにプッシュすることを指定します:
最後に、このコマンドを使用してコードをGitHubリポジトリにプッシュします:
プロンプトが表示されたら、GitHubの資格情報を入力してコードをプッシュします。
コードがGitHubリポジトリにプッシュされると、成功メッセージが表示されます。
このセクションでは、プロジェクトをGitHubにプッシュして、DigitalOcean Appsを使用してアクセスできるようにしました。次のステップは、プロジェクトを使用して新しいDigitalOceanアプリを作成し、自動デプロイを設定することです。
この過程では、Reactアプリケーションを取り、DigitalOceanのアプリプラットフォームを介してデプロイする準備を行います。GitHubリポジトリをDigitalOceanにリンクし、アプリがどのようにビルドされるかを設定し、プロジェクトの初期デプロイを作成します。プロジェクトがデプロイされた後、行った追加の変更は自動的に再ビルドされ、更新されます。
このステップの終わりまでに、継続的デリバリーに対応したアプリケーションがDigitalOceanにデプロイされている状態になります。
DigitalOceanアカウントにログインし、アプリ ページに移動します。アプリを作成 ボタンをクリックします:
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
の代わりに。
このチュートリアルでは、Refineを使ってゼロからHR管理アプリケーションを構築し、完全に機能するCRUDアプリの作り方に親しみました。
また、アプリケーションをDigitalOcean App Platform にデプロイする方法も示します。
Refineについてもっと学びたい場合は、ドキュメント を確認することができ、質問やフィードバックがある場合は、Refine Discordサーバー に参加できます。