Refine을 사용한 HR 앱 구축 및 배포

소개

이 튜토리얼에서는 Refine Framework를 사용하여 HR 관리 애플리케이션을 구축하고 DigitalOcean App Platform에 배포할 것입니다.

이 튜토리얼의 끝에는 다음이 포함 된 HR 관리 애플리케이션이 생성됩니다:

  • 로그인 페이지: 사용자가 관리자 또는 직원으로 로그인할 수 있습니다. 관리자는 휴가요청 페이지에 액세스 할 수 있으며, 직원은 휴가 페이지에만 액세스 할 수 있습니다.
  • 휴가 페이지: 직원이 휴가를 요청, 보기 및 취소할 수 있습니다. 또한 관리자는 새로운 휴가를 할당할 수 있습니다.
  • 요청 페이지: HR 관리자만이 휴가 요청을 승인 또는 거부할 수 있습니다.

참고: 이 튜토리얼에서 구축 할 앱의 전체 소스 코드는 이 GitHub 리포지토리에서 얻을 수 있습니다.

이 작업을 수행하는 동안 사용할 것입니다:

  • 레스트 API: 데이터를 가져오고 업데이트하기 위한 것입니다. Refine에는 기본 데이터 제공자 패키지와 REST API가 내장되어 있지만, 사용자 고유의 요구 사항에 맞게 직접 만들 수도 있습니다. 이 안내서에서는 백엔드 서비스로 NestJs CRUD를 사용하고 데이터 제공자로 @refinedev/nestjsx-crud 패키지를 사용할 것입니다.
  • 매터리얼 UI: UI 구성 요소에 사용하며, 디자인에 따라 완전히 사용자 정의할 것입니다. Refine에는 매터리얼 UI를 지원하는 기능이 내장되어 있지만, 원하는 UI 라이브러리를 사용할 수 있습니다.

앱을 만든 후에는 DigitalOcean의 앱 플랫폼을 사용하여 온라인으로 배포할 것입니다. 이를 통해 앱 및 정적 웹사이트를 쉽게 설정, 시작 및 확장할 수 있습니다. GitHub 저장소를 가리키기만 하면 코드를 배포하고 앱 플랫폼이 인프라, 앱 런타임 및 종속성 관리의 부담을 대신할 것입니다.

전제 조건

Refine은 무엇인가요?

Refine은 복잡한 B2B 웹 애플리케이션을 구축하기 위한 오픈 소스 React 메타 프레임워크로, 주로 내부 도구, 관리자 패널 및 대시보드와 같은 데이터 관리 중심 사용 사례에 중점을 둡니다. 개발자의 작업 흐름을 개선하기 위해 일련의 훅(hooks)과 컴포넌트를 제공하여 설계되었습니다.

이는 기업급 앱에 대해 제품 출시 준비가 완료된 기능을 제공하여 상태 및 데이터 관리, 인증 및 액세스 제어와 같은 유료 작업을 간소화합니다. 이를 통해 개발자가 많은 방대한 구현 세부 정보와 추상화된 상태로 핵심 응용프로그램에 집중할 수 있도록 합니다.

단계 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: TypeScript 경로 별칭을 Vite 프로젝트에서 사용할 수 있게 해주는 Vite 플러그인입니다.
npm install @mui/x-date-pickers @mui/x-date-pickers-pro dayjs react-hot-toast react-infinite-scroll-component
npm install --save-dev vite-tsconfig-paths

