Erstellung und Bereitstellung einer HR-App mit Refine

Einführung

In diesem Tutorial werden wir eine HR-Management-Anwendung mit dem Refine Framework erstellen und sie auf der DigitalOcean App Platform bereitstellen.

Am Ende dieses Tutorials werden wir eine HR-Management-Anwendung haben, die Folgendes umfasst:

  • Login-Seite: Ermöglicht es Benutzern, sich entweder als Manager oder als Mitarbeiter anzumelden. Manager haben Zugriff auf die Seiten Urlaub und Anfragen, während Mitarbeiter nur Zugriff auf die Seite Urlaub haben.
  • Urlaubsseiten: Ermöglicht es Mitarbeitern, ihren Urlaub anzufordern, anzusehen und zu stornieren. Auch Manager können neue Urlaube zuweisen.
  • Anfrageseite: Nur für HR-Manager zugänglich, um Urlaubsanfragen zu genehmigen oder abzulehnen.

Hinweis: Sie können den vollständigen Quellcode der App, die wir in diesem Tutorial erstellen, aus diesem GitHub-Repository abrufen.

Während wir dies tun, verwenden wir:

  • Rest API: Um die Daten abzurufen und zu aktualisieren. Refine hat integrierte Datenanbieter-Pakete und REST-APIs, aber Sie können auch Ihre eigenen erstellen, um Ihre spezifischen Anforderungen zu erfüllen. In diesem Leitfaden werden wir NestJs CRUD als unseren Backend-Service und das @refinedev/nestjsx-crud-Paket als unseren Datenanbieter verwenden.
  • Material UI: Wir werden es für UI-Komponenten verwenden und vollständig an unser eigenes Design anpassen. Refine bietet integrierte Unterstützung für Material UI, aber Sie können jede UI-Bibliothek verwenden, die Ihnen gefällt.

Nachdem wir die App erstellt haben, werden wir sie online stellen, indem wir DigitalOcean’s App Platform verwenden, die das Einrichten, Starten und Wachsen von Apps und statischen Websites erleichtert. Sie können den Code bereitstellen, indem Sie einfach auf ein GitHub-Repository verweisen und die App Platform die schwere Arbeit des Managements der Infrastruktur, App-Laufzeiten und Abhängigkeiten erledigen lassen.

Voraussetzungen

Was ist Refine?

Refine ist ein Open-Source-React-Metaframework zum Aufbau komplexer B2B-Webanwendungen, hauptsächlich datenmanagementorientierte Anwendungsfälle wie interne Tools, Admin-Panel und Dashboards. Es wurde entwickelt, um einem Entwickler ein Set von Hooks und Komponenten bereitzustellen, um den Entwicklungsprozess mit einem besseren Workflow zu verbessern.

Es bietet funktionsfertige, produktionsbereite Funktionen für Apps auf Unternehmensebene, um bezahlte Aufgaben wie Zustands- und Datenverwaltung, Authentifizierung und Zugriffskontrolle zu vereinfachen. Dies ermöglicht es Entwicklern, sich auf den Kern ihrer Anwendung zu konzentrieren, der von vielen überwältigenden Implementierungsdetails abstrahiert ist.

Schritt 1 – Einrichten des Projekts

Wir werden den Befehl npm create refine-app verwenden, um das Projekt interaktiv zu initialisieren.

npm create refine-app@latest

Wählen Sie die folgenden Optionen aus, wenn Sie dazu aufgefordert werden:

✔ 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

Nach Abschluss des Setups navigieren Sie zum Projektordner und starten Ihre App mit:

npm run dev

Öffnen Sie http://localhost:5173 in Ihrem Browser, um die App zu sehen.

Projekt vorbereiten

Jetzt, da wir unser Projekt eingerichtet haben, machen wir einige Änderungen an der Projektstruktur und entfernen die unnötigen Dateien.

Zuerst, installieren Sie die Drittanbieter-Abhängigkeiten:

  • @mui/x-date-pickers, @mui/x-date-pickers-pro: Dies sind Datumsauswahlkomponenten für Material UI. Wir werden sie verwenden, um den Zeitraum für die Urlaubsanfragen auszuwählen.
  • react-hot-toast: Eine minimalistische Toast-Bibliothek für React. Wir werden sie verwenden, um Erfolgs- und Fehlermeldungen anzuzeigen.
  • react-infinite-scroll-component: Eine React-Komponente, um das Endlos-Scrollen einfach zu machen. Wir werden sie verwenden, um weitere Urlaubsanfragen zu laden, während der Benutzer die Seite nach unten scrollt, um mehr Anfragen anzuzeigen.
  • dayjs: Eine leichtgewichtige Datumsbibliothek zum Parsen, Validieren, Manipulieren und Formatieren von Daten.
  • vite-tsconfig-paths: Ein Vite-Plugin, das es Ihnen ermöglicht, TypeScript-Pfadaliase in Ihrem Vite-Projekt zu verwenden.
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

Nachdem Sie die Abhängigkeiten installiert haben, aktualisieren Sie vite.config.ts und tsconfig.json, um das vite-tsconfig-paths-Plugin zu verwenden. Dies ermöglicht TypeScript-Pfadalias in Vite-Projekten und erlaubt Importe mit dem @-Alias.

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" }]
}

N als nächstes entfernen wir die unnötigen Dateien und Ordner:

  • src/contexts: Dieser Ordner enthält eine einzelne Datei, die ColorModeContext ist. Sie verwaltet den Dunkel-/Hellmodus für die App. Wir werden sie in diesem Tutorial nicht verwenden.
  • src/components: Dieser Ordner enthält die <Header />-Komponente. Wir werden in diesem Tutorial eine benutzerdefinierte Header-Komponente verwenden.
rm -rf src/contexts src/components

