Introduction

In this complete tutorial, you will build a React-based PDF invoice generator application with Refine Framework and deploy it to the cloud provider'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 the cloud provider'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.

[slideshow images/building-a-react-pdf-invoice-generator-app-with-refine-and-deploying-to-cloud-provider-s-app-platform-section-1.png images/building-a-react-pdf-invoice-generator-app-with-refine-and-deploying-to-cloud-provider-s-app-platform-section-1.png images/building-a-react-pdf-invoice-generator-app-with-refine-and-deploying-to-cloud-provider-s-app-platform-section-1.png images/building-a-react-pdf-invoice-generator-app-with-refine-and-deploying-to-cloud-provider-s-app-platform-section-1.png images/building-a-react-pdf-invoice-generator-app-with-refine-and-deploying-to-cloud-provider-s-app-platform-section-1.png images/building-a-react-pdf-invoice-generator-app-with-refine-and-deploying-to-cloud-provider-s-app-platform-section-1.png images/building-a-react-pdf-invoice-generator-app-with-refine-and-deploying-to-cloud-provider-s-app-platform-section-1.png images/building-a-react-pdf-invoice-generator-app-with-refine-and-deploying-to-cloud-provider-s-app-platform-section-1.png images/building-a-react-pdf-invoice-generator-app-with-refine-and-deploying-to-cloud-provider-s-app-platform-section-1.png images/building-a-react-pdf-invoice-generator-app-with-refine-and-deploying-to-cloud-provider-s-app-platform-section-1.png images/building-a-react-pdf-invoice-generator-app-with-refine-and-deploying-to-cloud-provider-s-app-platform-section-1.png 320 480]

Prerequisites

provider illustration for: 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.

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.

