Построение и развертывание приложения HR с использованием Refine

Введение

В этом руководстве мы создадим приложение управления кадрами с помощью Refine Framework и развернем его на платформе DigitalOcean App Platform.

По завершении этого руководства у нас будет приложение управления кадрами, включающее:

  • Страницу входа: Позволяет пользователям войти как менеджерам, так и сотрудникам. Менеджеры имеют доступ к страницам Отгул и Запросы, в то время как сотрудники имеют доступ только к странице Отгул.
  • Страницы отгула: Позволяет сотрудникам запрашивать, просматривать и отменять свой отгул. Также менеджеры могут назначать новые отгулы.
  • Страница запросов: Доступна только менеджерам по кадрам для утверждения или отклонения запросов на отгул.

Примечание: Вы можете получить полный исходный код приложения, которое мы создадим в этом руководстве, из этого репозитория на GitHub

В процессе работы мы будем использовать:

  • REST API: Для получения и обновления данных. Refine имеет встроенные пакеты поставщиков данных и REST API, но вы также можете создать свой собственный, чтобы соответствовать вашим конкретным требованиям. В этом руководстве мы собираемся использовать NestJs CRUD в качестве нашего бэкенд-сервиса и пакет @refinedev/nestjsx-crud в качестве поставщика данных.
  • Material UI: Мы будем использовать его для компонентов пользовательского интерфейса и полностью настроим его согласно нашему дизайну. Refine имеет встроенную поддержку Material UI, но вы можете использовать любую библиотеку пользовательского интерфейса, которая вам нравится.

После создания приложения мы разместим его в Интернете, используя Платформу приложений DigitalOcean, которая позволяет легко настроить, запустить и развивать приложения и статические веб-сайты. Вы можете развернуть код, просто указав на репозиторий GitHub и позволив Платформе приложений выполнить основную работу по управлению инфраструктурой, временем выполнения приложения и зависимостями.

Предварительные требования

Что такое Refine?

Refine – это открытый мета-фреймворк React для создания сложных веб-приложений B2B, в основном ориентированных на управление данными, такие как внутренние инструменты, панели администратора и панели управления. Он разработан путем предоставления набора хуков и компонентов для улучшения процесса разработки с более эффективным рабочим процессом для разработчика.

Он предоставляет полностью готовые к использованию функции для приложений уровня предприятия, чтобы упростить оплачиваемые задачи, такие как управление состоянием и данными, аутентификация и контроль доступа. Это позволяет разработчикам оставаться сосредоточенными на ядре своего приложения способом, который абстрагирован от многих избыточных деталей реализации.

Шаг 1 — Настройка проекта

Мы будем использовать команду npm create refine-app для интерактивной инициализации проекта.

npm create refine-app@latest

Выберите следующие опции при запросе:

✔ Choose a project template · Vite
✔ What would you like to name your project?: · hr-app
✔ Choose your backend service to connect: · nestjsx-crud
✔ Do you want to use a UI Framework?: · Material UI
✔ Do you want to add example pages?: · No
✔ Do you need any Authentication logic?: · None
✔ Choose a package manager: · npm

После завершения установки, перейдите в папку проекта и запустите приложение с помощью:

npm run dev

Откройте http://localhost:5173 в вашем браузере, чтобы увидеть приложение.

Подготовка проекта

Теперь, когда у нас настроен проект, давайте внесем некоторые изменения в структуру проекта и удалим ненужные файлы.

Сначала установите сторонние зависимости:

  • @mui/x-date-pickers, @mui/x-date-pickers-pro: Это компоненты выбора даты для Material UI. Мы будем использовать их для выбора диапазона дат для запросов на отпуск.
  • react-hot-toast: Минималистическая библиотека тостов для React. Мы будем использовать ее для отображения сообщений об успешном выполнении и ошибок.
  • react-infinite-scroll-component: Компонент React для упрощения бесконечной прокрутки. Мы будем использовать его для загрузки дополнительных запросов на отпуск по мере прокрутки пользователем страницы для просмотра дополнительных запросов.
  • dayjs: Легкая библиотека дат для разбора, валидации, манипулирования и форматирования дат.
  • vite-tsconfig-paths: Плагин Vite, который позволяет использовать псевдонимы путей TypeScript в вашем проекте 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.ts и tsconfig.json, чтобы использовать плагин vite-tsconfig-paths. Это позволит использовать псевдонимы путей TypeScript в проектах Vite, позволяя импортировать с псевдонимом @.

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. С этими файлами у нас будет базовая структура для нашего приложения управления кадрами.

  • icons: Папка с иконками, содержащая все иконки приложения.
  • types:
    • index.ts: Типы приложения.
  • утилиты:
    • constants.ts: Константы приложения.
    • axios.ts: Экземпляр Axios для запросов к API, обработки токенов доступа, обновления токенов и ошибок.
    • init-dayjs.ts: Инициализирует Day.js с необходимыми плагинами.
  • провайдеры:
    • access-control: Управляет разрешениями пользователей с помощью accessControlProvider; контролирует видимость страницы Requests в зависимости от роли пользователя.
    • auth-provider: Управляет аутентификацией с помощью authProvider; гарантирует защиту всех страниц и требует входа в систему.
    • notification-provider: Отображает сообщения об успехе и ошибках через react-hot-toast.
    • query-client: Пользовательский клиент запросов для полного контроля и настройки.
    • theme-provider: Управляет темой 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: Для манипулирования датой и временем.
  • ресурсы: Массив, указывающий сущности данных (employee и manager), которые Refine будет извлекать. Мы используем родительские и дочерние ресурсы для организации данных и управления разрешениями. У каждого ресурса есть scope, определяющий роль пользователя, который контролирует доступ к различным частям приложения.
  • queryClient: Пользовательский клиент запросов для полного контроля и настройки извлечения данных.
  • syncWithLocation: Позволяет синхронизировать состояние приложения (фильтры, сортировки, пагинация и т. д.) с URL.
  • warnWhenUnsavedChanges: Показывает предупреждение, когда пользователь пытается покинуть страницу с несохраненными изменениями.
  • <Макет />: Пользовательский компонент макета, который оборачивает содержимое приложения. Он содержит заголовок, боковую панель и основную область содержимого. Мы объясним этот компонент на следующих этапах.

Теперь мы готовы начать создание приложения по управлению персоналом.


Шаг 2 — Настройка и стилизация

Приглядитесь к поставщику-темы. Мы сильно настроили тему 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 /> Компонента

На предыдущем этапе мы добавили настраиваемый компонент макета в приложение. Обычно мы могли бы использовать стандартный макет пользовательского интерфейса, но мы хотим показать, как можно осуществить настройку.

Компонент макета содержит заголовок, боковую панель и основную область контента. Он использует <ThemedLayoutV2 /> в качестве базы и настраивает его для соответствия дизайну приложения управления кадрами.

<Sider />

Боковая панель содержит логотип приложения и навигационные ссылки. На мобильных устройствах это раскрывающаяся боковая панель, которая открывается, когда пользователь нажимает на значок меню. Навигационные ссылки подготовлены с помощью хука useMenu из Refine и рендерятся на основе роли пользователя с помощью компонента <CanAccess />.

<UserSelect />

Размещенный на боковой панели, показывает аватар и имя вошедшего пользователя. При нажатии открывается всплывающее окно с данными пользователя и кнопкой выхода. Пользователи могут переключаться между различными ролями, выбирая из выпадающего списка. Этот компонент позволяет тестировать, переключаясь между пользователями с разными ролями.

