Bouwen en implementeren van een HR-app met Refine

Inleiding

In deze zelfstudie zullen we een HR-beheerstoepassing bouwen met het Refine Framework en deze implementeren op het DigitalOcean App Platform.

Aan het einde van deze zelfstudie hebben we een HR-beheerstoepassing met onder andere:

  • Aanmeldingspagina: Stelt gebruikers in staat om in te loggen als manager of werknemer. Managers hebben toegang tot de pagina’s Vrijaf en Aanvragen, terwijl werknemers alleen toegang hebben tot de pagina Vrijaf.
  • Vrijaf-pagina’s: Stelt werknemers in staat om verlof aan te vragen, te bekijken en te annuleren. Managers kunnen ook nieuw verlof toewijzen.
  • Aanvragenpagina: Alleen toegankelijk voor HR-managers om verlofaanvragen goed te keuren of af te wijzen.

Opmerking: Je kunt de volledige broncode van de app die we in deze zelfstudie zullen bouwen vinden in deze GitHub-opslagplaats

Tijdens het uitvoeren van deze taken zullen we gebruik maken van:

  • Rest API: Om gegevens op te halen en bij te werken. Refine heeft ingebouwde datapakketaanbieders en REST-API’s, maar je kunt ook je eigen bouwen om aan je specifieke eisen te voldoen. In deze handleiding gaan we NestJs CRUD gebruiken als onze backend-service en het @refinedev/nestjsx-crud pakket als onze datapakketaanbieder.
  • Material UI: We zullen het gebruiken voor UI-componenten en het volledig aanpassen aan ons eigen ontwerp. Refine heeft ingebouwde ondersteuning voor Material UI, maar je kunt elke UI-bibliotheek gebruiken die je wilt.

Zodra we de app hebben gebouwd, zullen we deze online zetten met behulp van het App Platform van DigitalOcean, waarmee het eenvoudig is om apps en statische websites op te zetten, te lanceren en te laten groeien. Je kunt code implementeren door simpelweg te verwijzen naar een GitHub-opslagplaats en het App Platform het zware werk laten doen van het beheren van de infrastructuur, app-runtimes en afhankelijkheden.

Vereisten

Wat is Refine?

Refine is een open source React-metaframework voor het bouwen van complexe B2B-webapplicaties, voornamelijk gericht op gegevensbeheer zoals interne tools, beheerpanelen en dashboards. Het is ontworpen door een reeks hooks en componenten te bieden om het ontwikkelingsproces te verbeteren met een betere workflow voor de ontwikkelaar.

Het biedt functierijke, productierijpe mogelijkheden voor apps op bedrijfsniveau om betaalde taken zoals status- en gegevensbeheer, authenticatie en toegangsbeheer te vereenvoudigen. Dit stelt ontwikkelaars in staat zich te blijven richten op de kern van hun applicatie op een manier die is geabstraheerd van vele overweldigende implementatiedetails.

Stap 1 — Project instellen

We zullen het npm create refine-app commando gebruiken om interactief het project te initialiseren.

npm create refine-app@latest

Selecteer de volgende opties wanneer daarom wordt gevraagd:

✔ 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

Zodra de installatie is voltooid, navigeer naar de projectmap en start je app met:

npm run dev

Open http://localhost:5173 in je browser om de app te bekijken.

Het project voorbereiden

Nu het project is opgezet, laten we wat wijzigingen aanbrengen in de projectstructuur en onnodige bestanden verwijderen.

Installeer eerst de 3rd party afhankelijkheden:

  • @mui/x-date-pickers, @mui/x-date-pickers-pro: Dit zijn datumkiezercomponenten voor Material UI. We zullen ze gebruiken om het datumbereik te selecteren voor de verlofaanvragen.
  • react-hot-toast: Een minimalistische toast-bibliotheek voor React. We zullen het gebruiken om succes- en foutmeldingen weer te geven.
  • react-infinite-scroll-component: Een React-component om oneindig scrollen eenvoudig te maken. We zullen het gebruiken om meer verlofaanvragen te laden wanneer de gebruiker omlaag scrolt om meer verzoeken te bekijken.
  • dayjs: Een lichtgewicht datum bibliotheek voor het parsen, valideren, manipuleren en formatteren van datums.
  • vite-tsconfig-paths: Een Vite-plugin waarmee je TypeScript pad-aliassen kunt gebruiken in je Vite-project.
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

Na het installeren van afhankelijkheden, update vite.config.ts en tsconfig.json om de plugin vite-tsconfig-paths te gebruiken. Dit maakt het mogelijk om TypeScript-pad-aliassen te gebruiken in Vite-projecten, waardoor imports met de alias @ mogelijk zijn.

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

Vervolgens verwijderen we de onnodige bestanden en mappen:

  • src/contexts: Deze map bevat één bestand, namelijk ColorModeContext. Het handelt de donkere/lichte modus af voor de app. We zullen het niet gebruiken in deze tutorial.
  • src/components: Deze map bevat het component <Header />. We zullen een aangepast headercomponent gebruiken in deze tutorial.
rm -rf src/contexts src/components

