In this complete tutorial, you will build a React-based PDF invoice generator application with Refine Framework and deploy it to DigitalOcean’s App Platform.
This sample appliction you’re going to build is an internal tool useful for enterprise companies that need to generate invoices for their clients. It will have the necessary functionality to meet real use cases and can be customized to fit your specific requirements.
By the end of this tutorial, you’ll have a internal tool that includes:
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:
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.
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.
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.
vite.config.ts
codeimport 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"],
},
},
},
},
});
To build this app, you’ll need some essential components, styles, and helper functions from the completed version repository of this app.
You recommend you to download these files from the GitHub repository and add them to the project you just set up.
This app is comprehensive and fully-functional, so having these files ready will make it easier to follow along with the tutorial and keep it from taking too long.
First, please remove the following files and folders from the project you just created by CLI:
src/components
folder.src/contexts
folder.src/authProvider.ts
file.src/contants.ts
file.Then, you can copy the following files and folders to the same location in the project:
After these steps, the project structure should look like this:
└── 📁src
└── 📁components
└── 📁providers
└── 📁styles
└── 📁types
└── 📁utils
└── App.tsx
└── index.tsx
└── vite-env.d.ts
In the next steps, you will use these components and helper functions when building the “accounts”, “clients”, and “invoices” pages.
Now, your App.tsx
files gives an error because you removed imported authProvider.ts
and constants.ts
files. You’ll fix this by updating the App.tsx
file in the next step.
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:
src/providers/auth-provider/index.ts
codeimport { AuthProvider } from "@refinedev/core";
import { AuthHelper } from "@refinedev/strapi-v4";
import { API_URL, TOKEN_KEY } from "@/utils/constants";
export const strapiAuthHelper = AuthHelper(`${API_URL}/api`);
export const authProvider: AuthProvider = {
login: async ({ email, password }) => {
try {
const { data, status } = await strapiAuthHelper.login(email, password);
if (status === 200) {
localStorage.setItem(TOKEN_KEY, data.jwt);
return {
success: true,
redirectTo: "/",
};
}
} catch (error: any) {
const errorObj = error?.response?.data?.message?.[0]?.messages?.[0];
return {
success: false,
error: {
message: errorObj?.message || "Login failed",
name: errorObj?.id || "Invalid email or password",
},
};
}
return {
success: false,
error: {
message: "Login failed",
name: "Invalid email or password",
},
};
},
logout: async () => {
localStorage.removeItem(TOKEN_KEY);
return {
success: true,
redirectTo: "/login",
};
},
onError: async (error) => {
if (error.response?.status === 401) {
return {
logout: true,
};
}
return { error };
},
check: async () => {
const token = localStorage.getItem(TOKEN_KEY);
if (token) {
return {
authenticated: true,
};
}
return {
authenticated: false,
error: {
message: "Authentication failed",
name: "Token not found",
},
logout: true,
redirectTo: "/login",
};
},
getIdentity: async () => {
const token = localStorage.getItem(TOKEN_KEY);
if (!token) {
return null;
}
const { data, status } = await strapiAuthHelper.me(token);
if (status === 200) {
const { id, username, email } = data;
return {
id,
username,
email,
};
}
return null;
},
};
login
: It sends a request to the Strapi API to authenticate the user. If the authentication is successful, it saves the JWT token to the local storage and redirects the user to the home page. If the authentication fails, it returns an error message.logout
: It removes the JWT token from the local storage and redirects the user to the login page.onError
: This function is called when an error occurs during the authentication process. If the error is due to an unauthorized request, it logs the user out.check
: It checks if the JWT token is present in the local storage. If the token is present, it returns that the user is authenticated. If the token is not present, it returns an error message and logs the user out.getIdentity
: It sends a request to the Strapi API to get the user’s identity. If the request is successful, it returns the user’s id, username, and email. If the request fails, it returns null.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:
App.tsx
codeimport { Authenticated, Refine } from "@refinedev/core";
import { ErrorComponent, useNotificationProvider } from "@refinedev/antd";
import routerProvider, {
UnsavedChangesNotifier,
DocumentTitleHandler,
NavigateToResource,
} from "@refinedev/react-router-v6";
import { BrowserRouter, Outlet, Route, Routes } from "react-router-dom";
import { DevtoolsPanel, DevtoolsProvider } from "@refinedev/devtools";
import { App as AntdApp } from "antd";
import { dataProvider } from "@/providers/data-provider";
import { authProvider } from "@/providers/auth-provider";
import { ConfigProvider } from "@/providers/config-provider";
import "@refinedev/antd/dist/reset.css";
import "./styles/custom.css";
const App: React.FC = () => {
return (
<DevtoolsProvider>
<BrowserRouter>
<ConfigProvider>
<AntdApp>
<Refine
routerProvider={routerProvider}
authProvider={authProvider}
dataProvider={dataProvider}
notificationProvider={useNotificationProvider}
options={{
syncWithLocation: true,
warnWhenUnsavedChanges: true,
breadcrumb: false,
}}
>
<Routes>
<Route
element={
<Authenticated
key="authenticated-routes"
redirectOnFail="/login"
>
<Outlet />
</Authenticated>
}
>
<Route path="/" element={<div>Home page</div>} />
</Route>
<Route
element={
<Authenticated key="auth-pages" fallback={<Outlet />}>
<NavigateToResource />
</Authenticated>
}
>
<Route path="/login" element={<div>Login page</div>} />
</Route>
<Route
element={
<Authenticated key='catch-all'>
<Outlet />
</Authenticated>
}>
<Route path='*' element={<ErrorComponent />} />
</Route>
</Routes>
<UnsavedChangesNotifier />
<DocumentTitleHandler />
</Refine>
</AntdApp>
</ConfigProvider>
<DevtoolsPanel />
</BrowserRouter>
</DevtoolsProvider>
);
};
export default App;
In the highlighted code lines above, you have created a protected route for “/”.
If the user is not authenticated, they will be redirected to the “/login” page; if authenticated, it will render the children.
You also created a catch-all route to show the <ErrorComponent />
component when the user navigates to a non-existing route(404 not-found page).
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:
App.tsx
codeimport { Authenticated, Refine } from "@refinedev/core";
import {
AuthPage,
ErrorComponent,
useNotificationProvider,
} from "@refinedev/antd";
import routerProvider, {
UnsavedChangesNotifier,
DocumentTitleHandler,
NavigateToResource,
} from "@refinedev/react-router-v6";
import { BrowserRouter, Outlet, Route, Routes } from "react-router-dom";
import { DevtoolsPanel, DevtoolsProvider } from "@refinedev/devtools";
import { App as AntdApp } from "antd";
import { Logo } from "@/components/logo";
import { dataProvider } from "@/providers/data-provider";
import { authProvider } from "@/providers/auth-provider";
import { ConfigProvider } from "@/providers/config-provider";
import "@refinedev/antd/dist/reset.css";
import "./styles/custom.css";
const App: React.FC = () => {
return (
<DevtoolsProvider>
<BrowserRouter>
<ConfigProvider>
<AntdApp>
<Refine
routerProvider={routerProvider}
authProvider={authProvider}
dataProvider={dataProvider}
notificationProvider={useNotificationProvider}
options={{
syncWithLocation: true,
warnWhenUnsavedChanges: true,
breadcrumb: false,
}}
>
<Routes>
<Route
element={
<Authenticated
key="authenticated-routes"
redirectOnFail="/login"
>
<Outlet />
</Authenticated>
}
>
<Route path="/" element={<div>Home page</div>} />
</Route>
<Route
element={
<Authenticated key="auth-pages" fallback={<Outlet />}>
<NavigateToResource />
</Authenticated>
}
>
<Route
path="/login"
element={
<AuthPage
type="login"
registerLink={false}
forgotPasswordLink={false}
title={
<Logo
titleProps={{ level: 2 }}
svgProps={{
width: "48px",
height: "40px",
}}
/>
}
formProps={{
initialValues: {
email: "demo@refine.dev",
password: "demodemo",
},
}}
/>
}
/>
</Route>
<Route
element={
<Authenticated key="catch-all">
<Outlet />
</Authenticated>
}
>
<Route path="*" element={<ErrorComponent />} />
</Route>
</Routes>
<UnsavedChangesNotifier />
<DocumentTitleHandler />
</Refine>
</AntdApp>
</ConfigProvider>
<DevtoolsPanel />
</BrowserRouter>
</DevtoolsProvider>
);
};
export default App;
With the highlighted codes above, you’ve created a login page using the <AuthPage />
component. You specified the type
prop as "login"
to enable the display of the login form. The registerLink
and forgotPasswordLink
props are set to false
, thereby hiding the registration and forgot password links, which are not required for this tutorial.
Additionally, the formProps
prop is used to initialize the email and password fields. You also set the title
property to show the <Logo />
component, previously copied from the GitHub repository.
After everything is set up, our “/login” page should look like this:
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.
App.tsx
codeimport { Authenticated, Refine } from "@refinedev/core";
import {
AuthPage,
ErrorComponent,
ThemedLayoutV2,
useNotificationProvider,
} from "@refinedev/antd";
import routerProvider, {
UnsavedChangesNotifier,
DocumentTitleHandler,
NavigateToResource,
CatchAllNavigate,
} from "@refinedev/react-router-v6";
import { BrowserRouter, Outlet, Route, Routes } from "react-router-dom";
import { DevtoolsPanel, DevtoolsProvider } from "@refinedev/devtools";
import { App as AntdApp } from "antd";
import { dataProvider } from "@/providers/data-provider";
import { authProvider } from "@/providers/auth-provider";
import { ConfigProvider } from "@/providers/config-provider";
import { Logo } from "@/components/logo";
import { Header } from "@/components/header";
import "@refinedev/antd/dist/reset.css";
import "./styles/custom.css";
const App: React.FC = () => {
return (
<DevtoolsProvider>
<BrowserRouter>
<ConfigProvider>
<AntdApp>
<Refine
routerProvider={routerProvider}
authProvider={authProvider}
dataProvider={dataProvider}
notificationProvider={useNotificationProvider}
options={{
syncWithLocation: true,
warnWhenUnsavedChanges: true,
breadcrumb: false,
}}
>
<Routes>
<Route
element={
<Authenticated
key="authenticated-routes"
fallback={<CatchAllNavigate to="/login" />}
>
<ThemedLayoutV2
Header={() => <Header />}
Sider={() => null}
>
<div
style={{
maxWidth: "1280px",
padding: "24px",
margin: "0 auto",
}}
>
<Outlet />
</div>
</ThemedLayoutV2>
</Authenticated>
}
>
<Route path="/" element={<div>Home page</div>} />
</Route>
<Route
element={
<Authenticated key="auth-pages" fallback={<Outlet />}>
<NavigateToResource />
</Authenticated>
}
>
<Route
path="/login"
element={
<AuthPage
type="login"
registerLink={false}
forgotPasswordLink={false}
title={
<Logo
titleProps={{ level: 2 }}
svgProps={{
width: "48px",
height: "40px",
}}
/>
}
formProps={{
initialValues: {
email: "demo@refine.dev",
password: "demodemo",
},
}}
/>
}
/>
</Route>
<Route
element={
<Authenticated key="catch-all">
<ThemedLayoutV2
Header={() => <Header />}
Sider={() => null}
>
<Outlet />
</ThemedLayoutV2>
</Authenticated>
}
>
<Route path="*" element={<ErrorComponent />} />
</Route>
</Routes>
<UnsavedChangesNotifier />
<DocumentTitleHandler />
</Refine>
</AntdApp>
</ConfigProvider>
<DevtoolsPanel />
</BrowserRouter>
</DevtoolsProvider>
);
};
export default App;
In the code lines highlighted above, you’ve added the <ThemedLayoutV2 />
component to the project. This component provides a layout with a header and a content area. You set the Sider
prop to null
since you don’t need a sidebar for this project.
You also added the <Header />
component to the Header
prop, which you previously copied from the GitHub repository. This component will be used to navigate between the “accounts,” “clients,” and “invoices” pages, display the user’s name and logout button, show the logo, and include a search input to search the accounts and clients.
After everything is set up, our layout should look like this:
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.
App.tsx
codeimport { Authenticated, Refine } from "@refinedev/core";
import {
AuthPage,
ErrorComponent,
ThemedLayoutV2,
useNotificationProvider,
} from "@refinedev/antd";
import routerProvider, {
UnsavedChangesNotifier,
DocumentTitleHandler,
NavigateToResource,
CatchAllNavigate,
} from "@refinedev/react-router-v6";
import { BrowserRouter, Outlet, Route, Routes } from "react-router-dom";
import { DevtoolsPanel, DevtoolsProvider } from "@refinedev/devtools";
import { App as AntdApp } from "antd";
import { dataProvider } from "@/providers/data-provider";
import { authProvider } from "@/providers/auth-provider";
import { ConfigProvider } from "@/providers/config-provider";
import { Logo } from "@/components/logo";
import { Header } from "@/components/header";
import "@refinedev/antd/dist/reset.css";
import "./styles/custom.css";
const App: React.FC = () => {
return (
<DevtoolsProvider>
<BrowserRouter>
<ConfigProvider>
<AntdApp>
<Refine
routerProvider={routerProvider}
authProvider={authProvider}
dataProvider={dataProvider}
notificationProvider={useNotificationProvider}
resources={[
{
name: "accounts",
list: "/accounts",
create: "/accounts/new",
edit: "/accounts/:id/edit",
},
]}
options={{
syncWithLocation: true,
warnWhenUnsavedChanges: true,
breadcrumb: false,
}}
>
<Routes>
<Route
element={
<Authenticated
key="authenticated-routes"
fallback={<CatchAllNavigate to="/login" />}
>
<ThemedLayoutV2
Header={() => <Header />}
Sider={() => null}
>
<div
style={{
maxWidth: "1280px",
padding: "24px",
margin: "0 auto",
}}
>
<Outlet />
</div>
</ThemedLayoutV2>
</Authenticated>
}
>
<Route path="/" element={<div>Home page</div>} />
</Route>
<Route
element={
<Authenticated key="auth-pages" fallback={<Outlet />}>
<NavigateToResource />
</Authenticated>
}
>
<Route
path="/login"
element={
<AuthPage
type="login"
registerLink={false}
forgotPasswordLink={false}
title={
<Logo
titleProps={{ level: 2 }}
svgProps={{
width: "48px",
height: "40px",
}}
/>
}
formProps={{
initialValues: {
email: "demo@refine.dev",
password: "demodemo",
},
}}
/>
}
/>
</Route>
<Route
element={
<Authenticated key="catch-all">
<ThemedLayoutV2
Header={() => <Header />}
Sider={() => null}
>
<Outlet />
</ThemedLayoutV2>
</Authenticated>
}
>
<Route path="*" element={<ErrorComponent />} />
</Route>
</Routes>
<UnsavedChangesNotifier />
<DocumentTitleHandler />
</Refine>
</AntdApp>
</ConfigProvider>
<DevtoolsPanel />
</BrowserRouter>
</DevtoolsProvider>
);
};
export default App;
The resource definition doesn’t create any CRUD pages itself. Instead, it establishes the routes that these CRUD pages will follow. These routes are essential for ensuring the proper functionality of various Refine hooks and components.
For example, you will use the useNavigation
hook, which relies on these resource routes (list
, create
, edit
, and show
) to help users navigate between different pages in your application. Additionally, data hooks like useTable
will automatically use the resource name if the resource prop is not explicitly provided.
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:
<AccountsPageList />
componentimport type { PropsWithChildren } from 'react'
import { getDefaultFilter, useGo } from '@refinedev/core'
import {
CreateButton,
DeleteButton,
EditButton,
FilterDropdown,
List,
NumberField,
getDefaultSortOrder,
useSelect,
useTable,
} from '@refinedev/antd'
import { EyeOutlined, SearchOutlined } from '@ant-design/icons'
import { Avatar, Flex, Input, Select, Table, Typography } from 'antd'
import { API_URL } from '@/utils/constants'
import type { Account } from '@/types'
import { getRandomColorFromString } from '@/utils/get-random-color'
export const AccountsPageList = ({ children }: PropsWithChildren) => {
const go = useGo()
const { tableProps, filters, sorters } = useTable<Account>({
sorters: {
initial: [{ field: 'updatedAt', order: 'desc' }],
},
filters: {
initial: [
{
field: 'owner_email',
operator: 'contains',
value: '',
},
{
field: 'phone',
operator: 'contains',
value: '',
},
],
},
meta: {
populate: ['logo', 'invoices'],
},
})
const { selectProps: companyNameSelectProps } = useSelect({
resource: 'accounts',
optionLabel: 'company_name',
optionValue: 'company_name',
})
const { selectProps: selectPropsOwnerName } = useSelect({
resource: 'accounts',
optionLabel: 'owner_name',
optionValue: 'owner_name',
})
return (
<>
<List
title='Accounts'
headerButtons={() => {
return (
<CreateButton
size='large'
onClick={() =>
go({
to: { resource: 'accounts', action: 'create' },
options: { keepQuery: true },
})
}>
Add new account
</CreateButton>
)
}}>
<Table
{...tableProps}
rowKey={'id'}
pagination={{
...tableProps.pagination,
showSizeChanger: true,
}}
scroll={{ x: 960 }}>
<Table.Column
title='ID'
dataIndex='id'
key='id'
width={80}
defaultFilteredValue={getDefaultFilter('id', filters)}
filterIcon={<SearchOutlined />}
filterDropdown={(props) => {
return (
<FilterDropdown {...props}>
<Input placeholder='Search ID' />
</FilterDropdown>
)
}}
/>
<Table.Column
title='Title'
dataIndex='company_name'
key='company_name'
sorter
defaultSortOrder={getDefaultSortOrder('company_name', sorters)}
defaultFilteredValue={getDefaultFilter('company_name', filters, 'in')}
filterDropdown={(props) => (
<FilterDropdown {...props}>
<Select
mode='multiple'
placeholder='Search Company Name'
style={{ width: 220 }}
{...companyNameSelectProps}
/>
</FilterDropdown>
)}
render={(name: string, record: Account) => {
const logoUrl = record?.logo?.url
const src = logoUrl ? `${API_URL}${logoUrl}` : undefined
return (
<Flex align='center' gap={8}>
<Avatar
alt={name}
src={src}
shape='square'
style={{
backgroundColor: src
? "none"
: getRandomColorFromString(name || ""),
}}>
<Typography.Text>{name?.[0]?.toUpperCase()}</Typography.Text>
</Avatar>
<Typography.Text>{name}</Typography.Text>
</Flex>
)
}}
/>
<Table.Column
title='Owner'
dataIndex='owner_name'
key='owner_name'
sorter
defaultSortOrder={getDefaultSortOrder('owner_name', sorters)}
defaultFilteredValue={getDefaultFilter('owner_name', filters, 'in')}
filterDropdown={(props) => (
<FilterDropdown {...props}>
<Select
mode='multiple'
placeholder='Search Owner Name'
style={{ width: 220 }}
{...selectPropsOwnerName}
/>
</FilterDropdown>
)}
/>
<Table.Column
title='Email'
dataIndex='owner_email'
key='owner_email'
defaultFilteredValue={getDefaultFilter('owner_email', filters, 'contains')}
filterIcon={<SearchOutlined />}
filterDropdown={(props) => {
return (
<FilterDropdown {...props}>
<Input placeholder='Search Email' />
</FilterDropdown>
)
}}
/>
<Table.Column
title='Phone'
dataIndex='phone'
key='phone'
width={154}
defaultFilteredValue={getDefaultFilter('phone', filters, 'contains')}
filterIcon={<SearchOutlined />}
filterDropdown={(props) => {
return (
<FilterDropdown {...props}>
<Input placeholder='Search Phone' />
</FilterDropdown>
)
}}
/>
<Table.Column
title='Income'
dataIndex='income'
key='income'
width={120}
align='end'
render={(_, record: Account) => {
let total = 0
for (const invoice of record.invoices || []) {
total += invoice.total
}
return <NumberField value={total} options={{ style: 'currency', currency: 'USD' }} />
}}
/>
<Table.Column
title='Actions'
key='actions'
fixed='right'
align='end'
width={106}
render={(_, record: Account) => {
return (
<Flex align='center' gap={8}>
<EditButton hideText recordItemId={record.id} icon={<EyeOutlined />} />
<DeleteButton hideText recordItemId={record.id} />
</Flex>
)
}}
/>
</Table>
</List>
{children}
</>
)
}
Let’s break down the code above:
You fetched data using the useTable
hook from the @refinedev/antd
package, specifying relationships via meta.populate
, and displayed it with the <Table />
component. For Strapi queries, refer to the Strapi v4 documentation.
The table includes columns like company name, owner name, owner email, phone, and income, following Ant Design Table guidelines. You used components like <FilterDropdown />
, and <Select />
from @refinedev/antd
and antd
for customizing the UI.
Search inputs were added to each column for data filtering, using getDefaultFilter
and getDefaultSortOrder
from "@refinedev/core"
and "@refinedev/antd"
to set defaults from query parameters.
The useSelect
hook allow us to manage Ant Design’s <Select />
component when the records in a resource needs to be used as select options. You used it to fetch values for the company_name
and owner_name
columns to filter the table data.
You used the children
prop to render a modal for creating new accounts when the “Add new account” button is clicked.
The <CreateButton />
normally navigates to the create page but was modified with the onClick
prop and go
function from "@refinedev/core"
to open it as a modal, preserving query parameters.
Finally, the <EditButton />
and <DeleteButton />
components handle editing and deleting accounts. The <EditButton />
opens the edit page as a modal, and the <DeleteButton />
deletes the account when clicked.
To import the account list page from other files, you need to create a src/pages/accounts/index.tsx
file with following:
export { AccountsPageList } from "./list";
Next, import the <AccountsPageList />
component in src/App.tsx
and add a route for rendering it.
App.tsx
codeimport { Authenticated, Refine } from "@refinedev/core";
import {
AuthPage,
ErrorComponent,
ThemedLayoutV2,
useNotificationProvider,
} from "@refinedev/antd";
import routerProvider, {
UnsavedChangesNotifier,
DocumentTitleHandler,
NavigateToResource,
CatchAllNavigate,
} from "@refinedev/react-router-v6";
import { BrowserRouter, Outlet, Route, Routes } from "react-router-dom";
import { DevtoolsPanel, DevtoolsProvider } from "@refinedev/devtools";
import { App as AntdApp } from "antd";
import { dataProvider } from "@/providers/data-provider";
import { authProvider } from "@/providers/auth-provider";
import { ConfigProvider } from "@/providers/config-provider";
import { Logo } from "@/components/logo";
import { Header } from "@/components/header";
import { AccountsPageList } from "@/pages/accounts";
import "@refinedev/antd/dist/reset.css";
import "./styles/custom.css";
const App: React.FC = () => {
return (
<DevtoolsProvider>
<BrowserRouter>
<ConfigProvider>
<AntdApp>
<Refine
routerProvider={routerProvider}
authProvider={authProvider}
dataProvider={dataProvider}
notificationProvider={useNotificationProvider}
resources={[
{
name: "accounts",
list: "/accounts",
create: "/accounts/new",
edit: "/accounts/:id/edit",
},
]}
options={{
syncWithLocation: true,
warnWhenUnsavedChanges: true,
breadcrumb: false,
}}
>
<Routes>
<Route
element={
<Authenticated
key="authenticated-routes"
fallback={<CatchAllNavigate to="/login" />}
>
<ThemedLayoutV2
Header={() => <Header />}
Sider={() => null}
>
<div
style={{
maxWidth: "1280px",
padding: "24px",
margin: "0 auto",
}}
>
<Outlet />
</div>
</ThemedLayoutV2>
</Authenticated>
}
>
<Route index element={<NavigateToResource />} />
<Route
path="/accounts"
element={
<AccountsPageList>
<Outlet />
</AccountsPageList>
}
>
<Route index element={null} />
</Route>
</Route>
<Route
element={
<Authenticated key="auth-pages" fallback={<Outlet />}>
<NavigateToResource />
</Authenticated>
}
>
<Route
path="/login"
element={
<AuthPage
type="login"
registerLink={false}
forgotPasswordLink={false}
title={
<Logo
titleProps={{ level: 2 }}
svgProps={{
width: "48px",
height: "40px",
}}
/>
}
formProps={{
initialValues: {
email: "demo@refine.dev",
password: "demodemo",
},
}}
/>
}
/>
</Route>
<Route
element={
<Authenticated key="catch-all">
<ThemedLayoutV2
Header={() => <Header />}
Sider={() => null}
>
<Outlet />
</ThemedLayoutV2>
</Authenticated>
}
>
<Route path="*" element={<ErrorComponent />} />
</Route>
</Routes>
<UnsavedChangesNotifier />
<DocumentTitleHandler />
</Refine>
</AntdApp>
</ConfigProvider>
<DevtoolsPanel />
</BrowserRouter>
</DevtoolsProvider>
);
};
export default App;
Let’s look at the changes you made to the App.tsx
file:
<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.<Route />
for “/accounts”:
path="/accounts"
indicates that this route configuration applies when the URL matches “/accounts”.element
property determines which component is displayed when this route is accessed. Here, it is <AccountsPageList />
.<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.<Route />
with index:
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.
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:
<AccountsPageCreate />
componentimport { type HttpError, useGo } from "@refinedev/core";
import { useForm } from "@refinedev/antd";
import { Flex, Form, Input, Modal } from "antd";
import InputMask from "react-input-mask";
import { FormItemUploadLogoDraggable } from "@/components/form";
import type { Account, AccountForm } from "@/types";
export const AccountsPageCreate = () => {
const go = useGo();
const { formProps } = useForm<Account, HttpError, AccountForm>();
return (
<Modal
okButtonProps={{ form: "create-account-form", htmlType: "submit" }}
title="Add new account"
open
onCancel={() => {
go({
to: { resource: "accounts", action: "list" },
options: { keepQuery: true },
});
}}
>
<Form
layout="vertical"
id="create-account-form"
{...formProps}
onFinish={(values) => {
const logoId = values.logo?.file?.response?.[0]?.id;
return formProps.onFinish?.({
...values,
logo: logoId,
} as AccountForm);
}}
>
<Flex gap={40}>
<FormItemUploadLogoDraggable />
<Flex
vertical
style={{
width: "420px",
}}
>
<Form.Item
name="company_name"
label="Company Name"
rules={[{ required: true }]}
>
<Input />
</Form.Item>
<Form.Item
name="owner_name"
label="Owner Name"
rules={[{ required: true }]}
>
<Input />
</Form.Item>
<Form.Item
name="owner_email"
label="Owner email"
rules={[{ required: true, type: "email" }]}
>
<Input />
</Form.Item>
<Form.Item
name="address"
label="Address"
rules={[{ required: true }]}
>
<Input />
</Form.Item>
<Form.Item name="phone" label="Phone" rules={[{ required: true }]}>
<InputMask mask="(999) 999-9999">
{/* @ts-expect-error <InputMask /> expects JSX.Element but you are using React.ReactNode */}
{(props: InputProps) => (
<Input {...props} placeholder="Please enter phone number" />
)}
</InputMask>
</Form.Item>
</Flex>
</Flex>
</Form>
</Modal>
);
};
As explained earlier, <AccountsPageCreate />
will be a sub-route of the list page (/accounts/new
). <AccountsPageList />
uses the children
prop as an <Outlet />
to render nested routes. This allows us to add nested routes as a modal or drawer to the list page, which is why you used <Modal />
from antd
.
The okButtonProps
prop is used to submit the form when the “OK” button is clicked. The onCancel
prop is used to navigate back to the list page when the “Cancel” button is clicked.
Let’s closer look at the custom components and logics you used in the <AccountsPageCreate />
component:
useForm
: This hook manages form state and handles submission. The formProps
object from useForm
is passed to the <Form />
component to control these aspects.
action
and resource
props are inferred from the route parameters of the resource you defined earlier. You don’t need to pass them explicitly.<FormItemUploadLogoDraggable />
:
This component is used to upload a logo for the account. It is a custom component that you copied from the GitHub repository. It uses the <Upload.Dragger />
component from antd
to upload the logo. It not contains much logic, you just made couple of changes to the original component to fit our design needs.
customRequest
: prop is used to upload the logo to the Strapi media library. It’s just a basic post request to the Strapi media endpoint with the given file from the Ant Design’s <Upload />
component. You also need to catch errors and set the form’s error state if the upload fails.getValueProps
: from @refinedev/strapi-v4
is used to get the Strapi media’s URL. You pass the data
and API_URL
as arguments to the get the media URL.fieldValue
: This state watches the logo field value with Form.useWatch
hook from antd
. You use this state to show the uploaded logo in the form.You override the onFinish
prop of the <Form />
component to handle the form submission. You extract the uploaded media id from the form values and pass it to the onFinish
function as logo
field. When you give id of the media, Strapi will automatically create a relation between the media and the account.
Rest of form fields are basic antd
form fields. You used the rules
prop to set the required fields and the type of the email field. You can refer to the Forms guide for more information.
To import the company create page from other files, you need to update the src/pages/accounts/index.tsx
file:
export { AccountsPageList } from "./list";
export { AccountsPageCreate } from "./create";
Next, import the <AccountsPageCreate />
component in src/App.tsx
and add a route for rendering it.
App.tsx
code//...
import { AccountsPageCreate, AccountsPageList } from '@/pages/accounts'
const App: React.FC = () => {
return (
//...
<Refine
//...
>
<Routes>
<Route
element={
<Authenticated key='authenticated-routes' fallback={<CatchAllNavigate to='/login' />}>
<ThemedLayoutV2 Header={() => <Header />} Sider={() => null}>
<div
style={{
maxWidth: '1280px',
padding: '24px',
margin: '0 auto',
}}>
<Outlet />
</div>
</ThemedLayoutV2>
</Authenticated>
}>
<Route index element={<NavigateToResource />} />
<Route
path='/accounts'
element={
<AccountsPageList>
<Outlet />
</AccountsPageList>
}>
<Route index element={null} />
<Route path='new' element={<AccountsPageCreate />} />
</Route>
</Route>
{/* ... */}
</Routes>
{/* ... */}
</Refine>
//...
)
}
export default App
Now, when you click the “Add new account” button on the list page, you should see the create page as a modal.
After you fill out the form and click the “OK” button, the new account record will be created and you will be redirected to the list page.
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:
<AccountsPageEdit />
componentimport { type HttpError, useNavigation } from "@refinedev/core";
import {
DateField,
DeleteButton,
EditButton,
NumberField,
Show,
ShowButton,
useForm,
} from "@refinedev/antd";
import { Card, Divider, Flex, Form, Table, Typography } from "antd";
import {
BankOutlined,
UserOutlined,
MailOutlined,
EnvironmentOutlined,
PhoneOutlined,
ExportOutlined,
ContainerOutlined,
ShopOutlined,
} from "@ant-design/icons";
import { Col, Row } from "antd";
import {
FormItemEditableInputText,
FormItemEditableText,
FormItemUploadLogo,
} from "@/components/form";
import type { Account, AccountForm } from "@/types";
export const AccountsPageEdit = () => {
const { list } = useNavigation();
const { formProps, queryResult } = useForm<Account, HttpError, AccountForm>({
redirect: false,
meta: {
populate: ["logo", "clients", "invoices.client"],
},
});
const account = queryResult?.data?.data;
const clients = account?.clients || [];
const invoices = account?.invoices || [];
const isLoading = queryResult?.isLoading;
return (
<Show
title="Accounts"
headerButtons={() => false}
contentProps={{
styles: {
body: {
padding: 0,
},
},
style: {
background: "transparent",
boxShadow: "none",
},
}}
>
<Form
{...formProps}
onFinish={(values) => {
const logoId = values.logo?.file?.response?.[0]?.id;
return formProps.onFinish?.({
...values,
logo: logoId,
} as AccountForm);
}}
layout="vertical"
>
<Row>
<Col span={24}>
<Flex gap={16}>
<FormItemUploadLogo
isLoading={isLoading}
label={account?.company_name || " "}
onUpload={() => {
formProps.form?.submit();
}}
/>
<FormItemEditableText
loading={isLoading}
formItemProps={{
name: "company_name",
rules: [{ required: true }],
}}
/>
</Flex>
</Col>
</Row>
<Row
gutter={32}
style={{
marginTop: "32px",
}}
>
<Col xs={{ span: 24 }} xl={{ span: 8 }}>
<Card
bordered={false}
styles={{ body: { padding: 0 } }}
title={
<Flex gap={12} align="center">
<BankOutlined />
<Typography.Text>Account info</Typography.Text>
</Flex>
}
>
<FormItemEditableInputText
loading={isLoading}
icon={<UserOutlined />}
placeholder="Add owner name"
formItemProps={{
name: "owner_name",
label: "Owner name",
rules: [{ required: true }],
}}
/>
<Divider style={{ margin: 0 }} />
<FormItemEditableInputText
loading={isLoading}
icon={<MailOutlined />}
placeholder="Add email"
formItemProps={{
name: "owner_email",
label: "Owner email",
rules: [{ required: true }],
}}
/>
<Divider style={{ margin: 0 }} />
<Divider style={{ margin: 0 }} />
<FormItemEditableInputText
loading={isLoading}
icon={<EnvironmentOutlined />}
placeholder="Add address"
formItemProps={{
name: "address",
label: "Address",
rules: [{ required: true }],
}}
/>
<Divider style={{ margin: 0 }} />
<FormItemEditableInputText
loading={isLoading}
icon={<PhoneOutlined />}
placeholder="Add phone number"
formItemProps={{
name: "phone",
label: "Phone",
rules: [{ required: true }],
}}
/>
</Card>
<DeleteButton
type="text"
style={{
marginTop: "16px",
}}
onSuccess={() => {
list("clients");
}}
>
Delete account
</DeleteButton>
</Col>
<Col xs={{ span: 24 }} xl={{ span: 16 }}>
<Card
bordered={false}
title={
<Flex gap={12} align="center">
<ShopOutlined />
<Typography.Text>Clients</Typography.Text>
</Flex>
}
styles={{
header: {
padding: "0 16px",
},
body: {
padding: "0",
},
}}
>
<Table
dataSource={clients}
pagination={false}
loading={isLoading}
rowKey={"id"}
>
<Table.Column title="ID" dataIndex="id" key="id" />
<Table.Column title="Client" dataIndex="name" key="name" />
<Table.Column
title="Owner"
dataIndex="owner_name"
key="owner_name"
/>
<Table.Column
title="Email"
dataIndex="owner_email"
key="owner_email"
/>
<Table.Column
key="actions"
width={64}
render={(_, record: Account) => {
return (
<EditButton
hideText
resource="clients"
recordItemId={record.id}
icon={<ExportOutlined />}
/>
);
}}
/>
</Table>
</Card>
<Card
bordered={false}
title={
<Flex gap={12} align="center">
<ContainerOutlined />
<Typography.Text>Invoices</Typography.Text>
</Flex>
}
style={{ marginTop: "32px" }}
styles={{
header: {
padding: "0 16px",
},
body: {
padding: 0,
},
}}
>
<Table
dataSource={invoices}
pagination={false}
loading={isLoading}
rowKey={"id"}
>
<Table.Column title="ID" dataIndex="id" key="id" width={72} />
<Table.Column
title="Date"
dataIndex="date"
key="date"
render={(date) => (
<DateField value={date} format="D MMM YYYY" />
)}
/>
<Table.Column
title="Client"
dataIndex="client"
key="client"
render={(client) => client?.name}
/>
<Table.Column
title="Amount"
dataIndex="total"
key="total"
render={(total) => (
<NumberField
value={total}
options={{ style: "currency", currency: "USD" }}
/>
)}
/>
<Table.Column
key="actions"
width={64}
render={(_, record: Account) => {
return (
<ShowButton
hideText
resource="invoices"
recordItemId={record.id}
icon={<ExportOutlined />}
/>
);
}}
/>
</Table>
</Card>
</Col>
</Row>
</Form>
</Show>
);
};
In the <AccountsPageEdit />
component its’a mix of show and edit page. You used the <Show />
component from @refinedev/antd
for a layout. With help of useForm
hook, you fetched the account data and populated the logo, clients, and invoices relationships. To display the clients and invoices, you used the <Table />
component from antd
.
Note: The Invoice and Customers CRUD sheets are not available in this step, you will add them in the next steps, but since you have already prepared the API for this tutorial, these tables will be populated with data.
Let’s closer look at the custom components and logics you used in the <AccountsPageEdit />
component:
useForm
: Similar the create page with couple of differences:
redirect
option to prevent the form from redirecting after submission.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:
export { AccountsPageList } from "./list";
export { AccountsPageCreate } from "./create";
export { AccountsPageEdit } from "./edit";
Next, import the <AccountsPageEdit />
component in src/App.tsx
and add a route for rendering it.
App.tsx
codeimport {
AccountsPageCreate,
AccountsPageEdit,
AccountsPageList,
} from "@/pages/accounts";
//...
const App: React.FC = () => {
return (
//...
<Refine>
<Routes>
{/*...*/}
<Route
path="/accounts"
element={
<AccountsPageList>
<Outlet />
</AccountsPageList>
}
>
<Route index element={null} />
<Route path="new" element={<AccountsPageCreate />} />
</Route>
<Route path="/accounts/:id/edit" element={<AccountsPageEdit />} />
{/*...*/}
</Routes>
{/*...*/}
</Refine>
//...
);
};
export default App;
After clicking the “Edit” button with the eye icon on the list page, you should see the edit page.
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:
src/App.tsx
code// ...
const App: React.FC = () => {
return (
// ...
<Refine
// ...
resources={[
{
name: "accounts",
list: "/accounts",
create: "/accounts/new",
edit: "/accounts/:id/edit",
},
{
name: 'clients',
list: '/clients',
create: '/clients/new',
edit: '/clients/:id/edit',
},
]}
// ...
>
{/* ... */}
</Refine>
// ...
)
}
export default App
Let’s create CRUD pages for the "clients"
resource as follows:
Create src/pages/clients/list.tsx
file with the following code:
<ClientsPageList />
codeimport type { PropsWithChildren } from "react";
import { getDefaultFilter, useGo } from "@refinedev/core";
import {
CreateButton,
DeleteButton,
EditButton,
FilterDropdown,
List,
NumberField,
getDefaultSortOrder,
useSelect,
useTable,
} from "@refinedev/antd";
import { Avatar, Flex, Input, Select, Table, Typography } from "antd";
import { EyeOutlined, SearchOutlined } from "@ant-design/icons";
import { API_URL } from "@/utils/constants";
import { getRandomColorFromString } from "@/utils/get-random-color";
import type { Client } from "@/types";
export const ClientsPageList = ({ children }: PropsWithChildren) => {
const go = useGo();
const { tableProps, filters, sorters } = useTable<Client>({
sorters: {
initial: [{ field: "updatedAt", order: "desc" }],
},
filters: {
initial: [
{
field: "owner_email",
operator: "contains",
value: "",
},
],
},
meta: {
populate: ["account.logo", "invoices"],
},
});
const { selectProps: selectPropsName } = useSelect({
resource: "clients",
optionLabel: "name",
optionValue: "name",
});
const { selectProps: selectPropsOwnerName } = useSelect({
resource: "clients",
optionLabel: "owner_name",
optionValue: "owner_name",
});
const { selectProps: selectPropsAccountName } = useSelect({
resource: "accounts",
optionLabel: "company_name",
optionValue: "company_name",
});
return (
<>
<List
title="Clients"
headerButtons={() => {
return (
<CreateButton
size="large"
onClick={() =>
go({
to: { resource: "clients", action: "create" },
options: { keepQuery: true },
})
}
>
Add new client
</CreateButton>
);
}}
>
<Table
{...tableProps}
rowKey={"id"}
pagination={{
...tableProps.pagination,
showSizeChanger: true,
}}
scroll={{ x: 960 }}
>
<Table.Column
title="ID"
dataIndex="id"
key="id"
width={80}
defaultFilteredValue={getDefaultFilter("id", filters)}
filterIcon={<SearchOutlined />}
filterDropdown={(props) => {
return (
<FilterDropdown {...props}>
<Input placeholder="Search ID" />
</FilterDropdown>
);
}}
/>
<Table.Column
title="Title"
dataIndex="name"
key="name"
sorter
defaultSortOrder={getDefaultSortOrder("name", sorters)}
defaultFilteredValue={getDefaultFilter("name", filters, "in")}
filterDropdown={(props) => (
<FilterDropdown {...props}>
<Select
mode="multiple"
placeholder="Search Name"
style={{ width: 220 }}
{...selectPropsName}
/>
</FilterDropdown>
)}
/>
<Table.Column
title="Owner"
dataIndex="owner_name"
key="owner_name"
sorter
defaultSortOrder={getDefaultSortOrder("owner_name", sorters)}
defaultFilteredValue={getDefaultFilter("owner_name", filters, "in")}
filterDropdown={(props) => (
<FilterDropdown {...props}>
<Select
mode="multiple"
placeholder="Search Owner"
style={{ width: 220 }}
{...selectPropsOwnerName}
/>
</FilterDropdown>
)}
/>
<Table.Column
title="Email"
dataIndex="owner_email"
key="owner_email"
defaultFilteredValue={getDefaultFilter(
"owner_email",
filters,
"contains",
)}
filterIcon={<SearchOutlined />}
filterDropdown={(props) => {
return (
<FilterDropdown {...props}>
<Input placeholder="Search Email" />
</FilterDropdown>
);
}}
/>
<Table.Column
title="Total"
dataIndex="total"
key="total"
width={120}
align="end"
render={(_, record: Client) => {
let total = 0;
record.invoices?.forEach((invoice) => {
total += invoice.total;
});
return (
<NumberField
value={total}
options={{ style: "currency", currency: "USD" }}
/>
);
}}
/>
<Table.Column
title="Account"
dataIndex="account.company_name"
key="account.company_name"
defaultFilteredValue={getDefaultFilter(
"account.company_name",
filters,
"in",
)}
filterDropdown={(props) => (
<FilterDropdown {...props}>
<Select
mode="multiple"
placeholder="Search Account"
style={{ width: 220 }}
{...selectPropsAccountName}
/>
</FilterDropdown>
)}
render={(_, record: Client) => {
const logoUrl = record?.account?.logo?.url;
const src = logoUrl ? `${API_URL}${logoUrl}` : null;
const name = record?.account?.company_name || "";
return (
<Flex align="center" gap={8}>
<Avatar
alt={name}
src={src}
shape="square"
style={{
backgroundColor: src
? "none"
: getRandomColorFromString(name),
}}
>
<Typography.Text>
{name?.[0]?.toUpperCase()}
</Typography.Text>
</Avatar>
<Typography.Text>{name}</Typography.Text>
</Flex>
);
}}
/>
<Table.Column
title="Actions"
key="actions"
fixed="right"
align="end"
width={106}
render={(_, record: Client) => {
return (
<Flex align="center" gap={8}>
<EditButton
hideText
recordItemId={record.id}
icon={<EyeOutlined />}
/>
<DeleteButton hideText recordItemId={record.id} />
</Flex>
);
}}
/>
</Table>
</List>
{children}
</>
);
};
Create src/pages/clients/create.tsx
file with the following code:
<ClientsPageCreate />
codeimport { useGo } from "@refinedev/core";
import { useForm, useSelect } from "@refinedev/antd";
import { Flex, Form, Input, Modal, Select } from "antd";
import InputMask from "react-input-mask";
export const ClientsPageCreate = () => {
const go = useGo();
const { formProps } = useForm();
const { selectProps: selectPropsAccount } = useSelect({
resource: "accounts",
optionLabel: "company_name",
optionValue: "id",
});
return (
<Modal
okButtonProps={{ form: "create-client-form", htmlType: "submit" }}
title="Add new client"
open
onCancel={() => {
go({
to: { resource: "accounts", action: "list" },
options: { keepQuery: true },
});
}}
>
<Form layout="vertical" id="create-client-form" {...formProps}>
<Flex
vertical
style={{
margin: "0 auto",
width: "420px",
}}
>
<Form.Item
name="account"
label="Account"
rules={[{ required: true }]}
>
<Select
{...selectPropsAccount}
placeholder="Please select an account"
/>
</Form.Item>
<Form.Item
name="name"
label="Client title"
rules={[{ required: true }]}
>
<Input placeholder="Please enter client title" />
</Form.Item>
<Form.Item
name="owner_name"
label="Owner name"
rules={[{ required: true }]}
>
<Input placeholder="Please enter owner name" />
</Form.Item>
<Form.Item
name="owner_email"
label="Owner email"
rules={[{ required: true, type: "email" }]}
>
<Input placeholder="Please enter owner email" />
</Form.Item>
<Form.Item
name="address"
label="Address"
rules={[{ required: true }]}
>
<Input placeholder="Please enter address" />
</Form.Item>
<Form.Item name="phone" label="Phone" rules={[{ required: true }]}>
<InputMask mask="(999) 999-9999">
{/* @ts-expect-error <InputMask /> expects JSX.Element but you are using React.ReactNode */}
{(props: InputProps) => (
<Input {...props} placeholder="Please enter phone number" />
)}
</InputMask>
</Form.Item>
</Flex>
</Form>
</Modal>
);
};
Create src/pages/clients/edit.tsx
file with the following code:
<ClientsPageEdit />
codeimport { useNavigation } from "@refinedev/core";
import {
DateField,
DeleteButton,
NumberField,
Show,
ShowButton,
useForm,
useSelect,
} from "@refinedev/antd";
import { Card, Divider, Flex, Form, Table, Typography } from "antd";
import {
ShopOutlined,
UserOutlined,
ExportOutlined,
BankOutlined,
MailOutlined,
EnvironmentOutlined,
PhoneOutlined,
ContainerOutlined,
} from "@ant-design/icons";
import { Col, Row } from "antd";
import {
FormItemEditableInputText,
FormItemEditableText,
FormItemEditableSelect,
} from "@/components/form";
import type { Invoice } from "@/types";
export const ClientsPageEdit = () => {
const { listUrl } = useNavigation();
const { formProps, queryResult } = useForm({
redirect: false,
meta: {
populate: ["account", "invoices.client", "invoices.account.logo"],
},
});
const { selectProps: selectPropsAccount } = useSelect({
resource: "accounts",
optionLabel: "company_name",
optionValue: "id",
});
const invoices = queryResult?.data?.data?.invoices || [];
const isLoading = queryResult?.isLoading;
return (
<Show
title="Clients"
headerButtons={() => false}
contentProps={{
styles: {
body: {
padding: 0,
},
},
style: {
background: "transparent",
boxShadow: "none",
},
}}
>
<Form {...formProps} layout="vertical">
<Row>
<Col span={24}>
<Flex gap={16}>
<FormItemEditableText
loading={isLoading}
formItemProps={{
name: "name",
rules: [{ required: true }],
}}
/>
</Flex>
</Col>
</Row>
<Row
gutter={32}
style={{
marginTop: "32px",
}}
>
<Col xs={{ span: 24 }} xl={{ span: 8 }}>
<Card
bordered={false}
styles={{ body: { padding: 0 } }}
title={
<Flex gap={12} align="center">
<ShopOutlined />
<Typography.Text>Client info</Typography.Text>
</Flex>
}
>
<FormItemEditableSelect
loading={isLoading}
icon={<BankOutlined />}
editIcon={<ExportOutlined />}
selectProps={{
showSearch: true,
placeholder: "Select account",
...selectPropsAccount,
}}
formItemProps={{
name: "account",
getValueProps: (value) => {
return {
value: value?.id,
label: value?.company_name,
};
},
label: "Account",
rules: [{ required: true }],
}}
/>
<FormItemEditableInputText
loading={isLoading}
icon={<UserOutlined />}
placeholder="Add owner name"
formItemProps={{
name: "owner_name",
label: "Owner name",
rules: [{ required: true }],
}}
/>
<Divider style={{ margin: 0 }} />
<FormItemEditableInputText
loading={isLoading}
icon={<MailOutlined />}
placeholder="Add email"
formItemProps={{
name: "owner_email",
label: "Owner email",
rules: [{ required: true, type: "email" }],
}}
/>
<Divider style={{ margin: 0 }} />
<Divider style={{ margin: 0 }} />
<FormItemEditableInputText
loading={isLoading}
icon={<EnvironmentOutlined />}
placeholder="Add address"
formItemProps={{
name: "address",
label: "Address",
rules: [{ required: true }],
}}
/>
<Divider style={{ margin: 0 }} />
<FormItemEditableInputText
loading={isLoading}
icon={<PhoneOutlined />}
placeholder="Add phone number"
formItemProps={{
name: "phone",
label: "Phone",
rules: [{ required: true }],
}}
/>
</Card>
<DeleteButton
type="text"
style={{
marginTop: "16px",
}}
onSuccess={() => {
listUrl("clients");
}}
>
Delete client
</DeleteButton>
</Col>
<Col xs={{ span: 24 }} xl={{ span: 16 }}>
<Card
bordered={false}
title={
<Flex gap={12} align="center">
<ContainerOutlined />
<Typography.Text>Invoices</Typography.Text>
</Flex>
}
styles={{
header: {
padding: "0 16px",
},
body: {
padding: 0,
},
}}
>
<Table
dataSource={invoices}
pagination={false}
loading={isLoading}
rowKey={"id"}
>
<Table.Column title="ID" dataIndex="id" key="id" width={72} />
<Table.Column
title="Date"
dataIndex="date"
key="date"
render={(date) => (
<DateField value={date} format="D MMM YYYY" />
)}
/>
<Table.Column
title="Client"
dataIndex="client"
key="client"
render={(client) => client?.name}
/>
<Table.Column
title="Amount"
dataIndex="total"
key="total"
render={(total) => (
<NumberField
value={total}
options={{ style: "currency", currency: "USD" }}
/>
)}
/>
<Table.Column
key="actions"
width={64}
render={(_, record: Invoice) => {
return (
<Flex align="center" gap={8}>
<ShowButton
hideText
resource="invoices"
recordItemId={record.id}
icon={<ExportOutlined />}
/>
</Flex>
);
}}
/>
</Table>
</Card>
</Col>
</Row>
</Form>
</Show>
);
};
After creating the CRUD pages, let’s create a src/pages/clients/index.ts
file to export the pages as follows:
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:
src/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 {
AccountsPageCreate,
AccountsPageEdit,
AccountsPageList,
} from "@/pages/accounts";
import {
ClientsPageCreate,
ClientsPageEdit,
ClientsPageList,
} from "@/pages/clients";
import "@refinedev/antd/dist/reset.css";
import "./styles/custom.css";
const App: React.FC = () => {
return (
<DevtoolsProvider>
<BrowserRouter>
<ConfigProvider>
<AntdApp>
<Refine
routerProvider={routerProvider}
authProvider={authProvider}
dataProvider={dataProvider}
notificationProvider={useNotificationProvider}
resources={[
{
name: "accounts",
list: "/accounts",
create: "/accounts/new",
edit: "/accounts/:id/edit",
},
{
name: "clients",
list: "/clients",
create: "/clients/new",
edit: "/clients/:id/edit",
},
]}
options={{
syncWithLocation: true,
warnWhenUnsavedChanges: true,
breadcrumb: false,
}}
>
<Routes>
<Route
element={
<Authenticated
key="authenticated-routes"
fallback={<CatchAllNavigate to="/login" />}
>
<ThemedLayoutV2
Header={() => <Header />}
Sider={() => null}
>
<div
style={{
maxWidth: "1280px",
padding: "24px",
margin: "0 auto",
}}
>
<Outlet />
</div>
</ThemedLayoutV2>
</Authenticated>
}
>
<Route index element={<NavigateToResource />} />
<Route
path="/accounts"
element={
<AccountsPageList>
<Outlet />
</AccountsPageList>
}
>
<Route index element={null} />
<Route path="new" element={<AccountsPageCreate />} />
</Route>
<Route
path="/accounts/:id/edit"
element={<AccountsPageEdit />}
/>
<Route
path="/clients"
element={
<ClientsPageList>
<Outlet />
</ClientsPageList>
}
>
<Route index element={null} />
<Route path="new" element={<ClientsPageCreate />} />
</Route>
<Route
path="/clients/:id/edit"
element={<ClientsPageEdit />}
/>
</Route>
<Route
element={
<Authenticated key="auth-pages" fallback={<Outlet />}>
<NavigateToResource />
</Authenticated>
}
>
<Route
path="/login"
element={
<AuthPage
type="login"
registerLink={false}
forgotPasswordLink={false}
title={
<Logo
titleProps={{ level: 2 }}
svgProps={{
width: "48px",
height: "40px",
}}
/>
}
formProps={{
initialValues: {
email: "demo@refine.dev",
password: "demodemo",
},
}}
/>
}
/>
</Route>
<Route
element={
<Authenticated key="catch-all">
<ThemedLayoutV2
Header={() => <Header />}
Sider={() => null}
>
<Outlet />
</ThemedLayoutV2>
</Authenticated>
}
>
<Route path="*" element={<ErrorComponent />} />
</Route>
</Routes>
<UnsavedChangesNotifier />
<DocumentTitleHandler />
</Refine>
</AntdApp>
</ConfigProvider>
<DevtoolsPanel />
</BrowserRouter>
</DevtoolsProvider>
);
};
export default App;
After these changes, you should be able to navigate to the clients CRUD pages as the below:
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:
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:
src/App.tsx
codeimport { Authenticated, Refine } from '@refinedev/core'
import { AuthPage, ErrorComponent, ThemedLayoutV2, useNotificationProvider } from '@refinedev/antd'
import routerProvider, {
UnsavedChangesNotifier,
DocumentTitleHandler,
NavigateToResource,
CatchAllNavigate,
} from '@refinedev/react-router-v6'
import { BrowserRouter, Outlet, Route, Routes } from 'react-router-dom'
import { DevtoolsPanel, DevtoolsProvider } from '@refinedev/devtools'
import { App as AntdApp } from 'antd'
import { dataProvider } from '@/providers/data-provider'
import { authProvider } from '@/providers/auth-provider'
import { ConfigProvider } from '@/providers/config-provider'
import { Logo } from '@/components/logo'
import { Header } from '@/components/header'
import { AccountsPageCreate, AccountsPageEdit, AccountsPageList } from '@/pages/accounts'
import { ClientsPageCreate, ClientsPageEdit, ClientsPageList } from '@/pages/clients'
import '@refinedev/antd/dist/reset.css'
import './styles/custom.css'
const App: React.FC = () => {
return (
<DevtoolsProvider>
<BrowserRouter>
<ConfigProvider>
<AntdApp>
<Refine
routerProvider={routerProvider}
authProvider={authProvider}
dataProvider={dataProvider}
notificationProvider={useNotificationProvider}
resources={[
{
name: "accounts",
list: "/accounts",
create: "/accounts/new",
edit: "/accounts/:id/edit",
},
{
name: "clients",
list: "/clients",
create: "/clients/new",
edit: "/clients/:id/edit",
},
{
name: "invoices",
list: "/invoices",
show: "/invoices/:id",
create: "/invoices/new",
},
]}
options={{
syncWithLocation: true,
warnWhenUnsavedChanges: true,
breadcrumb: false,
}}>
<Routes>
<Route
element={
<Authenticated key="authenticated-routes" fallback={<CatchAllNavigate to="/login" />}>
<ThemedLayoutV2 Header={() => <Header />} Sider={() => null}>
<div
style={{
maxWidth: "1280px",
padding: "24px",
margin: "0 auto",
}}>
<Outlet />
</div>
</ThemedLayoutV2>
</Authenticated>
}>
<Route index element={<NavigateToResource />} />
<Route
path="/accounts"
element={
<AccountsPageList>
<Outlet />
</AccountsPageList>
}>
<Route index element={null} />
<Route path="new" element={<AccountsPageCreate />} />
</Route>
<Route path="/accounts/:id/edit" element={<AccountsPageEdit />} />
<Route
path="/clients"
element={
<ClientsPageList>
<Outlet />
</ClientsPageList>
}>
<Route index element={null} />
<Route path="new" element={<ClientsPageCreate />} />
</Route>
<Route path="/clients/:id/edit" element={<ClientsPageEdit />} />
</Route>
<Route
element={
<Authenticated key="auth-pages" fallback={<Outlet />}>
<NavigateToResource />
</Authenticated>
}>
<Route
path="/login"
element={
<AuthPage
type="login"
registerLink={false}
forgotPasswordLink={false}
title={
<Logo
titleProps={{ level: 2 }}
svgProps={{
width: "48px",
height: "40px",
}}
/>
}
formProps={{
initialValues: {
email: "demo@refine.dev",
password: "demodemo",
},
}}
/>
}
/>
</Route>
<Route
element={
<Authenticated key="catch-all">
<ThemedLayoutV2 Header={() => <Header />} Sider={() => null}>
<Outlet />
</ThemedLayoutV2>
</Authenticated>
}>
<Route path="*" element={<ErrorComponent />} />
</Route>
</Routes>
<UnsavedChangesNotifier />
<DocumentTitleHandler />
</Refine>
</AntdApp>
</ConfigProvider>
<DevtoolsPanel />
</BrowserRouter>
</DevtoolsProvider>
)
}
export default App
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:
<InvoicesPageList />
codeimport { getDefaultFilter, useGo } from "@refinedev/core";
import {
CreateButton,
DateField,
DeleteButton,
FilterDropdown,
List,
NumberField,
ShowButton,
getDefaultSortOrder,
useSelect,
useTable,
} from "@refinedev/antd";
import { Avatar, Flex, Input, Select, Table, Typography } from "antd";
import { EyeOutlined, SearchOutlined } from "@ant-design/icons";
import { API_URL } from "@/utils/constants";
import { getRandomColorFromString } from "@/utils/get-random-color";
import type { Invoice } from "@/types";
export const InvoicePageList = () => {
const go = useGo();
const { tableProps, filters, sorters } = useTable<Invoice>({
meta: {
populate: ["client", "account.logo"],
},
sorters: {
initial: [{ field: "updatedAt", order: "desc" }],
},
});
const { selectProps: selectPropsAccounts } = useSelect({
resource: "accounts",
optionLabel: "company_name",
optionValue: "company_name",
});
const { selectProps: selectPropsClients } = useSelect({
resource: "clients",
optionLabel: "name",
optionValue: "name",
});
return (
<List
title="Invoices"
headerButtons={() => {
return (
<CreateButton
size="large"
onClick={() =>
go({
to: { resource: "invoices", action: "create" },
options: { keepQuery: true },
})
}
>
Add new invoice
</CreateButton>
);
}}
>
<Table
{...tableProps}
rowKey={"id"}
pagination={{
...tableProps.pagination,
showSizeChanger: true,
}}
scroll={{ x: 960 }}
>
<Table.Column
title="ID"
dataIndex="id"
key="id"
width={80}
defaultFilteredValue={getDefaultFilter("id", filters)}
filterIcon={<SearchOutlined />}
filterDropdown={(props) => {
return (
<FilterDropdown {...props}>
<Input placeholder="Search ID" />
</FilterDropdown>
);
}}
/>
<Table.Column
title="Account"
dataIndex="account.company_name"
key="account.company_name"
defaultFilteredValue={getDefaultFilter(
"account.company_name",
filters,
"in",
)}
filterDropdown={(props) => (
<FilterDropdown {...props}>
<Select
mode="multiple"
placeholder="Search Account"
style={{ width: 220 }}
{...selectPropsAccounts}
/>
</FilterDropdown>
)}
render={(_, record: Invoice) => {
const logoUrl = record?.account?.logo?.url;
const src = logoUrl ? `${API_URL}${logoUrl}` : undefined;
const name = record?.account?.company_name;
return (
<Flex align="center" gap={8}>
<Avatar
alt={name}
src={src}
shape="square"
style={{
backgroundColor: getRandomColorFromString(name),
}}
>
<Typography.Text>{name?.[0]?.toUpperCase()}</Typography.Text>
</Avatar>
<Typography.Text>{name}</Typography.Text>
</Flex>
);
}}
/>
<Table.Column
title="Client"
dataIndex="client.name"
key="client.name"
render={(_, record: Invoice) => {
return <Typography.Text>{record.client?.name}</Typography.Text>;
}}
defaultFilteredValue={getDefaultFilter("company_name", filters, "in")}
filterDropdown={(props) => (
<FilterDropdown {...props}>
<Select
mode="multiple"
placeholder="Search Company Name"
style={{ width: 220 }}
{...selectPropsClients}
/>
</FilterDropdown>
)}
/>
<Table.Column
title="Date"
dataIndex="date"
key="date"
width={120}
sorter
defaultSortOrder={getDefaultSortOrder("date", sorters)}
render={(date) => {
return <DateField value={date} format="D MMM YYYY" />;
}}
/>
<Table.Column
title="Total"
dataIndex="total"
key="total"
width={132}
align="end"
sorter
defaultSortOrder={getDefaultSortOrder("total", sorters)}
render={(total) => {
return (
<NumberField
value={total}
options={{ style: "currency", currency: "USD" }}
/>
);
}}
/>
<Table.Column
title="Actions"
key="actions"
fixed="right"
align="end"
width={102}
render={(_, record: Invoice) => {
return (
<Flex align="center" gap={8}>
<ShowButton
hideText
recordItemId={record.id}
icon={<EyeOutlined />}
/>
<DeleteButton hideText recordItemId={record.id} />
</Flex>
);
}}
/>
</Table>
</List>
);
};
After creating the list page, let’s create a src/pages/invoices/index.ts
file to export the pages as follows:
export { InvoicePageList } from "./list";
Now you are ready to add our “list” page to the src/App.tsx
file as follows:
src/App.tsx
code// ...
import { InvoicePageList } from "@/pages/invoices";
const App: React.FC = () => {
return (
//...
<Refine
//...
>
<Routes>
<Route
//...
>
{/* ... */}
<Route
path="/clients"
element={
<ClientsPageList>
<Outlet />
</ClientsPageList>
}
>
<Route index element={null} />
<Route path="new" element={<ClientsPageCreate />} />
</Route>
<Route path="/clients/:id/edit" element={<ClientsPageEdit />} />
<Route path="/invoices">
<Route index element={<InvoicePageList />} />
</Route>
</Route>
<Route
element={
<Authenticated key="auth-pages" fallback={<Outlet />}>
<NavigateToResource />
</Authenticated>
}
>
{/* ... */}
</Route>
{/* ... */}
</Routes>
{/* ... */}
</Refine>
//...
);
};
export default App;
After these changes, you should be able to navigate to the invoice list pages as the below:
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:
src/pages/invoices/create.styled.tsx
codeimport { createStyles } from "antd-style";
export const useStyles = createStyles(({ token, isDarkMode }) => {
return {
serviceTableWrapper: {
overflow: "auto",
},
serviceTableContainer: {
minWidth: "960px",
borderRadius: "8px",
border: `1px solid ${token.colorBorder}`,
},
serviceHeader: {
background: isDarkMode ? "#1F1F1F" : "#FAFAFA",
borderRadius: "8px 8px 0 0",
},
serviceHeaderDivider: {
height: "24px",
marginTop: "auto",
marginBottom: "auto",
marginInline: "0",
},
serviceHeaderColumn: {
fontWeight: 600,
display: "flex",
alignItems: "center",
justifyContent: "space-between",
padding: "12px 16px",
},
serviceRowColumn: {
display: "flex",
alignItems: "center",
padding: "12px 16px",
},
addNewServiceItemButton: {
color: token.colorPrimary,
},
labelTotal: {
color: token.colorTextSecondary,
},
};
});
To write CSS you used the createStyles
function from the antd-style
package. This function accepts a callback function that provides the token
and isDarkMode
values. The token
object contains the color values of the current theme, and the isDarkMode
value indicates whether the current theme is dark or light.
Let’s create the src/pages/invoices/create.tsx
file with the following code:
<InvoicesPageCreate />
codeimport { Fragment, useState } from "react";
import { NumberField, Show, useForm, useSelect } from "@refinedev/antd";
import {
Button,
Card,
Col,
Divider,
Flex,
Form,
Input,
InputNumber,
Row,
Select,
Typography,
} from "antd";
import { DeleteOutlined, PlusCircleOutlined } from "@ant-design/icons";
import type { Invoice, Service } from "@/types";
import { useStyles } from "./create.styled";
export const InvoicesPageCreate = () => {
const [tax, setTax] = useState<number>(0);
const [services, setServices] = useState<Service[]>([
{
title: "",
unitPrice: 0,
quantity: 0,
discount: 0,
totalPrice: 0,
},
]);
const subtotal = services.reduce(
(acc, service) =>
acc +
(service.unitPrice * service.quantity * (100 - service.discount)) / 100,
0,
);
const total = subtotal + (subtotal * tax) / 100;
const { styles } = useStyles();
const { formProps } = useForm<Invoice>();
const { selectProps: selectPropsAccounts } = useSelect({
resource: "accounts",
optionLabel: "company_name",
optionValue: "id",
});
const { selectProps: selectPropsClients } = useSelect({
resource: "clients",
optionLabel: "name",
optionValue: "id",
});
const handleServiceNumbersChange = (
index: number,
key: "quantity" | "discount" | "unitPrice",
value: number,
) => {
setServices((prev) => {
const currentService = { ...prev[index] };
currentService[key] = value;
currentService.totalPrice =
currentService.unitPrice *
currentService.quantity *
((100 - currentService.discount) / 100);
return prev.map((item, i) => (i === index ? currentService : item));
});
};
const onFinishHandler = (values: Invoice) => {
const valuesWithServices = {
...values,
total,
tax,
date: new Date().toISOString(),
services: services.filter((service) => service.title),
};
formProps?.onFinish?.(valuesWithServices);
};
return (
<Show
title="Invoices"
headerButtons={() => false}
contentProps={{
styles: {
body: {
padding: 0,
},
},
style: {
background: "transparent",
boxShadow: "none",
},
}}
>
<Form
{...formProps}
onFinish={(values) => onFinishHandler(values as Invoice)}
layout="vertical"
>
<Flex vertical gap={32}>
<Typography.Title level={3}>New Invoice</Typography.Title>
<Card
bordered={false}
styles={{
body: {
padding: 0,
},
}}
>
<Flex
align="center"
gap={40}
wrap="wrap"
style={{ padding: "32px" }}
>
<Form.Item
label="Account"
name="account"
rules={[{ required: true }]}
style={{ flex: 1 }}
>
<Select
{...selectPropsAccounts}
placeholder="Please select account"
/>
</Form.Item>
<Form.Item
label="Client"
name="client"
rules={[{ required: true }]}
style={{ flex: 1 }}
>
<Select
{...selectPropsClients}
placeholder="Please select client"
/>
</Form.Item>
</Flex>
<Divider style={{ margin: 0 }} />
<div style={{ padding: "32px" }}>
<Typography.Title
level={4}
style={{ marginBottom: "32px", fontWeight: 400 }}
>
Products / Services
</Typography.Title>
<div className={styles.serviceTableWrapper}>
<div className={styles.serviceTableContainer}>
<Row className={styles.serviceHeader}>
<Col
xs={{ span: 7 }}
className={styles.serviceHeaderColumn}
>
Title
<Divider
type="vertical"
className={styles.serviceHeaderDivider}
/>
</Col>
<Col
xs={{ span: 5 }}
className={styles.serviceHeaderColumn}
>
Unit Price
<Divider
type="vertical"
className={styles.serviceHeaderDivider}
/>
</Col>
<Col
xs={{ span: 4 }}
className={styles.serviceHeaderColumn}
>
Quantity
<Divider
type="vertical"
className={styles.serviceHeaderDivider}
/>
</Col>
<Col
xs={{ span: 4 }}
className={styles.serviceHeaderColumn}
>
Discount
<Divider
type="vertical"
className={styles.serviceHeaderDivider}
/>
</Col>
<Col
xs={{ span: 3 }}
style={{
display: "flex",
alignItems: "center",
justifyContent: "flex-end",
}}
className={styles.serviceHeaderColumn}
>
Total Price
</Col>
<Col xs={{ span: 1 }}> </Col>
</Row>
<Row>
{services.map((service, index) => {
return (
// biome-ignore lint/suspicious/noArrayIndexKey: You don't have a unique key for each service item when you create a new one
<Fragment key={index}>
<Col
xs={{ span: 7 }}
className={styles.serviceRowColumn}
>
<Input
placeholder="Title"
value={service.title}
onChange={(e) => {
setServices((prev) =>
prev.map((item, i) =>
i === index
? { ...item, title: e.target.value }
: item,
),
);
}}
/>
</Col>
<Col
xs={{ span: 5 }}
className={styles.serviceRowColumn}
>
<InputNumber
addonBefore="$"
style={{ width: "100%" }}
placeholder="Unit Price"
min={0}
value={service.unitPrice}
onChange={(value) => {
handleServiceNumbersChange(
index,
"unitPrice",
value || 0,
);
}}
/>
</Col>
<Col
xs={{ span: 4 }}
className={styles.serviceRowColumn}
>
<InputNumber
style={{ width: "100%" }}
placeholder="Quantity"
min={0}
value={service.quantity}
onChange={(value) => {
handleServiceNumbersChange(
index,
"quantity",
value || 0,
);
}}
/>
</Col>
<Col
xs={{ span: 4 }}
className={styles.serviceRowColumn}
>
<InputNumber
addonAfter="%"
style={{ width: "100%" }}
placeholder="Discount"
min={0}
value={service.discount}
onChange={(value) => {
handleServiceNumbersChange(
index,
"discount",
value || 0,
);
}}
/>
</Col>
<Col
xs={{ span: 3 }}
className={styles.serviceRowColumn}
style={{
justifyContent: "flex-end",
}}
>
<NumberField
value={service.totalPrice}
options={{ style: "currency", currency: "USD" }}
/>
</Col>
<Col
xs={{ span: 1 }}
className={styles.serviceRowColumn}
style={{
paddingLeft: "0",
justifyContent: "flex-end",
}}
>
<Button
danger
size="small"
icon={<DeleteOutlined />}
onClick={() => {
setServices((prev) =>
prev.filter((_, i) => i !== index),
);
}}
/>
</Col>
</Fragment>
);
})}
</Row>
<Divider
style={{
margin: "0",
}}
/>
<div style={{ padding: "12px" }}>
<Button
icon={<PlusCircleOutlined />}
type="text"
className={styles.addNewServiceItemButton}
onClick={() => {
setServices((prev) => [
...prev,
{
title: "",
unitPrice: 0,
quantity: 0,
discount: 0,
totalPrice: 0,
},
]);
}}
>
Add new item
</Button>
</div>
</div>
</div>
<Flex
gap={16}
vertical
style={{
marginLeft: "auto",
marginTop: "24px",
width: "220px",
}}
>
<Flex
justify="space-between"
style={{
paddingLeft: 32,
}}
>
<Typography.Text className={styles.labelTotal}>
Subtotal:
</Typography.Text>
<NumberField
value={subtotal}
options={{ style: "currency", currency: "USD" }}
/>
</Flex>
<Flex
align="center"
justify="space-between"
style={{
paddingLeft: 32,
}}
>
<Typography.Text className={styles.labelTotal}>
Sales tax:
</Typography.Text>
<InputNumber
addonAfter="%"
style={{ width: "96px" }}
value={tax}
min={0}
onChange={(value) => {
setTax(value || 0);
}}
/>
</Flex>
<Divider
style={{
margin: "0",
}}
/>
<Flex
justify="space-between"
style={{
paddingLeft: 16,
}}
>
<Typography.Text
className={styles.labelTotal}
style={{
fontWeight: 700,
}}
>
Total value:
</Typography.Text>
<NumberField
value={total}
options={{ style: "currency", currency: "USD" }}
/>
</Flex>
</Flex>
</div>
<Divider style={{ margin: 0 }} />
<Flex justify="end" gap={8} style={{ padding: "32px" }}>
<Button>Cancel</Button>
<Button type="primary" htmlType="submit">
Save
</Button>
</Flex>
</Card>
</Flex>
</Form>
</Show>
);
};
You have created a form to create a new invoice. The form includes the account and client fields, and a table to add service items. The user can add multiple service items to the invoice. The total value of the invoice is calculated based on the subtotal and sales tax.
Refine useSelect
hook is used to fetch the accounts and clients from the API and populate and manage to <Select />
components to add the account and client relation to the invoice.
After creating the create page, let’s create a src/pages/invoices/index.ts
file to export the pages as follows:
export { InvoicePageList } from "./list";
export { InvoicesPageCreate } from "./create";
Now you are ready to add our “create” page to the src/App.tsx
file as follows:
src/App.tsx
code// ...
import { InvoicePageList, InvoicesPageCreate } from "@/pages/invoices";
const App: React.FC = () => {
return (
//...
<Refine
//...
>
<Routes>
<Route
//...
>
{/* ... */}
<Route
path="/clients"
element={
<ClientsPageList>
<Outlet />
</ClientsPageList>
}
>
<Route index element={null} />
<Route path="new" element={<ClientsPageCreate />} />
</Route>
<Route path="/clients/:id/edit" element={<ClientsPageEdit />} />
<Route path="/invoices">
<Route index element={<InvoicePageList />} />
<Route path="new" element={<InvoicesPageCreate />} />
</Route>
</Route>
<Route
element={
<Authenticated key="auth-pages" fallback={<Outlet />}>
<NavigateToResource />
</Authenticated>
}
>
{/* ... */}
</Route>
{/* ... */}
</Routes>
{/* ... */}
</Refine>
//...
);
};
export default App;
After these changes, you should be able to navigate to the invoice create pages as the below:
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:
src/pages/invoices/show.styled.tsx
codeimport { createStyles } from "antd-style";
export const useStyles = createStyles(({ token }) => {
return {
container: {
".ant-card-body": {
padding: "0",
},
".ant-card-head": {
padding: "32px",
background: token.colorBgContainer,
},
"@media print": {
margin: "0 auto",
minHeight: "100dvh",
maxWidth: "892px",
".ant-card": {
boxShadow: "none",
border: "none",
},
".ant-card-head": {
padding: "0 !important",
},
".ant-col": {
maxWidth: "50% !important",
flex: "0 0 50% !important",
},
table: {
width: "unset !important",
},
".ant-table-container::after": {
content: "none",
},
".ant-table-container::before": {
content: "none",
},
},
},
fromToContainer: {
minHeight: "192px",
padding: "32px",
"@media print": {
flexWrap: "nowrap",
flexFlow: "row nowrap",
minHeight: "unset",
padding: "32px 0",
},
},
productServiceContainer: {
padding: "32px",
"@media print": {
padding: "0",
marginTop: "32px",
},
},
labelTotal: {
color: token.colorTextSecondary,
},
};
});
Let’s create the src/pages/invoices/show.tsx
file with the following code:
<InvoicesPageShow />
codeimport { useShow } from "@refinedev/core";
import { FilePdfOutlined } from "@ant-design/icons";
import {
Button,
Avatar,
Card,
Col,
Divider,
Flex,
Row,
Skeleton,
Spin,
Table,
Typography,
} from "antd";
import { DateField, NumberField, Show } from "@refinedev/antd";
import { API_URL } from "@/utils/constants";
import { getRandomColorFromString } from "@/utils/get-random-color";
import type { Invoice, Service } from "@/types";
import { useStyles } from "./show.styled";
export const InvoicesPageShow = () => {
const { styles } = useStyles();
const { queryResult } = useShow<Invoice>({
meta: {
populate: ["client", "account.logo"],
},
});
const invoice = queryResult?.data?.data;
const loading = queryResult?.isLoading;
const logoUrl = invoice?.account?.logo?.url
? `${API_URL}${invoice?.account?.logo?.url}`
: undefined;
return (
<Show
title="Invoices"
headerButtons={() => (
<>
<Button
disabled={!invoice}
icon={<FilePdfOutlined />}
onClick={() => window.print()}
>
Export PDF
</Button>
</>
)}
contentProps={{
styles: {
body: {
padding: 0,
},
},
style: {
background: "transparent",
},
}}
>
<div id="invoice-pdf" className={styles.container}>
<Card
bordered={false}
title={
<Typography.Text
style={{
fontWeight: 400,
}}
>
{loading ? (
<Skeleton.Button style={{ width: 100, height: 22 }} />
) : (
`Invoice ID #${invoice?.id}`
)}
</Typography.Text>
}
extra={
<Flex gap={8} align="center">
{loading ? (
<Skeleton.Button style={{ width: 140, height: 22 }} />
) : (
<>
<Typography.Text>Date:</Typography.Text>
<DateField
style={{ width: 84 }}
value={invoice?.date}
format="D MMM YYYY"
/>
</>
)}
</Flex>
}
>
<Spin spinning={loading}>
<Row className={styles.fromToContainer}>
<Col xs={24} md={12}>
<Flex vertical gap={24}>
<Typography.Text>From:</Typography.Text>
<Flex gap={24}>
<Avatar
alt={invoice?.account?.company_name}
size={64}
src={logoUrl}
shape="square"
style={{
backgroundColor: logoUrl
? "transparent"
: getRandomColorFromString(
invoice?.account?.company_name || "",
),
}}
>
<Typography.Text>
{invoice?.account?.company_name?.[0]?.toUpperCase()}
</Typography.Text>
</Avatar>
<Flex vertical gap={8}>
<Typography.Text
style={{
fontWeight: 700,
}}
>
{invoice?.account?.company_name}
</Typography.Text>
<Typography.Text>
{invoice?.account?.address}
</Typography.Text>
<Typography.Text>
{invoice?.account?.phone}
</Typography.Text>
</Flex>
</Flex>
</Flex>
</Col>
<Col xs={24} md={12}>
<Flex vertical gap={24} align="flex-end">
<Typography.Text>To:</Typography.Text>
<Flex vertical gap={8} align="flex-end">
<Typography.Text
style={{
fontWeight: 700,
}}
>
{invoice?.client?.name}
</Typography.Text>
<Typography.Text>
{invoice?.client?.address}
</Typography.Text>
<Typography.Text>{invoice?.client?.phone}</Typography.Text>
</Flex>
</Flex>
</Col>
</Row>
</Spin>
<Divider
style={{
margin: 0,
}}
/>
<Flex vertical gap={24} className={styles.productServiceContainer}>
<Typography.Title
level={4}
style={{
margin: 0,
fontWeight: 400,
}}
>
Product / Services
</Typography.Title>
<Table
dataSource={invoice?.services || []}
rowKey={"id"}
pagination={false}
loading={loading}
scroll={{ x: 960 }}
>
<Table.Column title="Title" dataIndex="title" key="title" />
<Table.Column
title="Unit Price"
dataIndex="unitPrice"
key="unitPrice"
render={(unitPrice: number) => (
<NumberField
value={unitPrice}
options={{ style: "currency", currency: "USD" }}
/>
)}
/>
<Table.Column
title="Quantity"
dataIndex="quantity"
key="quantity"
/>
<Table.Column
title="Discount"
dataIndex="discount"
key="discount"
render={(discount: number) => (
<Typography.Text>{`${discount}%`}</Typography.Text>
)}
/>
<Table.Column
title="Total Price"
dataIndex="total"
key="total"
align="right"
width={128}
render={(_, record: Service) => {
return (
<NumberField
value={record.totalPrice}
options={{ style: "currency", currency: "USD" }}
/>
);
}}
/>
</Table>
<Flex
gap={16}
vertical
style={{
marginLeft: "auto",
marginTop: "24px",
width: "200px",
}}
>
<Flex
justify="space-between"
style={{
paddingLeft: 32,
}}
>
<Typography.Text className={styles.labelTotal}>
Subtotal:
</Typography.Text>
<NumberField
value={invoice?.subTotal || 0}
options={{ style: "currency", currency: "USD" }}
/>
</Flex>
<Flex
justify="space-between"
style={{
paddingLeft: 32,
}}
>
<Typography.Text className={styles.labelTotal}>
Sales tax:
</Typography.Text>
<Typography.Text>{invoice?.tax || 0}%</Typography.Text>
</Flex>
<Divider
style={{
margin: "0",
}}
/>
<Flex
justify="space-between"
style={{
paddingLeft: 16,
}}
>
<Typography.Text
className={styles.labelTotal}
style={{
fontWeight: 700,
}}
>
Total value:
</Typography.Text>
<NumberField
value={invoice?.total || 0}
options={{ style: "currency", currency: "USD" }}
/>
</Flex>
</Flex>
</Flex>
</Card>
</div>
</Show>
);
};
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:
export { InvoicePageList } from "./list";
export { InvoicesPageCreate } from "./create";
export { InvoicesPageShow } from "./show";
Now you are ready to add our “show” page to the src/App.tsx
file as follows:
[details Show src/App.tsx
code
import { Authenticated, Refine } from "@refinedev/core";
import {
AuthPage,
ErrorComponent,
ThemedLayoutV2,
useNotificationProvider,
} from "@refinedev/antd";
import routerProvider, {
UnsavedChangesNotifier,
DocumentTitleHandler,
NavigateToResource,
CatchAllNavigate,
} from "@refinedev/react-router-v6";
import { BrowserRouter, Outlet, Route, Routes } from "react-router-dom";
import { DevtoolsPanel, DevtoolsProvider } from "@refinedev/devtools";
import { App as AntdApp } from "antd";
import { dataProvider } from "@/providers/data-provider";
import { authProvider } from "@/providers/auth-provider";
import { ConfigProvider } from "@/providers/config-provider";
import { Logo } from "@/components/logo";
import { Header } from "@/components/header";
import {
AccountsPageCreate,
AccountsPageEdit,
AccountsPageList,
} from "@/pages/accounts";
import {
ClientsPageCreate,
ClientsPageEdit,
ClientsPageList,
} from "@/pages/clients";
import {
InvoicePageList,
InvoicesPageCreate,
InvoicesPageShow,
} from "@/pages/invoices";
import "@refinedev/antd/dist/reset.css";
import "./styles/custom.css";
const App: React.