בניית ופריסת אפליקציית משאבי אנוש באמצעות Refine

מבוא

במדריך זה, נבנה אפליקציית ניהול משאבי אנוש עם מסגרת Refine ונפרוס אותה על פלטפורמת האפליקציות של DigitalOcean.

בתום מדריך זה, תהיה לנו אפליקציית ניהול משאבי אנוש שכוללת:

  • עמוד כניסה: מאפשר למשתמשים להיכנס כמנהל או כעובד. למנהלים יש גישה לעמודי חופשה ובקשות, בעוד שעובדים יש להם גישה רק לעמוד חופשה.
  • עמודי חופשה: מאפשרים לעובדים לבקש, לראות ולבטל את החופשות שלהם. כמו כן, מנהלים יכולים להקצות חופשות חדשות.
  • עמוד בקשות: נגיש רק למנהלי משאבי אנוש לאישור או דחיית בקשות חופשה.

הערה: ניתן לקבל את קוד המקור המלא של האפליקציה שנבנה במדריך זה מהמאגר GitHub הזה

בעת ביצוע אלה, נשתמש ב:

  • ממשק API: כדי לאחזר ולעדכן את הנתונים. לרפין יש חבילות ספקי נתונים וממשקי REST מובנים, אך אתה יכול גם לבנות את שלך כדי להתאים לדרישות הספציפיות שלך. במדריך הזה, אנו הולכים להשתמש בNestJs CRUD כשירות צד אחורי שלנו ובחבילה @refinedev/nestjsx-crud כספק הנתונים שלנו.
  • Material UI: נשתמש בזה עבור רכיבי UI וניצור התאמה אישית מלאה בהתאם לעיצוב שלנו. לרפין יש תמיכה מובנית עבור Material UI, אך אתה יכול להשתמש בכל ספריית UI שתרצה.

ברגע שנבנה את האפליקציה, נשים אותה באינטרנט באמצעות DigitalOcean’s App Platform שמקלה על ההקמה, השקה וצמיחה של אפליקציות ואתרים סטטיים. תוכל להפעיל קוד פשוט על ידי הפניה למאגר GitHub ולתת לפלטפורמת האפליקציה לבצע את העבודות הקשות של ניהול התשתית, ריצות האפליקציה ותלויות.

דרישות מוקדמות

מה זה Refine?

Refine הוא מסגרת מטה-React קוד פתוח לבניית יישומי אינטרנט B2B מורכבים, בעיקר מקרים של ניהול נתונים כמו כלים פנימיים, לוחות בקרה ולוחות ניהול. הוא מעוצב על מנת לספק סט של הוקים ורכיבים כדי לשפר את תהליך הפיתוח עם זרימת עבודה טובה יותר למפתח.

הוא מספק תכונות משלימות, מוכנות לייצור עבור אפליקציות ברמת ארגון כדי לפשט משימות בתשלום כמו ניהול מצב ונתונים, אימות, ובקרת גישה. זה מאפשר למפתחים להישאר ממוקדים בליבת האפליקציה שלהם בצורה שמתאימה ממספר פרטי יישום מכבידים.

שלב 1 — הגדרת הפרויקט

נשתמש בפקודת npm create refine-app כדי לאתחל את הפרויקט באופן אינטראקטיבי.

npm create refine-app@latest

בחר את האפשרויות הבאות כאשר תתבקש:

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

לאחר שההתקנה הושלמה, נווט לתיקיית הפרויקט והתחל את האפליקציה שלך עם:

npm run dev

פתח את http://localhost:5173 בדפדפן שלך כדי לראות את האפליקציה.

הכנת הפרויקט

עכשיו כשיש לנו את הפרויקט מוכן, בואו נעשה כמה שינויים במבנה הפרויקט ונשמור על הקבצים הלא נחוצים.