<Header />

На настольных устройствах ничего не рендерится. На мобильных устройствах показывается логотип приложения и значок меню для открытия боковой панели. Шапка закреплена и всегда видна вверху страницы.

<PageHeader />

Это показывает заголовок страницы и кнопки навигации. Заголовок страницы автоматически генерируется с помощью хука useResource, который извлекает имя ресурса из контекста Refine. Это позволяет нам использовать одинаковый стиль и макет во всем приложении.

Шаг 3 — Реализация аутентификации и авторизации

На этом этапе мы реализуем логику аутентификации и авторизации для нашего приложения по управлению HR. Это послужит отличным примером управления доступом в корпоративных приложениях.

Когда пользователи входят как менеджеры, они могут видеть страницы Отгул и Запросы. Если они входят как сотрудники, они могут видеть только страницу Отгул. Менеджеры могут утверждать или отклонять запросы на отгул на странице Запросы.

Сотрудники могут запрашивать отпуск и просматривать свою историю на странице Time Off. Для реализации этого мы будем использовать функции authProvider и accessControlProvider Refine.

Authentication

В Refine аутентификация обрабатывается с помощью authProvider. Он позволяет определить логику аутентификации для вашего приложения. На предыдущем шаге мы уже скопировали authProvider из репозитория GitHub и передали его компоненту <Refine /> в качестве свойства. Мы будем использовать следующие хуки и компоненты для управления поведением нашего приложения в зависимости от того, вошел пользователь в систему или нет.

  • useLogin: Хук, предоставляющий функцию mutate для входа пользователя.
  • useLogout: Хук, предоставляющий функцию mutate для выхода пользователя.
  • useIsAuthenticated: Хук, возвращающий логическое значение, указывающее, аутентифицирован ли пользователь.
  • <Authenticated />: Компонент, который рендерит свои дочерние элементы только в том случае, если пользователь аутентифицирован.

Authorization

В Refine авторизация обрабатывается с помощью accessControlProvider. Он позволяет определять роли пользователей и разрешения, а также контролировать доступ к различным частям приложения на основе роли пользователя. На предыдущем шаге мы уже скопировали accessControlProvider из репозитория GitHub и передали его компоненту <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,
    };
  },
};


В нашем приложении есть две роли: MANAGER и EMPLOYEE.

Менеджеры имеют доступ к странице Requests, в то время как сотрудники имеют доступ только к странице Time Off. Провайдер доступа accessControlProvider проверяет роль пользователя и область ресурса, чтобы определить, может ли пользователь получить доступ к ресурсу. Если роль пользователя соответствует области ресурса, пользователь может получить доступ к ресурсу. В противном случае доступ будет запрещен. Мы будем использовать хук useCan и компонент <CanAccess /> для управления поведением нашего приложения на основе роли пользователя.

Настройка страницы входа

На предыдущем этапе мы добавили authProvider к компоненту <Refine />. authProvider отвечает за обработку аутентификации.

Сначала нам нужно получить изображения. Мы будем использовать эти изображения в качестве фоновых изображений для страницы входа. Создайте новую папку с именем images в папке public и получите изображения из репозитория GitHub.

После получения изображений давайте создадим новый файл с именем index.tsx в папке src/pages/login и добавим следующий код:

src/pages/login/index.tsx
import { useState } from "react";
import { useLogin } from "@refinedev/core";
import {
  Avatar,
  Box,
  Button,
  Divider,
  MenuItem,
  Select,
  Typography,
} from "@mui/material";
import { HrLogo } from "@/icons";

export const PageLogin = () => {
  const [selectedEmail, setSelectedEmail] = useState<string>(
    mockUsers.managers[0].email,
  );

  const { mutate: login } = useLogin();

  return (
    <Box
      sx={{
        position: "relative",
        background:
          "linear-gradient(180deg, #7DE8CD 0%, #C6ECD9 24.5%, #5CD6D6 100%)",
        display: "flex",
        flexDirection: "column",
        justifyContent: "center",
        alignItems: "center",
        height: "100dvh",
      }}
    >
      <Box
        sx={{
          zIndex: 2,
          background: "white",
          width: "328px",
          padding: "24px",
          borderRadius: "36px",
          display: "flex",
          flexDirection: "column",
          gap: "24px",
        }}
      >
        <Box
          sx={{
            display: "flex",
            flexDirection: "column",
            alignItems: "center",
            gap: "16px",
          }}
        >
          <HrLogo />
          <Typography variant="body1" fontWeight={600}>
            Welcome to RefineHR
          </Typography>
        </Box>

        <Divider />

        <Box sx={{ display: "flex", flexDirection: "column", gap: "8px" }}>
          <Typography variant="caption" color="text.secondary">
            Select user
          </Typography>
          <Select
            size="small"
            value={selectedEmail}
            sx={{
              height: "40px",
              borderRadius: "12px",

              "& .MuiOutlinedInput-notchedOutline": {
                borderWidth: "1px !important",
                borderColor: (theme) => `${theme.palette.divider} !important`,
              },
            }}
            MenuProps={{
              sx: {
                "& .MuiList-root": {
                  paddingBottom: "0px",
                },

                "& .MuiPaper-root": {
                  border: (theme) => `1px solid ${theme.palette.divider}`,
                  borderRadius: "12px",
                  boxShadow: "none",
                },
              },
            }}
          >
            <Typography
              variant="caption"
              textTransform="uppercase"
              color="text.secondary"
              sx={{
                paddingLeft: "12px",
                paddingBottom: "8px",
                display: "block",
              }}
            >
              Managers
            </Typography>
            {mockUsers.managers.map((user) => (
              <MenuItem
                key={user.email}
                value={user.email}
                onClick={() => setSelectedEmail(user.email)}
              >
                <Box sx={{ display: "flex", alignItems: "center", gap: "8px" }}>
                  <Avatar
                    src={user.avatarUrl}
                    alt={`${user.firstName} ${user.lastName}`}
                    sx={{ width: "24px", height: "24px" }}
                  />
                  <Typography
                    noWrap
                    variant="caption"
                    sx={{ display: "flex", alignItems: "center" }}
                  >
                    {`${user.firstName} ${user.lastName}`}
                  </Typography>
                </Box>
              </MenuItem>
            ))}

            <Divider />

            <Typography
              variant="caption"
              textTransform="uppercase"
              color="text.secondary"
              sx={{
                paddingLeft: "12px",
                paddingBottom: "8px",
                display: "block",
              }}
            >
              Employees
            </Typography>
            {mockUsers.employees.map((user) => (
              <MenuItem
                key={user.email}
                value={user.email}
                onClick={() => setSelectedEmail(user.email)}
              >
                <Box sx={{ display: "flex", alignItems: "center", gap: "8px" }}>
                  <Avatar
                    src={user.avatarUrl}
                    alt={`${user.firstName} ${user.lastName}`}
                    sx={{ width: "24px", height: "24px" }}
                  />
                  <Typography
                    noWrap
                    variant="caption"
                    sx={{ display: "flex", alignItems: "center" }}
                  >
                    {`${user.firstName} ${user.lastName}`}
                  </Typography>
                </Box>
              </MenuItem>
            ))}
          </Select>
        </Box>

        <Button
          variant="contained"
          sx={{
            borderRadius: "12px",
            height: "40px",
            width: "100%",
            color: "white",
            backgroundColor: (theme) => theme.palette.grey[900],
          }}
          onClick={() => {
            login({ email: selectedEmail });
          }}
        >
          Sign in
        </Button>
      </Box>

      <Box
        sx={{
          zIndex: 1,
          width: {
            xs: "240px",
            sm: "370px",
            md: "556px",
          },
          height: {
            xs: "352px",
            sm: "554px",
            md: "816px",
          },
          position: "absolute",
          left: "0px",
          bottom: "0px",
        }}
      >
        <img
          src="/images/login-left.png"
          alt="flowers"
          width="100%"
          height="100%"
        />
      </Box>
      <Box
        sx={{
          zIndex: 1,
          width: {
            xs: "320px",
            sm: "480px",
            md: "596px",
          },
          height: {
            xs: "312px",
            sm: "472px",
            md: "584px",
          },
          position: "absolute",
          right: "0px",
          top: "0px",
        }}
      >
        <img
          src="/images/login-right.png"
          alt="flowers"
          width="100%"
          height="100%"
        />
      </Box>
    </Box>
  );
};