의존성을 설치한 후 vite.config.tstsconfig.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:
  • 유틸리티:
    • constants.ts: 앱 상수.
    • axios.ts: API 요청을 위한 Axios 인스턴스, 액세스 토큰, 리프레시 토큰 및 오류 처리.
    • init-dayjs.ts: 필요한 플러그인으로 Day.js 초기화.
  • 제공자:
    • 접근 제어: accessControlProvider를 사용하여 사용자 권한을 관리하며, 사용자 역할에 따라 요청 페이지의 가시성을 제어합니다.
    • 인증 제공자: authProvider로 인증을 관리하며, 모든 페이지가 보호되고 로그인이 필요하도록 보장합니다.
    • 알림 제공자: react-hot-toast를 통해 성공 및 오류 메시지를 표시합니다.
    • 쿼리 클라이언트: 완전한 제어와 사용자 지정을 위한 맞춤형 쿼리 클라이언트입니다.
    • 테마 제공자: Material UI 테마를 관리합니다.
  • 구성 요소:
    • 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 파일을 업데이트하십시오.

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

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

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

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

import { Role } from './types'

import '@/utilities/init-dayjs'

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

export default App


  • 우리가 App.tsx 파일에 한 중요한 변경 사항을 살펴보겠습니다:<Refine />: 애플리케이션 전체를 감싸 데이터 가져오기, 상태 관리 및 기타 기능을 제공하는 @refinedev/core의 핵심 컴포넌트입니다.
  • <DevtoolsProvider /><DevtoolsPanel />: 디버깅 및 개발 목적으로 사용됩니다.
  • <ThemeProvider />: 앱 전역에 사용자 정의 테마를 적용합니다.
  • Day.js 초기화: 날짜 및 시간 조작을 위한 것입니다.
  • 리소스: Refine이 가져올 데이터 엔티티(employeemanager)를 지정하는 배열입니다. 우리는 부모 및 자식 리소스를 사용하여 데이터를 정리하고 권한을 관리합니다. 각 리소스는 사용자 역할을 정의하는 scope를 가지고 있으며, 이는 앱의 다양한 부분에 대한 접근을 제어합니다.
  • queryClient: 데이터 가져오기를 완전히 제어하고 사용자 정의할 수 있는 맞춤형 쿼리 클라이언트입니다.
  • syncWithLocation: 앱 상태(필터, 정렬기, 페이지 매김 등)를 URL과 동기화할 수 있게 합니다.
  • 저장되지 않은 변경 사항 경고: 사용자가 저장되지 않은 변경 사항이 있는 페이지에서 벗어나려고 할 때 경고를 표시합니다.
  • <레이아웃 />: 앱 콘텐츠를 감싸는 사용자 정의 레이아웃 구성 요소입니다. 헤더, 사이드바 및 본문 콘텐츠 영역을 포함합니다. 다음 단계에서 이 구성 요소에 대해 설명하겠습니다.

이제 HR 관리 애플리케이션 구축을 시작할 준비가 되었습니다.


단계 2— 사용자 정의 및 스타일링

테마-제공자를 자세히 살펴보세요. HR 관리 앱의 디자인과 일치하도록 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 프레임워크의 기본 레이아웃을 사용할 수 있지만 사용자 정의 방법을 보여주고 싶습니다.

레이아웃 구성 요소에는 헤더, 사이드바 및 주요 콘텐츠 영역이 포함되어 있습니다. HR 관리 앱의 디자인과 일치하도록 <ThemedLayoutV2 />를 기본으로 사용하여 사용자 정의했습니다.

<Sider />

사이드바에는 앱 로고와 내비게이션 링크가 포함되어 있습니다. 모바일 기기에서는 사용자가 메뉴 아이콘을 클릭할 때 열리는 접이식 사이드바입니다. 내비게이션 링크는 Refine의 useMenu 훅으로 준비되었으며, 사용자의 역할에 따라 <CanAccess /> 컴포넌트의 도움으로 렌더링됩니다.

<UserSelect />

사이드바에 장착되어 로그인한 사용자의 아바타와 이름을 표시합니다. 클릭하면 사용자 세부 정보와 로그아웃 버튼이 있는 팝오버가 열립니다. 사용자는 드롭다운에서 선택하여 다른 역할 간에 전환할 수 있습니다. 이 컴포넌트를 사용하여 다른 역할을 가진 사용자 간에 전환하면서 테스트할 수 있습니다.