Na het verwijderen van de bestanden en mappen geeft App.tsx een foutmelding die we in de volgende stappen zullen oplossen.
Gedurende de tutorial zullen we de kernpagina’s en -componenten coderen. Dus haal de benodigde bestanden en mappen op uit de GitHub-opslagplaats. Met deze bestanden hebben we een basisstructuur voor onze HR Management-toepassing.

  • icons: Ikonenmap met alle app-iconen.
  • types:
  • utilities:
    • constants.ts: App constants.
    • axios.ts: Axios instantie voor API-verzoeken, behandeling van toegangstokens, vernieuwingstokens en fouten.
    • init-dayjs.ts: Initialiseert Day.js met vereiste plug-ins.
  • aanbieders:
    • toegangscontrole: Beheert gebruikersmachtigingen met accessControlProvider; regelt de zichtbaarheid van de pagina Verzoeken op basis van gebruikersrol.
    • auth-provider: Beheert authenticatie met authProvider; zorgt ervoor dat alle pagina’s beschermd zijn en aanmelding vereisen.
    • melding-provider: Toont succes- en foutmeldingen via react-hot-toast.
    • query-client: Aangepaste query-client voor volledige controle en aanpassing.
    • themaprovider: Beheert het Material UI-thema.
  • componenten:
    • layout: Layout componenten.
    • loading-overlay: Toont een laad-overlay tijdens het ophalen van gegevens.
    • input: Render formuliervelden.
    • frame: Aangepast component dat randen, titels en pictogrammen toevoegt aan pagina-secties.
    • modal: Aangepast modaal dialoogcomponent.

Na het kopiëren van de bestanden en mappen, moet de bestandsstructuur er als volgt uitzien:

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

Vervolgens de App.tsx bestand bijwerken om de benodigde providers en componenten op te nemen.

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


Laten we de belangrijke wijzigingen die we hebben aangebracht in het App.tsx bestand doornemen:

  • <Refine />: Het kerncomponent van @refinedev/core dat de hele applicatie omhult om gegevens ophalen, toestandsbeheer en andere functies te bieden.
  • <DevtoolsProvider /> en <DevtoolsPanel />: Gebruikt voor debuggen en ontwikkelingsdoeleinden.
  • <ThemeProvider />: Past thema’s toe over de app.
  • Initialiseren van Day.js: Voor datum- en tijdmanipulatie.
  • resources: Een array dat de gegevensentiteiten (employee en manager) specificeert die Refine zal ophalen. We gebruiken ouder- en kindresources om gegevens te organiseren en machtigingen te beheren. Elke resource heeft een scope die de gebruikersrol definieert, die toegang tot verschillende delen van de app regelt.
  • queryClient: Een aangepaste query-client voor volledige controle en aanpassing van het ophalen van gegevens.
  • syncWithLocation: Maakt het mogelijk om de app-status (filters, sorteerders, paginering, enz.) te synchroniseren met de URL.
  • warnWhenUnsavedChanges: Toont een waarschuwing wanneer de gebruiker probeert te navigeren van een pagina met niet-opgeslagen wijzigingen.
  • <Indeling />: Een aangepast indelingscomponent dat de inhoud van de app omvat. Het bevat de koptekst, zijbalk en hoofdinhoudsgebied. We zullen dit component uitleggen in de volgende stappen.

Nu zijn we klaar om te beginnen met het bouwen van de HR Management applicatie.


Stap 2— Aanpassing en opmaak

Bekijk de thema-provider van dichterbij. We hebben het Material UI-thema sterk aangepast om overeen te komen met het ontwerp van de HR Management app, waarbij we twee thema’s hebben gemaakt: één voor managers en één voor werknemers om ze te onderscheiden met verschillende kleuren.

We hebben ook Inter toegevoegd als een aangepast lettertype voor de app. Om te installeren moet je de volgende regel toevoegen aan het index.html bestand:

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

Inspecteren van aangepast <Layout /> Component

In de vorige stap hebben we een aangepast layout component aan de app toegevoegd. Normaal gesproken zouden we het standaard layout van het UI-framework kunnen gebruiken, maar we willen laten zien hoe je aanpassingen kunt maken.

Het layout component bevat de header, zijbalk en hoofdinhoudsgebied. Het gebruikt <ThemedLayoutV2 /> als basis en heeft het aangepast om overeen te komen met het ontwerp van de HR Management-app.

<Sider />

De zijbalk bevat het app-logo en navigatielinks. Op mobiele apparaten is het een inklapbare zijbalk die wordt geopend wanneer de gebruiker op het menupictogram klikt. Navigatielinks worden voorbereid met behulp van de useMenu hook van Refine en worden weergegeven op basis van de rol van de gebruiker met behulp van de <CanAccess /> component.

<UserSelect />

Gemonteerd aan de zijkant, toont het de avatar en de naam van de ingelogde gebruiker. Wanneer erop wordt geklikt, opent het een popover met gebruikersgegevens en een uitlogknop. Gebruikers kunnen schakelen tussen verschillende rollen door te selecteren uit de vervolgkeuzelijst. Deze component maakt het mogelijk om te testen door te schakelen tussen gebruikers met verschillende rollen.

<Header />

Op desktopapparaten wordt er niets weergegeven. Op mobiele apparaten toont het het app-logo en een menupictogram om de zijbalk te openen. De header is vastgezet en altijd zichtbaar bovenaan de pagina.

<PageHeader />

Hiermee worden de paginatitel en navigatieknoppen weergegeven. De paginatitel wordt automatisch gegenereerd met de useResource hook, die de resourcenaam ophaalt uit de Refine-context. Het stelt ons in staat om dezelfde opmaak en lay-out in de hele app te delen.

Stap 3 — Implementatie van de Authenticatie & Autorisatie

In deze stap zullen we de authenticatie- en autorisatielogica implementeren voor onze HR Management applicatie. Dit zal dienen als een goed voorbeeld van toegangsbeheer in bedrijfstoepassingen.

Wanneer gebruikers zich aanmelden als manager, kunnen ze de pagina’s Time Off en Verzoeken zien. Als ze zich aanmelden als werknemer, zullen ze alleen de pagina Time Off zien. Managers kunnen verlofaanvragen goedkeuren of afwijzen op de pagina Verzoeken.

Werknemers kunnen verlof aanvragen en hun geschiedenis bekijken op de pagina Verlof. Om dit te implementeren zullen we de authProvider en accessControlProvider functies van Refine gebruiken.

Authenticatie