ראשית, התקן את התלויות של צד שלישי:

  • @mui/x-date-pickers, @mui/x-date-pickers-pro: אלו הם רכיבי בחירת תאריך עבור Material UI. נשתמש בהם כדי לבחור את טווח התאריכים לבקשות חופשה.
  • react-hot-toast: ספריית טוסט מינימליסטית עבור React. נשתמש בה כדי להציג הודעות הצלחה ושגיאה.
  • react-infinite-scroll-component: רכיב React שמקל על גלילה אינסופית. נשתמש בו כדי לטעון עוד בקשות חופשה כאשר המשתמש גולל למטה בדף כדי לראות בקשות נוספות.
  • dayjs: ספריית תאריכים קלה לעיבוד, אימות, מניפולציה ועיצוב תאריכים.
  • vite-tsconfig-paths: תוסף Vite שמאפשר לך להשתמש באליאסים של נתיב TypeScript בפרויקט Vite שלך.
npm install @mui/x-date-pickers @mui/x-date-pickers-pro dayjs react-hot-toast react-infinite-scroll-component
npm install --save-dev vite-tsconfig-paths

לאחר התקנת התלויות, עדכן את vite.config.ts ואת tsconfig.json כדי להשתמש בתוסף vite-tsconfig-paths. זה מאפשר קיצורי דרך של TypeScript בפרויקטי Vite, ומאפשר ייבוא עם הקיצור @.

import react from "@vitejs/plugin-react";
import { defineConfig } from "vite";
import tsconfigPaths from "vite-tsconfig-paths";

// https://vitejs.dev/config/
export default defineConfig({
  plugins: [tsconfigPaths({ root: __dirname }), react()],
});
{
  "compilerOptions": {
    "target": "ESNext",
    "useDefineForClassFields": true,
    "lib": ["DOM", "DOM.Iterable", "ESNext"],
    "allowJs": false,
    "skipLibCheck": true,
    "esModuleInterop": false,
    "allowSyntheticDefaultImports": true,
    "strict": true,
    "forceConsistentCasingInFileNames": true,
    "module": "ESNext",
    "moduleResolution": "bundler",
    "resolveJsonModule": true,
    "isolatedModules": true,
    "noEmit": true,
    "jsx": "react-jsx",
    "baseUrl": "./src",
    "paths": {
      "@/*": ["./*"]
    }
  },
  "include": ["src"],
  "references": [{ "path": "./tsconfig.node.json" }]
}

עכשיו, נבצע הסרה של הקבצים והתקיות המיותרות:

  • src/contexts: התיקיה הזו מכילה קובץ אחד שהוא ColorModeContext. הוא מטפל במצב כהה/בהיר עבור האפליקציה. לא נשתמש בו במדריך הזה.
  • src/components: התיקיה הזו מכילה את הרכיב <Header />. נשתמש ברכיב כותרת מותאם אישית במדריך הזה.
rm -rf src/contexts src/components

לאחר הסרת הקבצים והתקיות, App.tsx נותן שגיאה אותה נתקן בשלב הבא.
במהלך המדריך, נעסוק בקידוד הדפים והרכיבים המרכזיים. אז, קח את הקבצים והתקיות הנדרשות מהמאגר GitHub. עם קבצים אלה, תהיה לנו מבנה בסיסי עבור אפליקציית ניהול משאבי אנוש שלנו.

  • אייקונים: תיקיית אייקונים המכילה את כל האייקונים של האפליקציה.
  • סוגים:
    • index.ts: סוגי האפליקציה.
  • כלים:
    • constants.ts: קבועים של האפליקציה.
    • axios.ts: מופע של אקסיוס לבקשות API, טיפול בטוקני גישה, טוקני רענון ושגיאות.
    • init-dayjs.ts: מאתחל את Day.js עם הפלאגינים הנדרשים.
  • ספקים:
    • control-access: מנהל הרשאות משתמשים באמצעות accessControlProvider; שולט בהופעה של דף Requests בהתאם לתפקיד המשתמש.
    • auth-provider: מנהל אימות עם authProvider; מבטיח שכל הדפים מוגנים ודורשים כניסה.
    • notification-provider: מציג הודעות הצלחה ושגיאה באמצעות react-hot-toast.
    • query-client: לקוח שאילתה מותאם אישית לשליטה מלאה והתאמה אישית.
    • theme-provider: מנהל את נושא Material UI.
  • רכיבים:
    • layout: רכיבי תצוגה.
    • loading-overlay: מציג מסך טעינה במהלך אחזור נתונים.
    • input: מייצר שדות קלט לטופס.
    • frame: רכיב מותאם אישית המוסיף גבולות, כותרות ואייקונים לאזורי העמוד.
    • modal: רכיב דיאלוג מודאלי מותאם אישית.