const mockUsers = {
  managers: [
    {
      email: "[email protected]",
      firstName: "Michael",
      lastName: "Scott",
      avatarUrl:
        "https://refine-hr-example.s3.eu-west-1.amazonaws.com/avatars/Michael-Scott.png",
    },
    {
      avatarUrl:
        "https://refine-hr-example.s3.eu-west-1.amazonaws.com/avatars/Jim-Halpert.png",
      firstName: "Jim",
      lastName: "Halpert",
      email: "[email protected]",
    },
    {
      avatarUrl:
        "https://refine-hr-example.s3.eu-west-1.amazonaws.com/avatars/Toby-Flenderson.png",
      firstName: "Toby",
      lastName: "Flenderson",
      email: "[email protected]",
    },
  ],
  employees: [
    {
      avatarUrl:
        "https://refine-hr-example.s3.eu-west-1.amazonaws.com/avatars/Pam-Beesly.png",
      firstName: "Pam",
      lastName: "Beesly",
      email: "[email protected]",
    },
    {
      avatarUrl:
        "https://refine-hr-example.s3.eu-west-1.amazonaws.com/avatars/Andy-Bernard.png",
      firstName: "Andy",
      lastName: "Bernard",
      email: "[email protected]",
    },
    {
      avatarUrl:
        "https://refine-hr-example.s3.eu-west-1.amazonaws.com/avatars/Ryan-Howard.png",
      firstName: "Ryan",
      lastName: "Howard",
      email: "[email protected]",
    },
  ],
};

Для упрощения процесса аутентификации мы создали объект mockUsers с двумя массивами: managers и employees. Каждый массив содержит заранее определенные объекты пользователей. Когда пользователь выбирает электронную почту из выпадающего списка и нажимает кнопку Sign in, вызывается функция login с выбранной электронной почтой. Функция login является мутационной функцией, предоставляемой хуком useLogin из библиотеки Refine. Она вызывает 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 мы добавили компонент <Authenticated /> из библиотеки Refine. Этот компонент используется для защиты маршрутов, требующих аутентификации. Он принимает свойство key для уникальной идентификации компонента, свойство fallback для отображения, когда пользователь не аутентифицирован, и свойство redirectOnFail для перенаправления пользователя на указанный маршрут при неудачной аутентификации. Под капотом он вызывает метод authProvider.check для проверки аутентификации пользователя.

Давайте ближе рассмотрим, что у нас есть на key="auth-pages"

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

Компонент <Authenticated /> оборачивает маршрут «/login» для проверки статуса аутентификации пользователя.

  • fallback={<Outlet />}: Если пользователь не аутентифицирован, отображается вложенный маршрут (т.е. показывается компонент <PageLogin />).
  • Дочерние элементы (<Navigate to="/" />): Если пользователь аутентифицирован, их перенаправляют на домашнюю страницу (/).

Давайте ближе рассмотрим, что у нас есть на key="catch-all"

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

<Authenticated /> компонент оборачивает маршрут path="*" для проверки статуса аутентификации пользователя. Этот маршрут является общим для всех, он отображает компонент <ErrorComponent />, когда пользователь аутентифицирован. Это позволяет нам показать страницу 404, когда пользователь пытается получить доступ к несуществующему маршруту.

Теперь, когда вы запустите приложение и перейдете по ссылке http://localhost:5173/login, вы должны увидеть страницу входа с выпадающим списком для выбора пользователя.

Прямо сейчас страница «/» ничего не делает. В следующих шагах мы реализуем страницы Time Off и Requests.

Шаг 4 — Создание страницы Отпусков

Построение страницы списка отпусков

На этом этапе мы построим страницу Отпуска. Сотрудники могут запрашивать отпуск и просматривать свою историю отпусков. Менеджеры также могут просматривать свою историю, но вместо запроса отпуска они могут назначить его себе напрямую. Мы сделаем это, используя accessControlProvider, компонент <CanAccess /> и хук useCan от Refine.

<PageEmployeeTimeOffsList />

Прежде чем мы начнем создавать страницу отпусков, нам нужно создать несколько компонентов для отображения истории отпусков, предстоящих запросов на отпуск и статистики использованных отпусков. В конце этого этапа мы будем использовать эти компоненты для создания страницы отпусков.

Построение компонента <TimeOffList /> для отображения истории отпусков

Создайте новую папку под названием time-offs в папке src/components. Внутри папки 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 довольно объемный, но большая часть занимается стилизацией и представлением пользовательского интерфейса.

<TimeOffList />

Мы будем использовать компонент <TimeOffList /> в трех различных контекстах:

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

Свойство type определяет, какой вид списка отсутствия показывать:

  • inReview: Показывает запросы на отсутствие, находящиеся на рассмотрении.
  • upcoming: Отображает предстоящие одобренные, но еще не наступившие отсутствия.
  • history: Список одобренных и уже произошедших отсутствий.

Внутри компонента мы создадим фильтры и сортировщики на основе свойства type. Мы будем использовать эти фильтры и сортировщики для получения данных об отсутствии из API.

Давайте разберем ключевые части компонента:

1. Получение текущего пользователя
const { data: employee } = useGetIdentity<Employee>();
  • useGetIdentity<Employee>(): Получает информацию о текущем пользователе.
    • Мы используем идентификатор сотрудника для фильтрации отсутствий, чтобы каждый пользователь видел только свои запросы.
2. Получение данных об отсутствии с бесконечным скроллингом
const { data, isLoading, hasNextPage, fetchNextPage } =
  useInfiniteList <
  TimeOff >
  {
    resource: "time-offs",
    sorters: sorters[props.type],
    filters: [
      ...filters[props.type],
      { field: "employeeId", operator: "eq", value: employee?.id },
    ],
    queryOptions: { enabled: !!employee?.id },
  };

// ...

<InfiniteScroll
  dataLength={timeOffHistory.length}
  next={() => fetchNextPage()}
  hasMore={hasNextPage || false}
  // ... другие свойства
>
  {/* Здесь рендерятся элементы списка */}
</InfiniteScroll>;
  • useInfiniteList<TimeOff>(): Получает данные об отпусках с бесконечной прокруткой.

    • resource: Указывает конечную точку API.
    • sorters и filters: Настроены в соответствии с type для получения правильных данных.
    • Фильтр employeeId: Гарантирует получение только отпусков текущего пользователя.
    • queryOptions.enabled: Запускает запрос только когда данные о сотруднике доступны.
  • <InfiniteScroll />: Позволяет загружать больше данных по мере прокрутки пользователем вниз.
    • next: Функция для загрузки следующей страницы данных.
    • hasMore: Указывает, доступны ли дополнительные данные.