In Refine wordt authenticatie afgehandeld door de authProvider. Hiermee kun je de authenticatielogica voor je app definiëren. In de vorige stap hebben we de authProvider al van het GitHub-opslagplaats gekopieerd en aan de <Refine />-component gegeven als een prop. We zullen de volgende hooks en componenten gebruiken om het gedrag van onze app te controleren op basis van of de gebruiker is ingelogd of niet.

  • useLogin: Een hook die een mutate-functie biedt om de gebruiker in te loggen.
  • useLogout: Een hook die een mutate-functie biedt om de gebruiker uit te loggen.
  • useIsAuthenticated: Een hook die een boolean retourneert die aangeeft of de gebruiker geauthenticeerd is.
  • <Authenticated />: Een component die zijn kinderen alleen rendert als de gebruiker geauthenticeerd is.

Authorisatie

In Refine wordt autorisatie afgehandeld door de accessControlProvider. Hiermee kunt u gebruikersrollen en -machtigingen definiëren en toegang tot verschillende delen van de app controleren op basis van de rol van de gebruiker. In de vorige stap hebben we de accessControlProvider al vanuit de GitHub repository gekopieerd en aan de <Refine /> component doorgegeven als een eigenschap. Laten we eens nader bekijken hoe de accessControlProvider werkt.

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;
    // als de bron geen scope heeft, is deze niet toegankelijk
    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,
      };
    }

    // gebruikers hebben alleen toegang tot bronnen als hun rol overeenkomt met de scopes van de bron
    return {
      can: user.role === scope,
    };
  },
};


In onze app hebben we twee rollen: MANAGER en EMPLOYEE.

Managers hebben toegang tot de Requests pagina, terwijl werknemers alleen toegang hebben tot de Time Off pagina. De accessControlProvider controleert de rol van de gebruiker en de reikwijdte van de bron om te bepalen of de gebruiker toegang heeft tot de bron. Als de rol van de gebruiker overeenkomt met de reikwijdte van de bron, hebben ze toegang tot de bron. Anders wordt de toegang geweigerd. We zullen de useCan hook en <CanAccess /> component gebruiken om het gedrag van onze app te controleren op basis van de rol van de gebruiker.

Instellen van de Login Pagina

In de vorige stap hebben we de authProvider toegevoegd aan de <Refine /> component. De authProvider is verantwoordelijk voor het afhandelen van authenticatie.

Allereerst moeten we afbeeldingen krijgen. We zullen deze afbeeldingen gebruiken als achtergrondafbeeldingen voor de login pagina. Maak een nieuwe map genaamd images in de public map en haal de afbeeldingen op van de GitHub repository.

Na het verkrijgen van de afbeeldingen, laten we een nieuw bestand genaamd index.tsx aanmaken in de map src/pages/login en voeg de volgende code toe:

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

Om het authenticatieproces te vereenvoudigen, hebben we een mockUsers object aangemaakt met twee arrays: managers en employees. Elke array bevat vooraf gedefinieerde gebruikersobjecten. Wanneer een gebruiker een e-mailadres selecteert uit de vervolgkeuzelijst en op de knop Aanmelden klikt, wordt de login functie aangeroepen met het geselecteerde e-mailadres. De login functie is een mutatiefunctie die wordt geleverd door de useLogin hook van Refine. Het roept authProvider.login aan met het geselecteerde e-mailadres.

Vervolgens, laten we het <PageLogin /> component importeren en de App.tsx bestand bijwerken met de gemarkeerde wijzigingen.

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 het bijgewerkte App.tsx bestand, hebben we het <Authenticated /> component van Refine toegevoegd. Dit component wordt gebruikt om routes te beschermen die authenticatie vereisen. Het neemt een key attribuut om het component uniek te identificeren, een fallback attribuut om te renderen wanneer de gebruiker niet geauthenticeerd is, en een redirectOnFail attribuut om de gebruiker naar de gespecificeerde route te leiden wanneer authenticatie mislukt. Onder de motorkap roept het de authProvider.check methode aan om te controleren of de gebruiker geauthenticeerd is.

Laten we eens kijken wat we hebben op key="auth-pages"

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

Het <Geverifieerd /> component wikkelt de “/login” route om de authenticatiestatus van de gebruiker te controleren.

  • fallback={<Outlet />}: Als de gebruiker niet geauthenticeerd is, toon dan de geneste route (d.w.z. toon het <PaginaLogin /> component).
  • Kinderen (<Navigeer naar="/" />): Als de gebruiker geauthenticeerd is, stuur ze dan door naar de startpagina (/).

Laten we eens kijken wat we hebben op sleutel="alles-vangen"

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

<Geverifieerd /> component wikkelt om pad="*" route om de authenticatiestatus van de gebruiker te controleren. Deze route is een vangnetroute die het <FoutComponent /> weergeeft wanneer de gebruiker is geauthenticeerd. Het stelt ons in staat om een 404-pagina te tonen wanneer de gebruiker probeert toegang te krijgen tot een niet-bestaande route.

Nu, wanneer je de app uitvoert en navigeert naar http://localhost:5173/login, zou je de inlogpagina moeten zien met het vervolgkeuzemenu om de gebruiker te selecteren.

Op dit moment doet de “/” pagina niets. In de volgende stappen zullen we de pagina’s Tijd Afwezig en Aanvragen implementeren.

Stap 4 — Het bouwen van een Tijd Afwezig Pagina

Opbouwen van Time Off-lijstpagina

In deze stap zullen we de pagina Time Off bouwen. Werknemers kunnen verlof aanvragen en hun verlofgeschiedenis bekijken. Managers kunnen ook hun geschiedenis bekijken, maar in plaats van verlof aan te vragen, kunnen ze het direct aan zichzelf toewijzen. We zullen dit laten werken met behulp van Refine’s accessControlProvider, het <CanAccess /> component, en de useCan hook.

