بناء ونشر تطبيق HR باستخدام Refine

مقدمة

في هذا البرنامج التعليمي، سنقوم ببناء تطبيق إدارة الموارد البشرية باستخدام إطار العمل Refine وننشره على منصة تطبيقات DigitalOcean.

في نهاية هذا البرنامج التعليمي، سنمتلك تطبيق إدارة الموارد البشرية الذي يتضمن:

  • صفحة تسجيل الدخول: تتيح للمستخدمين تسجيل الدخول كمدير أو موظف. يمكن للمديرين الوصول إلى صفحات الإجازات الشخصية و الطلبات، بينما يمكن للموظفين الوصول إلى صفحة الإجازات الشخصية فقط.
  • صفحات الإجازات الشخصية: تتيح للموظفين طلب، عرض، وإلغاء إجازاتهم. ويمكن للمديرين أيضًا تعيين إجازات جديدة.
  • صفحة الطلبات: يمكن لمديري الموارد البشرية فقط الوصول إليها للموافقة على الطلبات أو رفضها.

ملحوظة: يمكنك الحصول على الشيفرة البرمجية الكاملة للتطبيق الذي سنبنيه في هذا البرنامج التعليمي من هذا مستودع GitHub

أثناء القيام بذلك، سنستخدم:

  • واجهة برمجة التطبيقات REST: لجلب وتحديث البيانات. يحتوي Refine على حزم مزود بيانات وواجهات برمجة التطبيقات REST مدمجة، ولكن يمكنك أيضًا بناء واجهتك الخاصة لتناسب متطلباتك المحددة. في هذا الدليل، سنستخدم 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. هذا يتيح استخدام اختصارات المسارات في مشاريع 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. مع هذه الملفات، سيكون لدينا هيكل أساسي لتطبيق إدارة الموارد البشرية الخاص بنا.

  • الأيقونات: مجلد الأيقونات يحتوي على جميع أيقونات التطبيق.
  • الأنواع:
  • الأدوات المساعدة:
    • constants.ts: ثوابت التطبيق.
    • axios.ts: مثيل Axios لطلبات API، يتعامل مع رموز الوصول، رموز التحديث، والأخطاء.
    • init-dayjs.ts: يقوم بتهيئة Day.js مع الإضافات المطلوبة.
  • المزودين:
    • تحكم-الوصول: يدير أذونات المستخدم باستخدام مزودتحكمالوصول; يتحكم في رؤية صفحة الطلبات بناءً على دور المستخدم.
    • مزود-التوثيق: يدير التوثيق باستخدام مزودالتوثيق; يضمن أن جميع الصفحات محمية وتتطلب تسجيل دخول.
    • مزود-الإشعارات: يعرض رسائل النجاح والخطأ عبر react-hot-toast.
    • عميل-الاستعلام: عميل استعلام مخصص للتحكم الكامل والتخصيص.
    • مزود-الثيم: يدير ثيم Material UI.
  • المكونات:

بعد نسخ الملفات والمجلدات ، يجب أن تبدو هيكلة الملف بهذا الشكل:

└── 📁src
    └── 📁components
        └── 📁frame
        └── 📁input
        └── 📁layout
            └── 📁header
            └── 📁page-header
            └── 📁sider
        └── 📁loading-overlay
        └── 📁modal
    └── 📁icons
    └── 📁providers
        └── 📁access-control
        └── 📁auth-provider
        └── 📁notification-provider
        └── 📁query-client
        └── 📁theme-provider
    └── 📁types
    └── 📁utilities
    └── App.tsx
    └── index.tsx
    └── vite-env.d.ts

بعد ذلك ، قم بتحديث ملف App.tsx لتضمين مقدمي الخدمة والعناصر الضرورية.

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

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

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

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

import { Role } from './types'

import '@/utilities/init-dayjs'

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

export default App