3. Отмена запроса на отпуск
const { mutateAsync: timeOffCancel } = useDelete<TimeOff>();

// Внутри компонента ListItem
await timeOffCancel({
  resource: "time-offs",
  id: timeOff.id,
  invalidates: ["all"],
  successNotification: () => ({
    type: "success",
    message: "Time off request cancelled successfully.",
  }),
});
  • useDelete: Предоставляет функцию timeOffCancel для удаления запроса на отпуск.
    • Используется, когда пользователь отменяет свой отпуск.
    • Отображает сообщение об успешном завершении.
4. Отображение дат с <DateField />
<DateField
  value={timeOff.startsAt}
  color="text.secondary"
  variant="caption"
  format="MMMM DD"
/>
  • <DateField />: Форматирует и отображает даты удобным для пользователя способом.
    • value: Дата для отображения.
    • format: Указывает формат даты (например, “Январь 05”).
5. Создание фильтров и сортировщиков на основе type

Фильтры:

const filters: Record<Props["type"], CrudFilters> = {
  history: [
    {
      field: "status",
      operator: "eq",
      value: TimeOffStatus.APPROVED,
    },
    {
      field: "endsAt",
      operator: "lt",
      value: today,
    },
  ],
  // ... другие типы
};
  • Определяет критерии для получения временных отпусков на основе статуса и дат.
    • history: Получает утвержденные временные отпуска, которые уже закончились.
    • upcoming: Получает утвержденные временные отпуска, которые предстоят.

Сортировщики:

const sorters: Record<Props["type"], CrudSort[]> = {
  history: [{ field: "startsAt", order: "desc" }],
  // ... другие типы
};
  • Определяет порядок полученных данных.
    • history: Сортирует по дате начала в порядке убывания.

Построение компонента <TimeOffLeaveCards /> для отображения статистики использованных временных отпусков

Создайте новый файл с именем leave-cards.tsx в папке src/components/time-offs и добавьте следующий код:

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

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

type Props = {
  employeeId?: number;
};

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

  const { data: timeOffsSick, isLoading: isLoadingTimeOffsSick } =
    useList<TimeOff>({
      resource: "time-offs",
      // нам нужно только общее количество больничных дней, поэтому мы можем установить pageSize равным 1, чтобы снизить нагрузку
      pagination: { pageSize: 1 },
      filters: [
        {
          field: "status",
          operator: "eq",
          value: TimeOffStatus.APPROVED,
        },
        {
          field: "timeOffType",
          operator: "eq",
          value: TimeOffType.SICK,
        },
        {
          field: "employeeId",
          operator: "eq",
          value: employee?.id,
        },
      ],
      queryOptions: {
        enabled: !!employee?.id,
      },
    });

  const { data: timeOffsCasual, isLoading: isLoadingTimeOffsCasual } =
    useList<TimeOff>({
      resource: "time-offs",
      // нам нужно только общее количество больничных дней, поэтому мы можем установить pageSize равным 1, чтобы снизить нагрузку
      pagination: { pageSize: 1 },
      filters: [
        {
          field: "status",
          operator: "eq",
          value: TimeOffStatus.APPROVED,
        },
        {
          field: "timeOffType",
          operator: "eq",
          value: TimeOffType.CASUAL,
        },
        {
          field: "employeeId",
          operator: "eq",
          value: employee?.id,
        },
      ],
      queryOptions: {
        enabled: !!employee?.id,
      },
    });

  const loading =
    isLoadingEmployee || isLoadingTimeOffsSick || isLoadingTimeOffsCasual;

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

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

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

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


<TimeOffLeaveCards />

Компонент <TimeOffLeaveCards /> отображает статистику об отпуске сотрудника. Он показывает три карточки для Ежегодного Отпуска, Больничного Отпуска и Отпуска по болезни, указывая количество доступных или использованных дней.

Давайте разберем ключевые части компонента:

1. Получение данных
  • Данные сотрудника: Использует useGetIdentity для получения информации о текущем сотруднике, такой как доступные дни ежегодного отпуска.
  • Статистика отпусков: Использует useList для получения общего количества использованных дней по болезни и отпуску по собственному усмотрению сотрудником. Устанавливает pageSize равным 1, потому что нам нужно только общее количество, а не все детали.
2. Отображение Карточек
  • Компонент отображает три карточки, по одной для каждого типа отпуска.
  • Каждая карточка показывает:
    • Тип отпуска (например, Ежегодный Отпуск).
    • Количество доступных или использованных дней.
    • Иконка, представляющая тип отпуска.
3. Обработка состояний загрузки
  • Если данные все еще загружаются, вместо фактических чисел показывается плейсхолдер-скелетон.
  • Свойство loading передается в карточки для управления этим состоянием.
4. Компонент Card
  • Принимает 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 для определения, является ли текущий пользователь менеджером.
  • Устанавливает isManager в true, если у пользователя есть разрешения менеджера.
2. Применение темы в зависимости от роли
  • Оборачивает содержимое в <ThemeProvider />.
  • Тема меняется в зависимости от того, является ли пользователь менеджером или сотрудником.
3. Шапка страницы с условной кнопкой
  • Отображает <PageHeader /> с заголовком “Отпуск”.
  • Включает <CreateButton />, который меняется в зависимости от роли пользователя:
    • Если пользователь является менеджером, кнопка говорит “Назначить отпуск”.
    • Если пользователь не является менеджером, она говорит “Запросить отпуск”.
  • Это обрабатывается с использованием компонента <CanAccess />, который проверяет разрешения.
4. Отображение статистики отпусков
  • Включает компонент <TimeOffLeaveCards /> для отображения балансов и использования отпусков.
  • Это предоставляет сводку ежегодного, болезненного и случайного отпуска.
5. Список запросов на отпуск
  • Использует макет <Grid /> для организации контента.
  • Слева (md={6}) отображается:
    • TimeOffList с type="inReview": Показывает ожидающие запросы на отпуск.
    • TimeOffList с type="upcoming": Показывает предстоящие утвержденные отпуска.
  • Справа (md={6}) отображается:
    • TimeOffList с type="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, который может использоваться для организации ресурсов в пользовательском интерфейсе (например, в боковом меню) или для контроля доступа.
    • scope: Role.EMPLOYEE: Указывает, что этот ресурс доступен пользователям с ролью EMPLOYEE. Мы используем это в accessControlProvider для управления разрешениями.
    • label: 'Time Off': Наименование ресурса в пользовательском интерфейсе.
    • icon: <TimeOffIcon />: Ассоциирует TimeOffIcon с этим ресурсом для визуальной идентификации.
2. Перенаправление на ресурс “отпуска” при переходе пользователей на маршрут /
<Route index element={<NavigateToResource resource="time-offs" />} />

Мы используем компонент <NavigateToResource /> для перенаправления пользователей на ресурс time-offs, когда они переходят на маршрут /. Это гарантирует, что пользователи увидят список отсутствий по умолчанию.

3. Перенаправление на ресурс «time-offs» при аутентификации пользователей
<Route
  element={
    <Authenticated key="auth-pages" fallback={<Outlet />}>
      <NavigateToResource resource="time-offs" />
    </Authenticated>
  }