<Header />

데스크톱 장치에서는 아무것도 렌더링하지 않습니다. 모바일 기기에서는 앱 로고와 사이드바를 열기 위한 메뉴 아이콘을 표시합니다. 헤더는 상단에 고정되어 있으며 항상 페이지 상단에 표시됩니다.

<PageHeader />

페이지 제목과 내비게이션 버튼을 표시합니다. 페이지 제목은 Refine 컨텍스트에서 리소스 이름을 가져오는 useResource 훅을 사용하여 자동으로 생성됩니다. 이를 통해 앱 전반에 걸쳐 동일한 스타일과 레이아웃을 공유할 수 있습니다.

3단계 — 인증 및 권한 부여 구현

이 단계에서는 HR 관리 애플리케이션을 위한 인증 및 권한 부여 로직을 구현할 것입니다. 이는 기업 애플리케이션에서의 접근 제어의 훌륭한 예가 될 것입니다.

사용자가 관리자 로 로그인하면 Time Off 페이지와 Requests 페이지를 볼 수 있습니다. 직원으로 로그인하면 Time Off 페이지만 볼 수 있습니다. 관리자는 Requests 페이지에서 휴가 요청을 승인하거나 거부할 수 있습니다.

직원들은 시간 요청 페이지에서 휴가를 요청하고 자신의 기록을 조회할 수 있습니다. 이를 구현하기 위해 우리는 Refine의 authProvideraccessControlProvider 기능을 사용할 것입니다.

인증

Refine에서 인증은 authProvider에 의해 처리됩니다. 이를 통해 앱의 인증 로직을 정의할 수 있습니다. 이전 단계에서 우리는 이미 GitHub 리포지토리에서 authProvider를 복사하여 <Refine /> 컴포넌트에 prop으로 제공했습니다. 우리는 사용자가 로그인했는지 여부에 따라 앱의 동작을 제어하기 위해 다음 훅과 컴포넌트를 사용할 것입니다.

  • useLogin: 사용자를 로그인시키기 위한 mutate 함수를 제공하는 훅입니다.
  • useLogout: 사용자를 로그아웃시키기 위한 mutate 함수를 제공하는 훅입니다.
  • useIsAuthenticated: 사용자가 인증되었는지를 나타내는 부울 값을 반환하는 후크입니다.
  • <Authenticated />: 사용자가 인증되었을 때에만 자식 요소를 렌더링하는 컴포넌트입니다.

Authorization

Refine에서 권한 부여는 accessControlProvider에 의해 처리됩니다. 사용자 역할과 권한을 정의하고 사용자의 역할에 따라 응용 프로그램의 다른 부분에 대한 액세스를 제어할 수 있습니다. 이전 단계에서 이미 GitHub 저장소에서 accessControlProvider를 복사하여 <Refine /> 컴포넌트에 속성으로 제공했습니다. accessControlProvider를 더 자세히 살펴보겠습니다.

src/providers/access-control/index.ts

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

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

    const scope = params?.resource?.meta?.scope;
    // 리소스에 범위가 없으면 액세스할 수 없습니다
    if (!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,
    };
  },
};


우리 앱에서는 MANAGEREMPLOYEE 두 가지 역할이 있습니다.

관리자는 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 객체를 만들었습니다. 두 개의 배열, managersemployees,를 포함합니다. 각 배열에는 미리 정의된 사용자 객체가 포함되어 있습니다. 사용자가 드롭다운에서 이메일을 선택하고 Sign in 버튼을 클릭하면 선택한 이메일로 login 함수가 호출됩니다. login 함수는 Refine에서 제공하는 useLogin 훅으로부터 제공되는 변이 함수입니다. 이 함수는 선택한 이메일로 authProvider.login을 호출합니다.

다음으로, <PageLogin /> 컴포넌트를 가져와 App.tsx 파일을 강조된 변경 사항으로 업데이트합니다.

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

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

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

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

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