Nachdem wir die Dateien und Ordner entfernt haben, gibt App.tsx einen Fehler aus, den wir in den nächsten Schritten beheben werden.
Im Verlauf des Tutorials werden wir die Kernseiten und -komponenten codieren. Besorgen Sie sich also die benötigten Dateien und Ordner aus dem GitHub-Repository. Mit diesen Dateien haben wir eine grundlegende Struktur für unsere HR-Management-Anwendung.

  • icons: Icons-Ordner, der alle App-Icons enthält.
  • types:
  • Hilfsprogramme:
    • constants.ts: App-Konstanten.
    • axios.ts: Axios-Instanz für API-Anfragen, die Zugriffstoken, Aktualisierungstoken und Fehler behandelt.
    • init-dayjs.ts: Initialisiert Day.js mit erforderlichen Plugins.
  • Anbieter:
    • zugriffssteuerung: Verwaltet Benutzerberechtigungen mit accessControlProvider; steuert die Sichtbarkeit der Anfragen-Seite basierend auf der Benutzerrolle.
    • auth-anbieter: Verwaltet die Authentifizierung mit authProvider; stellt sicher, dass alle Seiten geschützt sind und eine Anmeldung erfordern.
    • benachrichtigungs-anbieter: Zeigt Erfolgs- und Fehlermeldungen über react-hot-toast an.
    • abfrage-client: Benutzerdefinierter Abfrage-Client für volle Kontrolle und Anpassung.
    • thema-anbieter: Verwaltet das Material UI-Design.
  • Komponenten:
    • layout: Layout-Komponenten.
    • loading-overlay: Zeigt ein Ladeoverlay während der Datenabfragen an.
    • input: Rendert Eingabefelder für Formulare.
    • frame: Benutzerdefinierte Komponente, die Rahmen, Titel und Symbole zu Seitenabschnitten hinzufügt.
    • modal: Benutzerdefinierte modale Dialogkomponente.

Nach dem Kopieren der Dateien und Ordner sollte die Dateistruktur folgendermaßen aussehen:

└── 📁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

Nächster Schritt, aktualisieren Sie die App.tsx Datei, um die notwendigen Provider und Komponenten einzufügen.

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


Lassen Sie uns die wichtigen Änderungen aufschlüsseln, die wir an der App.tsx Datei vorgenommen haben:

  • <Refine />: Die Hauptkomponente von @refinedev/core, die die gesamte Anwendung umschließt, um Datenabruf, Zustandsverwaltung und andere Funktionen bereitzustellen.
  • <DevtoolsProvider /> und <DevtoolsPanel />: Wird für Debugging- und Entwicklungszwecke verwendet.
  • <ThemeProvider />: Wendet benutzerdefiniertes Design in der gesamten App an.
  • Initialisierung von Day.js: Zur Manipulation von Datum und Uhrzeit.
  • Ressourcen: Ein Array, das die Dateneinheiten (employee und manager) angibt, die Refine abrufen wird. Wir verwenden Haupt- und untergeordnete Ressourcen, um Daten zu organisieren und Berechtigungen zu verwalten. Jede Ressource hat einen scope, der die Benutzerrolle definiert und den Zugriff auf verschiedene Teile der App steuert.
  • queryClient: Ein benutzerdefinierter Abfrage-Client für vollständige Kontrolle und Anpassung des Datenabrufs.
  • syncWithLocation: Ermöglicht das Synchronisieren des App-Zustands (Filter, Sortierungen, Seitenpagination usw.) mit der URL.
  • warnWhenUnsavedChanges: Zeigt eine Warnung an, wenn der Benutzer versucht, von einer Seite mit nicht gespeicherten Änderungen wegzugehen.
  • <Layout />: Eine benutzerdefinierte Layout-Komponente, die den Inhalt der App umschließt. Sie enthält den Header, die Seitenleiste und den Hauptinhaltbereich. Wir werden diese Komponente in den nächsten Schritten erklären.

Jetzt sind wir bereit, mit dem Bau der HR-Management-Anwendung zu beginnen.


Schritt 2— Anpassung und Styling

Werfen Sie einen genaueren Blick auf den theme-provider. Wir haben das Material UI-Thema stark angepasst, um dem Design der HR-Management-App zu entsprechen, und zwei Themen erstellt: eines für Manager und eines für Mitarbeiter, um sie mit unterschiedlichen Farben zu unterscheiden.

Außerdem haben wir Inter als benutzerdefinierte Schriftart für die App hinzugefügt. Um sie zu installieren, müssen Sie die folgende Zeile in die index.html Datei einfügen:

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

Überprüfung des benutzerdefinierten <Layout /> Komponente

Im vorherigen Schritt haben wir eine benutzerdefinierte Layout-Komponente zur App hinzugefügt. Normalerweise könnten wir das Standardlayout des UI-Frameworks verwenden, aber wir möchten zeigen, wie Sie Anpassungen vornehmen können.

Die Layout-Komponente enthält den Header, die Seitenleiste und den Hauptinhaltbereich. Sie verwendet <ThemedLayoutV2 /> als Basis und hat sie angepasst, um dem Design der HR-Management-App zu entsprechen.

<Sider />

Die Sidebar enthält das App-Logo und Navigationslinks. Auf mobilen Geräten handelt es sich um eine ausklappbare Seitenleiste, die sich öffnet, wenn der Benutzer auf das Menüsymbol klickt. Die Navigationslinks sind mit dem useMenu-Hook von Refine vorbereitet und basierend auf der Benutzerrolle mit Hilfe des <CanAccess />-Komponenten gerendert.

<UserSelect />

Montiert an der Seitenleiste, zeigt es den eingeloggten Benutzer mit Avatar und Namen an. Beim Klicken öffnet sich ein Popover mit Benutzerdetails und einem Logout-Button. Benutzer können zwischen verschiedenen Rollen wechseln, indem sie aus dem Dropdown-Menü auswählen. Diese Komponente ermöglicht es, durch Wechseln zwischen Benutzern mit verschiedenen Rollen zu testen.

<Header />

Auf Desktop-Geräten wird nichts gerendert. Auf mobilen Geräten zeigt es das App-Logo und ein Menüsymbol, um die Seitenleiste zu öffnen. Der Header ist fest und immer oben auf der Seite sichtbar.

<PageHeader />

Es zeigt den Seitentitel und die Navigationsschaltflächen. Der Seitentitel wird automatisch mit dem useResource-Hook generiert, der den Ressourcennamen aus dem Refine-Kontext abruft. Es ermöglicht uns, das gleiche Styling und Layout in der gesamten App zu teilen.

Schritt 3 — Implementierung der Authentifizierung & Autorisierung

In diesem Schritt werden wir die Authentifizierungs- und Autorisierungslogik für unsere HR-Management-Anwendung implementieren. Dies wird ein großartiges Beispiel für die Zugriffskontrolle in Unternehmensanwendungen sein.

Wenn sich Benutzer als Manager anmelden, können sie die Seiten Time Off und Requests sehen. Wenn sie sich als Mitarbeiter anmelden, sehen sie nur die Seite Time Off. Manager können Urlaubsanträge auf der Seite Requests genehmigen oder ablehnen.

Mitarbeiter können Urlaub beantragen und ihre Historie auf der Seite „Zeit frei“ einsehen. Um dies umzusetzen, werden wir die Funktionen authProvider und accessControlProvider von Refine verwenden.

Authentifizierung