[details 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:

  • components: https://github.com/refinedev/refine/tree/master/examples/invoicer/src/components
  • providers: https://github.com/refinedev/refine/tree/master/examples/invoicer/src/providers
  • utils: https://github.com/refinedev/refine/tree/master/examples/invoicer/src/utils
  • types: https://github.com/refinedev/refine/tree/master/examples/invoicer/src/types
  • styles: https://github.com/refinedev/refine/tree/master/examples/invoicer/src/styles

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:

[details Show src/providers/auth-provider/index.ts code

				
					[label 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 }) =&gt; {
 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 () =&gt; {
 localStorage.removeItem(TOKEN_KEY);
 return {
 success: true,
 redirectTo: "/login",
 };
 },
 onError: async (error) =&gt; {
 if (error.response?.status === 401) {
 return {
 logout: true,
 };
 }

 return { error };
 },
 check: async () =&gt; {
 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 () =&gt; {
 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:

[details Show App.tsx code

				
					&lt;^&gt;import { Authenticated, Refine } from "@refinedev/core";&lt;^&gt;
import { &lt;^&gt;ErrorComponent&lt;^&gt;, useNotificationProvider } from "@refinedev/antd";
import routerProvider, {
 UnsavedChangesNotifier,
 DocumentTitleHandler,
 &lt;^&gt;NavigateToResource,&lt;^&gt;
} from "@refinedev/react-router-v6";
&lt;^&gt;import { BrowserRouter, Outlet, Route, Routes } from "react-router-dom";&lt;^&gt;
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 = () =&gt; {
 return (
 &lt;DevtoolsProvider&gt;
 &lt;BrowserRouter&gt;
 &lt;ConfigProvider&gt;
 &lt;AntdApp&gt;
 &lt;Refine
 routerProvider={routerProvider}
 authProvider={authProvider}
 dataProvider={dataProvider}
 notificationProvider={useNotificationProvider}
 options={{
 syncWithLocation: true,
 warnWhenUnsavedChanges: true,
 breadcrumb: false,
 }}
 &gt;
 &lt;^&gt;&lt;Routes&gt;&lt;^&gt;
 &lt;^&gt;&lt;Route&lt;^&gt;
 &lt;^&gt;element={&lt;^&gt;
 &lt;^&gt;&lt;Authenticated&lt;^&gt;
 &lt;^&gt;key="authenticated-routes"&lt;^&gt;
 &lt;^&gt;redirectOnFail="/login"&lt;^&gt;
 &lt;^&gt;&gt;&lt;^&gt;
 &lt;^&gt;&lt;Outlet /&gt;&lt;^&gt;
 &lt;^&gt;&lt;/Authenticated&gt;&lt;^&gt;
 }
 &gt;
 &lt;^&gt;&lt;Route path="/" element={&lt;div&gt;Home page&lt;/div&gt;} /&gt;&lt;^&gt;
 &lt;^&gt;&lt;/Route&gt;&lt;^&gt;

 &lt;^&gt;&lt;Route&lt;^&gt;
 &lt;^&gt;element={&lt;^&gt;
 &lt;^&gt;&lt;Authenticated key="auth-pages" fallback={&lt;Outlet /&gt;}&gt;&lt;^&gt;
 &lt;^&gt;&lt;NavigateToResource /&gt;&lt;^&gt;
 &lt;^&gt;&lt;/Authenticated&gt;&lt;^&gt;
 &lt;^&gt;}&lt;^&gt;
 &lt;^&gt;&gt;&lt;^&gt;
 &lt;^&gt;&lt;Route path="/login" element={&lt;div&gt;Login page&lt;/div&gt;} /&gt;&lt;^&gt;
 &lt;^&gt;&lt;/Route&gt;&lt;^&gt;

 &lt;^&gt;&lt;Route&lt;^&gt;
 &lt;^&gt;element={&lt;^&gt;
 &lt;^&gt;&lt;Authenticated key='catch-all'&gt;&lt;^&gt;
 &lt;^&gt;&lt;Outlet /&gt;&lt;^&gt;
 &lt;^&gt;&lt;/Authenticated&gt;&lt;^&gt;
 &lt;^&gt;}&gt;&lt;^&gt;
 &lt;^&gt;&lt;Route path='*' element={&lt;ErrorComponent /&gt;} /&gt;&lt;^&gt;
 &lt;^&gt;&lt;/Route&gt;&lt;^&gt;

 &lt;^&gt;&lt;/Routes&gt;&lt;^&gt;
 &lt;UnsavedChangesNotifier /&gt;
 &lt;DocumentTitleHandler /&gt;
 &lt;/Refine&gt;
 &lt;/AntdApp&gt;
 &lt;/ConfigProvider&gt;
 &lt;DevtoolsPanel /&gt;
 &lt;/BrowserRouter&gt;
 &lt;/DevtoolsProvider&gt;
 );
};

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:

[details Show App.tsx code

				
					import { Authenticated, Refine } from "@refinedev/core";
import {
 &lt;^&gt;AuthPage,&lt;^&gt;
 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";
&lt;^&gt;import { Logo } from "@/components/logo";&lt;^&gt;
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 = () =&gt; {
 return (
 &lt;DevtoolsProvider&gt;
 &lt;BrowserRouter&gt;
 &lt;ConfigProvider&gt;
 &lt;AntdApp&gt;
 &lt;Refine
 routerProvider={routerProvider}
 authProvider={authProvider}
 dataProvider={dataProvider}
 notificationProvider={useNotificationProvider}
 options={{
 syncWithLocation: true,
 warnWhenUnsavedChanges: true,
 breadcrumb: false,
 }}
 &gt;
 &lt;Routes&gt;
 &lt;Route
 element={
 &lt;Authenticated
 key="authenticated-routes"
 redirectOnFail="/login"
 &gt;
 &lt;Outlet /&gt;
 &lt;/Authenticated&gt;
 }
 &gt;
 &lt;Route path="/" element={&lt;div&gt;Home page&lt;/div&gt;} /&gt;
 &lt;/Route&gt;

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

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

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:

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.

[details 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";
&lt;^&gt;import { Header } from "@/components/header";&lt;^&gt;
import "@refinedev/antd/dist/reset.css";
import "./styles/custom.css";

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

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

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

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:

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.

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

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

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

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:

[details Show <AccountsPageList /> component

				
					[label 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) =&gt; {
 const go = useGo()

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

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

				
			

]

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.

[details Show App.tsx code

				
					[label src/App.tsx]
import { Authenticated, Refine } from "@refinedev/core";
import {
 AuthPage,
 ErrorComponent,
 ThemedLayoutV2,
 useNotificationProvider,
} from "@refinedev/antd";
import routerProvider, {
 UnsavedChangesNotifier,
 DocumentTitleHandler,
 &lt;^&gt;NavigateToResource,&lt;^&gt;
 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";
&lt;^&gt;import { AccountsPageList } from "@/pages/accounts";&lt;^&gt;
import "@refinedev/antd/dist/reset.css";
import "./styles/custom.css";

const App: React.FC = () =&gt; {
 return (
 &lt;DevtoolsProvider&gt;
 &lt;BrowserRouter&gt;
 &lt;ConfigProvider&gt;
 &lt;AntdApp&gt;
 &lt;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,
 }}
 &gt;
 &lt;Routes&gt;
 &lt;Route
 element={
 &lt;Authenticated
 key="authenticated-routes"
 fallback={&lt;CatchAllNavigate to="/login" /&gt;}
 &gt;
 &lt;ThemedLayoutV2
 Header={() =&gt; &lt;Header /&gt;}
 Sider={() =&gt; null}
 &gt;
 &lt;div
 style={{
 maxWidth: "1280px",
 padding: "24px",
 margin: "0 auto",
 }}
 &gt;
 &lt;Outlet /&gt;
 &lt;/div&gt;
 &lt;/ThemedLayoutV2&gt;
 &lt;/Authenticated&gt;
 }
 &gt;
 &lt;^&gt;&lt;Route index element={&lt;NavigateToResource /&gt;} /&gt;&lt;^&gt;
 &lt;^&gt;&lt;Route&lt;^&gt;
 &lt;^&gt;path="/accounts"&lt;^&gt;
 &lt;^&gt;element={&lt;^&gt;
 &lt;^&gt;&lt;AccountsPageList&gt;&lt;^&gt;
 &lt;^&gt;&lt;Outlet /&gt;&lt;^&gt;
 &lt;^&gt;&lt;/AccountsPageList&gt;&lt;^&gt;
 &lt;^&gt;}&lt;^&gt;
 &lt;^&gt;&gt;&lt;^&gt;
 &lt;^&gt;&lt;Route index element={null} /&gt;&lt;^&gt;
 &lt;^&gt;&lt;/Route&gt;&lt;^&gt;
 &lt;^&gt;&lt;/Route&gt;&lt;^&gt;

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

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

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

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:

[details Show <AccountsPageCreate /> component

				
					[label 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 = () =&gt; {
 const go = useGo();

 const { formProps } = useForm&lt;Account, HttpError, AccountForm&gt;();

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

]

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:

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.

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

				
					[label src/pages/accounts/index.tsx]
export { AccountsPageList } from "./list";
&lt;^&gt;export { AccountsPageCreate } from "./create";&lt;^&gt;
				
			

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

[details Show App.tsx code

				
					[label src/App.tsx]
//...
&lt;^&gt;import { AccountsPageCreate, AccountsPageList } from '@/pages/accounts'&lt;^&gt;

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

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.

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:

[details Show <AccountsPageEdit /> component

				
					[label 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 = () =&gt; {
 const { list } = useNavigation();

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

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

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

]

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:

				
					[label src/pages/accounts/index.tsx]
export { AccountsPageList } from "./list";
export { AccountsPageCreate } from "./create";
&lt;^&gt;export { AccountsPageEdit } from "./edit";&lt;^&gt;
				
			

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

[details Show App.tsx code

				
					[label src/App.tsx]
import {
 AccountsPageCreate,
 &lt;^&gt;AccountsPageEdit,&lt;^&gt;
 AccountsPageList,
} from "@/pages/accounts";
//...

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

 {/*...*/}
 &lt;/Routes&gt;
 {/*...*/}
 &lt;/Refine&gt;
 //...
 );
};

export default App;

				
			

]

After clicking the "Edit" button with the eye icon on the list page, you should see the 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:

[details Show src/App.tsx code

				
					[label src/App.tsx]
// ...

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

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:

[details Show <ClientsPageList /> code

				
					[label 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) =&gt; {
 const go = useGo();

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

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

]

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

[details Show <ClientsPageCreate /> code

				
					[label 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 = () =&gt; {
 const go = useGo();

 const { formProps } = useForm();

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

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

				
			

]

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

[details Show <ClientsPageEdit /> code

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

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


				
			

]

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

				
					[label 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:

[details Show src/App.tsx code

				
					[label 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 = () =&gt; {
 return (
 &lt;DevtoolsProvider&gt;
 &lt;BrowserRouter&gt;
 &lt;ConfigProvider&gt;
 &lt;AntdApp&gt;
 &lt;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,
 }}
 &gt;
 &lt;Routes&gt;
 &lt;Route
 element={
 &lt;Authenticated
 key="authenticated-routes"
 fallback={&lt;CatchAllNavigate to="/login" /&gt;}
 &gt;
 &lt;ThemedLayoutV2
 Header={() =&gt; &lt;Header /&gt;}
 Sider={() =&gt; null}
 &gt;
 &lt;div
 style={{
 maxWidth: "1280px",
 padding: "24px",
 margin: "0 auto",
 }}
 &gt;
 &lt;Outlet /&gt;
 &lt;/div&gt;
 &lt;/ThemedLayoutV2&gt;
 &lt;/Authenticated&gt;
 }
 &gt;
 &lt;Route index element={&lt;NavigateToResource /&gt;} /&gt;

 &lt;Route
 path="/accounts"
 element={
 &lt;AccountsPageList&gt;
 &lt;Outlet /&gt;
 &lt;/AccountsPageList&gt;
 }
 &gt;
 &lt;Route index element={null} /&gt;
 &lt;Route path="new" element={&lt;AccountsPageCreate /&gt;} /&gt;
 &lt;/Route&gt;
 &lt;Route
 path="/accounts/:id/edit"
 element={&lt;AccountsPageEdit /&gt;}
 /&gt;

 &lt;&lt;^&gt;Route&lt;^&gt;
 &lt;^&gt;path="/clients"&lt;^&gt;
 &lt;^&gt;element={&lt;^&gt;
 &lt;^&gt;&lt;ClientsPageList&gt;&lt;^&gt;
 &lt;^&gt;&lt;Outlet /&gt;&lt;^&gt;
 &lt;^&gt;&lt;/ClientsPageList&gt;&lt;^&gt;
 &lt;^&gt;}&lt;^&gt;
 &lt;^&gt;&gt;&lt;^&gt;
 &lt;^&gt;&lt;Route index element={null} /&gt;&lt;^&gt;
 &lt;^&gt;&lt;Route path="new" element={&lt;ClientsPageCreate /&gt;} /&gt;&lt;^&gt;
 &lt;^&gt;&lt;/Route&gt;&lt;^&gt;
 &lt;^&gt;&lt;Route&lt;^&gt;
 &lt;^&gt;path="/clients/:id/edit"&lt;^&gt;
 &lt;^&gt;element={&lt;ClientsPageEdit /&gt;}&lt;^&gt;
 &lt;^&gt;/&gt;&lt;^&gt;
 &lt;^&gt;&lt;/Route&gt;&lt;^&gt;

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

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

export default App;
				
			

]

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

[slideshow images/building-a-react-pdf-invoice-generator-app-with-refine-and-deploying-to-cloud-provider-s-app-platform-section-1.png images/building-a-react-pdf-invoice-generator-app-with-refine-and-deploying-to-cloud-provider-s-app-platform-section-1.png images/building-a-react-pdf-invoice-generator-app-with-refine-and-deploying-to-cloud-provider-s-app-platform-section-1.png 320 480]

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:

[details Show src/App.tsx code

				
					[label 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 = () =&gt; {
 return (
 &lt;DevtoolsProvider&gt;
 &lt;BrowserRouter&gt;
 &lt;ConfigProvider&gt;
 &lt;AntdApp&gt;
 &lt;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",
 },
 &lt;^&gt;{&lt;^&gt;
 &lt;^&gt;name: "invoices",&lt;^&gt;
 &lt;^&gt;list: "/invoices",&lt;^&gt;
 &lt;^&gt;show: "/invoices/:id",&lt;^&gt;
 &lt;^&gt;create: "/invoices/new",&lt;^&gt;
 &lt;^&gt;},&lt;^&gt;
 ]}
 options={{
 syncWithLocation: true,
 warnWhenUnsavedChanges: true,
 breadcrumb: false,
 }}&gt;
 &lt;Routes&gt;
 &lt;Route
 element={
 &lt;Authenticated key="authenticated-routes" fallback={&lt;CatchAllNavigate to="/login" /&gt;}&gt;
 &lt;ThemedLayoutV2 Header={() =&gt; &lt;Header /&gt;} Sider={() =&gt; null}&gt;
 &lt;div
 style={{
 maxWidth: "1280px",
 padding: "24px",
 margin: "0 auto",
 }}&gt;
 &lt;Outlet /&gt;
 &lt;/div&gt;
 &lt;/ThemedLayoutV2&gt;
 &lt;/Authenticated&gt;
 }&gt;
 &lt;Route index element={&lt;NavigateToResource /&gt;} /&gt;

 &lt;Route
 path="/accounts"
 element={
 &lt;AccountsPageList&gt;
 &lt;Outlet /&gt;
 &lt;/AccountsPageList&gt;
 }&gt;
 &lt;Route index element={null} /&gt;
 &lt;Route path="new" element={&lt;AccountsPageCreate /&gt;} /&gt;
 &lt;/Route&gt;
 &lt;Route path="/accounts/:id/edit" element={&lt;AccountsPageEdit /&gt;} /&gt;

 &lt;Route
 path="/clients"
 element={
 &lt;ClientsPageList&gt;
 &lt;Outlet /&gt;
 &lt;/ClientsPageList&gt;
 }&gt;
 &lt;Route index element={null} /&gt;
 &lt;Route path="new" element={&lt;ClientsPageCreate /&gt;} /&gt;
 &lt;/Route&gt;
 &lt;Route path="/clients/:id/edit" element={&lt;ClientsPageEdit /&gt;} /&gt;
 &lt;/Route&gt;

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

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

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:

[details Show <InvoicesPageList /> code

				
					[label 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 = () =&gt; {
 const go = useGo();

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

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

				
			

]

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

				
					[label 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:

[details Show src/App.tsx code

				
					[label src/App.tsx]
// ...
&lt;^&gt;import { InvoicePageList } from "@/pages/invoices";&lt;^&gt;

const App: React.FC = () =&gt; {
 return (
 //...
 &lt;Refine
 //...
 &gt;
 &lt;Routes&gt;
 &lt;Route
 //...
 &gt;
 {/* ... */}

 &lt;Route
 path="/clients"
 element={
 &lt;ClientsPageList&gt;
 &lt;Outlet /&gt;
 &lt;/ClientsPageList&gt;
 }
 &gt;
 &lt;Route index element={null} /&gt;
 &lt;Route path="new" element={&lt;ClientsPageCreate /&gt;} /&gt;
 &lt;/Route&gt;
 &lt;Route path="/clients/:id/edit" element={&lt;ClientsPageEdit /&gt;} /&gt;

 &lt;^&gt;&lt;Route path="/invoices"&gt;&lt;^&gt;
 &lt;^&gt;&lt;Route index element={&lt;InvoicePageList /&gt;} /&gt;&lt;^&gt;
 &lt;^&gt;&lt;/Route&gt;&lt;^&gt;
 &lt;/Route&gt;

 &lt;Route
 element={
 &lt;Authenticated key="auth-pages" fallback={&lt;Outlet /&gt;}&gt;
 &lt;NavigateToResource /&gt;
 &lt;/Authenticated&gt;
 }
 &gt;
 {/* ... */}
 &lt;/Route&gt;

 {/* ... */}
 &lt;/Routes&gt;
 {/* ... */}
 &lt;/Refine&gt;
 //...
 );
};

export default App;
				
			

]

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

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:

[details Show src/pages/invoices/create.styled.tsx code

				
					[label src/pages/invoices/create.styled.tsx]
import { createStyles } from "antd-style";

export const useStyles = createStyles(({ token, isDarkMode }) =&gt; {
 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:

[details Show <InvoicesPageCreate /> code

				
					[label 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 = () =&gt; {
 const [tax, setTax] = useState&lt;number&gt;(0);
 const [services, setServices] = useState&lt;Service[]&gt;([
 {
 title: "",
 unitPrice: 0,
 quantity: 0,
 discount: 0,
 totalPrice: 0,
 },
 ]);
 const subtotal = services.reduce(
 (acc, service) =&gt;
 acc +
 (service.unitPrice * service.quantity * (100 - service.discount)) / 100,
 0,
 );
 const total = subtotal + (subtotal * tax) / 100;

 const { styles } = useStyles();

 const { formProps } = useForm&lt;Invoice&gt;();

 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,
 ) =&gt; {
 setServices((prev) =&gt; {
 const currentService = { ...prev[index] };
 currentService[key] = value;
 currentService.totalPrice =
 currentService.unitPrice *
 currentService.quantity *
 ((100 - currentService.discount) / 100);

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

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

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

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


				
			

]

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:

				
					[label src/pages/invoices/index.ts]
export { InvoicePageList } from "./list";
&lt;^&gt;export { InvoicesPageCreate } from "./create";&lt;^&gt;
				
			

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

[details Show src/App.tsx code

				
					[label src/App.tsx]
// ...
import { InvoicePageList, &lt;^&gt;InvoicesPageCreate&lt;^&gt; } from "@/pages/invoices";

const App: React.FC = () =&gt; {
 return (
 //...
 &lt;Refine
 //...
 &gt;
 &lt;Routes&gt;
 &lt;Route
 //...
 &gt;
 {/* ... */}

 &lt;Route
 path="/clients"
 element={
 &lt;ClientsPageList&gt;
 &lt;Outlet /&gt;
 &lt;/ClientsPageList&gt;
 }
 &gt;
 &lt;Route index element={null} /&gt;
 &lt;Route path="new" element={&lt;ClientsPageCreate /&gt;} /&gt;
 &lt;/Route&gt;
 &lt;Route path="/clients/:id/edit" element={&lt;ClientsPageEdit /&gt;} /&gt;

 &lt;Route path="/invoices"&gt;
 &lt;Route index element={&lt;InvoicePageList /&gt;} /&gt;
 &lt;^&gt;&lt;Route path="new" element={&lt;InvoicesPageCreate /&gt;} /&gt;&lt;^&gt;
 &lt;/Route&gt;
 &lt;/Route&gt;

 &lt;Route
 element={
 &lt;Authenticated key="auth-pages" fallback={&lt;Outlet /&gt;}&gt;
 &lt;NavigateToResource /&gt;
 &lt;/Authenticated&gt;
 }
 &gt;
 {/* ... */}
 &lt;/Route&gt;

 {/* ... */}
 &lt;/Routes&gt;
 {/* ... */}
 &lt;/Refine&gt;
 //...
 );
};

export default App;


				
			

]

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

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:

[details Show src/pages/invoices/show.styled.tsx code

				
					[label src/pages/invoices/show.styled.tsx]
import { createStyles } from "antd-style";

export const useStyles = createStyles(({ token }) =&gt; {
 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:

[details Show <InvoicesPageShow /> code

				
					[label 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 = () =&gt; {
 const { styles } = useStyles();

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

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

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

				
			

]

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:

				
					[label src/pages/invoices/index.ts]
export { InvoicePageList } from "./list";
export { InvoicesPageCreate } from "./create";
&lt;^&gt;export { InvoicesPageShow } from "./show";&lt;^&gt;
				
			

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

[details Show src/App.tsx code

				
					[label 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,
 &lt;^&gt;InvoicesPageShow,&lt;^&gt;
} from "@/pages/invoices";
import "@refinedev/antd/dist/reset.css";
import "./styles/custom.css";

const App: React.FC = () =&gt; {
 return (
 &lt;DevtoolsProvider&gt;
 &lt;BrowserRouter&gt;
 &lt;ConfigProvider&gt;
 &lt;AntdApp&gt;
 &lt;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,
 }}
 &gt;
 &lt;Routes&gt;
 &lt;Route
 element={
 &lt;Authenticated
 key="authenticated-routes"
 fallback={&lt;CatchAllNavigate to="/login" /&gt;}
 &gt;
 &lt;ThemedLayoutV2
 Header={() =&gt; &lt;Header /&gt;}
 Sider={() =&gt; null}
 &gt;
 &lt;div
 style={{
 maxWidth: "1280px",
 padding: "24px",
 margin: "0 auto",
 }}
 &gt;
 &lt;Outlet /&gt;
 &lt;/div&gt;
 &lt;/ThemedLayoutV2&gt;
 &lt;/Authenticated&gt;
 }
 &gt;
 &lt;Route index element={&lt;NavigateToResource /&gt;} /&gt;

 &lt;Route
 path="/accounts"
 element={
 &lt;AccountsPageList&gt;
 &lt;Outlet /&gt;
 &lt;/AccountsPageList&gt;
 }
 &gt;
 &lt;Route index element={null} /&gt;
 &lt;Route path="new" element={&lt;AccountsPageCreate /&gt;} /&gt;
 &lt;/Route&gt;
 &lt;Route
 path="/accounts/:id/edit"
 element={&lt;AccountsPageEdit /&gt;}
 /&gt;

 &lt;Route
 path="/clients"
 element={
 &lt;ClientsPageList&gt;
 &lt;Outlet /&gt;
 &lt;/ClientsPageList&gt;
 }
 &gt;
 &lt;Route index element={null} /&gt;
 &lt;Route path="new" element={&lt;ClientsPageCreate /&gt;} /&gt;
 &lt;/Route&gt;
 &lt;Route
 path="/clients/:id/edit"
 element={&lt;ClientsPageEdit /&gt;}
 /&gt;

 &lt;Route path="/invoices"&gt;
 &lt;Route index element={&lt;InvoicePageList /&gt;} /&gt;
 &lt;Route path="new" element={&lt;InvoicesPageCreate /&gt;} /&gt;
 &lt;^&gt;&lt;Route path=":id" element={&lt;InvoicesPageShow /&gt;} /&gt;&lt;^&gt;
 &lt;/Route&gt;
 &lt;/Route&gt;

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

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

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.

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

Step 7 — Deploying to tthe cloud provider App platform

In this step, you'll deploy the application to the an 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:

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 &lt;your-github-repository-url&gt;
				
			

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 the cloud provider Apps. The next step is to create a new the cloud provider App using your project and set up automatic deployment.

Deploying to an app platform

In this step, you'll take your React application and set it up on the an app platform. You'll link your GitHub repository to the cloud provider, 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 the cloud provider with continuous delivery capabilities.

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

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

After you authorize the cloud provider, you'll be redirected back to the the cloud provider 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.

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 the cloud provider 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 an 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.