import { Role } from './types'

import "@/utilities/init-dayjs";

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

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

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

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

export default App;

업데이트된 App.tsx 파일에서는 Refine에서 <Authenticated /> 컴포넌트를 추가했습니다. 이 컴포넌트는 인증이 필요한 라우트를 보호하는 데 사용됩니다. 컴포넌트를 고유하게 식별하는 key prop, 사용자가 인증되지 않았을 때 렌더링할 fallback prop 및 인증이 실패했을 때 사용자를 지정된 경로로 리디렉션하는 redirectOnFail prop을 가져옵니다. 내부에서는 사용자가 인증되었는지 확인하기 위해 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 OffRequests 페이지를 구현할 것입니다.

4단계 — 휴가 페이지 만들기

휴가 목록 페이지 만들기

이 단계에서는 휴가 페이지를 만들 것입니다. 직원들은 휴가를 요청하고 휴가 이력을 볼 수 있습니다. 매니저들도 이력을 볼 수 있지만, 직접 휴가를 할당할 수 있습니다. 우리는 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 /> 컴포넌트를 세 가지 다른 맥락에서 사용할 것입니다:

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

type prop은 어떤 종류의 휴가 목록을 표시할지를 결정합니다:

  • inReview: 승인을 기다리고 있는 휴가 요청을 표시합니다.
  • upcoming: 승인되었지만 아직 발생하지 않은 다가오는 휴가를 표시합니다.
  • history: 승인되었고 이미 발생한 휴가를 나열합니다.

컴포넌트 내부에서는 type prop을 기반으로 필터와 정렬기를 생성합니다. 우리는 이 필터와 정렬기를 사용하여 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}
  // ... 기타 props
>
  {/* 여기에서 목록 항목을 렌더링합니다 */}
