Developer Center

Building a React PDF Invoice Generator App with Refine and Deploying it to DigitalOcean's App Platform

Published on June 1, 2024
authorauthor

Alican Erdurmaz and Anish Singh Walia

Building a React PDF Invoice Generator App with Refine and Deploying it to DigitalOcean's App Platform

Introduction

In this complete tutorial, you will build a React-based PDF invoice generator application with Refine Framework and deploy it to DigitalOcean’s App Platform.

This sample appliction you’re going to build is an internal tool useful for enterprise companies that need to generate invoices for their clients. It will have the necessary functionality to meet real use cases and can be customized to fit your specific requirements.

By the end of this tutorial, you’ll have a internal tool that includes:

  • Login page to authenticate user.
  • Accounts and Client pages to list, create, edit and show informations that invoice will include.
  • Invoices page is used to list, create, edit, and display billing invoice details, which can then be exported as a PDF.

Note: You can get the complete source code of the app you’ll build in this tutorial from this GitHub repository.

You will use the following technologies along with Refine:

  • Strapi cloud-based headless CMS to store our data. To fetch data from Strapi, you’ll use the built-in Strapi data-provider of Refine. You have already set up the API with Strapi to concentrate on the frontend aspect of our project. You can access the Strapi API at this URL: https://api.strapi-invoice.refine.dev
  • Ant Design UI library.
  • After building the app, you’ll deploy it using DigitalOcean’s App Platform. This service simplifies and accelerates the process of setting up, launching, and scaling apps and static youbsites. You can deploy your code by linking to a GitHub repository, and the App Platform will handle the infrastructure, app runtimes, and dependencies.
Slide #1Slide #2Slide #3Slide #4Slide #5Slide #6Slide #7Slide #8Slide #9Slide #10Slide #11

Prerequisites

Step 1 — What is Refine?

Refine is an open source React meta-framework for building data-heavy CRUD youb applications like internal tools, dashboards, admin panels and all type of CRUD apps. It comes with various hooks and components that save development time and enhance the developer experience.

It is designed for building production-ready enterprise B2B apps. Instead of starting from scratch, it provides essential hooks and components to help with data and state management, authentication, and permissions.

Its headless architecture allows you to use any UI library or custom CSS, and it has built-in support for popular open-source UI libraries like Ant Design, Material UI, Mantine, and Chakra UI.

This way, you can focus on building the important parts of your app without getting stuck on technical details.

Step 2 — Setting Up the Project

Creating a New Refine App

you’ll use the npm create refine-app command to interactively initialize the project.

npm create refine-app@latest

Select the following options when prompted:

✔ Choose a project template · Vite
✔ What would you like to name your project?: · refine-invoicer
✔ Choose your backend service to connect: · Strapi v4
✔ Do you want to use a UI Framework?: · Ant Design
✔ Do you want to add example pages?: · No
✔ Choose a package manager: · npm

Once the setup is complete, navigate to the project folder and start your app with:

npm run dev

Open http://localhost:5173 in your browser to see the app.

Welcome Page


Installing the 3rd Party Libraries

Let’s install some npm packages you’ll use in our application.

  • react-input-mask: To format the input fields, you will use this library to format the phone number field.
  • antd-style: css-in-js solution for Ant Design. You will use this library to customize the Ant Design components.
  • vite-tsconfig-paths: This plugin allows Vite to resolve imports using jsx’s path mapping.

Run the following command:

npm install react-input-mask antd-style

Then install the types:

npm install @types/react-input-mask vite-tsconfig-paths --save-dev

After installing the packages, you need to update the tsconfig.json file to use the jsx path mapping. This makes importing files easier to read and maintain.

For example, instead of import { Log } from "../../types/Log", you will use import { Log } from "@/types/Log".

To do this, add the following highlighted code to the tsconfig.json file:

[details Show tsconfig.json code

{
  "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", "vite.config.ts"],
  "references": [{ "path": "./tsconfig.node.json" }]
}

You also need to update the vite.config.ts file to use the vite-tsconfig-paths plugin, which allows Vite to resolve imports using jsx path mapping.

Show vite.config.ts code
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()],
  build: {
    rollupOptions: {
      output: {
        manualChunks: {
          antd: ["antd"],
        },
      },
    },
  },
});

Adding the Necessary Components and Helper Functions in Advance

To build this app, you’ll need some essential components, styles, and helper functions from the completed version repository of this app.

You recommend you to download these files from the GitHub repository and add them to the project you just set up.

This app is comprehensive and fully-functional, so having these files ready will make it easier to follow along with the tutorial and keep it from taking too long.

First, please remove the following files and folders from the project you just created by CLI:

  • src/components folder.
  • src/contexts folder.
  • src/authProvider.ts file.
  • src/contants.ts file.

Then, you can copy the following files and folders to the same location in the project:

After these steps, the project structure should look like this:

└── 📁src
    └── 📁components
    └── 📁providers
    └── 📁styles
    └── 📁types
    └── 📁utils
    └── App.tsx
    └── index.tsx
    └── vite-env.d.ts

In the next steps, you will use these components and helper functions when building the “accounts”, “clients”, and “invoices” pages.

Now, your App.tsx files gives an error because you removed imported authProvider.ts and constants.ts files. You’ll fix this by updating the App.tsx file in the next step.

Step 3 — Building Login Page and Authentication System

In this step, you will build a login page and set up an authentication mechanism to protect the routes from unauthorized access.

Refine handles authentication by Auth Provider and consumes the auth provider methods by Auth hooks.

You already copied authProvider file from the example app repository and it will be passed it to <Refine /> component to handle authentication.

Let’s closer look at the src/providers/auth-provider/index.ts file implemented for the Strapi API:

Show src/providers/auth-provider/index.ts code
src/providers/auth-provider/index.ts
import { AuthProvider } from "@refinedev/core";
import { AuthHelper } from "@refinedev/strapi-v4";
import { API_URL, TOKEN_KEY } from "@/utils/constants";

export const strapiAuthHelper = AuthHelper(`${API_URL}/api`);

export const authProvider: AuthProvider = {
  login: async ({ email, password }) => {
    try {
      const { data, status } = await strapiAuthHelper.login(email, password);
      if (status === 200) {
        localStorage.setItem(TOKEN_KEY, data.jwt);

        return {
          success: true,
          redirectTo: "/",
        };
      }
    } catch (error: any) {
      const errorObj = error?.response?.data?.message?.[0]?.messages?.[0];
      return {
        success: false,
        error: {
          message: errorObj?.message || "Login failed",
          name: errorObj?.id || "Invalid email or password",
        },
      };
    }

    return {
      success: false,
      error: {
        message: "Login failed",
        name: "Invalid email or password",
      },
    };
  },
  logout: async () => {
    localStorage.removeItem(TOKEN_KEY);
    return {
      success: true,
      redirectTo: "/login",
    };
  },
  onError: async (error) => {
    if (error.response?.status === 401) {
      return {
        logout: true,
      };
    }

    return { error };
  },
  check: async () => {
    const token = localStorage.getItem(TOKEN_KEY);
    if (token) {
      return {
        authenticated: true,
      };
    }

    return {
      authenticated: false,
      error: {
        message: "Authentication failed",
        name: "Token not found",
      },
      logout: true,
      redirectTo: "/login",
    };
  },
  getIdentity: async () => {
    const token = localStorage.getItem(TOKEN_KEY);
    if (!token) {
      return null;
    }

    const { data, status } = await strapiAuthHelper.me(token);
    if (status === 200) {
      const { id, username, email } = data;
      return {
        id,
        username,
        email,
      };
    }

    return null;
  },
};
  • login: It sends a request to the Strapi API to authenticate the user. If the authentication is successful, it saves the JWT token to the local storage and redirects the user to the home page. If the authentication fails, it returns an error message.
  • logout: It removes the JWT token from the local storage and redirects the user to the login page.
  • onError: This function is called when an error occurs during the authentication process. If the error is due to an unauthorized request, it logs the user out.
  • check: It checks if the JWT token is present in the local storage. If the token is present, it returns that the user is authenticated. If the token is not present, it returns an error message and logs the user out.
  • getIdentity: It sends a request to the Strapi API to get the user’s identity. If the request is successful, it returns the user’s id, username, and email. If the request fails, it returns null.

Authenticated Routes

To protect the routes, you will use the <Authenticated /> component from the @refinedev/core package. This component checks if the user is authenticated. If they are, it renders the children. If not, it renders the fallback prop if provided. Otherwise, it navigates to the data.redirectTo value returned from the authProvider.check method.

Let’s build our first protected route, and then you will build the login page.

Simply, add the following highlighted codes to the App.tsx file:

Show App.tsx code
import { Authenticated, Refine } from "@refinedev/core";
import { ErrorComponent, useNotificationProvider } from "@refinedev/antd";
import routerProvider, {
  UnsavedChangesNotifier,
  DocumentTitleHandler,
  NavigateToResource,
} from "@refinedev/react-router-v6";
import { BrowserRouter, Outlet, Route, Routes } from "react-router-dom";
import { DevtoolsPanel, DevtoolsProvider } from "@refinedev/devtools";
import { App as AntdApp } from "antd";
import { dataProvider } from "@/providers/data-provider";
import { authProvider } from "@/providers/auth-provider";
import { ConfigProvider } from "@/providers/config-provider";
import "@refinedev/antd/dist/reset.css";
import "./styles/custom.css";

const App: React.FC = () => {
  return (
    <DevtoolsProvider>
      <BrowserRouter>
        <ConfigProvider>
          <AntdApp>
            <Refine
              routerProvider={routerProvider}
              authProvider={authProvider}
              dataProvider={dataProvider}
              notificationProvider={useNotificationProvider}
              options={{
                syncWithLocation: true,
                warnWhenUnsavedChanges: true,
                breadcrumb: false,
              }}
            >
              <Routes>
                <Route
                  element={
                    <Authenticated
                      key="authenticated-routes"
                      redirectOnFail="/login"
                    >
                      <Outlet />
                    </Authenticated>
                  }
                >
                  <Route path="/" element={<div>Home page</div>} />
                </Route>

                <Route
                  element={
                    <Authenticated key="auth-pages" fallback={<Outlet />}>
                      <NavigateToResource />
                    </Authenticated>
                  }
                >
                  <Route path="/login" element={<div>Login page</div>} />
                </Route>

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

              </Routes>
              <UnsavedChangesNotifier />
              <DocumentTitleHandler />
            </Refine>
          </AntdApp>
        </ConfigProvider>
        <DevtoolsPanel />
      </BrowserRouter>
    </DevtoolsProvider>
  );
};

export default App;

In the highlighted code lines above, you have created a protected route for “/”.

If the user is not authenticated, they will be redirected to the “/login” page; if authenticated, it will render the children.

You also created a catch-all route to show the <ErrorComponent /> component when the user navigates to a non-existing route(404 not-found page).

Login Page

You are ready for building the Login page.

You will use the <AuthPage /> component from the @refinedev/antd package. This component provides a login form with email and password fields with validation, and a submit button. After form is submitted it will call the login method from the authprovider.tsx file you mentioned above.

Add the following highlighted codes to the App.tsx file:

Show App.tsx code
import { Authenticated, Refine } from "@refinedev/core";
import {
  AuthPage,
  ErrorComponent,
  useNotificationProvider,
} from "@refinedev/antd";
import routerProvider, {
  UnsavedChangesNotifier,
  DocumentTitleHandler,
  NavigateToResource,
} from "@refinedev/react-router-v6";
import { BrowserRouter, Outlet, Route, Routes } from "react-router-dom";
import { DevtoolsPanel, DevtoolsProvider } from "@refinedev/devtools";
import { App as AntdApp } from "antd";
import { Logo } from "@/components/logo";
import { dataProvider } from "@/providers/data-provider";
import { authProvider } from "@/providers/auth-provider";
import { ConfigProvider } from "@/providers/config-provider";
import "@refinedev/antd/dist/reset.css";
import "./styles/custom.css";

const App: React.FC = () => {
  return (
    <DevtoolsProvider>
      <BrowserRouter>
        <ConfigProvider>
          <AntdApp>
            <Refine
              routerProvider={routerProvider}
              authProvider={authProvider}
              dataProvider={dataProvider}
              notificationProvider={useNotificationProvider}
              options={{
                syncWithLocation: true,
                warnWhenUnsavedChanges: true,
                breadcrumb: false,
              }}
            >
              <Routes>
                <Route
                  element={
                    <Authenticated
                      key="authenticated-routes"
                      redirectOnFail="/login"
                    >
                      <Outlet />
                    </Authenticated>
                  }
                >
                  <Route path="/" element={<div>Home page</div>} />
                </Route>

                <Route
                  element={
                    <Authenticated key="auth-pages" fallback={<Outlet />}>
                      <NavigateToResource />
                    </Authenticated>
                  }
                >
                  <Route
                    path="/login"
                    element={
                      <AuthPage
                        type="login"
                        registerLink={false}
                        forgotPasswordLink={false}
                        title={
                          <Logo
                            titleProps={{ level: 2 }}
                            svgProps={{
                              width: "48px",
                              height: "40px",
                            }}
                          />
                        }
                        formProps={{
                          initialValues: {
                            email: "demo@refine.dev",
                            password: "demodemo",
                          },
                        }}
                      />
                    }
                  />
                </Route>

                <Route
                  element={
                    <Authenticated key="catch-all">
                      <Outlet />
                    </Authenticated>
                  }
                >
                  <Route path="*" element={<ErrorComponent />} />
                </Route>
              </Routes>
              <UnsavedChangesNotifier />
              <DocumentTitleHandler />
            </Refine>
          </AntdApp>
        </ConfigProvider>
        <DevtoolsPanel />
      </BrowserRouter>
    </DevtoolsProvider>
  );
};

export default App;

With the highlighted codes above, you’ve created a login page using the <AuthPage /> component. You specified the type prop as "login" to enable the display of the login form. The registerLink and forgotPasswordLink props are set to false, thereby hiding the registration and forgot password links, which are not required for this tutorial.

Additionally, the formProps prop is used to initialize the email and password fields. You also set the title property to show the <Logo /> component, previously copied from the GitHub repository.