לאחר העתקת הקבצים והתיקיות, מבנה הקבצים צריך להיראות כך:

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

בהמשך, עדכן את קובץ App.tsx כדי לכלול את הספקים והרכיבים הנדרשים.

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

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

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

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

import { Role } from './types'

import '@/utilities/init-dayjs'

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

export default App


בואו נפרק את השינויים החשובים שעשינו בקובץ App.tsx:

  • <Refine />: הרכיב המרכזי מ@refinedev/core שמקיף את כל האפליקציה כדי לספק שליפת נתונים, ניהול מצב ותכונות נוספות.
  • <DevtoolsProvider /> ו<DevtoolsPanel />: משמשים למטרות דיבוג ופיתוח.
  • <ThemeProvider />: מיישם עיצוב מותאם אישית ברחבי האפליקציה.
  • מאתחל את Day.js: למניפולציה של תאריכים ושעות.
  • משאבים: מערך המפרט את ישויות הנתונים (employee ו-manager) ש-Refine ישיג. אנו משתמשים במשאבים הורים וילדים כדי לארגן נתונים ולנהל הרשאות. לכל משאב יש scope המגדיר את תפקיד המשתמש, אשר שולט בגישה לחלקים שונים של האפליקציה.
  • queryClient: לקוח שאילתות מותאם אישית לשליטה מלאה והתאמה אישית של אחזור הנתונים.
  • syncWithLocation: מאפשר סנכרון של מצב האפליקציה (מניחים, מסננים, פייג'ינציה וכו') עם ה-URL.
  • warnWhenUnsavedChanges: מציג אזהרה כאשר המשתמש מנסה לניוד מעמוד עם שינויים לא נשמרים.
  • <Layout />: רכיב פריסת מותאם אישית שמעטיף את תוכן האפליקציה. הוא כולל את הכותרת, את סרגל הצד ואת אזור התוכן הראשי. נסביר רכיב זה בשלב הבא.

עכשיו, אנחנו מוכנים להתחיל לבנות את אפליקציית ניהול משאבי אנוש.


שלב 2— התאמה אישית וסגנון

הסתכל מקרוב על הtheme-provider. התאמתנו במידה רבה את נושא Material UI כך שיתאים לעיצוב של אפליקציית ניהול משאבי אנוש, ויצרנו שני נושאים – אחד למנהלים ואחד לעובדים כדי להבחין ביניהם בצבעים שונים.

בנוסף, הוספנו את Inter כגופן מותאם אישית לאפליקציה. כדי להתקין יש להוסיף את השורה הבאה לקובץ index.html:

<head>
  <meta charset="utf-8" />
  <link rel="icon" href="/favicon.ico" />

  <^>
  <link <^ />
  <^>
  href="https://fonts.googleapis.com/css2?family=Inter:ital,opsz,wght@0,14..32,100..900;1,14..32,100..900&display=swap"
    rel="stylesheet"   /> <^>

  <meta name="viewport" content="width=device-width, initial-scale=1" />
  <meta name="theme-color" content="#000000" />
  <meta
    name="description"
    content="refine | Build your React-based CRUD applications, without constraints."
  />
  <meta
    data-rh="true"
    property="og:image"
    content="https://refine.dev/img/refine_social.png"
  />
  <meta
    data-rh="true"
    name="twitter:image"
    content="https://refine.dev/img/refine_social.png"
  />
  <title>
    Refine - Build your React-based CRUD applications, without constraints.
  </title>
</head>

בדיקת מותאמת אישית <פריסה /> רכיב