</InfiniteScroll>;
  • useInfiniteList<TimeOff>(): 무한 스크롤로 휴가 데이터를 가져옵니다.

    • resource: API 엔드포인트를 지정합니다.
    • sortersfilters: 적절한 데이터를 가져오기 위해 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: 날짜 형식을 지정합니다 (예: “1월 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 파일을 만들고 다음 코드를 추가하십시오:

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

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

type Props = {
  employeeId?: number;
};

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

  const { data: timeOffsSick, isLoading: isLoadingTimeOffsSick } =
    useList<TimeOff>({
      resource: "time-offs",
      // we only need total number of sick leaves, so we can set pageSize to 1 to reduce the load
      pagination: { pageSize: 1 },
      filters: [
        {
          field: "status",
          operator: "eq",
          value: TimeOffStatus.APPROVED,
        },
        {
          field: "timeOffType",
          operator: "eq",
          value: TimeOffType.SICK,
        },
        {
          field: "employeeId",
          operator: "eq",
          value: employee?.id,
        },
      ],
      queryOptions: {
        enabled: !!employee?.id,
      },
    });

  const { data: timeOffsCasual, isLoading: isLoadingTimeOffsCasual } =
    useList<TimeOff>({
      resource: "time-offs",
      // we only need total number of sick leaves, so we can set pageSize to 1 to reduce the load
      pagination: { pageSize: 1 },
      filters: [
        {
          field: "status",
          operator: "eq",
          value: TimeOffStatus.APPROVED,
        },
        {
          field: "timeOffType",
          operator: "eq",
          value: TimeOffType.CASUAL,
        },
        {
          field: "employeeId",
          operator: "eq",
          value: employee?.id,
        },
      ],
      queryOptions: {
        enabled: !!employee?.id,
      },
    });

  const loading =
    isLoadingEmployee || isLoadingTimeOffsSick || isLoadingTimeOffsCasual;

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

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

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

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


<TimeOffLeaveCards />

<TimeOffLeaveCards /> 컴포넌트는 직원의 휴가에 관한 통계를 표시합니다. 연차 휴가, 병가, 비정기 휴가에 대한 세 가지 카드를 보여주며 사용 가능한 날짜나 사용된 날짜를 나타냅니다.

컴포넌트의 주요 부분을 살펴보겠습니다:

1. 데이터 가져오기
  • 직원 데이터: 현재 직원의 정보(예: 사용 가능한 연차 휴가 일수)를 가져오기 위해 useGetIdentity를 사용합니다.
  • 휴가 일수: 직원이 사용한 총 병가 및 비정기 휴가 일수를 가져오기 위해 useList를 사용합니다. 모든 세부 정보가 아닌 총 수만 필요하기 때문에 pageSize를 1로 설정합니다.
2. 카드 표시
  • 이 컴포넌트는 세 가지 휴가 유형 각각에 대한 카드 컴포넌트를 렌더링합니다.
  • 각 카드는 다음을 표시합니다:
    • 휴가 유형(예: 연차 휴가).
    • 사용 가능한 날짜 수 또는 사용된 날짜 수.
    • 휴가 유형을 나타내는 아이콘.
3. 로딩 상태 처리
  • 데이터가 여전히 로딩 중인 경우, 실제 숫자 대신 스켈레톤 플레이스홀더가 표시됩니다.
  • 이 상태를 관리하기 위해 loading 속성이 카드에 전달됩니다.
4. 카드 구성 요소
  • type, value, 및 loading을 속성으로 받습니다.
  • variantMap을 사용하여 휴가 유형에 따라 올바른 레이블, 색상, 아이콘을 가져옵니다.
  • 적절한 스타일링으로 휴가 정보를 표시합니다.

<PageEmployeeTimeOffsList /> 구축

휴가 목록 및 휴가 카드를 표시하기 위한 구성 요소가 준비되었으니, src/pages/employee/time-offs/ 폴더에 list.tsx라는 새 파일을 생성하고 다음 코드를 추가합니다:

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

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

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

        <TimeOffLeaveCards />

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

<PageEmployeeTimeOffsList />는 휴가 페이지의 주요 구성 요소로, 사용자가 /employee/time-offs 경로로 이동할 때 휴가 목록과 휴가 카드를 표시하기 위해 이 구성 요소를 사용할 것입니다.

<PageEmployeeTimeOffsList />

구성 요소의 주요 부분을 살펴봅시다:

1. 사용자 역할 확인
  • 현재 사용자가 관리자인지 확인하기 위해 useCan 훅을 사용합니다.
  • 사용자가 관리자 권한을 가지고 있다면 isManagertrue로 설정합니다.
2. 역할에 따른 테마 적용
  • <ThemeProvider />로 콘텐츠를 래핑합니다.
  • 사용자가 관리자인지 직원인지에 따라 테마가 변경됩니다.
3. 조건부 버튼이 있는 페이지 헤더
  • 제목이 “Time Off”인 <PageHeader />를 렌더링합니다.
  • 사용자의 역할에 따라 변경되는 <CreateButton />이 포함됩니다:
    • 사용자가 관리자인 경우 버튼은 “Assign Time Off”이라고 표시됩니다.
    • 사용자가 관리자가 아닌 경우 “Request Time Off”이라고 표시됩니다.
  • 이는 권한을 확인하는 <CanAccess /> 컴포넌트를 사용하여 처리됩니다.
4. 휴가 통계 표시
  • 휴가 잔액 및 사용을 보여주는 <TimeOffLeaveCards /> 컴포넌트가 포함됩니다.
  • 연차, 병가, 비상 휴가에 대한 요약을 제공합니다.
5. 휴가 요청 목록 표시
  • 콘텐츠를 구성하기 위해 <Grid /> 레이아웃을 사용합니다.
  • 왼쪽 부분(md={6})에는 다음이 표시됩니다:
    • type="inReview"를 가진 TimeOffList: 보류 중인 휴가 요청을 보여줍니다.
    • type="upcoming"을 가진 TimeOffList: 예정된 승인된 휴가를 보여줍니다.
  • 오른쪽(md={6}),에 표시됩니다:
    • TimeOffListtype="history": 이미 발생한 과거 휴가를 표시합니다.

“/employee/time-offs” 경로 추가

<PageEmployeeTimeOffsList /> 컴포넌트를 /employee/time-offs 경로에 렌더링할 준비가 되었습니다. 이 경로를 포함하도록 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 { 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': 리소스의 목록 보기를 표시하는 경로를 지정합니다.
  • 메타: 리소스에 대한 추가 메타데이터를 포함하는 객체입니다.
    • 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" />} />

우리는 사용자들을 time-offs 리소스로 리디렉션하기 위해 <NavigateToResource /> 컴포넌트를 사용합니다. 사용자가 / 경로로 이동할 때 기본적으로 휴가 목록을 볼 수 있도록 합니다.

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 경로로 이동하여 시간이 지난 목록 페이지를 확인할 수 있습니다.

/employee/time-offs

현재 시간이 지난 목록 페이지는 기능적이지만 새로운 휴가 요청을 만들 수 있는 기능이 없습니다. 새로운 휴가 요청을 만들 수 있는 기능을 추가해 봅시다.

휴가 생성 페이지 구축

요청 또는 휴가 일정 지정을 위한 새 페이지를 생성할 것입니다. 이 페이지에는 사용자가 휴가 유형, 시작 및 종료 날짜, 그리고 추가 사항을 지정할 수 있는 양식이 포함될 것입니다.

시작하기 전에, 양식에서 사용할 새 구성 요소를 만들어야 합니다:

<TimeOffFormSummary /> 구성 요소 작성

src/components/time-offs/ 폴더에 form-summary.tsx라는 새 파일을 만들고 다음 코드를 추가하세요:

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

<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. 사용자 역할 확인

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 파일을 업데이트하여 이 경로를 포함시킵니다:

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

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

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

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

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

import { TimeOffIcon } from '@/icons'

import { Role } from '@/types'

import '@/utilities/init-dayjs'

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

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

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

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

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

export default App


이러한 변경 사항을 추가한 후, /employee/time-offs/create 경로로 이동하거나 휴가 목록 페이지에서 “휴가 할당” 버튼을 클릭하여 휴가 생성 양식에 액세스할 수 있습니다.

/employee/time-offs/create

5단계 — 휴가 요청 관리 페이지 만들기

이번 단계에서는 휴가 요청을 관리할 새로운 페이지를 생성합니다. 이 페이지는 관리자가 직원이 제출한 휴가 요청을 검토하고 승인 또는 거부할 수 있도록 합니다.

/manager/requests

휴가 요청 목록 페이지 만들기

휴가 요청을 관리하기 위한 새로운 페이지를 생성합니다. 이 페이지에는 직원의 이름, 휴가 유형, 요청 날짜 및 현재 상태와 같은 세부정보가 포함된 휴가 요청 목록이 표시됩니다.

시작하기 전에 목록에서 사용할 새 구성 요소를 만들어야 합니다:

<RequestsList /> 구성 요소 만들기

src/components/requests/ 폴더에 list.tsx라는 새로운 파일을 생성하고 다음 코드를 추가합니다:

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
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을 prop으로 받아들입니다. 다음으로, 요청 세부정보를 표시하기 위해 코드를 사용한 모달 경로를 구현할 것입니다. <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
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 훅을 사용하여 직원이 각 유형의 휴가로 소진한 총 일수를 계산할 것입니다. 이 정보는 휴가 요청 세부 정보 페이지에 표시됩니다.

그 후, 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. 중첩된 승인된 휴가 시간 가져오기
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"],
  });
};