After everything is set up, our “/login” page should look like this:

Login Page

After the user logs in, they will be redirected to the home page, which currently only shows “Home page” text. In the next steps, you will add the “accounts,” “clients,” and “invoices” pages.

But before that, let’s add our layout and <Header /> components using the highlighted code below.

Show App.tsx code
import { Authenticated, Refine } from "@refinedev/core";
import {
  AuthPage,
  ErrorComponent,
  ThemedLayoutV2,
  useNotificationProvider,
} from "@refinedev/antd";
import routerProvider, {
  UnsavedChangesNotifier,
  DocumentTitleHandler,
  NavigateToResource,
  CatchAllNavigate,
} from "@refinedev/react-router-v6";
import { BrowserRouter, Outlet, Route, Routes } from "react-router-dom";
import { DevtoolsPanel, DevtoolsProvider } from "@refinedev/devtools";
import { App as AntdApp } from "antd";
import { dataProvider } from "@/providers/data-provider";
import { authProvider } from "@/providers/auth-provider";
import { ConfigProvider } from "@/providers/config-provider";
import { Logo } from "@/components/logo";
import { Header } from "@/components/header";
import "@refinedev/antd/dist/reset.css";
import "./styles/custom.css";

const App: React.FC = () => {
  return (
    <DevtoolsProvider>
      <BrowserRouter>
        <ConfigProvider>
          <AntdApp>
            <Refine
              routerProvider={routerProvider}
              authProvider={authProvider}
              dataProvider={dataProvider}
              notificationProvider={useNotificationProvider}
              options={{
                syncWithLocation: true,
                warnWhenUnsavedChanges: true,
                breadcrumb: false,
              }}
            >
              <Routes>
                <Route
                  element={
                    <Authenticated
                      key="authenticated-routes"
                      fallback={<CatchAllNavigate to="/login" />}
                    >
                      <ThemedLayoutV2
                        Header={() => <Header />}
                        Sider={() => null}
                      >
                        <div
                          style={{
                            maxWidth: "1280px",
                            padding: "24px",
                            margin: "0 auto",
                          }}
                        >
                          <Outlet />
                        </div>
                      </ThemedLayoutV2>
                    </Authenticated>
                  }
                >
                  <Route path="/" element={<div>Home page</div>} />
                </Route>

                <Route
                  element={
                    <Authenticated key="auth-pages" fallback={<Outlet />}>
                      <NavigateToResource />
                    </Authenticated>
                  }
                >
                  <Route
                    path="/login"
                    element={
                      <AuthPage
                        type="login"
                        registerLink={false}
                        forgotPasswordLink={false}
                        title={
                          <Logo
                            titleProps={{ level: 2 }}
                            svgProps={{
                              width: "48px",
                              height: "40px",
                            }}
                          />
                        }
                        formProps={{
                          initialValues: {
                            email: "demo@refine.dev",
                            password: "demodemo",
                          },
                        }}
                      />
                    }
                  />
                </Route>

                <Route
                  element={
                    <Authenticated key="catch-all">
                      <ThemedLayoutV2
                        Header={() => <Header />}
                        Sider={() => null}
                      >
                        <Outlet />
                      </ThemedLayoutV2>
                    </Authenticated>
                  }
                >
                  <Route path="*" element={<ErrorComponent />} />
                </Route>
              </Routes>
              <UnsavedChangesNotifier />
              <DocumentTitleHandler />
            </Refine>
          </AntdApp>
        </ConfigProvider>
        <DevtoolsPanel />
      </BrowserRouter>
    </DevtoolsProvider>
  );
};

export default App;

In the code lines highlighted above, you’ve added the <ThemedLayoutV2 /> component to the project. This component provides a layout with a header and a content area. You set the Sider prop to null since you don’t need a sidebar for this project.

You also added the <Header /> component to the Header prop, which you previously copied from the GitHub repository. This component will be used to navigate between the “accounts,” “clients,” and “invoices” pages, display the user’s name and logout button, show the logo, and include a search input to search the accounts and clients.

After everything is set up, our layout should look like this:

Layout

Step 4 — Building Accounts CRUD Pages

In this step, you will build the “accounts” page, which will list all accounts and allow users to create, edit, and delete them. The accounts will store information about the companies sending invoices to clients and will have a many-to-one relationship with the clients: each account can have multiple clients, but each client can belong to only one account.

Before you start, you need to update the <Refine /> component in App.tsx to include the accounts resource.

Show App.tsx code
import { Authenticated, Refine } from "@refinedev/core";
import {
  AuthPage,
  ErrorComponent,
  ThemedLayoutV2,
  useNotificationProvider,
} from "@refinedev/antd";
import routerProvider, {
  UnsavedChangesNotifier,
  DocumentTitleHandler,
  NavigateToResource,
  CatchAllNavigate,
} from "@refinedev/react-router-v6";
import { BrowserRouter, Outlet, Route, Routes } from "react-router-dom";
import { DevtoolsPanel, DevtoolsProvider } from "@refinedev/devtools";
import { App as AntdApp } from "antd";
import { dataProvider } from "@/providers/data-provider";
import { authProvider } from "@/providers/auth-provider";
import { ConfigProvider } from "@/providers/config-provider";
import { Logo } from "@/components/logo";
import { Header } from "@/components/header";
import "@refinedev/antd/dist/reset.css";
import "./styles/custom.css";

const App: React.FC = () => {
  return (
    <DevtoolsProvider>
      <BrowserRouter>
        <ConfigProvider>
          <AntdApp>
            <Refine
              routerProvider={routerProvider}
              authProvider={authProvider}
              dataProvider={dataProvider}
              notificationProvider={useNotificationProvider}
               resources={[ 
                 { 
                   name: "accounts",
                   list: "/accounts",
                   create: "/accounts/new",
                   edit: "/accounts/:id/edit",
                 },
               ]} 
              options={{
                syncWithLocation: true,
                warnWhenUnsavedChanges: true,
                breadcrumb: false,
              }}
            >
              <Routes>
                <Route
                  element={
                    <Authenticated
                      key="authenticated-routes"
                      fallback={<CatchAllNavigate to="/login" />}
                    >
                      <ThemedLayoutV2
                        Header={() => <Header />}
                        Sider={() => null}
                      >
                        <div
                          style={{
                            maxWidth: "1280px",
                            padding: "24px",
                            margin: "0 auto",
                          }}
                        >
                          <Outlet />
                        </div>
                      </ThemedLayoutV2>
                    </Authenticated>
                  }
                >
                  <Route path="/" element={<div>Home page</div>} />
                </Route>

                <Route
                  element={
                    <Authenticated key="auth-pages" fallback={<Outlet />}>
                      <NavigateToResource />
                    </Authenticated>
                  }
                >
                  <Route
                    path="/login"
                    element={
                      <AuthPage
                        type="login"
                        registerLink={false}
                        forgotPasswordLink={false}
                        title={
                          <Logo
                            titleProps={{ level: 2 }}
                            svgProps={{
                              width: "48px",
                              height: "40px",
                            }}
                          />
                        }
                        formProps={{
                          initialValues: {
                            email: "demo@refine.dev",
                            password: "demodemo",
                          },
                        }}
                      />
                    }
                  />
                </Route>

                <Route
                  element={
                    <Authenticated key="catch-all">
                      <ThemedLayoutV2
                        Header={() => <Header />}
                        Sider={() => null}
                      >
                        <Outlet />
                      </ThemedLayoutV2>
                    </Authenticated>
                  }
                >
                  <Route path="*" element={<ErrorComponent />} />
                </Route>
              </Routes>
              <UnsavedChangesNotifier />
              <DocumentTitleHandler />
            </Refine>
          </AntdApp>
        </ConfigProvider>
        <DevtoolsPanel />
      </BrowserRouter>
    </DevtoolsProvider>
  );
};

export default App;

The resource definition doesn’t create any CRUD pages itself. Instead, it establishes the routes that these CRUD pages will follow. These routes are essential for ensuring the proper functionality of various Refine hooks and components.

For example, you will use the useNavigation hook, which relies on these resource routes (list, create, edit, and show) to help users navigate between different pages in your application. Additionally, data hooks like useTable will automatically use the resource name if the resource prop is not explicitly provided.

List Page

The List page will show account data in a table. User can sort, filter, show, edit, and delete accounts from this page.

Let’s create a src/pages/accounts/list.tsx file with the following code:

Show <AccountsPageList /> component
src/pages/accounts/list.tsx
import type { PropsWithChildren } from 'react'
import { getDefaultFilter, useGo } from '@refinedev/core'
import {
  CreateButton,
  DeleteButton,
  EditButton,
  FilterDropdown,
  List,
  NumberField,
  getDefaultSortOrder,
  useSelect,
  useTable,
} from '@refinedev/antd'
import { EyeOutlined, SearchOutlined } from '@ant-design/icons'
import { Avatar, Flex, Input, Select, Table, Typography } from 'antd'
import { API_URL } from '@/utils/constants'
import type { Account } from '@/types'
import { getRandomColorFromString } from '@/utils/get-random-color'

export const AccountsPageList = ({ children }: PropsWithChildren) => {
  const go = useGo()

  const { tableProps, filters, sorters } = useTable<Account>({
    sorters: {
      initial: [{ field: 'updatedAt', order: 'desc' }],
    },
    filters: {
      initial: [
        {
          field: 'owner_email',
          operator: 'contains',
          value: '',
        },
        {
          field: 'phone',
          operator: 'contains',
          value: '',
        },
      ],
    },
    meta: {
      populate: ['logo', 'invoices'],
    },
  })

  const { selectProps: companyNameSelectProps } = useSelect({
    resource: 'accounts',
    optionLabel: 'company_name',
    optionValue: 'company_name',
  })

  const { selectProps: selectPropsOwnerName } = useSelect({
    resource: 'accounts',
    optionLabel: 'owner_name',
    optionValue: 'owner_name',
  })

  return (
    <>
      <List
        title='Accounts'
        headerButtons={() => {
          return (
            <CreateButton
              size='large'
              onClick={() =>
                go({
                  to: { resource: 'accounts', action: 'create' },
                  options: { keepQuery: true },
                })
              }>
              Add new account
            </CreateButton>
          )
        }}>
        <Table
          {...tableProps}
          rowKey={'id'}
          pagination={{
            ...tableProps.pagination,
            showSizeChanger: true,
          }}
          scroll={{ x: 960 }}>
          <Table.Column
            title='ID'
            dataIndex='id'
            key='id'
            width={80}
            defaultFilteredValue={getDefaultFilter('id', filters)}
            filterIcon={<SearchOutlined />}
            filterDropdown={(props) => {
              return (
                <FilterDropdown {...props}>
                  <Input placeholder='Search ID' />
                </FilterDropdown>
              )
            }}
          />
          <Table.Column
            title='Title'
            dataIndex='company_name'
            key='company_name'
            sorter
            defaultSortOrder={getDefaultSortOrder('company_name', sorters)}
            defaultFilteredValue={getDefaultFilter('company_name', filters, 'in')}
            filterDropdown={(props) => (
              <FilterDropdown {...props}>
                <Select
                  mode='multiple'
                  placeholder='Search Company Name'
                  style={{ width: 220 }}
                  {...companyNameSelectProps}
                />
              </FilterDropdown>
            )}
            render={(name: string, record: Account) => {
              const logoUrl = record?.logo?.url
              const src = logoUrl ? `${API_URL}${logoUrl}` : undefined

              return (
                <Flex align='center' gap={8}>
                  <Avatar
                    alt={name}
                    src={src}
                    shape='square'
                    style={{
                      backgroundColor: src
                        ? "none"
                        : getRandomColorFromString(name || ""),
                    }}>
                    <Typography.Text>{name?.[0]?.toUpperCase()}</Typography.Text>
                  </Avatar>
                  <Typography.Text>{name}</Typography.Text>
                </Flex>
              )
            }}
          />
          <Table.Column
            title='Owner'
            dataIndex='owner_name'
            key='owner_name'
            sorter
            defaultSortOrder={getDefaultSortOrder('owner_name', sorters)}
            defaultFilteredValue={getDefaultFilter('owner_name', filters, 'in')}
            filterDropdown={(props) => (
              <FilterDropdown {...props}>
                <Select
                  mode='multiple'
                  placeholder='Search Owner Name'
                  style={{ width: 220 }}
                  {...selectPropsOwnerName}
                />
              </FilterDropdown>
            )}
          />
          <Table.Column
            title='Email'
            dataIndex='owner_email'
            key='owner_email'
            defaultFilteredValue={getDefaultFilter('owner_email', filters, 'contains')}
            filterIcon={<SearchOutlined />}
            filterDropdown={(props) => {
              return (
                <FilterDropdown {...props}>
                  <Input placeholder='Search Email' />
                </FilterDropdown>
              )
            }}
          />
          <Table.Column
            title='Phone'
            dataIndex='phone'
            key='phone'
            width={154}
            defaultFilteredValue={getDefaultFilter('phone', filters, 'contains')}
            filterIcon={<SearchOutlined />}
            filterDropdown={(props) => {
              return (
                <FilterDropdown {...props}>
                  <Input placeholder='Search Phone' />
                </FilterDropdown>
              )
            }}
          />
          <Table.Column
            title='Income'
            dataIndex='income'
            key='income'
            width={120}
            align='end'
            render={(_, record: Account) => {
              let total = 0
              for (const invoice of record.invoices || []) {
                total += invoice.total
              }
              return <NumberField value={total} options={{ style: 'currency', currency: 'USD' }} />
            }}
          />
          <Table.Column
            title='Actions'
            key='actions'
            fixed='right'
            align='end'
            width={106}
            render={(_, record: Account) => {
              return (
                <Flex align='center' gap={8}>
                  <EditButton hideText recordItemId={record.id} icon={<EyeOutlined />} />
                  <DeleteButton hideText recordItemId={record.id} />
                </Flex>
              )
            }}
          />
        </Table>
      </List>
      {children}
    </>
  )
}

Let’s break down the code above:

You fetched data using the useTable hook from the @refinedev/antd package, specifying relationships via meta.populate, and displayed it with the <Table /> component. For Strapi queries, refer to the Strapi v4 documentation.