<PageEmployeeTimeOffsList />

Voordat we beginnen met het bouwen van de verlofpagina, moeten we een paar componenten maken om de verlofgeschiedenis, aankomende verlofaanvragen en statistieken van gebruikte verlofdagen te tonen. Aan het einde van deze stap zullen we deze componenten gebruiken om de verlofpagina te bouwen.

Opbouwen van het <TimeOffList /> component om de verlofgeschiedenis te tonen

Maak een nieuwe map genaamd time-offs in de map src/components. Maak binnen de map time-offs een nieuw bestand genaamd list.tsx aan en voeg de volgende code toe:

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


Het bestand list.tsx is lang, maar het meeste heeft te maken met styling en UI-presentatie.

<TimeOffList />

We zullen deze <TimeOffList /> component in drie verschillende contexten gebruiken:

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

De type prop bepaalt welk soort tijdvaklijst moet worden weergegeven:

  • inReview: Toont tijdvakverzoeken die in behandeling zijn.
  • upcoming: Toont aankomende tijdvakken die zijn goedgekeurd maar nog niet hebben plaatsgevonden.
  • history: Lijst met tijdvakken die zijn goedgekeurd en al hebben plaatsgevonden.

Binnen de component zullen we filters en sorteerders maken op basis van de type prop. We zullen deze filters en sorteerders gebruiken om de tijdvakgegevens op te halen uit de API.

Laten we de belangrijkste onderdelen van de component uitsplitsen:

1. Het ophalen van de huidige gebruiker
const { data: employee } = useGetIdentity<Employee>();
  • useGetIdentity<Employee>(): Haalt de informatie van de huidige gebruiker op.
    • We gebruiken de ID van de werknemer om tijdvakken te filteren zodat elke gebruiker alleen zijn eigen verzoeken ziet.
2. Het ophalen van tijdvakgegevens met oneindig scrollen
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 props
>
  {/* Render de lijstitems hier */}
</InfiniteScroll>;
  • useInfiniteList<TimeOff>(): Haalt tijdaf-gegevens op met oneindig scrollen.

    • resource: Specificeert het API-eindpunt.
    • sorters en filters: Aangepast op basis van type om de juiste gegevens op te halen.
    • Filter employeeId: Zorgt ervoor dat alleen de tijdaf van de huidige gebruiker wordt opgehaald.
    • queryOptions.enabled: Voert de query alleen uit wanneer de werknemergegevens beschikbaar zijn.
  • <InfiniteScroll />: Maakt het laden van meer gegevens mogelijk wanneer de gebruiker naar beneden scrolt.

    • next: Functie om de volgende pagina met gegevens op te halen.
    • hasMore: Geeft aan of er meer gegevens beschikbaar zijn.
3. Annuleren van een verlofaanvraag
const { mutateAsync: timeOffCancel } = useDelete<TimeOff>();

// Binnen de ListItem-component
await timeOffCancel({
  resource: "time-offs",
  id: timeOff.id,
  invalidates: ["all"],
  successNotification: () => ({
    type: "success",
    message: "Time off request cancelled successfully.",
  }),
});
  • useDelete: Biedt de functie timeOffCancel om een verlofaanvraag te verwijderen.
    • Gebruikt wanneer een gebruiker zijn verlof annuleert.
    • Toont een succesbericht bij voltooiing.
4. Weergeven van data met <DateField />
<DateField
  value={timeOff.startsAt}
  color="text.secondary"
  variant="caption"
  format="MMMM DD"
/>
  • <DateField />: Formateert en toont data op een gebruikersvriendelijke manier.
    • value: De te tonen datum.
    • format: Specificeert het datumformaat (bijv. “januari 05”).
5. Creëren van filters en sorteerders gebaseerd op type

Filters:

const filters: Record<Props["type"], CrudFilters> = {
  history: [
    {
      field: "status",
      operator: "eq",
      value: TimeOffStatus.APPROVED,
    },
    {
      field: "endsAt",
      operator: "lt",
      value: today,
    },
  ],
  // ... andere types
};
  • Definieert criteria voor het ophalen van vrije dagen op basis van status en data.
    • history: Haalt goedgekeurde vrije dagen op die al zijn verstreken.
    • upcoming: Haalt goedgekeurde vrije dagen op die nog moeten komen.

Sorteerders:

const sorters: Record<Props["type"], CrudSort[]> = {
  history: [{ field: "startsAt", order: "desc" }],
  // ... andere types
};
  • Bepaalt de volgorde van opgehaalde gegevens.
    • history: Sorteert op startdatum in dalende volgorde.

Bouwen van de <TimeOffLeaveCards /> component om statistieken van gebruikte vrije dagen weer te geven

Maak een nieuw bestand genaamd leave-cards.tsx in de map src/components/time-offs en voeg de volgende code toe:

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

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