In Refine wird die Authentifizierung durch den authProvider behandelt. Es ermöglicht Ihnen, die Authentifizierungslogik für Ihre App zu definieren. Im vorherigen Schritt haben wir bereits den authProvider aus dem GitHub-Repository kopiert und ihn dem <Refine />-Komponente als Prop übergeben. Wir werden die folgenden Hooks und Komponenten verwenden, um das Verhalten unserer App basierend darauf zu steuern, ob der Benutzer eingeloggt ist oder nicht.

  • useLogin: Ein Hook, der eine mutate-Funktion bereitstellt, um den Benutzer einzuloggen.
  • useLogout: Ein Hook, der eine mutate-Funktion bereitstellt, um den Benutzer auszuloggen.
  • useIsAuthenticated: Ein Hook, der einen Boolean zurückgibt, der angibt, ob der Benutzer authentifiziert ist.
  • <Authenticated />: Ein Komponente, die ihre Kinder nur rendert, wenn der Benutzer authentifiziert ist.

Authorisierung

In Refine wird die Autorisierung vom accessControlProvider behandelt. Es ermöglicht Ihnen, Benutzerrollen und Berechtigungen zu definieren und den Zugriff auf verschiedene Teile der App basierend auf der Benutzerrolle zu steuern. Im vorherigen Schritt haben wir bereits den accessControlProvider aus dem GitHub-Repository kopiert und ihn der <Refine />-Komponente als Prop übergeben. Schauen wir uns den accessControlProvider genauer an, um zu sehen, wie er funktioniert.

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;
    // Wenn die Ressource keinen Bereich hat, ist sie nicht zugänglich
    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,
      };
    }

    // Benutzer können nur auf Ressourcen zugreifen, wenn ihre Rolle dem Ressourcenbereich entspricht
    return {
      can: user.role === scope,
    };
  },
};


In unserer App haben wir zwei Rollen: MANAGER und EMPLOYEE.

Manager haben Zugriff auf die Requests Seite, während Mitarbeiter nur Zugriff auf die Time Off Seite haben. Der accessControlProvider überprüft die Rolle des Benutzers und den Ressourcenbereich, um zu bestimmen, ob der Benutzer auf die Ressource zugreifen kann. Wenn die Rolle des Benutzers mit dem Ressourcenbereich übereinstimmt, kann er auf die Ressource zugreifen. Andernfalls wird der Zugriff verweigert. Wir werden den useCan Hook und die <CanAccess /> Komponente verwenden, um das Verhalten unserer App basierend auf der Rolle des Benutzers zu steuern.

Einrichten der Anmeldeseite

Im vorherigen Schritt haben wir den authProvider zur <Refine /> Komponente hinzugefügt. Der authProvider ist verantwortlich für die Handhabung der Authentifizierung.

Zuerst müssen wir Bilder beschaffen. Wir werden diese Bilder als Hintergrundbilder für die Anmeldeseite verwenden. Erstellen Sie einen neuen Ordner namens images im public Ordner und holen Sie die Bilder aus dem GitHub-Repository.

Nachdem wir die Bilder erhalten haben, erstellen wir eine neue Datei mit dem Namen index.tsx im Ordner src/pages/login und fügen den folgenden Code hinzu:

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]",
    },
  ],
};

Um den Authentifizierungsprozess zu vereinfachen, haben wir ein mockUsers Objekt mit zwei Arrays erstellt: managers und employees. Jedes Array enthält vordefinierte Benutzerobjekte. Wenn ein Benutzer eine E-Mail aus dem Dropdown auswählt und auf die Schaltfläche Anmelden klickt, wird die login Funktion mit der ausgewählten E-Mail aufgerufen. Die login Funktion ist eine Mutationsfunktion, die vom useLogin Hook von Refine bereitgestellt wird. Sie ruft authProvider.login mit der ausgewählten E-Mail auf.

Als Nächstes importieren wir die <PageLogin /> Komponente und aktualisieren die App.tsx Datei mit den hervorgehobenen Änderungen.

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;

In der aktualisierten App.tsx Datei haben wir die <Authenticated /> Komponente von Refine hinzugefügt. Diese Komponente wird verwendet, um Routen zu schützen, die eine Authentifizierung erfordern. Sie nimmt eine key Prop an, um die Komponente eindeutig zu identifizieren, eine fallback Prop, um gerendert zu werden, wenn der Benutzer nicht authentifiziert ist, und eine redirectOnFail Prop, um den Benutzer zur angegebenen Route umzuleiten, wenn die Authentifizierung fehlschlägt. Im Hintergrund ruft sie die authProvider.check Methode auf, um zu überprüfen, ob der Benutzer authentifiziert ist.

Lassen Sie uns genauer ansehen, was wir bei key="auth-pages" haben.

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

Die <Authenticated />-Komponente umschließt die „/login“-Route, um den Authentifizierungsstatus des Benutzers zu überprüfen.

  • fallback={<Outlet />}: Wenn der Benutzer nicht authentifiziert ist, wird die verschachtelte Route gerendert (d.h. die <PageLogin />-Komponente wird angezeigt).
  • Kinder (<Navigate to="/" />): Wenn der Benutzer authentifiziert ist, wird er zur Startseite (/) umgeleitet.

Schauen wir uns genauer an, was wir auf key="catch-all" haben

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

<Authenticated />-Komponente umschließt die path="*"-Route, um den Authentifizierungsstatus des Benutzers zu überprüfen. Diese Route ist eine Auffangroute, die die <ErrorComponent /> rendert, wenn der Benutzer authentifiziert ist. Sie ermöglicht es uns, eine 404-Seite anzuzeigen, wenn der Benutzer versucht, auf eine nicht existierende Route zuzugreifen.

Wenn Sie die App ausführen und zu http://localhost:5173/login navigieren, sollten Sie die Anmeldeseite mit dem Dropdown-Menü zur Auswahl des Benutzers sehen.

Derzeit macht die „/“-Seite nichts. In den nächsten Schritten werden wir die Seiten Time Off und Requests implementieren.

Schritt 4 — Erstellen einer Freizeitseite

Erstellen der Urlaubsliste-Seite

In diesem Schritt werden wir die Seite Urlaubsliste erstellen. Mitarbeiter können Urlaub beantragen und ihren Urlaubsverlauf einsehen. Manager können auch ihren Verlauf einsehen, aber anstatt Urlaub zu beantragen, können sie ihn sich direkt zuweisen. Wir werden dies mit Hilfe von Refine’s accessControlProvider, dem <CanAccess /> Komponente und dem useCan Hook realisieren.

<PageEmployeeTimeOffsList />