لنقم بتحليل التغييرات الهامة التي قمنا بها في ملف App.tsx:

  • <Refine />: المكون الأساسي من @refinedev/core الذي يلف التطبيق بأكمله لتوفير جلب البيانات وإدارة الحالة وميزات أخرى.
  • <DevtoolsProvider /> و <DevtoolsPanel />: يستخدم لأغراض التصحيح والتطوير.
  • <ThemeProvider />: يطبق تصميمًا مخصصًا عبر التطبيق.
  • تهيئة Day.js: لتلاعب بالتاريخ والوقت.
  • الموارد: مصفوفة تحدد كيانات البيانات (موظف و مدير) التي ستقوم Refine بجلبها. نستخدم الموارد الأبوية والطفلية لتنظيم البيانات وإدارة الأذونات. تحتوي كل مورد على نطاق يحدد دور المستخدم، الذي يتحكم في الوصول إلى أجزاء مختلفة من التطبيق.
  • عميل الاستعلام: عميل استعلام مخصص للتحكم الكامل وتخصيص جلب البيانات.
  • المزامنة مع الموقع: يتيح مزامنة حالة التطبيق (الفلاتر، الترتيبات، الصفحات وما إلى ذلك) مع عنوان URL.
  • تحذير عند وجود تغييرات غير محفوظة: يظهر تحذيرًا عندما يحاول المستخدم الانتقال بعيدًا عن صفحة بها تغييرات غير محفوظة.
  • <التخطيط />: مكون تخطيط مخصص يلف محتوى التطبيق. يحتوي على الرأس، الشريط الجانبي، ومنطقة المحتوى الرئيسية. سنشرح هذا المكون في الخطوات القادمة.

الآن، نحن جاهزون للبدء في بناء تطبيق إدارة الموارد البشرية.


الخطوة 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 /> Component

في الخطوة السابقة، أضفنا مكون تخطيط مخصص إلى التطبيق. عادةً، يمكننا استخدام التخطيط الافتراضي لإطار واجهة المستخدم، ولكننا نريد أن نوضح كيف يمكنك إجراء التخصيص.

يتضمن مكون التخطيط الرأس، الشريط الجانبي، ومنطقة المحتوى الرئيسية. يستخدم <ThemedLayoutV2 /> كأساس وقمنا بتخصيصه ليتناسب مع تصميم تطبيق إدارة الموارد البشرية.

<Sider />

يحتوي الشريط الجانبي على شعار التطبيق وروابط التنقل. على الأجهزة المحمولة، هو شريط جانبي قابل للطي يفتح عندما ينقر المستخدم على أيقونة القائمة. تم إعداد روابط التنقل باستخدام useMenu من Refine وتم عرضها بناءً على دور المستخدم بمساعدة <CanAccess /> المكون.

<UserSelect />

المركب على الشريط الجانبي، يعرض صورة المستخدم المسجل الدخول واسمه. عند النقر، يفتح نافذة منبثقة تحتوي على تفاصيل المستخدم وزر تسجيل الخروج. يمكن للمستخدمين التبديل بين أدوار مختلفة عن طريق الاختيار من القائمة المنسدلة. يتيح هذا المكون الاختبار من خلال التبديل بين المستخدمين بمختلف الأدوار.

<Header />

لا يعرض أي شيء على أجهزة الكمبيوتر المكتبية. على الأجهزة المحمولة، يعرض شعار التطبيق وأيقونة قائمة لفتح الشريط الجانبي. الرأس ثابت ودائم الظهور في أعلى الصفحة.

<PageHeader />

يعرض عنوان الصفحة وأزرار التنقل. يتم توليد عنوان الصفحة تلقائيًا باستخدام useResource، الذي يجلب اسم المورد من سياق Refine. يسمح لنا بمشاركة نفس التصميم والتنسيق عبر التطبيق.

الخطوة 3 — تنفيذ المصادقة والتفويض

في هذه الخطوة، سنقوم بتنفيذ منطق المصادقة والتفويض لتطبيق إدارة الموارد البشرية الخاص بنا. سيكون هذا مثالًا رائعًا على التحكم في الوصول في التطبيقات المؤسسية.