type Props = {
  employeeId?: number;
};

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

  const { data: timeOffsSick, isLoading: isLoadingTimeOffsSick } =
    useList<TimeOff>({
      resource: "time-offs",
      // we hebben alleen het totale aantal ziektedagen nodig, dus we kunnen de pageSize instellen op 1 om de belasting te verminderen
      pagination: { pageSize: 1 },
      filters: [
        {
          field: "status",
          operator: "eq",
          value: TimeOffStatus.APPROVED,
        },
        {
          field: "timeOffType",
          operator: "eq",
          value: TimeOffType.SICK,
        },
        {
          field: "employeeId",
          operator: "eq",
          value: employee?.id,
        },
      ],
      queryOptions: {
        enabled: !!employee?.id,
      },
    });

  const { data: timeOffsCasual, isLoading: isLoadingTimeOffsCasual } =
    useList<TimeOff>({
      resource: "time-offs",
      // we hebben alleen het totale aantal ziektedagen nodig, dus we kunnen de pageSize instellen op 1 om de belasting te verminderen
      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 />

De <TimeOffLeaveCards /> component toont statistieken over het verlof van een werknemer. Het toont drie kaarten voor Jaarlijks Verlof, Ziekteverlof en Bijzonder Verlof, waarbij wordt aangegeven hoeveel dagen beschikbaar zijn of gebruikt worden.

Laten we de belangrijkste onderdelen van de component uiteenzetten:

1. Data Ophalen
  • Werknemersgegevens: Gebruikt useGetIdentity om de informatie van de huidige werknemer op te halen, zoals beschikbare jaarlijkse verlofdagen.
  • Verloftellingen: Maakt gebruik van useList om het totale aantal ziektedagen en bijzonder verlofdagen op te halen die door de werknemer zijn gebruikt. Het stelt pageSize in op 1 omdat we alleen het totale aantal nodig hebben, niet alle details.
2. De Kaarten Weergeven
  • De component rendert drie kaartcomponenten, één voor elk type verlof.
  • Elke kaart toont:
    • Het type verlof (bijv. Jaarlijks Verlof).
    • Het aantal beschikbare of gebruikte dagen.
    • Een icoon dat het type verlof vertegenwoordigt.
3. Omgaan met Laadstatussen
  • Als de gegevens nog steeds laden, wordt in plaats van de werkelijke cijfers een skeletplaatsvervanger weergegeven.
  • De loading eigenschap wordt doorgegeven aan de kaarten om deze status te beheren.
4. De Kaart Component
  • Ontvangt type, waarde en laden als eigenschappen.
  • Gebruikt een variantMap om de juiste labels, kleuren en pictogrammen te verkrijgen op basis van het verloftype.
  • Toont de verlofinformatie met de juiste opmaak.

Het bouwen van <PageEmployeeTimeOffsList />

Nu we de componenten hebben voor het vermelden van verloven en het tonen van verlofkaarten, laten we het nieuwe bestand aanmaken in de map src/pages/employee/time-offs/ genaamd list.tsx en voeg de volgende code toe:

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 /> is de hoofdcomponent voor de verlofpagina, we zullen deze component gebruiken om de lijsten van verlof en verlofkaarten weer te geven wanneer gebruikers naar de route /employee/time-offs navigeren.

<PageEmployeeTimeOffsList />

Laten we de belangrijkste onderdelen van de component bekijken:

1. Controleren van Gebruikersrollen
  • Gebruikt de useCan hook om te bepalen of de huidige gebruiker een manager is.
  • Stelt isManager in op true als de gebruiker managerbevoegdheden heeft.
2. Thema toepassen op basis van Rol
  • Omsluit de inhoud binnen een <ThemeProvider />.
  • Het thema verandert op basis van of de gebruiker een manager of een werknemer is.
3. Paginaheader met voorwaardelijke knop
  • Rendert een <PageHeader /> met de titel “Time Off”.
  • Inclusief een <CreateButton /> die verandert op basis van de rol van de gebruiker:
    • Als de gebruiker een manager is, staat er “Time Off toewijzen” op de knop.
    • Als de gebruiker geen manager is, staat er “Time Off aanvragen” op de knop.
  • Dit wordt afgehandeld met behulp van de <CanAccess /> component, die machtigingen controleert.
4. Weergave van verlofstatistieken
  • Inclusief de <TimeOffLeaveCards /> component om verlofsaldi en gebruik te tonen.
  • Dit geeft een samenvatting van jaarlijks, ziekte- en incidenteel verlof.
5. Lijst met Time Off-aanvragen
  • Maakt gebruik van een <Grid /> lay-out om de inhoud te organiseren.
  • Aan de linkerkant (md={6}) wordt het volgende weergegeven:
    • TimeOffList met type="inReview": Toont in behandeling zijnde verlofaanvragen.
    • TimeOffList met type="upcoming": Toont aankomend goedgekeurde verlofdagen.
  • Aan de rechterkant (md={6}), wordt het volgende weergegeven:
    • TimeOffList met type="history": Toont vrije dagen uit het verleden die al hebben plaatsgevonden.

Het toevoegen van de “/employee/time-offs” Route

We zijn klaar om het <PageEmployeeTimeOffsList /> component te renderen op de route /employee/time-offs. Laten we het App.tsx bestand bijwerken om deze route op te nemen:

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

Laten we de belangrijkste onderdelen van het bijgewerkte App.tsx bestand doornemen:

1. Definiëren van de Vrije Dagen Bron
{
  name: 'time-offs',
  list: '/employee/time-offs',
  meta: {
    parent: 'employee',
    scope: Role.EMPLOYEE,
    label: 'Time Off',
    icon: <TimeOffIcon />,
  },
}

We hebben een nieuwe resource voor vrije dagen toegevoegd als onderdeel van de employee resource. Dit geeft aan dat vrije dagen gerelateerd zijn aan werknemers en toegankelijk zijn voor werknemers.

  • name: 'time-offs': Dit is de identificatie voor de resource, intern gebruikt door Refine.
  • list: '/employee/time-offs': Specificeert de route die de lijstweergave van de resource weergeeft.
  • meta: Een object dat aanvullende metadata bevat over de resource.
    • parent: 'employee': Groepeert deze resource onder de employee scope, die kan worden gebruikt voor het organiseren van resources in de UI (zoals in een zijbalkmenu) of voor toegangscontrole.
    • scope: Role.EMPLOYEE: Geeft aan dat deze resource toegankelijk is voor gebruikers met de rol EMPLOYEE. We gebruiken dit in de accessControlProvider om machtigingen te beheren.
    • label: 'Time Off': De weergavenaam voor de resource in de UI.
    • icon: <TimeOffIcon />: Koppelt de TimeOffIcon aan deze resource voor visuele identificatie.
2. Doorverwijzen naar de resource “time-offs” wanneer gebruikers naar de / route navigeren
<Route index element={<NavigateToResource resource="time-offs" />} />

We gebruiken het <NavigateToResource /> component om gebruikers door te verwijzen naar de time-offs resource wanneer ze naar de / route navigeren. Dit zorgt ervoor dat gebruikers standaard de lijst met vrije dagen zien.

3. Doorverwijzen naar de “time-offs” resource wanneer gebruikers zijn ingelogd
<Route
  element={
    <Authenticated key="auth-pages" fallback={<Outlet />}>
      <NavigateToResource resource="time-offs" />
    </Authenticated>
  }
>
  <Route path="/login" element={<PageLogin />} />
</Route>

Wanneer gebruikers zijn ingelogd, verwijzen we ze door naar de time-offs resource. Als ze niet zijn ingelogd, zien ze de inlogpagina.

4. Toevoegen van de /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>

We organiseren de werknemerspagina’s met behulp van geneste routes. Eerst maken we een hoofdroute met path='employee' die de inhoud omhult in een thema en lay-out die specifiek zijn voor werknemers. Binnen deze route voegen we path='time-offs' toe, wat het PageEmployeeTimeOffsList component weergeeft. Deze structuur groepeert alle werknemersfuncties onder één pad en houdt de opmaak consistent.

Na het toevoegen van deze wijzigingen, kun je naar de /employee/time-offs route navigeren om de pagina met de lijst van vrije dagen in actie te zien.

/employee/time-offs

Op dit moment is de pagina met de lijst van vrije dagen functioneel, maar het mist de mogelijkheid om nieuwe verlofaanvragen aan te maken. Laten we de mogelijkheid toevoegen om nieuwe verlofaanvragen aan te maken.

Opbouwen van de Verlofaanvraagpagina

We zullen een nieuwe pagina maken voor het aanvragen of toewijzen van verlof. Deze pagina bevat een formulier waarin gebruikers het type verlof, start- en einddata, en eventuele aanvullende opmerkingen kunnen specificeren.

Voordat we beginnen, moeten we nieuwe componenten maken om te gebruiken in het formulier:

Het <TimeOffFormSummary /> Component Bouwen

Maak een nieuw bestand genaamd form-summary.tsx in de map src/components/time-offs/ en voeg de volgende code toe:

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

Het <TimeOffFormSummary /> component toont een samenvatting van het verlofaanvraag. Het toont de beschikbare jaarlijkse verlofdagen, het aantal aangevraagde dagen en de resterende dagen. We zullen dit component gebruiken in het verlof formulier om gebruikers een duidelijk overzicht van hun aanvraag te geven.

Het <PageEmployeeTimeOffsCreate /> Component Bouwen

Maak een nieuw bestand genaamd create.tsx in de map src/pages/employee/time-offs/ en voeg de volgende code toe:

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

Het <PageEmployeeTimeOffsCreate /> component toont een formulier voor het maken van nieuwe verlofaanvragen in een HR-beheerapp. Zowel medewerkers als managers kunnen het gebruiken om verlof aan te vragen of toe te wijzen. Het formulier bevat opties om het type verlof te selecteren, start- en einddatums te kiezen, opmerkingen toe te voegen, en het toont een samenvatting van het aangevraagde verlof.

Laten we de belangrijkste onderdelen van het component bekijken:

1. Controleren van Gebruikersrol

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

Met de useCan hook controleren we of de huidige gebruiker managerrechten heeft. Dit bepaalt of de gebruiker verlof kan toewijzen of alleen kan aanvragen. We zullen het formulier anders verwerken op onFinishHandler op basis van de rol van de gebruiker.

2. Formulierstatus en -indiening


 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 initialiseert het formulier met standaardwaarden en stelt succesmeldingen in op basis van de rol van de gebruiker. De functie onFinishHandler verwerkt de formuliergegevens voordat deze worden ingediend. Voor managers wordt de status onmiddellijk ingesteld op GOEDGEKEURD, terwijl de verzoeken van werknemers ter beoordeling worden ingediend.

3. Opmaak

<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 onze opmaak verandert de primaire kleur op basis van de rol van de gebruiker. We gebruiken de <ThemeProvider /> om het juiste thema toe te passen. De tekst en het pictogram van de verzendknop veranderen ook, afhankelijk of de gebruiker een manager of een werknemer is.

4. Toevoegen van de “/employee/time-offs/create” Route

We moeten de nieuwe route toevoegen voor de pagina om verlof aan te maken. Laten we het bestand App.tsx bijwerken om deze route op te nemen:

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


Na het toevoegen van deze wijzigingen, kun je naar de route /employee/time-offs/create navigeren of op de knop “Verlof toewijzen” op de pagina met verloven klikken om toegang te krijgen tot het formulier voor het aanmaken van verlof.

/employee/time-offs/create

Stap 5 — Pagina voor het beheren van verlofaanvragen bouwen

In deze stap zullen we een nieuwe pagina maken voor het beheren van verlofaanvragen. Deze pagina stelt managers in staat om verlofaanvragen die zijn ingediend door werknemers te bekijken en goed te keuren of af te wijzen.

/manager/requests

Opbouwen van de lijstpagina voor verlofaanvragen

We zullen een nieuwe pagina maken voor het beheren van verlofaanvragen. Deze pagina bevat een lijst van verlofaanvragen, met details zoals de naam van de werknemer, het type verlof, de aangevraagde data en de huidige status.

Voordat we beginnen, moeten we nieuwe componenten maken om te gebruiken in de lijst:

Component <RequestsList /> maken

Maak een nieuw bestand genaamd list.tsx in de map src/components/requests/ en voeg de volgende code toe:

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

Het <RequestsList /> component toont een lijst van verlofaanvragen met oneindig scrollen. Het bevat een laadindicator, skeletplaceholders en een bericht wanneer er geen gegevens zijn. Dit component is ontworpen om grote datasets efficiënt te verwerken en een soepele gebruikerservaring te bieden.

Het bouwen van het <RequestsListItem /> Component

Maak een nieuw bestand genaamd list-item.tsx aan in de map src/components/requests/ en voeg de volgende code toe:

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

Het <RequestsListItem /> component toont een enkel verlofaanvraagitem in de lijst. Het bevat de avatar van de werknemer, naam, omschrijving en een knop om de details van de aanvraag te bekijken. Dit component is herbruikbaar en kan worden gebruikt om elk item in de lijst van verlofaanvragen weer te geven.

Het bouwen van het <PageManagerRequestsList /> Component

Maak een nieuw bestand genaamd list.tsx aan in de map src/pages/manager/requests/ en voeg de volgende code toe:

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

Het <PageManagerRequestsList /> component toont uitstaande verlofaanvragen die managers moeten goedkeuren. Het toont details zoals de naam van de werknemer, type verlof, aangevraagde data en hoe lang geleden de aanvraag is gedaan. Managers kunnen op een aanvraag klikken om meer details te bekijken. Het maakt gebruik van <RequestsList /> en <RequestsListItem /> om de lijst weer te geven.

Dit component accepteert ook children als een prop. Vervolgens zullen we een modale route implementeren met behulp van <Outlet /> om de details van de aanvraag weer te geven, waarbij de /manager/requests/:id route binnen het component wordt weergegeven.

Het toevoegen van de “/manager/requests” Route

We moeten de nieuwe route toevoegen voor de pagina voor het beheer van verlofaanvragen. Laten we het bestand App.tsx bijwerken om deze route op te nemen:

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

Na het toevoegen van deze wijzigingen, kunt u naar de route /manager/requests navigeren om de pagina voor het beheer van verlofaanvragen in actie te zien

/manager/requests

Opbouwen van de pagina voor Verlofaanvraagdetails

In deze stap zullen we een nieuwe pagina maken om de details van een verlofaanvraag weer te geven. Deze pagina zal de naam van de werknemer, het type verlof, de aangevraagde data en de huidige status tonen. Managers kunnen de aanvraag vanaf deze pagina goedkeuren of afwijzen.

Opbouwen van de <TimeOffRequestModal /> Component

Eerst, maak een bestand genaamd use-get-employee-time-off-usage in de map src/hooks/ aan en voeg de volgende code toe:

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

We zullen de useGetEmployeeTimeOffUsage hook gebruiken om het totale aantal dagen te berekenen dat een werknemer heeft opgenomen voor elk type verlof. Deze informatie zal worden weergegeven op de pagina voor de details van de verlofaanvraag.

Daarna, maak een nieuw bestand genaamd time-off-request-modal.tsx in de map src/components/requests/ aan en voeg de volgende code toe:

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

Laten we de <TimeOffRequestModal /> component uitsplitsen:

1. Ophalen van gebruik van verlof door werknemer

De useGetEmployeeTimeOffUsage hook wordt gebruikt om het verlofgebruik van de werknemer op te halen. Deze hook berekent de resterende jaarlijkse verlofdagen en de eerder gebruikte ziektedagen en vrije dagen op basis van de verlofgeschiedenis van de werknemer.

2. Overlappende goedgekeurde verlofaanvragen ophalen
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,
      },
    ],
  },
];