בשלב הקודם הוספנו רכיב פריסה מותאם אישית לאפליקציה. לרוב, ניתן להשתמש בפריסת ברירת המחדל של מרכז הממשק המשתמש אך רצינו להראות כיצד ניתן לבצע התאמה אישית.

רכיב הפריסה מכיל את הכותרת, סרגל הצד ואזור התוכן הראשי. הוא משתמש ב־<פריסתערכהV2 /> כבסיס ומותאם אותו כך שיתאים לעיצוב של אפליקציית ניהול משאבי אנוש.

<ציד />

הסרגל הצדדי מכיל את לוגו האפליקציה וקישורי ניווט. במכשירים ניידים זהו סרגל צדדי מתכווץ שנפתח כאשר המשתמש לוחץ על אייקון התפריט. קישורי הניווט מוכנים עם useMenu מה-hook של Refine ומוצגים בהתאם לתפקיד המשתמש בעזרת <CanAccess /> רכיב.

<UserSelect />

המותקן על הסרגל הצדדי, מציג את האוואטר ושם המשתמש המחובר. כאשר לוחצים עליו, נפתח פופובר עם פרטי המשתמש וכפתור התנתקות. משתמשים יכולים לעבור בין תפקידים שונים על ידי בחירה מתוך התפריט הנפתח. רכיב זה מאפשר בדיקה על ידי מעבר בין משתמשים עם תפקידים שונים.

<Header />

הוא לא מציג דבר במכשירים שולחניים. במכשירים ניידים, הוא מציג את לוגו האפליקציה ואייקון תפריט לפתיחת הסרגל הצדדי. הכותרת דביקה ותמיד נראית בחלק העליון של העמוד.

<PageHeader />

זה מציג את כותרת הדף וכפתורי ניווט. כותרת הדף מיוצרת אוטומטית עם useResource הוק, אשר שולף את שם המשאב מההקשר של Refine. זה מאפשר לנו לשתף את אותו עיצוב ופריסה בכל האפליקציה.

שלב 3 — יישום האימות והרשאות

בשלב זה, ניישם את הלוגיקה של אימות והרשאות עבור אפליקציית ניהול משאבי אנוש שלנו. זה יהיה דוגמה מצוינת לשליטת גישה באפליקציות ארגוניות.

כאשר משתמשים נכנסים כמנהלים, הם יוכלו לראות את דפי Time Off ו־Requests. אם הם נכנסים כעובדים, הם יראו רק את דף Time Off. מנהלים יכולים לאשר או לדחות בקשות חופשה בדף Requests.

עובדים יכולים לבקש חופשה ולצפות בהיסטוריה שלהם בדף חופשה. כדי ליישם זאת, אנו נשתמש בתכונות 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. כל מערך מכיל אובייקטי משתמש מוגדרים מראש. כאשר משתמש בוחר אימייל מתוך הרשימה הנפתחת ולוחץ על כפתור Sign in, הפונקציה login נקראת עם האימייל שנבחר. הפונקציה login היא פונקציית מוטציה המסופקת על ידי ההוק useLogin מ-Refine. היא קוראת לauthProvider.login עם האימייל שנבחר.

לאחר מכן, בואו נייבא את רכיב <PageLogin /> ונעדכן את קובץ App.tsx עם השינויים המודגשים.

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

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

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

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

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

import { Role } from './types'

import "@/utilities/init-dayjs";

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

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

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

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

export default App;

בקובץ המעודכן App.tsx, הוספנו את רכיב <Authenticated /> מ-Refine. רכיב זה משמש להגן על מסלולים שדורשים אימות. הוא מקבל פרופ key כדי לזהות את הרכיב באופן ייחודי, פרופ fallback לה-render כאשר המשתמש אינו מאומת, ופרופ 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 — בניית עמוד Time Off

בניית דף רשימת חופשות

בשלב זה, נבנה את דף ה-חופשות. עובדים יכולים לבקש חופש ולראות את היסטוריית החופשות שלהם. מנהלים גם יכולים לצפות בהיסטוריה שלהם, אך במקום לבקש חופש, הם יכולים להקצות אותו לעצמם ישירות. נבצע זאת באמצעות accessControlProvider, רכיב ה-<CanAccess /> וה-useCan hook.

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