>
  <Route path="/login" element={<PageLogin />} />
</Route>

При аутентификации пользователей мы перенаправляем их на ресурс time-offs. Если пользователь не аутентифицирован, он видит страницу входа.

4. Добавление маршрута /employee/time-offs
<Route
  path="employee"
  element={
    <ThemeProvider role={Role.EMPLOYEE}>
      <Layout>
        <Outlet />
      </Layout>
    </ThemeProvider>
  }
>
  <Route path="time-offs" element={<Outlet />}>
    <Route index element={<PageEmployeeTimeOffsList />} />
  </Route>
</Route>

Мы организуем страницы сотрудников с помощью вложенных маршрутов. Сначала мы создаем основной маршрут с path='employee', который оборачивает контент в тематику и макет, специфичные для сотрудника. Внутри этого маршрута мы добавляем path='time-offs', который отображает компонент PageEmployeeTimeOffsList. Эта структура группирует все функции сотрудника под одним путем и сохраняет стиль однородным.

После внесения этих изменений вы можете перейти на маршрут /employee/time-offs, чтобы увидеть страницу со списком временных отсутствий в действии.

/employee/time-offs

В настоящее время страница со списком временных отсутствий функциональна, но не имеет возможности создавать новые запросы на отсутствие. Давайте добавим возможность создания новых запросов на отсутствие.

Создание страницы создания отсутствия

Мы создадим новую страницу для запроса или назначения отпуска. На этой странице будет форма, в которой пользователи смогут указать тип отпуска, даты начала и окончания, а также любые дополнительные заметки.

Прежде чем начать, нам нужно создать новые компоненты для использования в форме:

Создание компонента <TimeOffFormSummary />

Создайте новый файл с именем form-summary.tsx в папке src/components/time-offs/ и добавьте следующий код:

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

Создайте новый файл с именем create.tsx в папке src/pages/employee/time-offs/ и добавьте следующий код:

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 или нажать кнопку “Assign Time Off” на странице списка отпусков, чтобы перейти к форме создания отпуска.

/employee/time-offs/create

Шаг 5 — Создание страницы управления запросами на отгулы

На этом этапе мы создадим новую страницу для управления запросами на отгулы. Эта страница позволит менеджерам просматривать и утверждать или отклонять запросы на отгулы, отправленные сотрудниками.

/manager/requests

Создание страницы списка запросов на отгулы

Мы создадим новую страницу для управления запросами на отгулы. На этой странице будет отображаться список запросов на отгулы, показывая детали, такие как имя сотрудника, тип отгула, запрошенные даты и текущий статус.

Прежде чем мы начнем, нам нужно создать новые компоненты для использования в списке:

Создание компонента <RequestsList />

Создайте новый файл с именем list.tsx в папке src/components/requests/ и добавьте следующий код:

src/components/requests/list.tsx
import type { ReactNode } from "react";
import InfiniteScroll from "react-infinite-scroll-component";
import {
  Box,
  Button,
  CircularProgress,
  Skeleton,
  Typography,
} from "@mui/material";

type Props = {
  dataLength: number;
  hasMore: boolean;
  scrollableTarget: string;
  loading: boolean;
  noDataText: string;
  noDataIcon: ReactNode;
  children: ReactNode;
  next: () => void;
};

export const RequestsList = (props: Props) => {
  const hasData = props.dataLength > 0 || props.loading;
  if (!hasData) {
    return (
      <Box
        sx={{
          display: "flex",
          alignItems: "center",
          justifyContent: "center",
          flexDirection: "column",
          gap: "24px",
        }}
      >
        {props.noDataIcon}
        <Typography variant="body2" color="text.secondary">
          {props.noDataText || "No data."}
        </Typography>
      </Box>
    );
  }

  return (
    <Box
      sx={{
        position: "relative",
      }}
    >
      <Box
        id={props.scrollableTarget}
        sx={(theme) => ({
          maxHeight: "600px",
          [theme.breakpoints.up("lg")]: {
            height: "600px",
          },
          overflow: "auto",
          ...((props.dataLength > 6 || props.loading) && {
            "&::after": {
              pointerEvents: "none",
              content: '""',
              zIndex: 1,
              position: "absolute",
              bottom: "0",
              left: "12px",
              right: "12px",
              width: "calc(100% - 24px)",
              height: "60px",
              background:
                "linear-gradient(180deg, rgba(255, 255, 255, 0), #FFFFFF)",
            },
          }),
        })}
      >
        <InfiniteScroll
          dataLength={props.dataLength}
          hasMore={props.hasMore}
          next={props.next}
          scrollableTarget={props.scrollableTarget}
          endMessage={
            !props.loading &&
            props.dataLength > 6 && (
              <Box
                sx={{
                  pt: "40px",
                }}
              />
            )
          }
          loader={
            <Box
              sx={{
                display: "flex",
                alignItems: "center",
                justifyContent: "center",
                width: "100%",
                height: "100px",
              }}
            >
              <CircularProgress size={24} />
            </Box>
          }
        >
          <Box
            sx={{
              display: "flex",
              flexDirection: "column",
            }}
          >
            {props.loading ? <SkeletonList /> : props.children}
          </Box>
        </InfiniteScroll>
      </Box>
    </Box>
  );
};

const SkeletonList = () => {
  return (
    <>
      {[...Array(6)].map((_, index) => (
        <Box
          key={index}
          sx={(theme) => ({
            paddingRight: "24px",
            paddingLeft: "24px",
            display: "flex",
            flexDirection: "column",
            justifyContent: "flex-end",
            gap: "12px",
            paddingTop: "12px",
            paddingBottom: "4px",

            [theme.breakpoints.up("sm")]: {
              paddingTop: "20px",
              paddingBottom: "12px",
            },

            "& .MuiSkeleton-rectangular": {
              borderRadius: "2px",
            },
          })}
        >
          <Skeleton variant="rectangular" width="64px" height="12px" />
          <Box
            sx={{
              display: "flex",
              alignItems: "center",
              gap: "24px",
            }}
          >
            <Skeleton
              variant="circular"
              width={48}
              height={48}
              sx={{
                flexShrink: 0,
              }}
            />
            <Box
              sx={(theme) => ({
                height: "auto",
                width: "100%",
                [theme.breakpoints.up("md")]: {
                  height: "48px",
                },
                display: "flex",
                flex: 1,
                flexDirection: "column",
                justifyContent: "center",
                gap: "8px",
              })}
            >
              <Skeleton
                variant="rectangular"
                sx={(theme) => ({
                  width: "100%",
                  [theme.breakpoints.up("sm")]: {
                    width: "120px",
                  },
                })}
                height="16px"
              />
              <Skeleton
                variant="rectangular"
                sx={(theme) => ({
                  width: "100%",
                  [theme.breakpoints.up("sm")]: {
                    width: "230px",
                  },
                })}
                height="12px"
              />
            </Box>
            <Button
              size="small"
              color="inherit"
              sx={(theme) => ({
                display: "none",
                [theme.breakpoints.up("sm")]: {
                  display: "block",
                },

                alignSelf: "flex-start",
                flexShrink: 0,
                marginLeft: "auto",
              })}
            >
              View Request
            </Button>
          </Box>
        </Box>
      ))}
    </>
  );
};