Bevor wir mit dem Aufbau der Urlaubsseite beginnen, müssen wir einige Komponenten erstellen, um den Urlaubsverlauf, anstehende Urlaubsanfragen und die Statistiken der genutzten Urlaube anzuzeigen. Am Ende dieses Schritts werden wir diese Komponenten verwenden, um die Urlaubsseite zu erstellen.

Erstellen der <TimeOffList /> Komponente zur Anzeige des Urlaubsverlaufs

Erstellen Sie einen neuen Ordner namens time-offs im Ordner src/components. Erstellen Sie im Ordner time-offs eine neue Datei namens list.tsx und fügen Sie den folgenden Code hinzu:

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" }],
};


Die Datei list.tsx ist umfangreich, aber größtenteils beschäftigt sie sich mit Styling und UI-Präsentation.

<TimeOffList />

Wir verwenden diese <TimeOffList />-Komponente in drei verschiedenen Kontexten:

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

Die type-Eigenschaft bestimmt, welche Art von Urlaubsliste angezeigt werden soll:

  • inReview: Zeigt Urlaubsanfragen an, die auf Genehmigung warten.
  • upcoming: Zeigt bevorstehende Urlaube an, die genehmigt, aber noch nicht stattgefunden haben.
  • history: Listet Urlaube auf, die genehmigt wurden und bereits stattgefunden haben.

In der Komponente werden wir Filter und Sortierer basierend auf der type-Eigenschaft erstellen. Wir werden diese Filter und Sortierer verwenden, um die Urlaubsdaten von der API abzurufen.

Lassen Sie uns die wichtigsten Teile der Komponente aufschlüsseln:

1. Abrufen des aktuellen Benutzers
const { data: employee } = useGetIdentity<Employee>();
  • useGetIdentity<Employee>(): Ruft die Informationen des aktuellen Benutzers ab.
    • Wir verwenden die ID des Mitarbeiters, um Urlaube zu filtern, damit jeder Benutzer nur seine eigenen Anfragen sieht.
2. Abrufen von Urlaubsdaten mit Infinite Scrolling
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}
  // ... andere Eigenschaften
>
  {/* Rendern der Listenelemente hier */}
</InfiniteScroll>;
  • useInfiniteList<TimeOff>(): Ruft Zeitabrechnungsdaten mit unendlichem Scrollen ab.

    • resource: Gibt den API-Endpunkt an.
    • sorters und filters: Angepasst basierend auf type, um die richtigen Daten abzurufen.
    • employeeId Filter: Stellt sicher, dass nur die Zeitabrechnungen des aktuellen Benutzers abgerufen werden.
    • queryOptions.enabled: Führt die Abfrage nur aus, wenn die Mitarbeiterdaten verfügbar sind.
  • <InfiniteScroll />: Ermöglicht das Laden weiterer Daten, während der Benutzer nach unten scrollt.

    • next: Funktion zum Abrufen der nächsten Datenseite.
    • hasMore: Gibt an, ob weitere Daten verfügbar sind.
3. Stornierung eines Freizeitantrags
const { mutateAsync: timeOffCancel } = useDelete<TimeOff>();

// Innerhalb des ListItem-Komponenten
await timeOffCancel({
  resource: "time-offs",
  id: timeOff.id,
  invalidates: ["all"],
  successNotification: () => ({
    type: "success",
    message: "Time off request cancelled successfully.",
  }),
});
  • useDelete: Bietet die Funktion timeOffCancel zum Löschen eines Freizeitantrags.
    • Verwendet, wenn ein Benutzer seinen Freizeitantrag storniert.
    • Zeigt eine Erfolgsmeldung bei Abschluss an.
4. Anzeige von Daten mit <DateField />
<DateField
  value={timeOff.startsAt}
  color="text.secondary"
  variant="caption"
  format="MMMM DD"
/>
  • <DateField />: Formatiert und zeigt Daten auf benutzerfreundliche Weise an.
    • value: Das anzuzeigende Datum.
    • format: Gibt das Datumsformat an (z. B. „5. Januar“).
5. Erstellen von Filtern und Sortierern basierend auf type

Filter:

const filters: Record<Props["type"], CrudFilters> = {
  history: [
    {
      field: "status",
      operator: "eq",
      value: TimeOffStatus.APPROVED,
    },
    {
      field: "endsAt",
      operator: "lt",
      value: today,
    },
  ],
  // ... andere Typen
};
  • Definiert Kriterien für das Abrufen von Freistellungen basierend auf Status und Daten.
    • history: Ruft genehmigte Freistellungen ab, die bereits beendet sind.
    • upcoming: Ruft genehmigte Freistellungen ab, die bevorstehen.

Sortierer:

const sorters: Record<Props["type"], CrudSort[]> = {
  history: [{ field: "startsAt", order: "desc" }],
  // ... andere Typen
};
  • Bestimmt die Reihenfolge der abgerufenen Daten.
    • history: Sortiert nach Startdatum in absteigender Reihenfolge.

Erstellen der <TimeOffLeaveCards /> Komponente zur Anzeige von Statistiken über genutzte Freistellungen.

Erstellen Sie eine neue Datei mit dem Namen leave-cards.tsx im src/components/time-offs Ordner und fügen Sie den folgenden Code hinzu:

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",
      // Wir benötigen nur die Gesamtzahl der Krankentage, daher können wir die pageSize auf 1 setzen, um die Last zu reduzieren.
      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",
      // Wir benötigen nur die Gesamtzahl der Krankentage, daher können wir die pageSize auf 1 setzen, um die Last zu reduzieren.
      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 />

Die <TimeOffLeaveCards /> Komponente zeigt Statistiken über die Abwesenheit eines Mitarbeiters an. Sie zeigt drei Karten für Jahresurlaub, Krankheitsurlaub und Sonderurlaub an, die angeben, wie viele Tage verfügbar oder genutzt sind.

Lassen Sie uns die wichtigsten Teile der Komponente aufschlüsseln:

1. Daten abrufen
  • Mitarbeiterdaten: Verwendet useGetIdentity, um die Informationen des aktuellen Mitarbeiters abzurufen, wie z.B. die verfügbaren Jahresurlaubs Tage.
  • Abwesenheitszählungen: Verwendet useList, um die Gesamtzahl der genutzten Krankheits- und Sonderurlaubstage des Mitarbeiters abzurufen. Es setzt pageSize auf 1, da wir nur die Gesamtzahl benötigen, nicht alle Details.