De useList hook met bovenstaande filters haalt alle goedgekeurde verlofaanvragen op die overlappen met het huidige verlofaanvraag. Deze lijst wordt gebruikt om de werknemers weer te geven die afwezig zijn tussen de aangevraagde data.

3. Afhandelen van goedkeuring/afwijzing van verlofaanvraag

De handleSubmit functie wordt aangeroepen wanneer de manager de verlofaanvraag goedkeurt of afwijst.

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 maakt de broncache automatisch ongeldig nadat de bron is gewijzigd (verlofaanvragen in dit geval).
Aangezien het verlofgebruik van de werknemer wordt berekend op basis van de verlofgeschiedenis, maken we ook de broncache van werknemers ongeldig om het verlofgebruik van de werknemer bij te werken.

Het toevoegen van de “/manager/requests/:id” Route

In deze stap zullen we een nieuwe route maken om de details van de verlofaanvraag weer te geven, waar managers verzoeken kunnen goedkeuren of afwijzen.

Laten we een nieuw bestand genaamd edit.tsx maken in de map src/pages/manager/requests/time-offs/ en de volgende code toevoegen:

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

Nu moeten we de nieuwe route toevoegen om de pagina met details van de verlofaanvraag weer te geven. Laten we het bestand App.tsx bijwerken om deze route op te nemen:

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