Компонент <RequestsList /> отображает список запросов на отгулы с бесконечной прокруткой. Он включает индикатор загрузки, макеты-заглушки и сообщение, когда данных нет. Этот компонент спроектирован для эффективной работы с большими наборами данных и обеспечения плавного пользовательского опыта.

Построение компонента <RequestsListItem />

Создайте новый файл с именем list-item.tsx в папке src/components/requests/ и добавьте следующий код:

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

Создайте новый файл с именем list.tsx в папке src/pages/manager/requests/ и добавьте следующий код:

import type { PropsWithChildren } from "react";
import { useGo, useInfiniteList } from "@refinedev/core";
import { Box, Typography } from "@mui/material";
import dayjs from "dayjs";
import { Frame } from "@/components/frame";
import { PageHeader } from "@/components/layout/page-header";
import { RequestsListItem } from "@/components/requests/list-item";
import { RequestsList } from "@/components/requests/list";
import { indigo } from "@/providers/theme-provider/colors";
import { TimeOffIcon, RequestTypeIcon, NoTimeOffIcon } from "@/icons";
import { TimeOffStatus, type Employee, type TimeOff } from "@/types";

export const PageManagerRequestsList = ({ children }: PropsWithChildren) => {
  return (
    <>
      <Box>
        <PageHeader title="Awaiting Requests" />
        <TimeOffsList />
      </Box>
      {children}
    </>
  );
};

const TimeOffsList = () => {
  const go = useGo();

  const {
    data: timeOffsData,
    isLoading: timeOffsLoading,
    fetchNextPage: timeOffsFetchNextPage,
    hasNextPage: timeOffsHasNextPage,
  } = useInfiniteList<
    TimeOff & {
      employee: Employee;
    }
  >({
    resource: "time-offs",
    filters: [
      { field: "status", operator: "eq", value: TimeOffStatus.PENDING },
    ],
    sorters: [{ field: "createdAt", order: "desc" }],
    meta: {
      join: ["employee"],
    },
  });

  const timeOffs = timeOffsData?.pages.flatMap((page) => page.data) || [];
  const totalCount = timeOffsData?.pages[0].total;

  return (
    <Frame
      title="Time off Requests"
      titleSuffix={
        !!totalCount &&
        totalCount > 0 && (
          <Box
            sx={{
              padding: "4px",
              display: "flex",
              alignItems: "center",
              justifyContent: "center",
              minWidth: "24px",
              height: "24px",
              borderRadius: "4px",
              backgroundColor: indigo[100],
            }}
          >
            <Typography
              variant="caption"
              sx={{
                color: indigo[500],
                fontSize: "12px",
                lineHeight: "16px",
              }}
            >
              {totalCount}
            </Typography>
          </Box>
        )
      }
      icon={<TimeOffIcon width={24} height={24} />}
      sx={{
        flex: 1,
        paddingBottom: "0px",
      }}
      sxChildren={{
        padding: 0,
      }}
    >
      <RequestsList
        loading={timeOffsLoading}
        dataLength={timeOffs.length}
        hasMore={timeOffsHasNextPage || false}
        next={timeOffsFetchNextPage}
        scrollableTarget="scrollableDiv-timeOffs"
        noDataText="No time off requests right now."
        noDataIcon={<NoTimeOffIcon />}
      >
        {timeOffs.map((timeOff) => {
          const date = dayjs(timeOff.createdAt).fromNow();
          const fullName = `${timeOff.employee.firstName} ${timeOff.employee.lastName}`;
          const avatarURL = timeOff.employee.avatarUrl;
          const requestedDay =
            dayjs(timeOff.endsAt).diff(dayjs(timeOff.startsAt), "day") + 1;
          const description = `Requested ${requestedDay} ${
            requestedDay > 1 ? "days" : "day"
          } of time  ${timeOff.timeOffType.toLowerCase()} leave.`;

          return (
            <RequestsListItem
              key={timeOff.id}
              date={date}
              avatarURL={avatarURL}
              title={fullName}
              showTimeSince
              descriptionIcon={<RequestTypeIcon type={timeOff.timeOffType} />}
              description={description}
              onClick={() => {
                go({
                  type: "replace",
                  to: {
                    resource: "requests",
                    id: timeOff.id,
                    action: "edit",
                  },
                });
              }}
            />
          );
        })}
      </RequestsList>
    </Frame>
  );
};

Компонент <PageManagerRequestsList /> отображает ожидающие запросы на отпуск, которые менеджеры должны утвердить. Он показывает детали, такие как имя сотрудника, тип отпуска, запрошенные даты и сколько времени назад был сделан запрос. Менеджеры могут нажать на запрос, чтобы увидеть больше деталей. Он использует <RequestsList /> и <RequestsListItem /> для отображения списка.

Этот компонент также принимает children как свойство. Далее, мы реализуем модальный маршрут, используя <Outlet /> для отображения деталей запроса, отображая маршрут /manager/requests/:id внутри компонента.

Добавление маршрута “/manager/requests”

Нам нужно добавить новый маршрут для страницы управления запросами на отпуск. Давайте обновим файл App.tsx, чтобы добавить этот маршрут:

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

Сначала создайте файл с именем use-get-employee-time-off-usage в папке src/hooks/ и добавьте следующий код:

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 для расчета общего количества дней, которые сотрудник взял для каждого типа отпуска. Эта информация будет отображена на странице с подробностями запроса на отпуск.

После этого создайте новый файл с именем time-off-request-modal.tsx в папке src/components/requests/ и добавьте следующий код:

src/components/requests/time-off-request-modal.tsx
import type { ReactNode } from "react";
import { useInvalidate, useList, useUpdate } from "@refinedev/core";
import {
  Avatar,
  Box,
  Button,
  Divider,
  Tooltip,
  Typography,
} from "@mui/material";
import dayjs from "dayjs";
import { Modal } from "@/components/modal";
import {
  TimeOffStatus,
  TimeOffType,
  type Employee,
  type TimeOff,
} from "@/types";
import { RequestTypeIcon, ThumbsDownIcon, ThumbsUpIcon } from "@/icons";
import { useGetEmployeeTimeOffUsage } from "@/hooks/use-get-employee-time-off-usage";

type Props = {
  open: boolean;
  onClose: () => void;
  loading: boolean;
  onSuccess?: () => void;
  timeOff:
    | (TimeOff & {
        employee: Employee;
      })
    | null
    | undefined;
};