2. Anzeigen der Karten
  • Die Komponente rendert drei Kartenkomponenten, eine für jeden Urlaubsart.
  • Jede Karte zeigt:
    • Die Art des Urlaubs (z.B. Jahresurlaub).
    • Die Anzahl der verfügbaren oder genutzten Tage.
    • Ein Symbol, das die Urlaubsart darstellt.
3. Umgang mit Ladezuständen
  • Wenn Daten noch geladen werden, wird ein Platzhalter angezeigt, anstatt der tatsächlichen Zahlen.
  • Die loading Prop wird an die Karten übergeben, um diesen Zustand zu verwalten.
4. Die Kartenkomponente
  • Empfängt type, value und loading als Props.
  • Verwendet eine variantMap, um die richtigen Labels, Farben und Symbole basierend auf der Urlaubsart zu erhalten.
  • Zeigt die Urlaubsinformationen mit entsprechender Formatierung an.

Erstellung von <PageEmployeeTimeOffsList />

Jetzt, da wir die Komponenten zum Auflisten von Abwesenheiten und zum Anzeigen von Urlaubskarten haben, erstellen wir die neue Datei im src/pages/employee/time-offs/ Ordner mit dem Namen list.tsx und fügen den folgenden Code hinzu:

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 /> ist die Hauptkomponente für die Abwesenheitsseite. Wir werden diese Komponente verwenden, um die Abwesenheitslisten und Urlaubskarten anzuzeigen, wenn Benutzer zur Route /employee/time-offs navigieren.

<PageEmployeeTimeOffsList />

Lass uns die wichtigsten Teile der Komponente aufschlüsseln:

1. Überprüfen der Benutzerrollen
  • Verwendet den useCan Hook, um zu bestimmen, ob der aktuelle Benutzer ein Manager ist.
  • Setzt isManager auf true, wenn der Benutzer über Managerberechtigungen verfügt.
2. Anwenden des Themas basierend auf der Rolle
  • Umhüllt den Inhalt innerhalb eines <ThemeProvider />.
  • Das Thema ändert sich je nachdem, ob der Benutzer ein Manager oder ein Mitarbeiter ist.
3. Seitenkopf mit bedingtem Button
  • Zeigt einen <PageHeader /> mit dem Titel „Freizeit“.
  • Beinhaltet einen <CreateButton />, der je nach Rolle des Benutzers wechselt:
    • Wenn der Benutzer ein Manager ist, sagt der Button „Freizeit zuweisen“.
    • Wenn der Benutzer kein Manager ist, sagt er „Freizeit anfordern“.
  • Dies wird mit der <CanAccess />-Komponente behandelt, die die Berechtigungen überprüft.
4. Anzeigen von Urlaubsstatistiken
  • Beinhaltet die <TimeOffLeaveCards />-Komponente, um Urlaubsstände und -nutzungen anzuzeigen.
  • Dies bietet eine Übersicht über jährliche, kranke und gelegentliche Urlaube.
5. Auflisten von Urlaubsanträgen
  • Verwendet ein <Grid />-Layout, um den Inhalt zu organisieren.
  • Auf der linken Seite (md={6}) werden angezeigt:
    • TimeOffList mit type="inReview": Zeigt ausstehende Urlaubsanträge an.
    • TimeOffList mit type="upcoming": Zeigt bevorstehende genehmigte Urlaubsanträge an.
  • Auf der rechten Seite (md={6}) wird angezeigt:
    • TimeOffList mit type="history": Zeigt vergangene Urlaube, die bereits stattgefunden haben.

Hinzufügen der „/employee/time-offs“ Route

Wir sind bereit, das <PageEmployeeTimeOffsList /> Komponente auf der /employee/time-offs Route zu rendern. Aktualisieren wir die App.tsx Datei, um diese Route einzuschließen:

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

Lassen Sie uns die wichtigen Teile der aktualisierten App.tsx Datei zerlegen:

1. Definition der Urlaubs-Ressource
{
  name: 'time-offs',
  list: '/employee/time-offs',
  meta: {
    parent: 'employee',
    scope: Role.EMPLOYEE,
    label: 'Time Off',
    icon: <TimeOffIcon />,
  },
}

Wir haben eine neue Ressource für Urlaube als Kind der employee Ressource hinzugefügt. Dies zeigt an, dass Urlaube mit Mitarbeitern verbunden sind und von Mitarbeitern abgerufen werden können.

  • name: 'time-offs': Dies ist der Bezeichner für die Ressource, der intern von Refine verwendet wird.
  • list: '/employee/time-offs': Legt die Route fest, die die Listenansicht der Ressource anzeigt.
  • meta: Ein Objekt, das zusätzliche Metadaten über die Ressource enthält.
    • parent: 'employee': Gruppiert diese Ressource unter dem employee Bereich, der zur Organisation von Ressourcen in der Benutzeroberfläche (wie in einem Seitenmenü) oder zur Zugriffskontrolle verwendet werden kann.
    • scope: Role.EMPLOYEE: Gibt an, dass diese Ressource für Benutzer mit der Rolle EMPLOYEE zugänglich ist. Wir verwenden dies im accessControlProvider, um Berechtigungen zu verwalten.
    • label: 'Time Off': Der Anzeigename für die Ressource in der Benutzeroberfläche.
    • icon: <TimeOffIcon />: Verknüpft das TimeOffIcon mit dieser Ressource zur visuellen Identifikation.
2. Weiterleitung zur Ressource „time-offs“, wenn Benutzer zur Route / navigieren.
<Route index element={<NavigateToResource resource="time-offs" />} />

Wir verwenden die <NavigateToResource /> Komponente, um Benutzer zur time-offs Ressource weiterzuleiten, wenn sie zur / Route navigieren. Dies stellt sicher, dass Benutzer standardmäßig die Liste der Abwesenheiten sehen.

3. Weiterleitung zur „time-offs“ Ressource, wenn Benutzer authentifiziert sind
<Route
  element={
    <Authenticated key="auth-pages" fallback={<Outlet />}>
      <NavigateToResource resource="time-offs" />
    </Authenticated>
  }
>
  <Route path="/login" element={<PageLogin />} />
</Route>

Wenn Benutzer authentifiziert sind, leiten wir sie zur time-offs Ressource weiter. Wenn sie nicht authentifiziert sind, sehen sie die Anmeldeseite.

4. Hinzufügen der /employee/time-offs Route
<Route
  path="employee"
  element={
    <ThemeProvider role={Role.EMPLOYEE}>
      <Layout>
        <Outlet />
      </Layout>
    </ThemeProvider>
  }
>
  <Route path="time-offs" element={<Outlet />}>
    <Route index element={<PageEmployeeTimeOffsList />} />
  </Route>
</Route>