The table includes columns like company name, owner name, owner email, phone, and income, following Ant Design Table guidelines. You used components like <FilterDropdown />, and <Select /> from @refinedev/antd and antd for customizing the UI.

Search inputs were added to each column for data filtering, using getDefaultFilter and getDefaultSortOrder from "@refinedev/core" and "@refinedev/antd" to set defaults from query parameters.

The useSelect hook allow us to manage Ant Design’s <Select /> component when the records in a resource needs to be used as select options. You used it to fetch values for the company_name and owner_name columns to filter the table data.

You used the children prop to render a modal for creating new accounts when the “Add new account” button is clicked.

The <CreateButton /> normally navigates to the create page but was modified with the onClick prop and go function from "@refinedev/core" to open it as a modal, preserving query parameters.

Finally, the <EditButton /> and <DeleteButton /> components handle editing and deleting accounts. The <EditButton /> opens the edit page as a modal, and the <DeleteButton /> deletes the account when clicked.

To import the account list page from other files, you need to create a src/pages/accounts/index.tsx file with following:

export { AccountsPageList } from "./list";

Next, import the <AccountsPageList /> component in src/App.tsx and add a route for rendering it.

Show App.tsx code
src/App.tsx
import { Authenticated, Refine } from "@refinedev/core";
import {
  AuthPage,
  ErrorComponent,
  ThemedLayoutV2,
  useNotificationProvider,
} from "@refinedev/antd";
import routerProvider, {
  UnsavedChangesNotifier,
  DocumentTitleHandler,
  NavigateToResource,
  CatchAllNavigate,
} from "@refinedev/react-router-v6";
import { BrowserRouter, Outlet, Route, Routes } from "react-router-dom";
import { DevtoolsPanel, DevtoolsProvider } from "@refinedev/devtools";
import { App as AntdApp } from "antd";
import { dataProvider } from "@/providers/data-provider";
import { authProvider } from "@/providers/auth-provider";
import { ConfigProvider } from "@/providers/config-provider";
import { Logo } from "@/components/logo";
import { Header } from "@/components/header";
import { AccountsPageList } from "@/pages/accounts";
import "@refinedev/antd/dist/reset.css";
import "./styles/custom.css";

const App: React.FC = () => {
  return (
    <DevtoolsProvider>
      <BrowserRouter>
        <ConfigProvider>
          <AntdApp>
            <Refine
              routerProvider={routerProvider}
              authProvider={authProvider}
              dataProvider={dataProvider}
              notificationProvider={useNotificationProvider}
              resources={[
                {
                  name: "accounts",
                  list: "/accounts",
                  create: "/accounts/new",
                  edit: "/accounts/:id/edit",
                },
              ]}
              options={{
                syncWithLocation: true,
                warnWhenUnsavedChanges: true,
                breadcrumb: false,
              }}
            >
              <Routes>
                <Route
                  element={
                    <Authenticated
                      key="authenticated-routes"
                      fallback={<CatchAllNavigate to="/login" />}
                    >
                      <ThemedLayoutV2
                        Header={() => <Header />}
                        Sider={() => null}
                      >
                        <div
                          style={{
                            maxWidth: "1280px",
                            padding: "24px",
                            margin: "0 auto",
                          }}
                        >
                          <Outlet />
                        </div>
                      </ThemedLayoutV2>
                    </Authenticated>
                  }
                >
                  <Route index element={<NavigateToResource />} />
                  <Route
                    path="/accounts"
                    element={
                      <AccountsPageList>
                        <Outlet />
                      </AccountsPageList>
                    }
                  >
                    <Route index element={null} />
                  </Route>
                </Route>

                <Route
                  element={
                    <Authenticated key="auth-pages" fallback={<Outlet />}>
                      <NavigateToResource />
                    </Authenticated>
                  }
                >
                  <Route
                    path="/login"
                    element={
                      <AuthPage
                        type="login"
                        registerLink={false}
                        forgotPasswordLink={false}
                        title={
                          <Logo
                            titleProps={{ level: 2 }}
                            svgProps={{
                              width: "48px",
                              height: "40px",
                            }}
                          />
                        }
                        formProps={{
                          initialValues: {
                            email: "demo@refine.dev",
                            password: "demodemo",
                          },
                        }}
                      />
                    }
                  />
                </Route>

                <Route
                  element={
                    <Authenticated key="catch-all">
                      <ThemedLayoutV2
                        Header={() => <Header />}
                        Sider={() => null}
                      >
                        <Outlet />
                      </ThemedLayoutV2>
                    </Authenticated>
                  }
                >
                  <Route path="*" element={<ErrorComponent />} />
                </Route>
              </Routes>
              <UnsavedChangesNotifier />
              <DocumentTitleHandler />
            </Refine>
          </AntdApp>
        </ConfigProvider>
        <DevtoolsPanel />
      </BrowserRouter>
    </DevtoolsProvider>
  );
};

export default App;

Let’s look at the changes you made to the App.tsx file:

  1. You assigned the <NavigateToResource /> component to the “/” route. This automatically directs users to the first list page available in the resources array, which in this case, leads them to the “/accounts” path when they visit the home page.
  2. Main <Route /> for “/accounts”:
    • The path="/accounts" indicates that this route configuration applies when the URL matches “/accounts”.
    • The element property determines which component is displayed when this route is accessed. Here, it is <AccountsPageList />.
    • Inside <AccountsPageList />, there’s an <Outlet />. This <Outlet /> acts as a placeholder that renders the matched child route components. By using this pattern, our <AccountsPageList /> component acts as a layout component and with this structure, you can easily add nested routes as a modal or drawer to the list page.
  3. Nested <Route /> with index:
    • This nested route under “/accounts” matches exactly when the path is “/accounts”. The element={null} setting means that when users go directly to “/accounts”, they will see only the list page without any additional components. This configuration ensures a clean display of the list alone, without extra UI elements or forms from other nested routes.

Now, if you navigate to the “/accounts” path, you should see the list page.

Accounts List Page

Create Page

The create page will show a form to create a new account record.

You will use the <Form /> component, and for managing form submissions, the useForm hook will be utilized.

Let’s create a src/pages/accounts/create.tsx file with the following code:

Show <AccountsPageCreate /> component
src/pages/accounts/create.tsx
import { type HttpError, useGo } from "@refinedev/core";
import { useForm } from "@refinedev/antd";
import { Flex, Form, Input, Modal } from "antd";
import InputMask from "react-input-mask";
import { FormItemUploadLogoDraggable } from "@/components/form";
import type { Account, AccountForm } from "@/types";

export const AccountsPageCreate = () => {
  const go = useGo();

  const { formProps } = useForm<Account, HttpError, AccountForm>();

  return (
    <Modal
      okButtonProps={{ form: "create-account-form", htmlType: "submit" }}
      title="Add new account"
      open
      onCancel={() => {
        go({
          to: { resource: "accounts", action: "list" },
          options: { keepQuery: true },
        });
      }}
    >
      <Form
        layout="vertical"
        id="create-account-form"
        {...formProps}
        onFinish={(values) => {
          const logoId = values.logo?.file?.response?.[0]?.id;
          return formProps.onFinish?.({
            ...values,
            logo: logoId,
          } as AccountForm);
        }}
      >
        <Flex gap={40}>
          <FormItemUploadLogoDraggable />
          <Flex
            vertical
            style={{
              width: "420px",
            }}
          >
            <Form.Item
              name="company_name"
              label="Company Name"
              rules={[{ required: true }]}
            >
              <Input />
            </Form.Item>
            <Form.Item
              name="owner_name"
              label="Owner Name"
              rules={[{ required: true }]}
            >
              <Input />
            </Form.Item>
            <Form.Item
              name="owner_email"
              label="Owner email"
              rules={[{ required: true, type: "email" }]}
            >
              <Input />
            </Form.Item>
            <Form.Item
              name="address"
              label="Address"
              rules={[{ required: true }]}
            >
              <Input />
            </Form.Item>
            <Form.Item name="phone" label="Phone" rules={[{ required: true }]}>
              <InputMask mask="(999) 999-9999">
                {/* @ts-expect-error  <InputMask /> expects JSX.Element but you are using React.ReactNode */}
                {(props: InputProps) => (
                  <Input {...props} placeholder="Please enter phone number" />
                )}
              </InputMask>
            </Form.Item>
          </Flex>
        </Flex>
      </Form>
    </Modal>
  );
};

As explained earlier, <AccountsPageCreate /> will be a sub-route of the list page (/accounts/new). <AccountsPageList /> uses the children prop as an <Outlet /> to render nested routes. This allows us to add nested routes as a modal or drawer to the list page, which is why you used <Modal /> from antd.

The okButtonProps prop is used to submit the form when the “OK” button is clicked. The onCancel prop is used to navigate back to the list page when the “Cancel” button is clicked.

Let’s closer look at the custom components and logics you used in the <AccountsPageCreate /> component:

  • useForm: This hook manages form state and handles submission. The formProps object from useForm is passed to the <Form /> component to control these aspects.

    • action and resource props are inferred from the route parameters of the resource you defined earlier. You don’t need to pass them explicitly.
  • <FormItemUploadLogoDraggable />: This component is used to upload a logo for the account. It is a custom component that you copied from the GitHub repository. It uses the <Upload.Dragger /> component from antd to upload the logo. It not contains much logic, you just made couple of changes to the original component to fit our design needs.

    • customRequest: prop is used to upload the logo to the Strapi media library. It’s just a basic post request to the Strapi media endpoint with the given file from the Ant Design’s <Upload /> component. You also need to catch errors and set the form’s error state if the upload fails.
    • getValueProps: from @refinedev/strapi-v4 is used to get the Strapi media’s URL. You pass the data and API_URL as arguments to the get the media URL.
    • fieldValue: This state watches the logo field value with Form.useWatch hook from antd. You use this state to show the uploaded logo in the form.
  • You override the onFinish prop of the <Form /> component to handle the form submission. You extract the uploaded media id from the form values and pass it to the onFinish function as logo field. When you give id of the media, Strapi will automatically create a relation between the media and the account.

Rest of form fields are basic antd form fields. You used the rules prop to set the required fields and the type of the email field. You can refer to the Forms guide for more information.

To import the company create page from other files, you need to update the src/pages/accounts/index.tsx file:

src/pages/accounts/index.tsx
export { AccountsPageList } from "./list";
export { AccountsPageCreate } from "./create";

Next, import the <AccountsPageCreate /> component in src/App.tsx and add a route for rendering it.

Show App.tsx code
src/App.tsx
//...
import { AccountsPageCreate, AccountsPageList } from '@/pages/accounts'

const App: React.FC = () => {
  return (
    //...
    <Refine
    //...
    >
      <Routes>
        <Route
          element={
            <Authenticated key='authenticated-routes' fallback={<CatchAllNavigate to='/login' />}>
              <ThemedLayoutV2 Header={() => <Header />} Sider={() => null}>
                <div
                  style={{
                    maxWidth: '1280px',
                    padding: '24px',
                    margin: '0 auto',
                  }}>
                  <Outlet />
                </div>
              </ThemedLayoutV2>
            </Authenticated>
          }>
          <Route index element={<NavigateToResource />} />
          <Route
            path='/accounts'
            element={
              <AccountsPageList>
                <Outlet />
              </AccountsPageList>
            }>
            <Route index element={null} />
            <Route path='new' element={<AccountsPageCreate />} />
          </Route>
        </Route>
        {/* ... */}
      </Routes>
      {/* ... */}
    </Refine>
    //...
  )
}

export default App

Now, when you click the “Add new account” button on the list page, you should see the create page as a modal.

After you fill out the form and click the “OK” button, the new account record will be created and you will be redirected to the list page.

Accounts Create Page

Edit Page

The edit page will feature a form for modifying an existing account record. Unlike before, this will be a separate page, not a modal.

Additionally, it will display relationship data such as clients and invoices in a non-editable table.

Let’s create a src/pages/accounts/edit.tsx file with the following code:

Show <AccountsPageEdit /> component
src/pages/accounts/edit.tsx
import { type HttpError, useNavigation } from "@refinedev/core";
import {
  DateField,
  DeleteButton,
  EditButton,
  NumberField,
  Show,
  ShowButton,
  useForm,
} from "@refinedev/antd";
import { Card, Divider, Flex, Form, Table, Typography } from "antd";
import {
  BankOutlined,
  UserOutlined,
  MailOutlined,
  EnvironmentOutlined,
  PhoneOutlined,
  ExportOutlined,
  ContainerOutlined,
  ShopOutlined,
} from "@ant-design/icons";
import { Col, Row } from "antd";
import {
  FormItemEditableInputText,
  FormItemEditableText,
  FormItemUploadLogo,
} from "@/components/form";
import type { Account, AccountForm } from "@/types";