Laten we de wijzigingen van dichterbij bekijken:

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

De code hierboven stelt een geneste route-structuur in waarbij een modaal venster wordt weergegeven bij het navigeren naar een specifieke kindroute. Het <PageManagerRequestsTimeOffsEdit /> component is een modaal venster en wordt weergegeven als een kind van het <PageManagerRequestsList /> component. Deze structuur maakt het mogelijk om het modaal venster bovenop de lijstpagina weer te geven terwijl de lijstpagina zichtbaar blijft op de achtergrond.

Wanneer je naar de /manager/requests/:id/edit route navigeert of op een verlofaanvraag in de lijst klikt, zal de pagina met details van de verlofaanvraag worden weergegeven als een modaal venster bovenop de lijstpagina.

/manager/requests/:id/edit

Stap 6 — Implementatie van Autorisatie en Toegangscontrole

Autorisatie is een cruciaal onderdeel in toepassingen op enterprise-niveau, waarbij het een sleutelrol speelt in zowel beveiliging als operationele efficiëntie. Het zorgt ervoor dat alleen geautoriseerde gebruikers toegang hebben tot specifieke bronnen, waardoor gevoelige gegevens en functionaliteiten worden beschermd. Het autorisatiesysteem van Refine biedt de nodige infrastructuur om uw bronnen te beschermen en ervoor te zorgen dat gebruikers op een veilige en gecontroleerde manier met uw toepassing kunnen interacteren. In deze stap zullen we autorisatie en toegangsbeheer implementeren voor de functie voor het beheer van verlofaanvragen. We zullen de toegang tot de routes /manager/requests en /manager/requests/:id/edit beperken tot alleen managers met behulp van het <CanAccess />-onderdeel.