Wir organisieren die Mitarbeiterseiten mit verschachtelten Routen. Zuerst erstellen wir eine Hauptroute mit path='employee', die den Inhalt in einem spezifischen Mitarbeiter-Thema und Layout einfügt. Innerhalb dieser Route fügen wir path='time-offs' hinzu, die die PageEmployeeTimeOffsList Komponente anzeigt. Diese Struktur gruppiert alle Mitarbeiterfunktionen unter einem Pfad und sorgt für konsistente Gestaltung.

Nachdem Sie diese Änderungen vorgenommen haben, können Sie zur /employee/time-offs Route navigieren, um die Seite der Abwesenheitsliste in Aktion zu sehen.

/employee/time-offs

Momentan ist die Seite der Abwesenheitsliste funktional, aber sie fehlt die Möglichkeit, neue Abwesenheitsanträge zu erstellen. Lassen Sie uns die Möglichkeit hinzufügen, neue Abwesenheitsanträge zu erstellen.

Erstellen der Seite für Abwesenheitsanträge

Wir werden eine neue Seite zur Beantragung oder Zuweisung von Freizeit erstellen. Diese Seite wird ein Formular enthalten, in dem Benutzer die Art der Freizeit, Start- und Enddaten sowie zusätzliche Notizen angeben können.

Bevor wir beginnen, müssen wir neue Komponenten erstellen, die wir im Formular verwenden:

Erstellung der <TimeOffFormSummary /> Komponente

Erstellen Sie eine neue Datei mit dem Namen form-summary.tsx im Ordner src/components/time-offs/ und fügen Sie den folgenden Code hinzu:

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

Die <TimeOffFormSummary /> Komponente zeigt eine Zusammenfassung der Freizeit-Anfrage an. Sie zeigt die verfügbaren Urlaubstage, die Anzahl der angeforderten Tage und die verbleibenden Tage an. Wir werden diese Komponente im Freizeitformular verwenden, um den Benutzern einen klaren Überblick über ihre Anfrage zu geben.

Erstellung der <PageEmployeeTimeOffsCreate /> Komponente

Erstellen Sie eine neue Datei mit dem Namen create.tsx im Ordner src/pages/employee/time-offs/ und fügen Sie den folgenden Code hinzu:

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

Die <PageEmployeeTimeOffsCreate /> Komponente zeigt ein Formular zur Erstellung neuer Freizeit-Anfragen in einer HR-Management-App an. Sowohl Mitarbeiter als auch Manager können sie verwenden, um Freizeit zu beantragen oder zuzuweisen. Das Formular enthält Optionen zur Auswahl der Art der Freizeit, zur Auswahl von Start- und Enddaten, zum Hinzufügen von Notizen und zeigt eine Zusammenfassung der angeforderten Freizeit an.

Lassen Sie uns die wichtigsten Teile der Komponente aufschlüsseln:

1. Überprüfung der Benutzerrolle

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

Mit dem useCan-Hook überprüfen wir, ob der aktuelle Benutzer über Managerberechtigungen verfügt. Dies bestimmt, ob der Benutzer Urlaub anweisen oder nur anfordern kann. Wir werden die Formularübermittlung im onFinishHandler je nach Rolle des Benutzers unterschiedlich behandeln.

2. Formularzustand und Übermittlung


 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 initialisiert das Formular mit Standardwerten und setzt Erfolgsmeldungen basierend auf der Rolle des Benutzers. Die Funktion onFinishHandler verarbeitet die Formulardaten, bevor sie übermittelt werden. Bei Managern wird der Status sofort auf APPROVED gesetzt, während die Anfragen der Mitarbeiter zur Überprüfung eingereicht werden.

3. Gestaltung

<ThemeProvider role={isManager ? Role.MANAGER : Role.EMPLOYEE}>
  {/* ... */}
</ThemeProvider>

<Button
  variant="contained"
  size="large"
  type="submit"
  startIcon={isManager ? <CheckRectangleIcon /> : undefined}
>
  {isManager ? "Assign" : "Send Request"}
</Button>

In unserem Design ändert sich die Primärfarbe basierend auf der Rolle des Benutzers. Wir verwenden den <ThemeProvider />, um das entsprechende Thema anzuwenden. Der Text und das Symbol der Schaltfläche „Absenden“ ändern sich ebenfalls, je nachdem, ob der Benutzer ein Manager oder ein Mitarbeiter ist.

4. Hinzufügen der Route “/employee/time-offs/create”

Wir müssen die neue Route für die Seite zur Erstellung von Urlaub hinzufügen. Lassen Sie uns die Datei App.tsx aktualisieren, um diese Route einzufügen:

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


Nachdem Sie diese Änderungen vorgenommen haben, können Sie zur Route /employee/time-offs/create navigieren oder auf die Schaltfläche „Urlaub zuweisen“ auf der Seite der Urlaubsliste klicken, um auf das Formular zur Erstellung von Urlaub zuzugreifen.

/employee/time-offs/create

Schritt 5 — Erstellen der Seite für die Verwaltung von Abwesenheitsanträgen

In diesem Schritt erstellen wir eine neue Seite zur Verwaltung von Abwesenheitsanträgen. Diese Seite ermöglicht es Managern, Abwesenheitsanträge, die von Mitarbeitern eingereicht wurden, zu überprüfen und zu genehmigen oder abzulehnen.

/manager/requests

Erstellen der Seite für die Liste der Abwesenheitsanträge

Wir werden eine neue Seite zur Verwaltung von Abwesenheitsanträgen erstellen. Diese Seite enthält eine Liste von Abwesenheitsanträgen, die Details wie den Namen des Mitarbeiters, die Art der Abwesenheit, die angeforderten Daten und den aktuellen Status anzeigt.

Bevor wir beginnen, müssen wir neue Komponenten erstellen, die wir in der Liste verwenden:

Erstellen der <RequestsList />-Komponente

Erstellen Sie eine neue Datei mit dem Namen list.tsx im Ordner src/components/requests/ und fügen Sie den folgenden Code hinzu:

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>
      ))}
    </>
  );
};

Die <RequestsList />-Komponente zeigt eine Liste von Abwesenheitsanträgen mit unendlichem Scrollen an. Sie enthält einen Ladeindikator, Platzhalter für das Skelett und eine Nachricht, wenn keine Daten vorhanden sind. Diese Komponente ist so konzipiert, dass sie große Datensätze effizient verarbeitet und eine reibungslose Benutzererfahrung bietet.

Erstellen der <RequestsListItem />-Komponente

Erstellen Sie eine neue Datei mit dem Namen list-item.tsx im Ordner src/components/requests/ und fügen Sie den folgenden Code hinzu:

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>
  );
};