export const AccountsPageEdit = () => {
  const { list } = useNavigation();

  const { formProps, queryResult } = useForm<Account, HttpError, AccountForm>({
    redirect: false,
    meta: {
      populate: ["logo", "clients", "invoices.client"],
    },
  });
  const account = queryResult?.data?.data;
  const clients = account?.clients || [];
  const invoices = account?.invoices || [];
  const isLoading = queryResult?.isLoading;

  return (
    <Show
      title="Accounts"
      headerButtons={() => false}
      contentProps={{
        styles: {
          body: {
            padding: 0,
          },
        },
        style: {
          background: "transparent",
          boxShadow: "none",
        },
      }}
    >
      <Form
        {...formProps}
        onFinish={(values) => {
          const logoId = values.logo?.file?.response?.[0]?.id;
          return formProps.onFinish?.({
            ...values,
            logo: logoId,
          } as AccountForm);
        }}
        layout="vertical"
      >
        <Row>
          <Col span={24}>
            <Flex gap={16}>
              <FormItemUploadLogo
                isLoading={isLoading}
                label={account?.company_name || " "}
                onUpload={() => {
                  formProps.form?.submit();
                }}
              />
              <FormItemEditableText
                loading={isLoading}
                formItemProps={{
                  name: "company_name",
                  rules: [{ required: true }],
                }}
              />
            </Flex>
          </Col>
        </Row>
        <Row
          gutter={32}
          style={{
            marginTop: "32px",
          }}
        >
          <Col xs={{ span: 24 }} xl={{ span: 8 }}>
            <Card
              bordered={false}
              styles={{ body: { padding: 0 } }}
              title={
                <Flex gap={12} align="center">
                  <BankOutlined />
                  <Typography.Text>Account info</Typography.Text>
                </Flex>
              }
            >
              <FormItemEditableInputText
                loading={isLoading}
                icon={<UserOutlined />}
                placeholder="Add owner name"
                formItemProps={{
                  name: "owner_name",
                  label: "Owner name",
                  rules: [{ required: true }],
                }}
              />
              <Divider style={{ margin: 0 }} />
              <FormItemEditableInputText
                loading={isLoading}
                icon={<MailOutlined />}
                placeholder="Add email"
                formItemProps={{
                  name: "owner_email",
                  label: "Owner email",
                  rules: [{ required: true }],
                }}
              />
              <Divider style={{ margin: 0 }} />
              <Divider style={{ margin: 0 }} />
              <FormItemEditableInputText
                loading={isLoading}
                icon={<EnvironmentOutlined />}
                placeholder="Add address"
                formItemProps={{
                  name: "address",
                  label: "Address",
                  rules: [{ required: true }],
                }}
              />
              <Divider style={{ margin: 0 }} />
              <FormItemEditableInputText
                loading={isLoading}
                icon={<PhoneOutlined />}
                placeholder="Add phone number"
                formItemProps={{
                  name: "phone",
                  label: "Phone",
                  rules: [{ required: true }],
                }}
              />
            </Card>
            <DeleteButton
              type="text"
              style={{
                marginTop: "16px",
              }}
              onSuccess={() => {
                list("clients");
              }}
            >
              Delete account
            </DeleteButton>
          </Col>

          <Col xs={{ span: 24 }} xl={{ span: 16 }}>
            <Card
              bordered={false}
              title={
                <Flex gap={12} align="center">
                  <ShopOutlined />
                  <Typography.Text>Clients</Typography.Text>
                </Flex>
              }
              styles={{
                header: {
                  padding: "0 16px",
                },
                body: {
                  padding: "0",
                },
              }}
            >
              <Table
                dataSource={clients}
                pagination={false}
                loading={isLoading}
                rowKey={"id"}
              >
                <Table.Column title="ID" dataIndex="id" key="id" />
                <Table.Column title="Client" dataIndex="name" key="name" />
                <Table.Column
                  title="Owner"
                  dataIndex="owner_name"
                  key="owner_name"
                />
                <Table.Column
                  title="Email"
                  dataIndex="owner_email"
                  key="owner_email"
                />
                <Table.Column
                  key="actions"
                  width={64}
                  render={(_, record: Account) => {
                    return (
                      <EditButton
                        hideText
                        resource="clients"
                        recordItemId={record.id}
                        icon={<ExportOutlined />}
                      />
                    );
                  }}
                />
              </Table>
            </Card>

            <Card
              bordered={false}
              title={
                <Flex gap={12} align="center">
                  <ContainerOutlined />
                  <Typography.Text>Invoices</Typography.Text>
                </Flex>
              }
              style={{ marginTop: "32px" }}
              styles={{
                header: {
                  padding: "0 16px",
                },
                body: {
                  padding: 0,
                },
              }}
            >
              <Table
                dataSource={invoices}
                pagination={false}
                loading={isLoading}
                rowKey={"id"}
              >
                <Table.Column title="ID" dataIndex="id" key="id" width={72} />
                <Table.Column
                  title="Date"
                  dataIndex="date"
                  key="date"
                  render={(date) => (
                    <DateField value={date} format="D MMM YYYY" />
                  )}
                />
                <Table.Column
                  title="Client"
                  dataIndex="client"
                  key="client"
                  render={(client) => client?.name}
                />
                <Table.Column
                  title="Amount"
                  dataIndex="total"
                  key="total"
                  render={(total) => (
                    <NumberField
                      value={total}
                      options={{ style: "currency", currency: "USD" }}
                    />
                  )}
                />
                <Table.Column
                  key="actions"
                  width={64}
                  render={(_, record: Account) => {
                    return (
                      <ShowButton
                        hideText
                        resource="invoices"
                        recordItemId={record.id}
                        icon={<ExportOutlined />}
                      />
                    );
                  }}
                />
              </Table>
            </Card>
          </Col>
        </Row>
      </Form>
    </Show>
  );
};

In the <AccountsPageEdit /> component its’a mix of show and edit page. You used the <Show /> component from @refinedev/antd for a layout. With help of useForm hook, you fetched the account data and populated the logo, clients, and invoices relationships. To display the clients and invoices, you used the <Table /> component from antd.

Note: The Invoice and Customers CRUD sheets are not available in this step, you will add them in the next steps, but since you have already prepared the API for this tutorial, these tables will be populated with data.

Let’s closer look at the custom components and logics you used in the <AccountsPageEdit /> component:

  • useForm: Similar the create page with couple of differences:
    • You used the redirect option to prevent the form from redirecting after submission.
    • The meta option is used to populate the logo, clients, and invoices relationships.
    • action, resource, and id props are inferred from the route parameters of the resource you defined earlier. You don’t need to pass them explicitly.
  • <FormItemUploadLogo />: Sames as the create page, it uploads a logo for the account, using the onUpload prop to submit the form upon logo upload. The onFinish function extracts the uploaded media ID from the form values and passes it as the logo field. Providing the media ID allows Strapi to automatically create a relation between the media and the account.
  • <FormItemEditableInputText />: Is a custom component that you copied from the GitHub repository. It’s uses the <Form.Item /> and <Input /> component from antd with some additional logic to make the input fields editable. It allows us to edit each input field in the form individually.
    • handleEdit: This function is used to toggle the input field to editable mode. It sets the isEditing state to true.
    • handleOnCancel: This function is used to cancel the editing mode. It sets the isEditing state to false and resets the input field value to initial value.
    • handleOnSave: This function is used to save the edited value. It’s submits the form with the new value and sets the isEditing state to false.
  • <DeleteButton />: This component is used to delete the account.
  • useNavigation: This hook provides the list function to navigate to the list page of the resource. You used it to navigate to the clients list page after deleting the account.

To import the company edit page from other files, you need to update the src/pages/accounts/index.tsx file:

src/pages/accounts/index.tsx
export { AccountsPageList } from "./list";
export { AccountsPageCreate } from "./create";
export { AccountsPageEdit } from "./edit";

Next, import the <AccountsPageEdit /> component in src/App.tsx and add a route for rendering it.

Show App.tsx code
src/App.tsx
import {
  AccountsPageCreate,
  AccountsPageEdit,
  AccountsPageList,
} from "@/pages/accounts";
//...

const App: React.FC = () => {
  return (
    //...
    <Refine>
      <Routes>
        {/*...*/}
        <Route
          path="/accounts"
          element={
            <AccountsPageList>
              <Outlet />
            </AccountsPageList>
          }
        >
          <Route index element={null} />
          <Route path="new" element={<AccountsPageCreate />} />
        </Route>
        <Route path="/accounts/:id/edit" element={<AccountsPageEdit />} />

        {/*...*/}
      </Routes>
      {/*...*/}
    </Refine>
    //...
  );
};

export default App;

After clicking the “Edit” button with the eye icon on the list page, you should see the edit page.

Accounts Edit Page

Step 5 — Adding Clients CRUD Pages

In this step, you will create the “clients” page, which will list all clients and allow users to create, edit, and delete them. This page page will store information about clients receiving invoices from accounts and will have a one-to-many relationship with the accounts. Each account can have multiple clients, but each client can belong to only one account.

Since it will be similar to the accounts page, you won’t explain the same components and logic again to keep the tutorial easy to follow.

Before you start working on these pages, you need to update the <Refine /> component to include the clients resource.

Let’s start by defining the "clients" resource in src/App.tsx file as follows:

Show src/App.tsx code
src/App.tsx
// ...

const App: React.FC = () => {
  return (
    // ...
    <Refine
      // ...
      resources={[
        {
          name: "accounts",
          list: "/accounts",
          create: "/accounts/new",
          edit: "/accounts/:id/edit",
        },
        {
          name: 'clients',
          list: '/clients',
          create: '/clients/new',
          edit: '/clients/:id/edit',
        },
      ]}
      // ...
    >
      {/* ... */}
    </Refine>
    // ...
  )
}

export default App

Let’s create CRUD pages for the "clients" resource as follows:

Create src/pages/clients/list.tsx file with the following code:

Show <ClientsPageList /> code
src/pages/clients/list.tsx
import type { PropsWithChildren } from "react";
import { getDefaultFilter, useGo } from "@refinedev/core";
import {
  CreateButton,
  DeleteButton,
  EditButton,
  FilterDropdown,
  List,
  NumberField,
  getDefaultSortOrder,
  useSelect,
  useTable,
} from "@refinedev/antd";
import { Avatar, Flex, Input, Select, Table, Typography } from "antd";
import { EyeOutlined, SearchOutlined } from "@ant-design/icons";
import { API_URL } from "@/utils/constants";
import { getRandomColorFromString } from "@/utils/get-random-color";
import type { Client } from "@/types";

export const ClientsPageList = ({ children }: PropsWithChildren) => {
  const go = useGo();

  const { tableProps, filters, sorters } = useTable<Client>({
    sorters: {
      initial: [{ field: "updatedAt", order: "desc" }],
    },
    filters: {
      initial: [
        {
          field: "owner_email",
          operator: "contains",
          value: "",
        },
      ],
    },
    meta: {
      populate: ["account.logo", "invoices"],
    },
  });

  const { selectProps: selectPropsName } = useSelect({
    resource: "clients",
    optionLabel: "name",
    optionValue: "name",
  });

  const { selectProps: selectPropsOwnerName } = useSelect({
    resource: "clients",
    optionLabel: "owner_name",
    optionValue: "owner_name",
  });

  const { selectProps: selectPropsAccountName } = useSelect({
    resource: "accounts",
    optionLabel: "company_name",
    optionValue: "company_name",
  });

  return (
    <>
      <List
        title="Clients"
        headerButtons={() => {
          return (
            <CreateButton
              size="large"
              onClick={() =>
                go({
                  to: { resource: "clients", action: "create" },
                  options: { keepQuery: true },
                })
              }
            >
              Add new client
            </CreateButton>
          );
        }}
      >
        <Table
          {...tableProps}
          rowKey={"id"}
          pagination={{
            ...tableProps.pagination,
            showSizeChanger: true,
          }}
          scroll={{ x: 960 }}
        >
          <Table.Column
            title="ID"
            dataIndex="id"
            key="id"
            width={80}
            defaultFilteredValue={getDefaultFilter("id", filters)}
            filterIcon={<SearchOutlined />}
            filterDropdown={(props) => {
              return (
                <FilterDropdown {...props}>
                  <Input placeholder="Search ID" />
                </FilterDropdown>
              );
            }}
          />
          <Table.Column
            title="Title"
            dataIndex="name"
            key="name"
            sorter
            defaultSortOrder={getDefaultSortOrder("name", sorters)}
            defaultFilteredValue={getDefaultFilter("name", filters, "in")}
            filterDropdown={(props) => (
              <FilterDropdown {...props}>
                <Select
                  mode="multiple"
                  placeholder="Search Name"
                  style={{ width: 220 }}
                  {...selectPropsName}
                />
              </FilterDropdown>
            )}
          />
          <Table.Column
            title="Owner"
            dataIndex="owner_name"
            key="owner_name"
            sorter
            defaultSortOrder={getDefaultSortOrder("owner_name", sorters)}
            defaultFilteredValue={getDefaultFilter("owner_name", filters, "in")}
            filterDropdown={(props) => (
              <FilterDropdown {...props}>
                <Select
                  mode="multiple"
                  placeholder="Search Owner"
                  style={{ width: 220 }}
                  {...selectPropsOwnerName}
                />
              </FilterDropdown>
            )}
          />
          <Table.Column
            title="Email"
            dataIndex="owner_email"
            key="owner_email"
            defaultFilteredValue={getDefaultFilter(
              "owner_email",
              filters,
              "contains",
            )}
            filterIcon={<SearchOutlined />}
            filterDropdown={(props) => {
              return (
                <FilterDropdown {...props}>
                  <Input placeholder="Search Email" />
                </FilterDropdown>
              );
            }}
          />
          <Table.Column
            title="Total"
            dataIndex="total"
            key="total"
            width={120}
            align="end"
            render={(_, record: Client) => {
              let total = 0;
              record.invoices?.forEach((invoice) => {
                total += invoice.total;
              });
              return (
                <NumberField
                  value={total}
                  options={{ style: "currency", currency: "USD" }}
                />
              );
            }}
          />
          <Table.Column
            title="Account"
            dataIndex="account.company_name"
            key="account.company_name"
            defaultFilteredValue={getDefaultFilter(
              "account.company_name",
              filters,
              "in",
            )}
            filterDropdown={(props) => (
              <FilterDropdown {...props}>
                <Select
                  mode="multiple"
                  placeholder="Search Account"
                  style={{ width: 220 }}
                  {...selectPropsAccountName}
                />
              </FilterDropdown>
            )}
            render={(_, record: Client) => {
              const logoUrl = record?.account?.logo?.url;
              const src = logoUrl ? `${API_URL}${logoUrl}` : null;
              const name = record?.account?.company_name || "";

              return (
                <Flex align="center" gap={8}>
                  <Avatar
                    alt={name}
                    src={src}
                    shape="square"
                    style={{
                      backgroundColor: src
                        ? "none"
                        : getRandomColorFromString(name),
                    }}
                  >
                    <Typography.Text>
                      {name?.[0]?.toUpperCase()}
                    </Typography.Text>
                  </Avatar>
                  <Typography.Text>{name}</Typography.Text>
                </Flex>
              );
            }}
          />
          <Table.Column
            title="Actions"
            key="actions"
            fixed="right"
            align="end"
            width={106}
            render={(_, record: Client) => {
              return (
                <Flex align="center" gap={8}>
                  <EditButton
                    hideText
                    recordItemId={record.id}
                    icon={<EyeOutlined />}
                  />
                  <DeleteButton hideText recordItemId={record.id} />
                </Flex>
              );
            }}
          />
        </Table>
      </List>
      {children}
    </>
  );
};