عندما يقوم المستخدمون بتسجيل الدخول كمدير، سيكون بإمكانهم رؤية صفحات الإجازة والطلبات. إذا قاموا بتسجيل الدخول كموظف، سيرون فقط صفحة الإجازة. يمكن للمديرين الموافقة أو رفض طلبات الإجازة في صفحة الطلبات.

يمكن للموظفين طلب إجازة وعرض تاريخهم على صفحة الإجازات. لتنفيذ ذلك، سنستخدم ميزات authProvider وaccessControlProvider من Refine.

المصادقة

في 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. تحتوي كل مصفوفة على كائنات مستخدمين محددة مسبقًا. عندما يختار المستخدم بريدًا إلكترونيًا من القائمة المنسدلة وينقر على زر تسجيل الدخول، يتم استدعاء دالة 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 من Refine، ومكون <CanAccess />، وخطاف useCan.

<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. سنستخدم هذه المرشحات والفرزات لاسترداد بيانات الإجازات من واجهة برمجة التطبيقات.

دعنا نفصل أجزاء المكون الرئيسية:

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: يحدد نقطة النهاية لواجهة برمجة التطبيقات.
    • 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: يحدد تنسيق التاريخ (مثل “5 يناير”).
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. مكون البطاقة
  • يستقبل 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": يعرض الإجازات السابقة التي تمت بالفعل.

إضافة Route “/employee/time-offs”

نحن جاهزون لعرض مكون <PageEmployeeTimeOffsList /> على الRoute /employee/time-offs. دعنا نقوم بتحديث ملف App.tsx لتضمين هذا الRoute:

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': يحدد الRoute الذي يعرض عرض القائمة للمورد.
  • الميتا: كائن يحتوي على بيانات ذاتية إضافية عن المورد.
    • parent: 'employee': يقوم بتجميع هذا المورد تحت نطاق employee، والذي يمكن استخدامه لتنظيم الموارد في واجهة المستخدم (مثل القائمة الجانبية) أو لمراقبة الوصول.
    • scope: Role.EMPLOYEE: يشير إلى أن هذا المورد يمكن الوصول إليه من قبل المستخدمين الذين يحملون دور EMPLOYEE. نحن نستخدم هذا في accessControlProvider لإدارة الأذونات.
    • label: 'Time Off': الاسم الذي سيتم عرضه للمورد في واجهة المستخدم.
    • icon: <TimeOffIcon />: يرتبط رمز TimeOffIcon بهذا المورد للتعرف البصري.
2. إعادة توجيه المستخدمين إلى مورد “time-offs” عندما يقومون بالانتقال إلى المسار /
<Route index element={<NavigateToResource resource="time-offs" />} />

نستخدم مكون <NavigateToResource /> لتوجيه المستخدمين إلى مورد time-offs عندما يتصفحون المسار /. يضمن هذا أن يرون المستخدمون قائمة الإجازات بشكل افتراضي.

3. إعادة التوجيه إلى مورد “time-offs” عندما يكون المستخدمون مصادقين
<Route
  element={
    <Authenticated key="auth-pages" fallback={<Outlet />}>
      <NavigateToResource resource="time-offs" />
    </Authenticated>
  }
>
  <Route path="/login" element={<PageLogin />} />
</Route>

عندما يكون المستخدمون مصادقين، نوجههم إلى مورد time-offs. إذا لم يكونوا مصادقين، يرون صفحة تسجيل الدخول.

4. إضافة مسار /employee/time-offs
<Route
  path="employee"
  element={
    <ThemeProvider role={Role.EMPLOYEE}>
      <Layout>
        <Outlet />
      </Layout>
    </ThemeProvider>
  }
>
  <Route path="time-offs" element={<Outlet />}>
    <Route index element={<PageEmployeeTimeOffsList />} />
  </Route>
</Route>

ننظم صفحات الموظفين باستخدام مسارات متداخلة. أولاً، ننشئ مسارًا رئيسيًا بـ path='employee' يلف المحتوى بتصميم وتخطيط مخصصين للموظفين. داخل هذا المسار، نضيف path='time-offs'، الذي يعرض مكون PageEmployeeTimeOffsList. يقوم هذا الهيكل بتجميع جميع ميزات الموظف تحت مسار واحد ويحافظ على تنسيق النمط.