ה- prop type קובע איזה סוג של רשימת חופשות להצגה:

  • inReview: מציג בקשות חופשה הממתינות לאישור.
  • upcoming: מציג חופשות העתידיים שאושרו אך טרם התרחשו.
  • history: מפרט חופשות שאושרו וכבר התרחשו.

בתוך הרכיב, ניצור מסננים וממייןים בהתבסס על prop type. נשתמש במסננים ובממייןים אלו כדי לקבל נתוני חופשה מה- API.

בואו נפרק את חלקי המרכיב העיקריים:

1. קבלת המשתמש הנוכחי
const { data: employee } = useGetIdentity<Employee>();
  • useGetIdentity<Employee>(): מביא את המידע של המשתמש הנוכחי.
    • אנו משתמשים במזהה העובד כדי לסנן את החופשות כך שכל משתמש יראה רק את בקשותיו.
2. קבלת נתוני חופשה עם גלילה אינסופית
const { data, isLoading, hasNextPage, fetchNextPage } =
  useInfiniteList <
  TimeOff >
  {
    resource: "time-offs",
    sorters: sorters[props.type],
    filters: [
      ...filters[props.type],
      { field: "employeeId", operator: "eq", value: employee?.id },
    ],
    queryOptions: { enabled: !!employee?.id },
  };

// ...

<InfiniteScroll
  dataLength={timeOffHistory.length}
  next={() => fetchNextPage()}
  hasMore={hasNextPage || false}
  // ... פרופים נוספים
>
  {/* תצוגת פריטי הרשימה כאן */}
</InfiniteScroll>;
  • useInfiniteList<TimeOff>(): מביא נתוני חופש עם גלילה אינסופית.

    • resource: מציין את קצה ה- API.
    • sorters ו־filters: מותאמים בהתאם ל־type כדי לקבל את הנתונים הנכונים.
    • employeeId filter: מבטיח כי רק חופשות הזמן של המשתמש הנוכחי ייבאו.
    • 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": מציג חופשות עבר שכבר התרחשו.

הוספת המסלול “/employee/time-offs”

אנחנו מוכנים להציג את רכיב <PageEmployeeTimeOffsList /> במסלול /employee/time-offs. בואו נעזור לעדכן את קובץ App.tsx כדי לכלול את המסלול הזה:

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

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

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

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

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

import { TimeOffIcon } from '@/icons'

import { Role } from '@/types'

import '@/utilities/init-dayjs'

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

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

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

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

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

export default App

בואו נפרק את החלקים המרכזיים של קובץ App.tsx המעודכן:

1. הגדרת משאב חופשות
{
  name: 'time-offs',
  list: '/employee/time-offs',
  meta: {
    parent: 'employee',
    scope: Role.EMPLOYEE,
    label: 'Time Off',
    icon: <TimeOffIcon />,
  },
}

הוספנו משאב חדש לחופשות כילד של המשאב employee resource. זה מצביע על כך שחופשות קשורות לעובדים ונגישות על ידי עובדים.

  • name: 'time-offs': זהו המזהה עבור המשאב, המשמש פנימית על ידי Refine.
  • list: '/employee/time-offs': מציין את המסלול שמציג את תצוגת הרשימה של המשאב.
  • meta: אובייקט המכיל מטא-נתונים נוספים על המשאב.
    • 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 כאשר הם ניגשים לנתיב /. זה מבטיח שמשתמשים רואים את רשימת ה-time-off כברירת מחדל.

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 כדי לראות את עמוד רשימת ה-time offs בפעולה.

/employee/time-offs

כרגע, עמוד רשימת ה-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 או ללחוץ על כפתור "הקצאת זמן פנוי" בדף רשימת הפניות לזמן פנוי כדי לגשת לטופס יצירת הפנייה לזמן פנוי.