Create src/pages/clients/create.tsx file with the following code:

Show <ClientsPageCreate /> code
src/pages/clients/create.tsx
import { useGo } from "@refinedev/core";
import { useForm, useSelect } from "@refinedev/antd";
import { Flex, Form, Input, Modal, Select } from "antd";
import InputMask from "react-input-mask";

export const ClientsPageCreate = () => {
  const go = useGo();

  const { formProps } = useForm();

  const { selectProps: selectPropsAccount } = useSelect({
    resource: "accounts",
    optionLabel: "company_name",
    optionValue: "id",
  });

  return (
    <Modal
      okButtonProps={{ form: "create-client-form", htmlType: "submit" }}
      title="Add new client"
      open
      onCancel={() => {
        go({
          to: { resource: "accounts", action: "list" },
          options: { keepQuery: true },
        });
      }}
    >
      <Form layout="vertical" id="create-client-form" {...formProps}>
        <Flex
          vertical
          style={{
            margin: "0 auto",
            width: "420px",
          }}
        >
          <Form.Item
            name="account"
            label="Account"
            rules={[{ required: true }]}
          >
            <Select
              {...selectPropsAccount}
              placeholder="Please select an account"
            />
          </Form.Item>
          <Form.Item
            name="name"
            label="Client title"
            rules={[{ required: true }]}
          >
            <Input placeholder="Please enter client title" />
          </Form.Item>
          <Form.Item
            name="owner_name"
            label="Owner name"
            rules={[{ required: true }]}
          >
            <Input placeholder="Please enter owner name" />
          </Form.Item>
          <Form.Item
            name="owner_email"
            label="Owner email"
            rules={[{ required: true, type: "email" }]}
          >
            <Input placeholder="Please enter owner email" />
          </Form.Item>
          <Form.Item
            name="address"
            label="Address"
            rules={[{ required: true }]}
          >
            <Input placeholder="Please enter address" />
          </Form.Item>
          <Form.Item name="phone" label="Phone" rules={[{ required: true }]}>
            <InputMask mask="(999) 999-9999">
              {/* @ts-expect-error  <InputMask /> expects JSX.Element but you are using React.ReactNode */}
              {(props: InputProps) => (
                <Input {...props} placeholder="Please enter phone number" />
              )}
            </InputMask>
          </Form.Item>
        </Flex>
      </Form>
    </Modal>
  );
};

Create src/pages/clients/edit.tsx file with the following code:

Show <ClientsPageEdit /> code
src/pages/clients/edit.tsx
import { useNavigation } from "@refinedev/core";
import {
  DateField,
  DeleteButton,
  NumberField,
  Show,
  ShowButton,
  useForm,
  useSelect,
} from "@refinedev/antd";
import { Card, Divider, Flex, Form, Table, Typography } from "antd";
import {
  ShopOutlined,
  UserOutlined,
  ExportOutlined,
  BankOutlined,
  MailOutlined,
  EnvironmentOutlined,
  PhoneOutlined,
  ContainerOutlined,
} from "@ant-design/icons";
import { Col, Row } from "antd";
import {
  FormItemEditableInputText,
  FormItemEditableText,
  FormItemEditableSelect,
} from "@/components/form";
import type { Invoice } from "@/types";

export const ClientsPageEdit = () => {
  const { listUrl } = useNavigation();

  const { formProps, queryResult } = useForm({
    redirect: false,
    meta: {
      populate: ["account", "invoices.client", "invoices.account.logo"],
    },
  });

  const { selectProps: selectPropsAccount } = useSelect({
    resource: "accounts",
    optionLabel: "company_name",
    optionValue: "id",
  });

  const invoices = queryResult?.data?.data?.invoices || [];
  const isLoading = queryResult?.isLoading;

  return (
    <Show
      title="Clients"
      headerButtons={() => false}
      contentProps={{
        styles: {
          body: {
            padding: 0,
          },
        },
        style: {
          background: "transparent",
          boxShadow: "none",
        },
      }}
    >
      <Form {...formProps} layout="vertical">
        <Row>
          <Col span={24}>
            <Flex gap={16}>
              <FormItemEditableText
                loading={isLoading}
                formItemProps={{
                  name: "name",
                  rules: [{ required: true }],
                }}
              />
            </Flex>
          </Col>
        </Row>
        <Row
          gutter={32}
          style={{
            marginTop: "32px",
          }}
        >
          <Col xs={{ span: 24 }} xl={{ span: 8 }}>
            <Card
              bordered={false}
              styles={{ body: { padding: 0 } }}
              title={
                <Flex gap={12} align="center">
                  <ShopOutlined />
                  <Typography.Text>Client info</Typography.Text>
                </Flex>
              }
            >
              <FormItemEditableSelect
                loading={isLoading}
                icon={<BankOutlined />}
                editIcon={<ExportOutlined />}
                selectProps={{
                  showSearch: true,
                  placeholder: "Select account",
                  ...selectPropsAccount,
                }}
                formItemProps={{
                  name: "account",
                  getValueProps: (value) => {
                    return {
                      value: value?.id,
                      label: value?.company_name,
                    };
                  },
                  label: "Account",
                  rules: [{ required: true }],
                }}
              />
              <FormItemEditableInputText
                loading={isLoading}
                icon={<UserOutlined />}
                placeholder="Add owner name"
                formItemProps={{
                  name: "owner_name",
                  label: "Owner name",
                  rules: [{ required: true }],
                }}
              />
              <Divider style={{ margin: 0 }} />
              <FormItemEditableInputText
                loading={isLoading}
                icon={<MailOutlined />}
                placeholder="Add email"
                formItemProps={{
                  name: "owner_email",
                  label: "Owner email",
                  rules: [{ required: true, type: "email" }],
                }}
              />
              <Divider style={{ margin: 0 }} />
              <Divider style={{ margin: 0 }} />
              <FormItemEditableInputText
                loading={isLoading}
                icon={<EnvironmentOutlined />}
                placeholder="Add address"
                formItemProps={{
                  name: "address",
                  label: "Address",
                  rules: [{ required: true }],
                }}
              />
              <Divider style={{ margin: 0 }} />
              <FormItemEditableInputText
                loading={isLoading}
                icon={<PhoneOutlined />}
                placeholder="Add phone number"
                formItemProps={{
                  name: "phone",
                  label: "Phone",
                  rules: [{ required: true }],
                }}
              />
            </Card>
            <DeleteButton
              type="text"
              style={{
                marginTop: "16px",
              }}
              onSuccess={() => {
                listUrl("clients");
              }}
            >
              Delete client
            </DeleteButton>
          </Col>

          <Col xs={{ span: 24 }} xl={{ span: 16 }}>
            <Card
              bordered={false}
              title={
                <Flex gap={12} align="center">
                  <ContainerOutlined />
                  <Typography.Text>Invoices</Typography.Text>
                </Flex>
              }
              styles={{
                header: {
                  padding: "0 16px",
                },
                body: {
                  padding: 0,
                },
              }}
            >
              <Table
                dataSource={invoices}
                pagination={false}
                loading={isLoading}
                rowKey={"id"}
              >
                <Table.Column title="ID" dataIndex="id" key="id" width={72} />
                <Table.Column
                  title="Date"
                  dataIndex="date"
                  key="date"
                  render={(date) => (
                    <DateField value={date} format="D MMM YYYY" />
                  )}
                />
                <Table.Column
                  title="Client"
                  dataIndex="client"
                  key="client"
                  render={(client) => client?.name}
                />
                <Table.Column
                  title="Amount"
                  dataIndex="total"
                  key="total"
                  render={(total) => (
                    <NumberField
                      value={total}
                      options={{ style: "currency", currency: "USD" }}
                    />
                  )}
                />
                <Table.Column
                  key="actions"
                  width={64}
                  render={(_, record: Invoice) => {
                    return (
                      <Flex align="center" gap={8}>
                        <ShowButton
                          hideText
                          resource="invoices"
                          recordItemId={record.id}
                          icon={<ExportOutlined />}
                        />
                      </Flex>
                    );
                  }}
                />
              </Table>
            </Card>
          </Col>
        </Row>
      </Form>
    </Show>
  );
};


After creating the CRUD pages, let’s create a src/pages/clients/index.ts file to export the pages as follows:

src/pages/clients/index.ts
export { ClientsPageList } from "./list";
export { ClientsPageCreate } from "./create";
export { ClientsPageEdit } from "./edit";

To render the clients CRUD pages, let’s update the src/App.tsx file with the following code:

Show src/App.tsx code
src/App.tsx

import { Authenticated, Refine } from "@refinedev/core";
import {
  AuthPage,
  ErrorComponent,
  ThemedLayoutV2,
  useNotificationProvider,
} from "@refinedev/antd";
import routerProvider, {
  UnsavedChangesNotifier,
  DocumentTitleHandler,
  NavigateToResource,
  CatchAllNavigate,
} from "@refinedev/react-router-v6";
import { BrowserRouter, Outlet, Route, Routes } from "react-router-dom";
import { DevtoolsPanel, DevtoolsProvider } from "@refinedev/devtools";
import { App as AntdApp } from "antd";
import { dataProvider } from "@/providers/data-provider";
import { authProvider } from "@/providers/auth-provider";
import { ConfigProvider } from "@/providers/config-provider";
import { Logo } from "@/components/logo";
import { Header } from "@/components/header";
import {
  AccountsPageCreate,
  AccountsPageEdit,
  AccountsPageList,
} from "@/pages/accounts";
import {
  ClientsPageCreate,
  ClientsPageEdit,
  ClientsPageList,
} from "@/pages/clients";
import "@refinedev/antd/dist/reset.css";
import "./styles/custom.css";

const App: React.FC = () => {
  return (
    <DevtoolsProvider>
      <BrowserRouter>
        <ConfigProvider>
          <AntdApp>
            <Refine
              routerProvider={routerProvider}
              authProvider={authProvider}
              dataProvider={dataProvider}
              notificationProvider={useNotificationProvider}
              resources={[
                {
                  name: "accounts",
                  list: "/accounts",
                  create: "/accounts/new",
                  edit: "/accounts/:id/edit",
                },
                {
                  name: "clients",
                  list: "/clients",
                  create: "/clients/new",
                  edit: "/clients/:id/edit",
                },
              ]}
              options={{
                syncWithLocation: true,
                warnWhenUnsavedChanges: true,
                breadcrumb: false,
              }}
            >
              <Routes>
                <Route
                  element={
                    <Authenticated
                      key="authenticated-routes"
                      fallback={<CatchAllNavigate to="/login" />}
                    >
                      <ThemedLayoutV2
                        Header={() => <Header />}
                        Sider={() => null}
                      >
                        <div
                          style={{
                            maxWidth: "1280px",
                            padding: "24px",
                            margin: "0 auto",
                          }}
                        >
                          <Outlet />
                        </div>
                      </ThemedLayoutV2>
                    </Authenticated>
                  }
                >
                  <Route index element={<NavigateToResource />} />

                  <Route
                    path="/accounts"
                    element={
                      <AccountsPageList>
                        <Outlet />
                      </AccountsPageList>
                    }
                  >
                    <Route index element={null} />
                    <Route path="new" element={<AccountsPageCreate />} />
                  </Route>
                  <Route
                    path="/accounts/:id/edit"
                    element={<AccountsPageEdit />}
                  />

                  <Route
                    path="/clients"
                    element={
                      <ClientsPageList>
                        <Outlet />
                      </ClientsPageList>
                    }
                  >
                    <Route index element={null} />
                    <Route path="new" element={<ClientsPageCreate />} />
                  </Route>
                  <Route
                    path="/clients/:id/edit"
                    element={<ClientsPageEdit />}
                  />
                </Route>

                <Route
                  element={
                    <Authenticated key="auth-pages" fallback={<Outlet />}>
                      <NavigateToResource />
                    </Authenticated>
                  }
                >
                  <Route
                    path="/login"
                    element={
                      <AuthPage
                        type="login"
                        registerLink={false}
                        forgotPasswordLink={false}
                        title={
                          <Logo
                            titleProps={{ level: 2 }}
                            svgProps={{
                              width: "48px",
                              height: "40px",
                            }}
                          />
                        }
                        formProps={{
                          initialValues: {
                            email: "demo@refine.dev",
                            password: "demodemo",
                          },
                        }}
                      />
                    }
                  />
                </Route>

                <Route
                  element={
                    <Authenticated key="catch-all">
                      <ThemedLayoutV2
                        Header={() => <Header />}
                        Sider={() => null}
                      >
                        <Outlet />
                      </ThemedLayoutV2>
                    </Authenticated>
                  }
                >
                  <Route path="*" element={<ErrorComponent />} />
                </Route>
              </Routes>
              <UnsavedChangesNotifier />
              <DocumentTitleHandler />
            </Refine>
          </AntdApp>
        </ConfigProvider>
        <DevtoolsPanel />
      </BrowserRouter>
    </DevtoolsProvider>
  );
};

export default App;

After these changes, you should be able to navigate to the clients CRUD pages as the below:

Slide #1Slide #2Slide #3

Step 6 — Adding Invoices CRUD Pages

In this step you will build the invoices CRUD pages.

Invoices will be used to store information about the invoices created with clients and accounts informations. So, it will have a required relationship with the client and account. Each client and account can have multiple invoices, but each invoice can only belong to one client and account.

You’ll be able produce PDF invoices with the invoice information data like below:

  • Client: The recipient of the invoice.
  • Account: The sender of the invoice.
  • Date: The creation date of the invoice.
  • Total: The amount due after discount and tax.
  • Discount: The discount applied to the invoice.
  • Tax: The tax amount on the invoice.
  • Services: The services listed on the invoice, including:
    • Title: The name of the service.
    • Discount: The discount applied to the service.
    • Quantity: The quantity of the service.
    • Unit Price: The unit price of the service.
    • Total: The total amount for the service after the discount.

After the user creates an invoice with these fields, they will be able to view, edit, delete, and Export as PDF the invoice.

Let’s start by defining the "invoices" resource in the src/App.tsx file as follows:

Show src/App.tsx code
src/App.tsx
import { Authenticated, Refine } from '@refinedev/core'
import { AuthPage, ErrorComponent, ThemedLayoutV2, useNotificationProvider } from '@refinedev/antd'
import routerProvider, {
  UnsavedChangesNotifier,
  DocumentTitleHandler,
  NavigateToResource,
  CatchAllNavigate,
} from '@refinedev/react-router-v6'
import { BrowserRouter, Outlet, Route, Routes } from 'react-router-dom'
import { DevtoolsPanel, DevtoolsProvider } from '@refinedev/devtools'
import { App as AntdApp } from 'antd'
import { dataProvider } from '@/providers/data-provider'
import { authProvider } from '@/providers/auth-provider'
import { ConfigProvider } from '@/providers/config-provider'
import { Logo } from '@/components/logo'
import { Header } from '@/components/header'
import { AccountsPageCreate, AccountsPageEdit, AccountsPageList } from '@/pages/accounts'
import { ClientsPageCreate, ClientsPageEdit, ClientsPageList } from '@/pages/clients'
import '@refinedev/antd/dist/reset.css'
import './styles/custom.css'

const App: React.FC = () => {
  return (
    <DevtoolsProvider>
      <BrowserRouter>
        <ConfigProvider>
          <AntdApp>
            <Refine
              routerProvider={routerProvider}
              authProvider={authProvider}
              dataProvider={dataProvider}
              notificationProvider={useNotificationProvider}
              resources={[
                {
                  name: "accounts",
                  list: "/accounts",
                  create: "/accounts/new",
                  edit: "/accounts/:id/edit",
                },
                {
                  name: "clients",
                  list: "/clients",
                  create: "/clients/new",
                  edit: "/clients/:id/edit",
                },
                {
                  name: "invoices",
                  list: "/invoices",
                  show: "/invoices/:id",
                  create: "/invoices/new",
                },
              ]}
              options={{
                syncWithLocation: true,
                warnWhenUnsavedChanges: true,
                breadcrumb: false,
              }}>
              <Routes>
                <Route
                  element={
                    <Authenticated key="authenticated-routes" fallback={<CatchAllNavigate to="/login" />}>
                      <ThemedLayoutV2 Header={() => <Header />} Sider={() => null}>
                        <div
                          style={{
                            maxWidth: "1280px",
                            padding: "24px",
                            margin: "0 auto",
                          }}>
                          <Outlet />
                        </div>
                      </ThemedLayoutV2>
                    </Authenticated>
                  }>
                  <Route index element={<NavigateToResource />} />

                  <Route
                    path="/accounts"
                    element={
                      <AccountsPageList>
                        <Outlet />
                      </AccountsPageList>
                    }>
                    <Route index element={null} />
                    <Route path="new" element={<AccountsPageCreate />} />
                  </Route>
                  <Route path="/accounts/:id/edit" element={<AccountsPageEdit />} />

                  <Route
                    path="/clients"
                    element={
                      <ClientsPageList>
                        <Outlet />
                      </ClientsPageList>
                    }>
                    <Route index element={null} />
                    <Route path="new" element={<ClientsPageCreate />} />
                  </Route>
                  <Route path="/clients/:id/edit" element={<ClientsPageEdit />} />
                </Route>

                <Route
                  element={
                    <Authenticated key="auth-pages" fallback={<Outlet />}>
                      <NavigateToResource />
                    </Authenticated>
                  }>
                  <Route
                    path="/login"
                    element={
                      <AuthPage
                        type="login"
                        registerLink={false}
                        forgotPasswordLink={false}
                        title={
                          <Logo
                            titleProps={{ level: 2 }}
                            svgProps={{
                              width: "48px",
                              height: "40px",
                            }}
                          />
                        }
                        formProps={{
                          initialValues: {
                            email: "demo@refine.dev",
                            password: "demodemo",
                          },
                        }}
                      />
                    }
                  />
                </Route>

                <Route
                  element={
                    <Authenticated key="catch-all">
                      <ThemedLayoutV2 Header={() => <Header />} Sider={() => null}>
                        <Outlet />
                      </ThemedLayoutV2>
                    </Authenticated>
                  }>
                  <Route path="*" element={<ErrorComponent />} />
                </Route>
              </Routes>
              <UnsavedChangesNotifier />
              <DocumentTitleHandler />
            </Refine>
          </AntdApp>
        </ConfigProvider>
        <DevtoolsPanel />
      </BrowserRouter>
    </DevtoolsProvider>
  )
}

export default App


List Page

You will start by creating the list page to display all created invoices. Most of the components and logic will be similar to the accounts and clients list pages. So You’ll not explain the same components and logic again to keep the tutorial easy to follow.

Let’s create the src/pages/invoices/list.tsx file with the following code:

Show <InvoicesPageList /> code
src/pages/invoices/list.tsx
import { getDefaultFilter, useGo } from "@refinedev/core";
import {
  CreateButton,
  DateField,
  DeleteButton,
  FilterDropdown,
  List,
  NumberField,
  ShowButton,
  getDefaultSortOrder,
  useSelect,
  useTable,
} from "@refinedev/antd";
import { Avatar, Flex, Input, Select, Table, Typography } from "antd";
import { EyeOutlined, SearchOutlined } from "@ant-design/icons";
import { API_URL } from "@/utils/constants";
import { getRandomColorFromString } from "@/utils/get-random-color";
import type { Invoice } from "@/types";

export const InvoicePageList = () => {
  const go = useGo();

  const { tableProps, filters, sorters } = useTable<Invoice>({
    meta: {
      populate: ["client", "account.logo"],
    },
    sorters: {
      initial: [{ field: "updatedAt", order: "desc" }],
    },
  });

  const { selectProps: selectPropsAccounts } = useSelect({
    resource: "accounts",
    optionLabel: "company_name",
    optionValue: "company_name",
  });

  const { selectProps: selectPropsClients } = useSelect({
    resource: "clients",
    optionLabel: "name",
    optionValue: "name",
  });

  return (
    <List
      title="Invoices"
      headerButtons={() => {
        return (
          <CreateButton
            size="large"
            onClick={() =>
              go({
                to: { resource: "invoices", action: "create" },
                options: { keepQuery: true },
              })
            }
          >
            Add new invoice
          </CreateButton>
        );
      }}
    >
      <Table
        {...tableProps}
        rowKey={"id"}
        pagination={{
          ...tableProps.pagination,
          showSizeChanger: true,
        }}
        scroll={{ x: 960 }}
      >
        <Table.Column
          title="ID"
          dataIndex="id"
          key="id"
          width={80}
          defaultFilteredValue={getDefaultFilter("id", filters)}
          filterIcon={<SearchOutlined />}
          filterDropdown={(props) => {
            return (
              <FilterDropdown {...props}>
                <Input placeholder="Search ID" />
              </FilterDropdown>
            );
          }}
        />
        <Table.Column
          title="Account"
          dataIndex="account.company_name"
          key="account.company_name"
          defaultFilteredValue={getDefaultFilter(
            "account.company_name",
            filters,
            "in",
          )}
          filterDropdown={(props) => (
            <FilterDropdown {...props}>
              <Select
                mode="multiple"
                placeholder="Search Account"
                style={{ width: 220 }}
                {...selectPropsAccounts}
              />
            </FilterDropdown>
          )}
          render={(_, record: Invoice) => {
            const logoUrl = record?.account?.logo?.url;
            const src = logoUrl ? `${API_URL}${logoUrl}` : undefined;
            const name = record?.account?.company_name;

            return (
              <Flex align="center" gap={8}>
                <Avatar
                  alt={name}
                  src={src}
                  shape="square"
                  style={{
                    backgroundColor: getRandomColorFromString(name),
                  }}
                >
                  <Typography.Text>{name?.[0]?.toUpperCase()}</Typography.Text>
                </Avatar>
                <Typography.Text>{name}</Typography.Text>
              </Flex>
            );
          }}
        />
        <Table.Column
          title="Client"
          dataIndex="client.name"
          key="client.name"
          render={(_, record: Invoice) => {
            return <Typography.Text>{record.client?.name}</Typography.Text>;
          }}
          defaultFilteredValue={getDefaultFilter("company_name", filters, "in")}
          filterDropdown={(props) => (
            <FilterDropdown {...props}>
              <Select
                mode="multiple"
                placeholder="Search Company Name"
                style={{ width: 220 }}
                {...selectPropsClients}
              />
            </FilterDropdown>
          )}
        />
        <Table.Column
          title="Date"
          dataIndex="date"
          key="date"
          width={120}
          sorter
          defaultSortOrder={getDefaultSortOrder("date", sorters)}
          render={(date) => {
            return <DateField value={date} format="D MMM YYYY" />;
          }}
        />
        <Table.Column
          title="Total"
          dataIndex="total"
          key="total"
          width={132}
          align="end"
          sorter
          defaultSortOrder={getDefaultSortOrder("total", sorters)}
          render={(total) => {
            return (
              <NumberField
                value={total}
                options={{ style: "currency", currency: "USD" }}
              />
            );
          }}
        />
        <Table.Column
          title="Actions"
          key="actions"
          fixed="right"
          align="end"
          width={102}
          render={(_, record: Invoice) => {
            return (
              <Flex align="center" gap={8}>
                <ShowButton
                  hideText
                  recordItemId={record.id}
                  icon={<EyeOutlined />}
                />
                <DeleteButton hideText recordItemId={record.id} />
              </Flex>
            );
          }}
        />
      </Table>
    </List>
  );
};

After creating the list page, let’s create a src/pages/invoices/index.ts file to export the pages as follows:

src/pages/invoices/index.ts
export { InvoicePageList } from "./list";

Now you are ready to add our “list” page to the src/App.tsx file as follows:

Show src/App.tsx code
src/App.tsx
// ...
import { InvoicePageList } from "@/pages/invoices";

const App: React.FC = () => {
  return (
    //...
    <Refine
    //...
    >
      <Routes>
        <Route
        //...
        >
          {/* ... */}

          <Route
            path="/clients"
            element={
              <ClientsPageList>
                <Outlet />
              </ClientsPageList>
            }
          >
            <Route index element={null} />
            <Route path="new" element={<ClientsPageCreate />} />
          </Route>
          <Route path="/clients/:id/edit" element={<ClientsPageEdit />} />

          <Route path="/invoices">
            <Route index element={<InvoicePageList />} />
          </Route>
        </Route>

        <Route
          element={
            <Authenticated key="auth-pages" fallback={<Outlet />}>
              <NavigateToResource />
            </Authenticated>
          }
        >
          {/* ... */}
        </Route>

        {/* ... */}
      </Routes>
      {/* ... */}
    </Refine>
    //...
  );
};

export default App;

After these changes, you should be able to navigate to the invoice list pages as the below:

Invoices List page

Create Page

The "invoices" create page is very similar to the "accounts" create page, hover, it requires specific custom styles and additional business logic to compute the service items for the invoice.

To begin, let’s create the src/pages/invoices/create.styled.tsx file with the following code:

Show src/pages/invoices/create.styled.tsx code
src/pages/invoices/create.styled.tsx
import { createStyles } from "antd-style";

export const useStyles = createStyles(({ token, isDarkMode }) => {
  return {
    serviceTableWrapper: {
      overflow: "auto",
    },
    serviceTableContainer: {
      minWidth: "960px",
      borderRadius: "8px",
      border: `1px solid ${token.colorBorder}`,
    },
    serviceHeader: {
      background: isDarkMode ? "#1F1F1F" : "#FAFAFA",
      borderRadius: "8px 8px 0 0",
    },
    serviceHeaderDivider: {
      height: "24px",
      marginTop: "auto",
      marginBottom: "auto",
      marginInline: "0",
    },
    serviceHeaderColumn: {
      fontWeight: 600,
      display: "flex",
      alignItems: "center",
      justifyContent: "space-between",
      padding: "12px 16px",
    },
    serviceRowColumn: {
      display: "flex",
      alignItems: "center",
      padding: "12px 16px",
    },
    addNewServiceItemButton: {
      color: token.colorPrimary,
    },
    labelTotal: {
      color: token.colorTextSecondary,
    },
  };
});

To write CSS you used the createStyles function from the antd-style package. This function accepts a callback function that provides the token and isDarkMode values. The token object contains the color values of the current theme, and the isDarkMode value indicates whether the current theme is dark or light.

Let’s create the src/pages/invoices/create.tsx file with the following code:

Show <InvoicesPageCreate /> code
src/pages/invoices/create.tsx
import { Fragment, useState } from "react";
import { NumberField, Show, useForm, useSelect } from "@refinedev/antd";
import {
  Button,
  Card,
  Col,
  Divider,
  Flex,
  Form,
  Input,
  InputNumber,
  Row,
  Select,
  Typography,
} from "antd";
import { DeleteOutlined, PlusCircleOutlined } from "@ant-design/icons";
import type { Invoice, Service } from "@/types";
import { useStyles } from "./create.styled";