export const TimeOffRequestModal = ({
  open,
  timeOff,
  loading: loadingFromProps,
  onClose,
  onSuccess,
}: Props) => {
  const employeeUsedTimeOffs = useGetEmployeeTimeOffUsage({
    employeeId: timeOff?.employee.id,
  });

  const invalidate = useInvalidate();

  const { mutateAsync } = useUpdate<TimeOff>();

  const employee = timeOff?.employee;
  const duration =
    dayjs(timeOff?.endsAt).diff(dayjs(timeOff?.startsAt), "days") + 1;
  const remainingAnnualLeaveDays =
    (employee?.availableAnnualLeaveDays ?? 0) - duration;

  const { data: timeOffsData, isLoading: timeOffsLoading } = useList<
    TimeOff & { employee: Employee }
  >({
    resource: "time-offs",
    pagination: {
      pageSize: 999,
    },
    filters: [
      {
        field: "status",
        operator: "eq",
        value: TimeOffStatus.APPROVED,
      },
      {
        operator: "and",
        value: [
          {
            field: "startsAt",
            operator: "lte",
            value: timeOff?.endsAt,
          },
          {
            field: "endsAt",
            operator: "gte",
            value: timeOff?.startsAt,
          },
        ],
      },
    ],
    queryOptions: {
      enabled: !!timeOff,
    },
    meta: {
      join: ["employee"],
    },
  });
  const whoIsOutList = timeOffsData?.data || [];

  const handleSubmit = async (status: TimeOffStatus) => {
    await mutateAsync({
      resource: "time-offs",
      id: timeOff?.id,
      invalidates: ["resourceAll"],
      values: {
        status,
      },
    });

    onSuccess?.();
    invalidate({
      resource: "employees",
      invalidates: ["all"],
    });
  };

  const loading = timeOffsLoading || loadingFromProps;

  return (
    <Modal
      open={open}
      title="Time Off Request"
      loading={loading}
      sx={{
        maxWidth: "520px",
      }}
      onClose={onClose}
      footer={
        <>
          <Divider />
          <Box
            sx={{
              display: "flex",
              alignItems: "center",
              justifyContent: "space-between",
              gap: "8px",
              padding: "24px",
            }}
          >
            <Button
              sx={{
                backgroundColor: (theme) => theme.palette.error.light,
              }}
              startIcon={<ThumbsDownIcon />}
              onClick={() => handleSubmit(TimeOffStatus.REJECTED)}
            >
              Decline
            </Button>
            <Button
              sx={{
                backgroundColor: (theme) => theme.palette.success.light,
              }}
              onClick={() => handleSubmit(TimeOffStatus.APPROVED)}
              startIcon={<ThumbsUpIcon />}
            >
              Accept
            </Button>
          </Box>
        </>
      }
    >
      <Box
        sx={{
          display: "flex",
          alignItems: "center",
          padding: "24px",
          backgroundColor: (theme) => theme.palette.grey[50],
          borderBottom: (theme) => `1px solid ${theme.palette.divider}`,
        }}
      >
        <Avatar
          src={employee?.avatarUrl}
          alt={employee?.firstName}
          sx={{
            width: "80px",
            height: "80px",
            marginRight: "24px",
          }}
        />
        <Box
          sx={{
            display: "flex",
            flexDirection: "column",
          }}
        >
          <Typography
            variant="h2"
            fontSize="18px"
            lineHeight="28px"
            fontWeight="500"
          >
            {employee?.firstName} {employee?.lastName}
          </Typography>
          <Typography variant="caption">{employee?.jobTitle}</Typography>
          <Typography variant="caption">{employee?.role}</Typography>
        </Box>
      </Box>

      <Box
        sx={{
          padding: "24px",
        }}
      >
        <InfoRow
          loading={loading}
          label="Request Type"
          value={
            <Box
              component="span"
              sx={{
                display: "flex",
                alignItems: "center",
                gap: "8px",
              }}
            >
              <RequestTypeIcon type={timeOff?.timeOffType} />
              <Typography variant="body2" component="span">
                {timeOff?.timeOffType} Leave
              </Typography>
            </Box>
          }
        />
        <Divider />
        <InfoRow
          loading={loading}
          label="Duration"
          value={`${duration > 1 ? `${duration} days` : `${duration} day`}`}
        />
        <Divider />
        <InfoRow
          loading={loading}
          label={
            {
              [TimeOffType.ANNUAL]: "Remaining Annual Leave Days",
              [TimeOffType.SICK]: "Previously Used Sick Leave Days",
              [TimeOffType.CASUAL]: "Previously Used Casual Leave Days",
            }[timeOff?.timeOffType ?? TimeOffType.ANNUAL]
          }
          value={
            {
              [TimeOffType.ANNUAL]: remainingAnnualLeaveDays,
              [TimeOffType.SICK]: employeeUsedTimeOffs.sickCount,
              [TimeOffType.CASUAL]: employeeUsedTimeOffs.casualCount,
            }[timeOff?.timeOffType ?? TimeOffType.ANNUAL]
          }
        />
        <Divider />
        <InfoRow
          loading={loading}
          label="Start Date"
          value={dayjs(timeOff?.startsAt).format("MMMM DD")}
        />
        <Divider />
        <InfoRow
          loading={loading}
          label="End Date"
          value={dayjs(timeOff?.endsAt).format("MMMM DD")}
        />

        <Divider />
        <Box
          sx={{
            display: "flex",
            flexDirection: "column",
            gap: "8px",
            paddingY: "24px",
          }}
        >
          <Typography variant="body2" fontWeight={600}>
            Notes
          </Typography>
          <Typography
            variant="body2"
            sx={{
              height: "20px",
              fontStyle: timeOff?.notes ? "normal" : "italic",
            }}
          >
            {!loading && (timeOff?.notes || "No notes provided.")}
          </Typography>
        </Box>

        <Divider />
        <Box
          sx={{
            display: "flex",
            flexDirection: "column",
            gap: "8px",
            paddingY: "24px",
          }}
        >
          <Typography variant="body2" fontWeight={600}>
            Who's out between these days?
          </Typography>
          <Box
            sx={{
              display: "flex",
              alignItems: "center",
              flexWrap: "wrap",
              gap: "8px",
            }}
          >
            {whoIsOutList.length ? (
              whoIsOutList.map((whoIsOut) => (
                <Tooltip
                  key={whoIsOut.id}
                  sx={{
                    "& .MuiTooltip-tooltip": {
                      background: "red",
                    },
                  }}
                  title={
                    <Box
                      sx={{
                        display: "flex",
                        flexDirection: "column",
                        gap: "2px",
                      }}
                    >
                      <Typography variant="body2">
                        {whoIsOut.employee.firstName}{" "}
                        {whoIsOut.employee.lastName}
                      </Typography>
                      <Typography variant="caption">
                        {whoIsOut.timeOffType} Leave
                      </Typography>
                      <Typography variant="caption">
                        {dayjs(whoIsOut.startsAt).format("MMMM DD")} -{" "}
                        {dayjs(whoIsOut.endsAt).format("MMMM DD")}
                      </Typography>
                    </Box>
                  }
                  placement="top"
                >
                  <Avatar
                    src={whoIsOut.employee.avatarUrl}
                    alt={whoIsOut.employee.firstName}
                    sx={{
                      width: "32px",
                      height: "32px",
                    }}
                  />
                </Tooltip>
              ))
            ) : (
              <Typography
                variant="body2"
                sx={{
                  height: "32px",
                  fontStyle: "italic",
                }}
              >
                {loading ? "" : "No one is out between these days."}
              </Typography>
            )}
          </Box>
        </Box>
      </Box>
    </Modal>
  );
};

const InfoRow = ({
  label,
  value,
  loading,
}: { label: ReactNode; value: ReactNode; loading: boolean }) => {
  return (
    <Box
      sx={{
        display: "flex",
        justifyContent: "space-between",
        paddingY: "24px",
        height: "72px",
      }}
    >
      <Typography variant="body2">{label}</Typography>
      <Typography variant="body2">{loading ? "" : value}</Typography>
    </Box>
  );
};

Давайте разберем компонент <TimeOffRequestModal />:

1. Получение информации об использовании отпуска сотрудником

Хук useGetEmployeeTimeOffUsage используется для получения информации об использовании отпуска сотрудником. Этот хук вычисляет оставшиеся дни ежегодного отпуска и ранее использованные дни по болезни и личным причинам на основе истории отпусков сотрудника.