Die <RequestsListItem />-Komponente zeigt eine einzelne Urlaubsanfrage in der Liste an. Sie enthält das Avatar des Mitarbeiters, den Namen, die Beschreibung und einen Button, um die Details der Anfrage anzuzeigen. Diese Komponente ist wiederverwendbar und kann verwendet werden, um jedes Element in der Liste der Urlaubsanfragen darzustellen.

Erstellen der <PageManagerRequestsList />-Komponente

Erstellen Sie eine neue Datei mit dem Namen list.tsx im Ordner src/pages/manager/requests/ und fügen Sie den folgenden Code hinzu:

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>
  );
};

Die <PageManagerRequestsList />-Komponente zeigt ausstehende Urlaubsanfragen an, die von den Managern genehmigt werden müssen. Sie zeigt Details wie den Namen des Mitarbeiters, den Urlaubstyp, die angeforderten Daten und wie lange die Anfrage her ist. Manager können auf eine Anfrage klicken, um weitere Details zu sehen. Sie verwendet <RequestsList /> und <RequestsListItem />, um die Liste darzustellen.

Diese Komponente akzeptiert auch children als Prop. Als Nächstes implementieren wir eine modale Route mit <Outlet />, um die Antragsdetails anzuzeigen und die Route /manager/requests/:id innerhalb der Komponente darzustellen.

Hinzufügen der Route “/manager/requests”

Wir müssen die neue Route für die Seite zur Verwaltung von Urlaubsanfragen hinzufügen. Aktualisieren wir die Datei App.tsx, um diese Route einzuschließen:

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

Nachdem diese Änderungen hinzugefügt wurden, können Sie zur Route /manager/requests navigieren, um die Seite zur Verwaltung von Urlaubsanfragen in Aktion zu sehen

/manager/requests

Erstellen der Seite für die Details von Urlaubsanfragen

In diesem Schritt werden wir eine neue Seite erstellen, um die Details einer Urlaubsanfrage anzuzeigen. Diese Seite wird den Namen des Mitarbeiters, den Typ des Urlaubs, die angeforderten Daten und den aktuellen Status anzeigen. Manager können die Anfrage von dieser Seite aus genehmigen oder ablehnen.

Erstellen des <TimeOffRequestModal /> Komponenten

Zuerst erstellen Sie eine Datei namens use-get-employee-time-off-usage im Ordner src/hooks/ und fügen Sie den folgenden Code hinzu:

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,
  };
};

Wir werden den Hook useGetEmployeeTimeOffUsage verwenden, um die Gesamtanzahl der Urlaubstage zu berechnen, die ein Mitarbeiter für jeden Urlaubstyp genommen hat. Diese Informationen werden auf der Seite mit den Details zur Urlaubsanfrage angezeigt.

Danach erstellen Sie eine neue Datei namens time-off-request-modal.tsx im Ordner src/components/requests/ und fügen Sie den folgenden Code hinzu:

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>
  );
};

Lassen Sie uns die <TimeOffRequestModal /> Komponente aufschlüsseln:

1. Abrufen der Urlaubsnutzung des Mitarbeiters

Der useGetEmployeeTimeOffUsage Hook wird verwendet, um die Nutzung der Freistellungen des Mitarbeiters abzurufen. Dieser Hook berechnet die verbleibenden jährlichen Urlaubstage sowie die zuvor genutzten Krankheits- und Freizeittage basierend auf der Freistellungshistorie des Mitarbeiters.

2. Abrufen überlappender genehmigter Freistellungen
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,
      },
    ],
  },
];

Der useList Hook mit den oben genannten Filtern ruft alle genehmigten Freistellungen ab, die mit der aktuellen Freistellungsanfrage überlappen. Diese Liste wird verwendet, um die Mitarbeiter anzuzeigen, die zwischen den angeforderten Daten abwesend sind.

3. Umgang mit der Genehmigung/Ablehnung von Freistellungsanfragen

Die Funktion handleSubmit wird aufgerufen, wenn der Manager die Freistellungsanfrage genehmigt oder ablehnt.

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 ungültig macht automatisch den Ressourcen-Cache, nachdem die Ressource, die verändert wurde (time-offs in diesem Fall), aktualisiert wurde. Da die Nutzung der Freistellungen des Mitarbeiters auf der Freistellungshistorie basiert, invalidieren wir auch den Ressourcen-Cache employees, um die Nutzung der Freistellungen des Mitarbeiters zu aktualisieren.

Hinzufügen der Route “/manager/requests/:id”

In diesem Schritt werden wir eine neue Route erstellen, um die Detailseite der Freistellungsanfrage anzuzeigen, auf der Manager Anfragen genehmigen oder ablehnen können.

Lass uns eine neue Datei namens edit.tsx im src/pages/manager/requests/time-offs/ Ordner erstellen und den folgenden Code hinzufügen:

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",
          },
        });
      }}
    />
  );
};

Jetzt müssen wir die neue Route hinzufügen, um die Detailseite der Freistellungsanfrage darzustellen. Lass uns die App.tsx Datei aktualisieren, um diese Route einzufügen:

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



Lass uns einen genaueren Blick auf die Änderungen werfen:

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

Der obige Code richtet eine verschachtelte Routenstruktur ein, bei der ein Modal angezeigt wird, wenn zu einer bestimmten Unterroute navigiert wird. Die <PageManagerRequestsTimeOffsEdit /> Komponente ist ein Modal und wird als Kind der <PageManagerRequestsList /> Komponente gerendert. Diese Struktur ermöglicht es uns, das Modal über der Listenansicht anzuzeigen, während die Listenansicht im Hintergrund sichtbar bleibt.

Wenn Sie zu der Route /manager/requests/:id/edit navigieren oder auf eine Anfrage für eine Freistellung in der Liste klicken, wird die Detailseite der Freistellungsanfrage als Modal über der Listenansicht angezeigt.

/manager/requests/:id/edit

Schritt 6 — Implementierung von Autorisierung und Zugriffskontrolle

Die Autorisierung ist ein kritischer Bestandteil von Anwendungen auf Unternehmensebene und spielt eine Schlüsselrolle sowohl in der Sicherheit als auch in der betrieblichen Effizienz. Sie stellt sicher, dass nur autorisierte Benutzer auf bestimmte Ressourcen zugreifen können, wodurch sensible Daten und Funktionen geschützt werden. Das Autorisierungssystem von Refine bietet die notwendige Infrastruktur, um Ihre Ressourcen zu schützen und sicherzustellen, dass Benutzer in einer sicheren und kontrollierten Weise mit Ihrer Anwendung interagieren. In diesem Schritt werden wir die Autorisierung und Zugriffskontrolle für die Verwaltung von Urlaubsanträgen implementieren. Wir werden den Zugriff auf die /manager/requests und /manager/requests/:id/edit Routen nur für Manager mit Hilfe des <CanAccess /> Komponenten einschränken.