export const InvoicesPageCreate = () => {
  const [tax, setTax] = useState<number>(0);
  const [services, setServices] = useState<Service[]>([
    {
      title: "",
      unitPrice: 0,
      quantity: 0,
      discount: 0,
      totalPrice: 0,
    },
  ]);
  const subtotal = services.reduce(
    (acc, service) =>
      acc +
      (service.unitPrice * service.quantity * (100 - service.discount)) / 100,
    0,
  );
  const total = subtotal + (subtotal * tax) / 100;

  const { styles } = useStyles();

  const { formProps } = useForm<Invoice>();

  const { selectProps: selectPropsAccounts } = useSelect({
    resource: "accounts",
    optionLabel: "company_name",
    optionValue: "id",
  });

  const { selectProps: selectPropsClients } = useSelect({
    resource: "clients",
    optionLabel: "name",
    optionValue: "id",
  });

  const handleServiceNumbersChange = (
    index: number,
    key: "quantity" | "discount" | "unitPrice",
    value: number,
  ) => {
    setServices((prev) => {
      const currentService = { ...prev[index] };
      currentService[key] = value;
      currentService.totalPrice =
        currentService.unitPrice *
        currentService.quantity *
        ((100 - currentService.discount) / 100);

      return prev.map((item, i) => (i === index ? currentService : item));
    });
  };

  const onFinishHandler = (values: Invoice) => {
    const valuesWithServices = {
      ...values,
      total,
      tax,
      date: new Date().toISOString(),
      services: services.filter((service) => service.title),
    };

    formProps?.onFinish?.(valuesWithServices);
  };

  return (
    <Show
      title="Invoices"
      headerButtons={() => false}
      contentProps={{
        styles: {
          body: {
            padding: 0,
          },
        },
        style: {
          background: "transparent",
          boxShadow: "none",
        },
      }}
    >
      <Form
        {...formProps}
        onFinish={(values) => onFinishHandler(values as Invoice)}
        layout="vertical"
      >
        <Flex vertical gap={32}>
          <Typography.Title level={3}>New Invoice</Typography.Title>
          <Card
            bordered={false}
            styles={{
              body: {
                padding: 0,
              },
            }}
          >
            <Flex
              align="center"
              gap={40}
              wrap="wrap"
              style={{ padding: "32px" }}
            >
              <Form.Item
                label="Account"
                name="account"
                rules={[{ required: true }]}
                style={{ flex: 1 }}
              >
                <Select
                  {...selectPropsAccounts}
                  placeholder="Please select account"
                />
              </Form.Item>
              <Form.Item
                label="Client"
                name="client"
                rules={[{ required: true }]}
                style={{ flex: 1 }}
              >
                <Select
                  {...selectPropsClients}
                  placeholder="Please select client"
                />
              </Form.Item>
            </Flex>
            <Divider style={{ margin: 0 }} />
            <div style={{ padding: "32px" }}>
              <Typography.Title
                level={4}
                style={{ marginBottom: "32px", fontWeight: 400 }}
              >
                Products / Services
              </Typography.Title>
              <div className={styles.serviceTableWrapper}>
                <div className={styles.serviceTableContainer}>
                  <Row className={styles.serviceHeader}>
                    <Col
                      xs={{ span: 7 }}
                      className={styles.serviceHeaderColumn}
                    >
                      Title
                      <Divider
                        type="vertical"
                        className={styles.serviceHeaderDivider}
                      />
                    </Col>
                    <Col
                      xs={{ span: 5 }}
                      className={styles.serviceHeaderColumn}
                    >
                      Unit Price
                      <Divider
                        type="vertical"
                        className={styles.serviceHeaderDivider}
                      />
                    </Col>
                    <Col
                      xs={{ span: 4 }}
                      className={styles.serviceHeaderColumn}
                    >
                      Quantity
                      <Divider
                        type="vertical"
                        className={styles.serviceHeaderDivider}
                      />
                    </Col>
                    <Col
                      xs={{ span: 4 }}
                      className={styles.serviceHeaderColumn}
                    >
                      Discount
                      <Divider
                        type="vertical"
                        className={styles.serviceHeaderDivider}
                      />
                    </Col>
                    <Col
                      xs={{ span: 3 }}
                      style={{
                        display: "flex",
                        alignItems: "center",
                        justifyContent: "flex-end",
                      }}
                      className={styles.serviceHeaderColumn}
                    >
                      Total Price
                    </Col>
                    <Col xs={{ span: 1 }}> </Col>
                  </Row>
                  <Row>
                    {services.map((service, index) => {
                      return (
                        // biome-ignore lint/suspicious/noArrayIndexKey: You don't have a unique key for each service item when you create a new one
                        <Fragment key={index}>
                          <Col
                            xs={{ span: 7 }}
                            className={styles.serviceRowColumn}
                          >
                            <Input
                              placeholder="Title"
                              value={service.title}
                              onChange={(e) => {
                                setServices((prev) =>
                                  prev.map((item, i) =>
                                    i === index
                                      ? { ...item, title: e.target.value }
                                      : item,
                                  ),
                                );
                              }}
                            />
                          </Col>
                          <Col
                            xs={{ span: 5 }}
                            className={styles.serviceRowColumn}
                          >
                            <InputNumber
                              addonBefore="$"
                              style={{ width: "100%" }}
                              placeholder="Unit Price"
                              min={0}
                              value={service.unitPrice}
                              onChange={(value) => {
                                handleServiceNumbersChange(
                                  index,
                                  "unitPrice",
                                  value || 0,
                                );
                              }}
                            />
                          </Col>
                          <Col
                            xs={{ span: 4 }}
                            className={styles.serviceRowColumn}
                          >
                            <InputNumber
                              style={{ width: "100%" }}
                              placeholder="Quantity"
                              min={0}
                              value={service.quantity}
                              onChange={(value) => {
                                handleServiceNumbersChange(
                                  index,
                                  "quantity",
                                  value || 0,
                                );
                              }}
                            />
                          </Col>
                          <Col
                            xs={{ span: 4 }}
                            className={styles.serviceRowColumn}
                          >
                            <InputNumber
                              addonAfter="%"
                              style={{ width: "100%" }}
                              placeholder="Discount"
                              min={0}
                              value={service.discount}
                              onChange={(value) => {
                                handleServiceNumbersChange(
                                  index,
                                  "discount",
                                  value || 0,
                                );
                              }}
                            />
                          </Col>
                          <Col
                            xs={{ span: 3 }}
                            className={styles.serviceRowColumn}
                            style={{
                              justifyContent: "flex-end",
                            }}
                          >
                            <NumberField
                              value={service.totalPrice}
                              options={{ style: "currency", currency: "USD" }}
                            />
                          </Col>
                          <Col
                            xs={{ span: 1 }}
                            className={styles.serviceRowColumn}
                            style={{
                              paddingLeft: "0",
                              justifyContent: "flex-end",
                            }}
                          >
                            <Button
                              danger
                              size="small"
                              icon={<DeleteOutlined />}
                              onClick={() => {
                                setServices((prev) =>
                                  prev.filter((_, i) => i !== index),
                                );
                              }}
                            />
                          </Col>
                        </Fragment>
                      );
                    })}
                  </Row>
                  <Divider
                    style={{
                      margin: "0",
                    }}
                  />
                  <div style={{ padding: "12px" }}>
                    <Button
                      icon={<PlusCircleOutlined />}
                      type="text"
                      className={styles.addNewServiceItemButton}
                      onClick={() => {
                        setServices((prev) => [
                          ...prev,
                          {
                            title: "",
                            unitPrice: 0,
                            quantity: 0,
                            discount: 0,
                            totalPrice: 0,
                          },
                        ]);
                      }}
                    >
                      Add new item
                    </Button>
                  </div>
                </div>
              </div>
              <Flex
                gap={16}
                vertical
                style={{
                  marginLeft: "auto",
                  marginTop: "24px",
                  width: "220px",
                }}
              >
                <Flex
                  justify="space-between"
                  style={{
                    paddingLeft: 32,
                  }}
                >
                  <Typography.Text className={styles.labelTotal}>
                    Subtotal:
                  </Typography.Text>
                  <NumberField
                    value={subtotal}
                    options={{ style: "currency", currency: "USD" }}
                  />
                </Flex>
                <Flex
                  align="center"
                  justify="space-between"
                  style={{
                    paddingLeft: 32,
                  }}
                >
                  <Typography.Text className={styles.labelTotal}>
                    Sales tax:
                  </Typography.Text>
                  <InputNumber
                    addonAfter="%"
                    style={{ width: "96px" }}
                    value={tax}
                    min={0}
                    onChange={(value) => {
                      setTax(value || 0);
                    }}
                  />
                </Flex>
                <Divider
                  style={{
                    margin: "0",
                  }}
                />
                <Flex
                  justify="space-between"
                  style={{
                    paddingLeft: 16,
                  }}
                >
                  <Typography.Text
                    className={styles.labelTotal}
                    style={{
                      fontWeight: 700,
                    }}
                  >
                    Total value:
                  </Typography.Text>
                  <NumberField
                    value={total}
                    options={{ style: "currency", currency: "USD" }}
                  />
                </Flex>
              </Flex>
            </div>
            <Divider style={{ margin: 0 }} />
            <Flex justify="end" gap={8} style={{ padding: "32px" }}>
              <Button>Cancel</Button>
              <Button type="primary" htmlType="submit">
                Save
              </Button>
            </Flex>
          </Card>
        </Flex>
      </Form>
    </Show>
  );
};


You have created a form to create a new invoice. The form includes the account and client fields, and a table to add service items. The user can add multiple service items to the invoice. The total value of the invoice is calculated based on the subtotal and sales tax.

Refine useSelect hook is used to fetch the accounts and clients from the API and populate and manage to <Select /> components to add the account and client relation to the invoice.

After creating the create page, let’s create a src/pages/invoices/index.ts file to export the pages as follows:

src/pages/invoices/index.ts
export { InvoicePageList } from "./list";
export { InvoicesPageCreate } from "./create";

Now you are ready to add our “create” page to the src/App.tsx file as follows:

Show src/App.tsx code
src/App.tsx
// ...
import { InvoicePageList, InvoicesPageCreate } from "@/pages/invoices";

const App: React.FC = () => {
  return (
    //...
    <Refine
    //...
    >
      <Routes>
        <Route
        //...
        >
          {/* ... */}

          <Route
            path="/clients"
            element={
              <ClientsPageList>
                <Outlet />
              </ClientsPageList>
            }
          >
            <Route index element={null} />
            <Route path="new" element={<ClientsPageCreate />} />
          </Route>
          <Route path="/clients/:id/edit" element={<ClientsPageEdit />} />

          <Route path="/invoices">
            <Route index element={<InvoicePageList />} />
            <Route path="new" element={<InvoicesPageCreate />} />
          </Route>
        </Route>

        <Route
          element={
            <Authenticated key="auth-pages" fallback={<Outlet />}>
              <NavigateToResource />
            </Authenticated>
          }
        >
          {/* ... */}
        </Route>

        {/* ... */}
      </Routes>
      {/* ... */}
    </Refine>
    //...
  );
};

export default App;


After these changes, you should be able to navigate to the invoice create pages as the below:

Invoices Create page

Show Page

The show page includes the invoice’s details, such as the account, client, and services.

Let’s create the src/pages/invoices/show.styled.tsx file with the following code:

Show src/pages/invoices/show.styled.tsx code
src/pages/invoices/show.styled.tsx
import { createStyles } from "antd-style";

export const useStyles = createStyles(({ token }) => {
  return {
    container: {
      ".ant-card-body": {
        padding: "0",
      },

      ".ant-card-head": {
        padding: "32px",
        background: token.colorBgContainer,
      },

      "@media print": {
        margin: "0 auto",
        minHeight: "100dvh",
        maxWidth: "892px",

        ".ant-card": {
          boxShadow: "none",
          border: "none",
        },

        ".ant-card-head": {
          padding: "0 !important",
        },

        ".ant-col": {
          maxWidth: "50% !important",
          flex: "0 0 50% !important",
        },

        table: {
          width: "unset !important",
        },

        ".ant-table-container::after": {
          content: "none",
        },
        ".ant-table-container::before": {
          content: "none",
        },
      },
    },
    fromToContainer: {
      minHeight: "192px",
      padding: "32px",

      "@media print": {
        flexWrap: "nowrap",
        flexFlow: "row nowrap",
        minHeight: "unset",
        padding: "32px 0",
      },
    },
    productServiceContainer: {
      padding: "32px",

      "@media print": {
        padding: "0",
        marginTop: "32px",
      },
    },
    labelTotal: {
      color: token.colorTextSecondary,
    },
  };
});

Let’s create the src/pages/invoices/show.tsx file with the following code:

Show <InvoicesPageShow /> code
src/pages/invoices/show.tsx
import { useShow } from "@refinedev/core";
import { FilePdfOutlined } from "@ant-design/icons";
import {
  Button,
  Avatar,
  Card,
  Col,
  Divider,
  Flex,
  Row,
  Skeleton,
  Spin,
  Table,
  Typography,
} from "antd";
import { DateField, NumberField, Show } from "@refinedev/antd";
import { API_URL } from "@/utils/constants";
import { getRandomColorFromString } from "@/utils/get-random-color";
import type { Invoice, Service } from "@/types";
import { useStyles } from "./show.styled";