2. Получение перекрывающихся утвержденных отпусков
filters: [
  {
    field: "status",
    operator: "eq",
    value: TimeOffStatus.APPROVED,
  },
  {
    operator: "and",
    value: [
      {
        field: "startsAt",
        operator: "lte",
        value: timeOff?.endsAt,
      },
      {
        field: "endsAt",
        operator: "gte",
        value: timeOff?.startsAt,
      },
    ],
  },
];

Хук useList с указанными фильтрами извлекает все утвержденные отпуска, перекрывающиеся с текущим запросом на отпуск. Этот список используется для отображения сотрудников, находящихся в отпуске между запрошенными датами.

3. Обработка утверждения/отклонения запроса на отпуск

Функция handleSubmit вызывается, когда менеджер утверждает или отклоняет запрос на отпуск.

const invalidate = useInvalidate();

// ...

const handleSubmit = async (status: TimeOffStatus) => {
  await mutateAsync({
    resource: "time-offs",
    id: timeOff?.id,
    invalidates: ["resourceAll"],
    values: {
      status,
    },
  });

  onSuccess?.();
  invalidate({
    resource: "employees",
    invalidates: ["all"],
  });
};

Refine автоматически инвалидирует кэш ресурса после мутации ресурса (time-offs в данном случае). Поскольку использование отпуска сотрудника рассчитывается на основе истории отпусков, мы также инвалидируем кэш ресурса employees, чтобы обновить использование отпуска сотрудника.

Добавление маршрута “/manager/requests/:id”

На этом этапе мы создадим новый маршрут для отображения страницы с подробностями запроса на отпуск, где менеджеры могут утвердить или отклонить запросы.

Давайте создадим новый файл с именем 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 />.

В настоящее время, когда вы входите в систему как сотрудник, вы не видите ссылку на страницу Запросы в боковой панели, но все равно можете получить доступ к маршруту /manager/requests, введя URL в браузере. Мы добавим защиту, чтобы предотвратить несанкционированный доступ к этим маршрутам.

Давайте обновим файл App.tsx, чтобы добавить проверку авторизации:

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

import { PageEmployeeTimeOffsList } from '@/pages/employee/time-offs/list'
import { PageEmployeeTimeOffsCreate } from '@/pages/employee/time-offs/create'
import { PageManagerRequestsList } from '@/pages/manager/requests/list'
import { PageManagerRequestsTimeOffsEdit } from '@/pages/manager/requests/time-offs/edit'
import { PageLogin } from '@/pages/login'

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

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

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

import { RequestsIcon, TimeOffIcon } from '@/icons'

import { Role } from '@/types'

import '@/utilities/init-dayjs'

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

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

              <Route
                path='manager'
                element={
                  <ThemeProvider role={Role.MANAGER}>
                    <Layout>
                      <CanAccess action='manager' fallback={<NavigateToResource resource='time-offs' />}>
                        <Outlet />
                      </CanAccess>
                    </Layout>
                  </ThemeProvider>
                }>
                <Route
                  path='requests'
                  element={
                    <PageManagerRequestsList>
                      <Outlet />
                    </PageManagerRequestsList>
                  }>
                  <Route path=':id/edit' element={<PageManagerRequestsTimeOffsEdit />} />
                </Route>
              </Route>

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

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

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

export default App

В приведенном выше коде мы добавили компонент <CanAccess /> к маршруту “/manager”. Этот компонент проверяет, есть ли у пользователя роль “менеджер” перед рендерингом дочерних маршрутов. Если у пользователя нет роли “менеджер”, его перенаправят на страницу списка отпусков для сотрудников.

Теперь, когда вы войдете как сотрудник и попытаетесь получить доступ к маршруту /manager/requests, вы будете перенаправлены на страницу списка отсутствий для сотрудников.

Шаг 7 – Развертывание на платформе DigitalOcean App

На этом этапе мы развернем приложение на платформе DigitalOcean App. Для этого мы разместим исходный код на GitHub и подключим репозиторий GitHub к платформе приложений.

Отправка кода на GitHub

Войдите в свою учетную запись GitHub и создайте новый репозиторий с именем refine-hr. Вы можете сделать репозиторий публичным или частным:

После создания репозитория перейдите в директорию проекта и выполните следующую команду для инициализации нового репозитория Git:

git init

Затем добавьте все файлы в репозиторий Git этой командой:

git add .

Затем зафиксируйте файлы этой командой:

git commit -m "Initial commit"

Затем добавьте репозиторий GitHub в качестве удаленного репозитория этой командой:

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

Затем укажите, что вы хотите отправить свой код в ветку main этой командой:

git branch -M main

Наконец, отправьте код в репозиторий GitHub с помощью этой команды:

git push -u origin main

Когда попросят, введите учетные данные GitHub, чтобы отправить свой код.

После того как код будет отправлен в репозиторий GitHub, вы получите сообщение об успешной операции.

В этом разделе вы загрузили свой проект на GitHub, чтобы иметь к нему доступ через приложения DigitalOcean. Следующим шагом будет создание нового приложения DigitalOcean с использованием вашего проекта и настройка автоматической развертки.

Развертывание на платформе DigitalOcean App Platform

На этом этапе вы возьмете приложение React и подготовите его к развертыванию через платформу DigitalOcean App Platform. Вы подключите свой репозиторий GitHub к DigitalOcean, настроите процесс сборки приложения, а затем создадите первое развертывание проекта. После развертывания проекта, все дальнейшие изменения будут автоматически пересобраны и обновлены.

По завершении этого шага ваше приложение будет развернуто на DigitalOcean с возможностью непрерывной доставки.

Войдите в свою учетную запись DigitalOcean и перейдите на страницу Apps. Нажмите кнопку Создать приложение:

Если вы не подключили свою учетную запись GitHub к DigitalOcean, вам будет предложено это сделать. Нажмите кнопку Подключить к GitHub. Откроется новое окно, запрашивающее разрешение на доступ DigitalOcean к вашей учетной записи GitHub.

После того как вы разрешите доступ DigitalOcean, вас перенаправит обратно на страницу приложений DigitalOcean. Следующим шагом будет выбор вашего репозитория GitHub. После выбора репозитория вас попросят выбрать ветку для развертывания. Выберите ветку main и нажмите кнопку Далее.

После этого вы увидите этапы конфигурации вашего приложения. В этом руководстве вы можете нажать кнопку Далее, чтобы пропустить шаги конфигурации. Однако вы также можете настроить ваше приложение по вашему усмотрению.

Дождитесь завершения сборки. После завершения сборки нажмите Живое приложение, чтобы получить доступ к вашему проекту в браузере. Он будет таким же, как проект, который вы тестировали локально, но он будет доступен в сети с безопасным URL. Также вы можете следовать этому руководству, доступному на сайте сообщества DigitalOcean, чтобы узнать, как развертывать приложения на основе React на платформе App.

Примечание: В случае неудачного развертывания сборки вы можете настроить свою команду сборки на DigitalOcean, используя npm install --production=false && npm run build && npm prune --production вместо npm run build

Заключение

В этом учебнике мы создали приложение управления персоналом с нуля, используя Refine, и познакомились с тем, как создать полностью функциональное приложение CRUD.

Также мы продемонстрируем, как развернуть ваше приложение на Платформе приложений DigitalOcean.

Если вы хотите узнать больше о Refine, вы можете посмотреть документацию, и если у вас есть какие-либо вопросы или отзывы, вы можете присоединиться к серверу Discord Refine.

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