리소스가 변이된 후 자동으로 리소스 캐시를 무효화합니다(이 경우 time-offs).
직원의 휴가 사용 내역이 휴가 이력을 기반으로 계산되므로 직원의 휴가 사용 내역을 업데이트하기 위해 employees 리소스 캐시도 무효화합니다.

“manager/requests/:id” 경로 추가

이 단계에서는 매니저가 요청을 승인하거나 거부할 수 있는 휴가 요청 세부 정보 페이지를 표시하는 새 경로를 만들겠습니다.

edit.tsx 파일을 만들고 src/pages/manager/requests/time-offs/ 폴더에 다음 코드를 추가합니다:

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 파일을 업데이트하여 이 경로를 포함시킵니다:

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



변경 사항을 더 자세히 살펴보겠습니다:

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

위 코드는 특정 자식 경로로 이동할 때 모달이 표시되는 중첩 경로 구조를 설정합니다. <PageManagerRequestsTimeOffsEdit /> 컴포넌트는 모달이며 <PageManagerRequestsList /> 컴포넌트의 자식으로 렌더링됩니다. 이 구조를 통해 목록 페이지를 배경에 유지하면서 목록 페이지 위에 모달을 표시할 수 있습니다.

/manager/requests/:id/edit 경로로 이동하거나 목록에서 휴가 요청을 클릭하면 휴가 요청 세부정보 페이지가 목록 페이지 위에 모달로 표시됩니다.