بعد إضافة هذه التغييرات، يمكنك التنقل إلى مسار /employee/time-offs لرؤية صفحة قائمة الإجازات في العمل.

/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 /> يعرض نموذجًا لإنشاء طلبات إجازة جديدة في تطبيق إدارة الموارد البشرية. يمكن لكل من الموظفين والمديرين استخدامه لطلب أو تخصيص إجازة. يتضمن النموذج خيارات لتحديد نوع الإجازة، واختيار تواريخ البدء والانتهاء، وإضافة ملاحظات، ويظهر ملخصًا للإجازة المطلوبة.

دعونا نفصل الأجزاء الرئيسية من المكون:

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 لجلب استخدام الموظف لأيام الإجازة. يقوم هذا الـ hook بحساب الأيام المتبقية من الإجازة السنوية وأيام الإجازة المرضية والعادية المستخدمة سابقًا استنادًا إلى تاريخ إجازات الموظف.

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 /> المكون.

حاليًا، عندما تسجل الدخول كموظف، لا يمكنك رؤية رابط صفحة Requests في الشريط الجانبي، لكن يمكنك الوصول إلى مسار /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

خلال ذلك، ستقوم بأخذ تطبيق React وتحضيره للنشر عبر منصة تطبيقات DigitalOcean. ستربط مستودع GitHub الخاص بك بـ DigitalOcean، وتقوم بتكوين كيفية بناء التطبيق، ثم تنشئ نشرًا أوليًا لمشروع. بعد نشر المشروع، سيتم إعادة بناء التغييرات الإضافية التي تجريها تلقائيًا وتحديثها.

بنهاية هذه الخطوة، سيكون لديك تطبيقك منشورًا على DigitalOcean مع توفير تسليم مستمر.

قم بتسجيل الدخول إلى حسابك في DigitalOcean وانتقل إلى صفحة التطبيقات. انقر على زر إنشاء تطبيق:

إذا لم تكن قد قمت بربط حسابك على GitHub بـ DigitalOcean، سيُطلب منك القيام بذلك. انقر على زر Connect to GitHub. ستنفتح نافذة جديدة، تطلب منك تخويل DigitalOcean للوصول إلى حسابك على GitHub.

بعد أن تخوّل DigitalOcean، سيتم توجيهك مرة أخرى إلى صفحة تطبيقات DigitalOcean. الخطوة التالية هي اختيار مستودع GitHub الخاص بك. بعد اختيار المستودع، سيُطلب منك اختيار فرع للنشر. حدد الفرع main وانقر على زر Next.

بعد ذلك، سترى خطوات التكوين لتطبيقك. في هذا البرنامج التعليمي، يمكنك النقر على زر Next لتخطي خطوات التكوين. ومع ذلك، يمكنك أيضًا تكوين تطبيقك كما تريد.

انتظر حتى يكتمل البناء. بعد اكتمال البناء، اضغط على Live App للوصول إلى مشروعك في المتصفح. سيكون نفس المشروع الذي اختبرته محليًا، ولكن سيكون على الويب برابط URL آمن. كما يمكنك متابعة هذا البرنامج التعليمي المتوفر على موقع DigitalOcean لتعلم كيفية نشر تطبيقات React على منصة التطبيقات.

ملاحظة: في حال فشل بناءك في النشر بنجاح، يمكنك تكوين أمر البناء الخاص بك على DigitalOcean لاستخدام npm install --production=false && npm run build && npm prune --production بدلاً من npm run build

الخاتمة

في هذا الدليل، قمنا ببناء تطبيق لإدارة الموارد البشرية باستخدام Refine من الصفر وتعرفنا على كيفية بناء تطبيق CRUD كامل الوظائف.

كما سنوضح كيفية نشر تطبيقك على منصة تطبيقات DigitalOcean.

إذا كنت ترغب في معرفة المزيد عن Refine، يمكنك الاطلاع على الوثائق وإذا كان لديك أي أسئلة أو ملاحظات، يمكنك الانضمام إلى خادم ديسكورد Refine.

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