Op dit moment kunt u, wanneer u bent ingelogd als werknemer, de koppeling naar de pagina Aanvragen niet zien in de zijbalk, maar u kunt nog steeds toegang krijgen tot de route /manager/requests door de URL in de browser in te voeren. We zullen een beveiliging toevoegen om ongeautoriseerde toegang tot deze routes te voorkomen.

Laten we het bestand App.tsx bijwerken om de autorisatiecontroles op te nemen:

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

In de bovenstaande code hebben we het <CanAccess />-onderdeel toegevoegd aan de “/manager” route. Dit onderdeel controleert of de gebruiker de rol “manager” heeft voordat de onderliggende routes worden weergegeven. Als de gebruiker niet de rol “manager” heeft, worden ze doorgestuurd naar de pagina met de lijst van verlofaanvragen voor werknemers.

Nu, wanneer je inlogt als werknemer en probeert toegang te krijgen tot de route /manager/requests, word je doorverwezen naar de pagina met verlofaanvragen voor werknemers.

Stap 7 — Implementeren naar het DigitalOcean App-platform

In deze stap zullen we de toepassing implementeren naar het DigitalOcean App-platform. Om dit te doen, zullen we de broncode hosten op GitHub en de GitHub-opslagplaats verbinden met het App-platform.

Het Code naar GitHub pushen

Log in op je GitHub-account en maak een nieuwe opslagplaats met de naam refine-hr. Je kunt de opslagplaats openbaar of privé maken:

Na het maken van de opslagplaats, navigeer naar de projectdirectory en voer de volgende opdracht uit om een nieuw Git-opslagplaats te initialiseren:

git init

Vervolgens, voeg alle bestanden toe aan het Git-opslagplaats met deze opdracht:

git add .

Vervolgens, commit de bestanden met deze opdracht:

git commit -m "Initial commit"

Voeg daarna de GitHub-opslagplaats toe als een externe opslagplaats met deze opdracht:

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

Specificeer vervolgens dat je je code naar de main tak wilt pushen met deze opdracht:

git branch -M main

Tenslotte, duw de code naar de GitHub repository met dit commando:

git push -u origin main

Wanneer hierom wordt gevraagd, voer je je GitHub inloggegevens in om je code te pushen.

Je ontvangt een succesbericht nadat de code naar de GitHub repository is gepusht.

In dit gedeelte heb je je project naar GitHub gepusht zodat je er toegang toe hebt met behulp van DigitalOcean Apps. De volgende stap is het maken van een nieuwe DigitalOcean App met behulp van je project en het opzetten van automatische implementatie.

Implementeren naar DigitalOcean App Platform

Tijdens dit proces neem je een React-toepassing en bereid je deze voor op implementatie via het App Platform van DigitalOcean. Je koppelt je GitHub repository aan DigitalOcean, configureert hoe de app zal worden gebouwd en maakt vervolgens een initiële implementatie van een project. Nadat het project is geïmplementeerd, worden aanvullende wijzigingen automatisch opnieuw opgebouwd en bijgewerkt.

Aan het einde van deze stap zal je applicatie zijn geïmplementeerd op DigitalOcean met continue levering voorzien.

Log in op je DigitalOcean account en ga naar de Apps pagina. Klik op de App Maken knop:

Als je je GitHub-account nog niet hebt verbonden met DigitalOcean, wordt je gevraagd dit te doen. Klik op de Verbinden met GitHub knop. Er zal een nieuw venster geopend worden waarin je wordt gevraagd DigitalOcean toegang te verlenen tot je GitHub-account.

Nadat je DigitalOcean toegang hebt verleend, word je teruggeleid naar de DigitalOcean Apps pagina. De volgende stap is het selecteren van je GitHub repository. Nadat je je repository hebt geselecteerd, word je gevraagd een branch te kiezen om te implementeren. Selecteer de main branch en klik op de Volgende knop.

Hierna zie je de configuratiestappen voor je applicatie. In deze tutorial kun je op de Volgende knop klikken om de configuratiestappen over te slaan. Je kunt echter ook je applicatie configureren zoals je wilt.

Wacht tot het bouwen is voltooid. Na het voltooien van de build, klik op Live App om toegang te krijgen tot je project in de browser. Het zal hetzelfde zijn als het project dat je lokaal hebt getest, maar dit zal live zijn op het web met een beveiligde URL. Ook kun je deze tutorial volgen die beschikbaar is op de DigitalOcean community site om te leren hoe je op React gebaseerde applicaties implementeert op het App Platform.

Opmerking: Als je build niet met succes kan worden geïmplementeerd, kun je je build commando op DigitalOcean configureren om npm install --production=false && npm run build && npm prune --production te gebruiken in plaats van npm run build

Conclusie

In deze tutorial hebben we een HR-beheerapplicatie gebouwd met Refine vanaf nul en hebben we geleerd hoe we een volledig functionele CRUD-app kunnen maken.

Ook zullen we demonstreren hoe je jouw applicatie kunt implementeren op het DigitalOcean App Platform.

Als je meer wilt leren over Refine, kun je de documentatie bekijken en als je vragen of feedback hebt, kun je lid worden van de Refine Discord-server.

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