Momentan, wenn Sie sich als Mitarbeiter anmelden, können Sie den Link zur Seite Requests in der Seitenleiste nicht sehen, aber Sie können weiterhin auf die /manager/requests Route zugreifen, indem Sie die URL im Browser eingeben. Wir werden einen Schutz hinzufügen, um unbefugten Zugriff auf diese Routen zu verhindern.

Lassen Sie uns die App.tsx Datei aktualisieren, um die Autorisierungsprüfungen einzuschließen:

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

Im obigen Code haben wir die <CanAccess /> Komponente zur “/manager” Route hinzugefügt. Diese Komponente überprüft, ob der Benutzer die Rolle “Manager” hat, bevor die untergeordneten Routen gerendert werden. Wenn der Benutzer nicht die Rolle “Manager” hat, wird er zur Seite mit der Liste der Urlaubsanträge für Mitarbeiter umgeleitet.

Jetzt, wenn Sie sich als Mitarbeiter anmelden und versuchen, auf die Route /manager/requests zuzugreifen, werden Sie zur Seite mit der Liste der Urlaubsanträge für Mitarbeiter umgeleitet.

Schritt 7 — Bereitstellung auf der DigitalOcean App-Plattform

In diesem Schritt werden wir die Anwendung auf der DigitalOcean App-Plattform bereitstellen. Dazu hosten wir den Quellcode auf GitHub und verbinden das GitHub-Repository mit der App-Plattform.

Code zu GitHub pushen

Melden Sie sich bei Ihrem GitHub-Konto an und erstellen Sie ein neues Repository mit dem Namen refine-hr. Sie können das Repository öffentlich oder privat machen:

Nachdem Sie das Repository erstellt haben, navigieren Sie zum Projektverzeichnis und führen Sie den folgenden Befehl aus, um ein neues Git-Repository zu initialisieren:

git init

Fügen Sie dann alle Dateien mit diesem Befehl zum Git-Repository hinzu:

git add .

Committen Sie dann die Dateien mit diesem Befehl:

git commit -m "Initial commit"

Fügen Sie als Nächstes das GitHub-Repository als Remote-Repository mit diesem Befehl hinzu:

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

Geben Sie als Nächstes an, dass Sie Ihren Code mit diesem Befehl in den main-Branch pushen möchten:

git branch -M main

Schließlich pushen Sie den Code in das GitHub-Repository mit diesem Befehl:

git push -u origin main

Wenn Sie dazu aufgefordert werden, geben Sie Ihre GitHub-Anmeldeinformationen ein, um Ihren Code zu pushen.

Sie erhalten eine Erfolgsnachricht, nachdem der Code in das GitHub-Repository gepusht wurde.

In diesem Abschnitt haben Sie Ihr Projekt in GitHub gepusht, sodass Sie es mit DigitalOcean Apps abrufen können. Der nächste Schritt besteht darin, eine neue DigitalOcean App mit Ihrem Projekt zu erstellen und die automatische Bereitstellung einzurichten.

Bereitstellung auf der DigitalOcean App-Plattform

Dabei würden Sie eine React-Anwendung nehmen und sie für die Bereitstellung über die App-Plattform von DigitalOcean vorbereiten. Sie würden Ihr GitHub-Repository mit DigitalOcean verknüpfen, konfigurieren, wie die App gebaut wird, und dann eine erste Bereitstellung eines Projekts erstellen. Nachdem das Projekt bereitgestellt wurde, werden zusätzliche Änderungen, die Sie vornehmen, automatisch neu gebaut und aktualisiert.

Am Ende dieses Schrittes wird Ihre Anwendung auf DigitalOcean mit kontinuierlicher Bereitstellung bereitgestellt sein.

Melden Sie sich bei Ihrem DigitalOcean-Konto an und navigieren Sie zur Seite Apps. Klicken Sie auf die Schaltfläche App erstellen:

Wenn Sie Ihr GitHub-Konto noch nicht mit DigitalOcean verbunden haben, werden Sie dazu aufgefordert. Klicken Sie auf die Schaltfläche Mit GitHub verbinden. Ein neues Fenster öffnet sich, in dem Sie aufgefordert werden, DigitalOcean den Zugriff auf Ihr GitHub-Konto zu autorisieren.

Nachdem Sie DigitalOcean autorisiert haben, werden Sie zurück zur DigitalOcean Apps-Seite geleitet. Der nächste Schritt besteht darin, Ihr GitHub-Repository auszuwählen. Nachdem Sie Ihr Repository ausgewählt haben, werden Sie aufgefordert, einen Branch für die Bereitstellung auszuwählen. Wählen Sie den main Branch aus und klicken Sie auf die Schaltfläche Weiter.

Danach sehen Sie die Konfigurationsschritte für Ihre Anwendung. In diesem Tutorial können Sie auf die Schaltfläche Weiter klicken, um die Konfigurationsschritte zu überspringen. Sie können Ihre Anwendung jedoch auch nach Belieben konfigurieren.

Warten Sie, bis der Build abgeschlossen ist. Nachdem der Build abgeschlossen ist, drücken Sie Live-App, um Ihr Projekt im Browser zu öffnen. Es wird dasselbe sein wie das Projekt, das Sie lokal getestet haben, aber dies wird live im Web mit einer sicheren URL sein. Außerdem können Sie dieses Tutorial auf der DigitalOcean-Community-Website folgen, um zu lernen, wie Sie auf React basierende Anwendungen auf der App-Plattform bereitstellen.

Hinweis: Falls der Build nicht erfolgreich bereitgestellt werden kann, können Sie Ihren Build-Befehl auf DigitalOcean so konfigurieren, dass npm install --production=false && npm run build && npm prune --production anstelle von npm run build verwendet wird.

Fazit

In diesem Tutorial haben wir eine HR-Management-Anwendung von Grund auf mit Refine erstellt und uns damit vertraut gemacht, wie man eine voll funktionsfähige CRUD-App baut.

Außerdem zeigen wir, wie man Ihre Anwendung auf der DigitalOcean App-Plattform bereitstellt.

Wenn Sie mehr über Refine lernen möchten, können Sie die Dokumentation einsehen, und wenn Sie Fragen oder Feedback haben, können Sie dem Refine Discord-Server beitreten.

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