export const InvoicesPageShow = () => {
  const { styles } = useStyles();

  const { queryResult } = useShow<Invoice>({
    meta: {
      populate: ["client", "account.logo"],
    },
  });

  const invoice = queryResult?.data?.data;
  const loading = queryResult?.isLoading;
  const logoUrl = invoice?.account?.logo?.url
    ? `${API_URL}${invoice?.account?.logo?.url}`
    : undefined;

  return (
    <Show
      title="Invoices"
      headerButtons={() => (
        <>
          <Button
            disabled={!invoice}
            icon={<FilePdfOutlined />}
            onClick={() => window.print()}
          >
            Export PDF
          </Button>
        </>
      )}
      contentProps={{
        styles: {
          body: {
            padding: 0,
          },
        },
        style: {
          background: "transparent",
        },
      }}
    >
      <div id="invoice-pdf" className={styles.container}>
        <Card
          bordered={false}
          title={
            <Typography.Text
              style={{
                fontWeight: 400,
              }}
            >
              {loading ? (
                <Skeleton.Button style={{ width: 100, height: 22 }} />
              ) : (
                `Invoice ID #${invoice?.id}`
              )}
            </Typography.Text>
          }
          extra={
            <Flex gap={8} align="center">
              {loading ? (
                <Skeleton.Button style={{ width: 140, height: 22 }} />
              ) : (
                <>
                  <Typography.Text>Date:</Typography.Text>
                  <DateField
                    style={{ width: 84 }}
                    value={invoice?.date}
                    format="D MMM YYYY"
                  />
                </>
              )}
            </Flex>
          }
        >
          <Spin spinning={loading}>
            <Row className={styles.fromToContainer}>
              <Col xs={24} md={12}>
                <Flex vertical gap={24}>
                  <Typography.Text>From:</Typography.Text>
                  <Flex gap={24}>
                    <Avatar
                      alt={invoice?.account?.company_name}
                      size={64}
                      src={logoUrl}
                      shape="square"
                      style={{
                        backgroundColor: logoUrl
                          ? "transparent"
                          : getRandomColorFromString(
                              invoice?.account?.company_name || "",
                            ),
                      }}
                    >
                      <Typography.Text>
                        {invoice?.account?.company_name?.[0]?.toUpperCase()}
                      </Typography.Text>
                    </Avatar>
                    <Flex vertical gap={8}>
                      <Typography.Text
                        style={{
                          fontWeight: 700,
                        }}
                      >
                        {invoice?.account?.company_name}
                      </Typography.Text>
                      <Typography.Text>
                        {invoice?.account?.address}
                      </Typography.Text>
                      <Typography.Text>
                        {invoice?.account?.phone}
                      </Typography.Text>
                    </Flex>
                  </Flex>
                </Flex>
              </Col>
              <Col xs={24} md={12}>
                <Flex vertical gap={24} align="flex-end">
                  <Typography.Text>To:</Typography.Text>
                  <Flex vertical gap={8} align="flex-end">
                    <Typography.Text
                      style={{
                        fontWeight: 700,
                      }}
                    >
                      {invoice?.client?.name}
                    </Typography.Text>
                    <Typography.Text>
                      {invoice?.client?.address}
                    </Typography.Text>
                    <Typography.Text>{invoice?.client?.phone}</Typography.Text>
                  </Flex>
                </Flex>
              </Col>
            </Row>
          </Spin>

          <Divider
            style={{
              margin: 0,
            }}
          />
          <Flex vertical gap={24} className={styles.productServiceContainer}>
            <Typography.Title
              level={4}
              style={{
                margin: 0,
                fontWeight: 400,
              }}
            >
              Product / Services
            </Typography.Title>
            <Table
              dataSource={invoice?.services || []}
              rowKey={"id"}
              pagination={false}
              loading={loading}
              scroll={{ x: 960 }}
            >
              <Table.Column title="Title" dataIndex="title" key="title" />
              <Table.Column
                title="Unit Price"
                dataIndex="unitPrice"
                key="unitPrice"
                render={(unitPrice: number) => (
                  <NumberField
                    value={unitPrice}
                    options={{ style: "currency", currency: "USD" }}
                  />
                )}
              />
              <Table.Column
                title="Quantity"
                dataIndex="quantity"
                key="quantity"
              />
              <Table.Column
                title="Discount"
                dataIndex="discount"
                key="discount"
                render={(discount: number) => (
                  <Typography.Text>{`${discount}%`}</Typography.Text>
                )}
              />

              <Table.Column
                title="Total Price"
                dataIndex="total"
                key="total"
                align="right"
                width={128}
                render={(_, record: Service) => {
                  return (
                    <NumberField
                      value={record.totalPrice}
                      options={{ style: "currency", currency: "USD" }}
                    />
                  );
                }}
              />
            </Table>
            <Flex
              gap={16}
              vertical
              style={{
                marginLeft: "auto",
                marginTop: "24px",
                width: "200px",
              }}
            >
              <Flex
                justify="space-between"
                style={{
                  paddingLeft: 32,
                }}
              >
                <Typography.Text className={styles.labelTotal}>
                  Subtotal:
                </Typography.Text>
                <NumberField
                  value={invoice?.subTotal || 0}
                  options={{ style: "currency", currency: "USD" }}
                />
              </Flex>
              <Flex
                justify="space-between"
                style={{
                  paddingLeft: 32,
                }}
              >
                <Typography.Text className={styles.labelTotal}>
                  Sales tax:
                </Typography.Text>
                <Typography.Text>{invoice?.tax || 0}%</Typography.Text>
              </Flex>
              <Divider
                style={{
                  margin: "0",
                }}
              />
              <Flex
                justify="space-between"
                style={{
                  paddingLeft: 16,
                }}
              >
                <Typography.Text
                  className={styles.labelTotal}
                  style={{
                    fontWeight: 700,
                  }}
                >
                  Total value:
                </Typography.Text>
                <NumberField
                  value={invoice?.total || 0}
                  options={{ style: "currency", currency: "USD" }}
                />
              </Flex>
            </Flex>
          </Flex>
        </Card>
      </div>
    </Show>
  );
};

Exporting Invoice as PDF

You’ve created a display page for viewing invoice details, which includes information on accounts, clients, service items, and the total invoice amount. Users can convert the invoice to a PDF by clicking the “Export PDF” button.

For this, you utilized the browser’s native window.print API to avoid the need for a third-party library, enhancing efficiency and reducing complexity. However, print dialog is printing all the content of the page. To ensure that only the relevant invoice information is printed, you applied @media print CSS rules with display: none to hide unnecessary page content during the printing process.

After creating the show page, let’s create a src/pages/invoices/index.ts file to export the pages as follows:

src/pages/invoices/index.ts
export { InvoicePageList } from "./list";
export { InvoicesPageCreate } from "./create";
export { InvoicesPageShow } from "./show";

Now you are ready to add our “show” page to the src/App.tsx file as follows:

[details Show src/App.tsx code

src/App.tsx
import { Authenticated, Refine } from "@refinedev/core";
import {
  AuthPage,
  ErrorComponent,
  ThemedLayoutV2,
  useNotificationProvider,
} from "@refinedev/antd";
import routerProvider, {
  UnsavedChangesNotifier,
  DocumentTitleHandler,
  NavigateToResource,
  CatchAllNavigate,
} from "@refinedev/react-router-v6";
import { BrowserRouter, Outlet, Route, Routes } from "react-router-dom";
import { DevtoolsPanel, DevtoolsProvider } from "@refinedev/devtools";
import { App as AntdApp } from "antd";
import { dataProvider } from "@/providers/data-provider";
import { authProvider } from "@/providers/auth-provider";
import { ConfigProvider } from "@/providers/config-provider";
import { Logo } from "@/components/logo";
import { Header } from "@/components/header";
import {
  AccountsPageCreate,
  AccountsPageEdit,
  AccountsPageList,
} from "@/pages/accounts";
import {
  ClientsPageCreate,
  ClientsPageEdit,
  ClientsPageList,
} from "@/pages/clients";
import {
  InvoicePageList,
  InvoicesPageCreate,
  InvoicesPageShow,
} from "@/pages/invoices";
import "@refinedev/antd/dist/reset.css";
import "./styles/custom.css";

const App: React.FC = () => {
  return (
    <DevtoolsProvider>
      <BrowserRouter>
        <ConfigProvider>
          <AntdApp>
            <Refine
              routerProvider={routerProvider}
              authProvider={authProvider}
              dataProvider={dataProvider}
              notificationProvider={useNotificationProvider}
              resources={[
                {
                  name: "accounts",
                  list: "/accounts",
                  create: "/accounts/new",
                  edit: "/accounts/:id/edit",
                },
                {
                  name: "clients",
                  list: "/clients",
                  create: "/clients/new",
                  edit: "/clients/:id/edit",
                },
                {
                  name: "invoices",
                  list: "/invoices",
                  show: "/invoices/:id",
                  create: "/invoices/new",
                },
              ]}
              options={{
                syncWithLocation: true,
                warnWhenUnsavedChanges: true,
                breadcrumb: false,
              }}
            >
              <Routes>
                <Route
                  element={
                    <Authenticated
                      key="authenticated-routes"
                      fallback={<CatchAllNavigate to="/login" />}
                    >
                      <ThemedLayoutV2
                        Header={() => <Header />}
                        Sider={() => null}
                      >
                        <div
                          style={{
                            maxWidth: "1280px",
                            padding: "24px",
                            margin: "0 auto",
                          }}
                        >
                          <Outlet />
                        </div>
                      </ThemedLayoutV2>
                    </Authenticated>
                  }
                >
                  <Route index element={<NavigateToResource />} />

                  <Route
                    path="/accounts"
                    element={
                      <AccountsPageList>
                        <Outlet />
                      </AccountsPageList>
                    }
                  >
                    <Route index element={null} />
                    <Route path="new" element={<AccountsPageCreate />} />
                  </Route>
                  <Route
                    path="/accounts/:id/edit"
                    element={<AccountsPageEdit />}
                  />

                  <Route
                    path="/clients"
                    element={
                      <ClientsPageList>
                        <Outlet />
                      </ClientsPageList>
                    }
                  >
                    <Route index element={null} />
                    <Route path="new" element={<ClientsPageCreate />} />
                  </Route>
                  <Route
                    path="/clients/:id/edit"
                    element={<ClientsPageEdit />}
                  />

                  <Route path="/invoices">
                    <Route index element={<InvoicePageList />} />
                    <Route path="new" element={<InvoicesPageCreate />} />
                    <Route path=":id" element={<InvoicesPageShow />} />
                  </Route>
                </Route>

                <Route
                  element={
                    <Authenticated key="auth-pages" fallback={<Outlet />}>
                      <NavigateToResource />
                    </Authenticated>
                  }
                >
                  <Route
                    path="/login"
                    element={
                      <AuthPage
                        type="login"
                        registerLink={false}
                        forgotPasswordLink={false}
                        title={
                          <Logo
                            titleProps={{ level: 2 }}
                            svgProps={{
                              width: "48px",
                              height: "40px",
                            }}
                          />
                        }
                        formProps={{
                          initialValues: {
                            email: "demo@refine.dev",
                            password: "demodemo",
                          },
                        }}
                      />
                    }
                  />
                </Route>

                <Route
                  element={
                    <Authenticated key="catch-all">
                      <ThemedLayoutV2
                        Header={() => <Header />}
                        Sider={() => null}
                      >
                        <Outlet />
                      </ThemedLayoutV2>
                    </Authenticated>
                  }
                >
                  <Route path="*" element={<ErrorComponent />} />
                </Route>
              </Routes>
              <UnsavedChangesNotifier />
              <DocumentTitleHandler />
            </Refine>
          </AntdApp>
        </ConfigProvider>
        <DevtoolsPanel />
      </BrowserRouter>
    </DevtoolsProvider>
  );
};

export default App;

After these changes, you should be able to navigate to the invoice show page when you click on a show button from the list page.

Invoices Show page

When you click the “Export PDF” button, the browser’s print dialog will open, allowing you to save the invoice as a PDF.

Invoices Show page

Step 7 — Deploying to tDigitalOcean App platform

In this step, you’ll deploy the application to the DigitalOcean App Platform. To do that, you’ll host the source code on GitHub and connect the GitHub repository to the App Platform.

Pushing the Code to GitHub

Log in to your GitHub account and create a new repository named refine-invoicer. You can make the repository public or private:

Create an new repository

After creating the repository, navigate to the project directory and run the following command to initialize a new Git repository:

git init

Next, add all the files to the Git repository with this command:

git add .

Then, commit the files with this command:

git commit -m "Initial commit"

Next, add the GitHub repository as a remote repository with this command:

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

Next, specify that you want to push your code to the main branch with this command:

git branch -M main

Finally, push the code to the GitHub repository with this command:

git push -u origin main

When prompted, enter your GitHub credentials to push your code.

You’ll receive a success message after the code is pushed to the GitHub repository.

In this section, you pushed your project to GitHub so that you can access it using DigitalOcean Apps. The next step is to create a new DigitalOcean App using your project and set up automatic deployment.

Deploying to DigitalOcean App Platform

In this step, you’ll take your React application and set it up on the DigitalOcean App Platform. You’ll link your GitHub repository to DigitalOcean, set up the building process, and create your initial project deployment. Once your project is live, any future changes you make will automatically trigger a new build and update.

By the end of this step, you’ll have successfully deployed your application on DigitalOcean with continuous delivery capabilities.

Log in to your DigitalOcean account and navigate to the Apps page. Click the Create App button:

Digital Ocean create a new app

If you haven’t connected your GitHub account to DigitalOcean, you’ll be prompted to do so. Click the Connect to GitHub button. A new window will open, asking you to authorize DigitalOcean to access your GitHub account.

After you authorize DigitalOcean, you’ll be redirected back to the DigitalOcean AppsAlican Erdurmaz - Software Engineer page. The next step is to select your GitHub repository. After you select your repository, you’ll be prompted to select a branch to deploy. Select the main branch and click the Next button.

DigitalOcean select repository

After that, you’ll see the configuration steps for your application. In this tutorial, you can click the Next button to skip the configuration steps. However, you can also configure your application as you wish.

Wait for the build to complete. After the build is complete, press Live App to access your project in the browser. It will be the same as the project you tested locally, but this will be live on the web with a secure URL. Also, you can follow this tutorial available on DigitalOcean community site to learn how to deploy react based applications to App Platform.

Conclusion

In this tutorial, you built complete PDF Invoice generator application using Refine from scratch and got familiar with how to build a fully-functional CRUD app and deploy it on DigitalOcean App Platform.

If you want to learn more about Refine, you can check out the documentation and if you have any questions or feedback, you can join the Refine Discord Server.

Thanks for learning with the DigitalOcean Community. Check out our offerings for compute, storage, networking, and managed databases.

Learn more about our products

About the authors
Default avatar
Alican Erdurmaz

author


Default avatar

Sr Technical Writer

Senior Technical Writer @ DigitalOcean | 2x Medium Top Writers | 2 Million+ monthly views & 34K Subscribers | Ex Cloud Consultant @ AMEX | Ex SRE(DevOps) @ NUTANIX


Still looking for an answer?

Ask a questionSearch for more help

Was this helpful?
 
Leave a comment


This textbox defaults to using Markdown to format your answer.

You can type !ref in this text area to quickly search our full set of tutorials, documentation & marketplace offerings and insert the link!

Try DigitalOcean for free

Click below to sign up and get $200 of credit to try our products over 60 days!

Sign up

Join the Tech Talk
Success! Thank you! Please check your email for further details.

Please complete your information!

Become a contributor for community

Get paid to write technical tutorials and select a tech-focused charity to receive a matching donation.

DigitalOcean Documentation

Full documentation for every DigitalOcean product.

Resources for startups and SMBs

The Wave has everything you need to know about building a business, from raising funding to marketing your product.

Get our newsletter

Stay up to date by signing up for DigitalOcean’s Infrastructure as a Newsletter.

New accounts only. By submitting your email you agree to our Privacy Policy

The developer cloud

Scale up as you grow — whether you're running one virtual machine or ten thousand.

Get started for free

Sign up and get $200 in credit for your first 60 days with DigitalOcean.*

*This promotional offer applies to new accounts only.