/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 משמש כדי להביא את השימוש של העובד בימי חופשה. ה-h הזה מחשב את ימי החופשה השנתיים שנותרו ואת ימי המחלה והחופשה הרגילה שכבר נעשה שימוש בהם בהתבסס על היסטוריית החופשה של העובד.

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 לפלטפורמת ה-App.

דחיפת הקוד ל-GitHub

התחבר לחשבון GitHub שלך וצור מאגר חדש בשם refine-hr. ניתן להפוך את המאגר לציבורי או פרטי:

לאחר יצירת המאגר, נווט לתיקיית הפרויקט והרץ את הפקודה הבאה כדי לאתחל מאגר Git חדש:

git init

בשלב הבא, הוסף את כל הקבצים למאגר Git בעזרת הפקודה הזו:

git add .

לאחר מכן, קבע את הקבצים בעזרת הפקודה הזו:

git commit -m "Initial commit"

בשלב הבא, הוסף את מאגר ה-GitHub כמאגר מרחוק בעזרת הפקודה הזו:

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

לאחר מכן, ציין כי ברצונך לדחוף את הקוד שלך לענף main בעזרת הפקודה הזו:

git branch -M main

סוף סוף, הזן את הקוד למאגר הקוד של GitHub עם הפקודה הבאה:

git push -u origin main

כאשר יתבקש, הזן את הפרטים שלך ב-GitHub כדי להעלות את הקוד שלך.

תקבל הודעת הצלחה לאחר שהקוד מועלה למאגר הקוד של GitHub.

בחלק זה, העלית את הפרוייקט שלך ל-GitHub כדי שתוכל לגשת אליו באמצעות DigitalOcean Apps. השלב הבא הוא ליצור אפליקציית DigitalOcean חדשה באמצעות הפרוייקט שלך ולהגדיר פרסום אוטומטי.

פרסום לפלטפורמת DigitalOcean App

במהלך זה, תכין אפליקציית React ותכין אותה לפרסום דרך פלטפורמת App של DigitalOcean. תקשר את מאגר הקוד שלך ב-GitHub ל-DigitalOcean, תגדיר כיצד האפליקציה תבנה, ותיצור פרסום ראשוני של הפרוייקט. לאחר שהפרוייקט מופץ, שינויים נוספים שתבצע יושפעו באופן אוטומטי ויעודכנו.

עד סוף שלב זה, יהיה לך את האפליקציה שלך מופצת ב-DigitalOcean עם מסירה רציפה נלקחת בחשבון.

התחבר לחשבון ה-DigitalOcean שלך ונווט לעמוד Apps. לחץ על הכפתור Create App:

אם עדיין לא חיברת את חשבון GitHub שלך ל-DigitalOcean, תתבקש לעשות זאת. לחץ על כפתור Connect to GitHub. חלון חדש ייפתח, שבו תתבקש לאשר ל-DigitalOcean לגשת לחשבון GitHub שלך.

לאחר שתאשר את DigitalOcean, אתה תועבר חזרה לעמוד האפליקציות של DigitalOcean. השלב הבא הוא לבחור את מאגר ה-GitHub שלך. לאחר שתבחר את המאגר שלך, תתבקש לבחור ענף לפריסה. בחר את ענף main ולחץ על כפתור Next.

לאחר מכן, תראה את שלבי ההגדרה עבור האפליקציה שלך. במדריך זה, תוכל ללחוץ על כפתור Next כדי לדלג על שלבי ההגדרה. עם זאת, תוכל גם להגדיר את האפליקציה שלך כפי שתרצה.

חכה שהבנייה תושלם. לאחר שהבנייה הושלמה, לחץ על Live App כדי לגשת לפרויקט שלך בדפדפן. זה יהיה אותו פרויקט שבדקת מקומית, אבל זה יהיה חי באינטרנט עם כתובת URL מאובטחת. כמו כן, תוכל לעקוב אחרי המדריך הזה הזמין באתר הקהילה של DigitalOcean כדי ללמוד כיצד לפרוס אפליקציות מבוססות React ל-App Platform.

הערה: במקרה שהבנייה שלך לא מצליחה להיפרס בהצלחה, תוכל להגדיר את פקודת הבנייה שלך ב-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