/manager/requests/:id/edit

6단계 — 권한 및 접근 제어 구현

인증은 기업 수준 애플리케이션에서 중요한 구성 요소이며, 보안과 운영 효율성 모두에 중요한 역할을 합니다. 이는 허가된 사용자만 특정 리소스에 액세스할 수 있도록 보장하여 민감한 데이터와 기능을 보호합니다. Refine의 인가 시스템은 리소스를 보호하고 사용자가 응용 프로그램과 안전하고 통제된 방식으로 상호 작용할 수 있도록 보장하는 필수 인프라를 제공합니다. 이 단계에서는 휴가 요청 관리 기능에 대한 권한 및 액세스 제어를 구현할 것입니다. /manager/requests/manager/requests/:id/edit 경로로의 액세스를 <CanAccess /> 구성 요소의 도움으로 관리자만 제한할 것입니다.

현재 직원으로 로그인하면 사이드바에 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

위의 코드에서 “manager” 경로에 <CanAccess /> 구성 요소를 추가했습니다. 이 구성 요소는 사용자가 “manager” 역할을 가지고 있는지 확인한 후 자식 경로를 렌더링합니다. 사용자가 “manager” 역할을 가지고 있지 않은 경우, 직원용 휴가 목록 페이지로 리디렉션됩니다.

이제 직원으로 로그인하고 /manager/requests 경로에 접근하려고 하면, 직원의 휴가 목록 페이지로 리디렉션됩니다.

7단계 — DigitalOcean App 플랫폼에 배포하기

이번 단계에서는 애플리케이션을 DigitalOcean App Platform에 배포합니다. 이를 위해 소스 코드를 GitHub에 호스팅하고 GitHub 저장소를 App Platform에 연결합니다.

코드를 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 저장소에 푸시되면 성공 메시지를 받게 됩니다.

이 섹션에서는 DigitalOcean Apps를 사용하여 액세스할 수 있도록 프로젝트를 GitHub에 푸시했습니다. 다음 단계는 프로젝트를 사용하여 새로운 DigitalOcean App을 생성하고 자동 배포를 설정하는 것입니다.

DigitalOcean App 플랫폼에 배포하기

이 과정에서 React 애플리케이션을 가져와 DigitalOcean의 App 플랫폼을 통해 배포할 준비를 합니다. 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 앱 플랫폼에 배포하는 방법을 설명하겠습니다.

Refine에 대해 더 알고 싶다면 문서를 확인하거나 질문이나 피드백이 있으면 Refine Discord 서버에 참여할 수 있습니다.

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