π Table of Contents
- π€ Introduction
- βοΈ Tech Stack
- π Features
- π€Έ Quick Start
- πΈοΈ Snippets
- π Links
This repository contains the code that corresponds to building an app from scratch. .
If you prefer to learn from the doc, this is the perfect resource for you. Follow along to learn how to create projects like these step by step in a beginner-friendly way!
React-based CRM dashboard featuring comprehensive authentication, antd charts, sales management, and a fully operational kanban board with live updates for real-time actions across all devices.
If you are just starting out and need help, or if you encounter any bugs, you can ask. This is a place where people help each other.
- React.js
- TypeScript
- GraphQL
- Ant Design
- Refine
- Codegen
- Vite
π Authentication: Seamless onboarding with secure login and signup functionalities; robust password recovery ensures a smooth authentication experience.
π Authorization: Granular access control regulates user actions, maintaining data security and user permissions.
π Home Page: Dynamic home page showcases interactive charts for key metrics; real-time updates on activities, upcoming events, and a deals chart for business insights.
π Companies Page: Complete CRUD for company management and sales processes; detailed profiles with add/edit functions, associated contacts/leads, pagination, and field-specific search.
π Kanban Board: Collaborative board with real-time task updates; customization options include due dates, markdown descriptions, and multi-assignees, dynamically shifting tasks across dashboards.
π Account Settings: Personalized user account settings for profile management; streamlined configuration options for a tailored application experience.
π Responsive: Full responsiveness across devices for consistent user experience; fluid design adapts seamlessly to various screen sizes, ensuring accessibility.
and many more, including code architecture and reusability
Follow these steps to set up the project locally on your machine.
Prerequisites
Make sure you have the following installed on your machine:
Cloning the Repository
git clone https://github.com/emredkyc/react_admin_dashboard.git
cd react_admin_dashboard
Installation
Install the project dependencies using npm:
npm install
Running the Project
npm run dev
Open http://localhost:5173 in your browser to view the project.
providers/auth.ts
import { AuthBindings } from "@refinedev/core";
import { API_URL, dataProvider } from "./data";
// For demo purposes and to make it easier to test the app, you can use the following credentials
export const authCredentials = {
email: "[email protected]",
password: "demodemo",
};
export const authProvider: AuthBindings = {
login: async ({ email }) => {
try {
// call the login mutation
// dataProvider.custom is used to make a custom request to the GraphQL API
// this will call dataProvider which will go through the fetchWrapper function
const { data } = await dataProvider.custom({
url: API_URL,
method: "post",
headers: {},
meta: {
variables: { email },
// pass the email to see if the user exists and if so, return the accessToken
rawQuery: `
mutation Login($email: String!) {
login(loginInput: { email: $email }) {
accessToken
}
}
`,
},
});
// save the accessToken in localStorage
localStorage.setItem("access_token", data.login.accessToken);
return {
success: true,
redirectTo: "/",
};
} catch (e) {
const error = e as Error;
return {
success: false,
error: {
message: "message" in error ? error.message : "Login failed",
name: "name" in error ? error.name : "Invalid email or password",
},
};
}
},
// simply remove the accessToken from localStorage for the logout
logout: async () => {
localStorage.removeItem("access_token");
return {
success: true,
redirectTo: "/login",
};
},
onError: async (error) => {
// a check to see if the error is an authentication error
// if so, set logout to true
if (error.statusCode === "UNAUTHENTICATED") {
return {
logout: true,
...error,
};
}
return { error };
},
check: async () => {
try {
// get the identity of the user
// this is to know if the user is authenticated or not
await dataProvider.custom({
url: API_URL,
method: "post",
headers: {},
meta: {
rawQuery: `
query Me {
me {
name
}
}
`,
},
});
// if the user is authenticated, redirect to the home page
return {
authenticated: true,
redirectTo: "/",
};
} catch (error) {
// for any other error, redirect to the login page
return {
authenticated: false,
redirectTo: "/login",
};
}
},
// get the user information
getIdentity: async () => {
const accessToken = localStorage.getItem("access_token");
try {
// call the GraphQL API to get the user information
// we're using me:any because the GraphQL API doesn't have a type for the me query yet.
// we'll add some queries and mutations later and change this to User which will be generated by codegen.
const { data } = await dataProvider.custom<{ me: any }>({
url: API_URL,
method: "post",
headers: accessToken
? {
// send the accessToken in the Authorization header
Authorization: `Bearer ${accessToken}`,
}
: {},
meta: {
// get the user information such as name, email, etc.
rawQuery: `
query Me {
me {
id
name
email
phone
jobTitle
timezone
avatarUrl
}
}
`,
},
});
return data.me;
} catch (error) {
return undefined;
}
},
};
GraphQl and Codegen Setup
npm i -D @graphql-codegen/cli @graphql-codegen/typescript @graphql-codegen/typescript-operations @graphql-codegen/import-types-preset prettier vite-tsconfig-paths
graphql.config.ts
import type { IGraphQLConfig } from "graphql-config";
const config: IGraphQLConfig = {
// define graphQL schema provided by Refine
schema: "https://api.crm.refine.dev/graphql",
extensions: {
// codegen is a plugin that generates typescript types from GraphQL schema
// https://the-guild.dev/graphql/codegen
codegen: {
// hooks are commands that are executed after a certain event
hooks: {
afterOneFileWrite: ["eslint --fix", "prettier --write"],
},
// generates typescript types from GraphQL schema
generates: {
// specify the output path of the generated types
"src/graphql/schema.types.ts": {
// use typescript plugin
plugins: ["typescript"],
// set the config of the typescript plugin
// this defines how the generated types will look like
config: {
skipTypename: true, // skipTypename is used to remove __typename from the generated types
enumsAsTypes: true, // enumsAsTypes is used to generate enums as types instead of enums.
// scalars is used to define how the scalars i.e. DateTime, JSON, etc. will be generated
// scalar is a type that is not a list and does not have fields. Meaning it is a primitive type.
scalars: {
// DateTime is a scalar type that is used to represent date and time
DateTime: {
input: "string",
output: "string",
format: "date-time",
},
},
},
},
// generates typescript types from GraphQL operations
// graphql operations are queries, mutations, and subscriptions we write in our code to communicate with the GraphQL API
"src/graphql/types.ts": {
// preset is a plugin that is used to generate typescript types from GraphQL operations
// import-types suggests to import types from schema.types.ts or other files
// this is used to avoid duplication of types
// https://the-guild.dev/graphql/codegen/plugins/presets/import-types-preset
preset: "import-types",
// documents is used to define the path of the files that contain GraphQL operations
documents: ["src/**/*.{ts,tsx}"],
// plugins is used to define the plugins that will be used to generate typescript types from GraphQL operations
plugins: ["typescript-operations"],
config: {
skipTypename: true,
enumsAsTypes: true,
// determine whether the generated types should be resolved ahead of time or not.
// When preResolveTypes is set to false, the code generator will not try to resolve the types ahead of time.
// Instead, it will generate more generic types, and the actual types will be resolved at runtime.
preResolveTypes: false,
// useTypeImports is used to import types using import type instead of import.
useTypeImports: true,
},
// presetConfig is used to define the config of the preset
presetConfig: {
typesPath: "./schema.types",
},
},
},
},
},
};
export default config;
graphql/mutations.ts
import gql from "graphql-tag";
// Mutation to update user
export const UPDATE_USER_MUTATION = gql`
# The ! after the type means that it is required
mutation UpdateUser($input: UpdateOneUserInput!) {
# call the updateOneUser mutation with the input and pass the $input argument
# $variableName is a convention for GraphQL variables
updateOneUser(input: $input) {
id
name
avatarUrl
email
phone
jobTitle
}
}
`;
// Mutation to create company
export const CREATE_COMPANY_MUTATION = gql`
mutation CreateCompany($input: CreateOneCompanyInput!) {
createOneCompany(input: $input) {
id
salesOwner {
id
}
}
}
`;
// Mutation to update company details
export const UPDATE_COMPANY_MUTATION = gql`
mutation UpdateCompany($input: UpdateOneCompanyInput!) {
updateOneCompany(input: $input) {
id
name
totalRevenue
industry
companySize
businessType
country
website
avatarUrl
salesOwner {
id
name
avatarUrl
}
}
}
`;
// Mutation to update task stage of a task
export const UPDATE_TASK_STAGE_MUTATION = gql`
mutation UpdateTaskStage($input: UpdateOneTaskInput!) {
updateOneTask(input: $input) {
id
}
}
`;
// Mutation to create a new task
export const CREATE_TASK_MUTATION = gql`
mutation CreateTask($input: CreateOneTaskInput!) {
createOneTask(input: $input) {
id
title
stage {
id
title
}
}
}
`;
// Mutation to update a task details
export const UPDATE_TASK_MUTATION = gql`
mutation UpdateTask($input: UpdateOneTaskInput!) {
updateOneTask(input: $input) {
id
title
completed
description
dueDate
stage {
id
title
}
users {
id
name
avatarUrl
}
checklist {
title
checked
}
}
}
`;
graphql/queries.ts
import gql from "graphql-tag";
// Query to get Total Company, Contact and Deal Counts
export const DASHBOARD_TOTAL_COUNTS_QUERY = gql`
query DashboardTotalCounts {
companies {
totalCount
}
contacts {
totalCount
}
deals {
totalCount
}
}
`;
// Query to get upcoming events
export const DASHBORAD_CALENDAR_UPCOMING_EVENTS_QUERY = gql`
query DashboardCalendarUpcomingEvents(
$filter: EventFilter!
$sorting: [EventSort!]
$paging: OffsetPaging!
) {
events(filter: $filter, sorting: $sorting, paging: $paging) {
totalCount
nodes {
id
title
color
startDate
endDate
}
}
}
`;
// Query to get deals chart
export const DASHBOARD_DEALS_CHART_QUERY = gql`
query DashboardDealsChart(
$filter: DealStageFilter!
$sorting: [DealStageSort!]
$paging: OffsetPaging
) {
dealStages(filter: $filter, sorting: $sorting, paging: $paging) {
# Get all deal stages
nodes {
id
title
# Get the sum of all deals in this stage and group by closeDateMonth and closeDateYear
dealsAggregate {
groupBy {
closeDateMonth
closeDateYear
}
sum {
value
}
}
}
# Get the total count of all deals in this stage
totalCount
}
}
`;
// Query to get latest activities deals
export const DASHBOARD_LATEST_ACTIVITIES_DEALS_QUERY = gql`
query DashboardLatestActivitiesDeals(
$filter: DealFilter!
$sorting: [DealSort!]
$paging: OffsetPaging
) {
deals(filter: $filter, sorting: $sorting, paging: $paging) {
totalCount
nodes {
id
title
stage {
id
title
}
company {
id
name
avatarUrl
}
createdAt
}
}
}
`;
// Query to get latest activities audits
export const DASHBOARD_LATEST_ACTIVITIES_AUDITS_QUERY = gql`
query DashboardLatestActivitiesAudits(
$filter: AuditFilter!
$sorting: [AuditSort!]
$paging: OffsetPaging
) {
audits(filter: $filter, sorting: $sorting, paging: $paging) {
totalCount
nodes {
id
action
targetEntity
targetId
changes {
field
from
to
}
createdAt
user {
id
name
avatarUrl
}
}
}
}
`;
// Query to get companies list
export const COMPANIES_LIST_QUERY = gql`
query CompaniesList(
$filter: CompanyFilter!
$sorting: [CompanySort!]
$paging: OffsetPaging!
) {
companies(filter: $filter, sorting: $sorting, paging: $paging) {
totalCount
nodes {
id
name
avatarUrl
# Get the sum of all deals in this company
dealsAggregate {
sum {
value
}
}
}
}
}
`;
// Query to get users list
export const USERS_SELECT_QUERY = gql`
query UsersSelect(
$filter: UserFilter!
$sorting: [UserSort!]
$paging: OffsetPaging!
) {
# Get all users
users(filter: $filter, sorting: $sorting, paging: $paging) {
totalCount # Get the total count of users
# Get specific fields for each user
nodes {
id
name
avatarUrl
}
}
}
`;
// Query to get contacts associated with a company
export const COMPANY_CONTACTS_TABLE_QUERY = gql`
query CompanyContactsTable(
$filter: ContactFilter!
$sorting: [ContactSort!]
$paging: OffsetPaging!
) {
contacts(filter: $filter, sorting: $sorting, paging: $paging) {
totalCount
nodes {
id
name
avatarUrl
jobTitle
email
phone
status
}
}
}
`;
// Query to get task stages list
export const TASK_STAGES_QUERY = gql`
query TaskStages(
$filter: TaskStageFilter!
$sorting: [TaskStageSort!]
$paging: OffsetPaging!
) {
taskStages(filter: $filter, sorting: $sorting, paging: $paging) {
totalCount # Get the total count of task stages
nodes {
id
title
}
}
}
`;
// Query to get tasks list
export const TASKS_QUERY = gql`
query Tasks(
$filter: TaskFilter!
$sorting: [TaskSort!]
$paging: OffsetPaging!
) {
tasks(filter: $filter, sorting: $sorting, paging: $paging) {
totalCount # Get the total count of tasks
nodes {
id
title
description
dueDate
completed
stageId
# Get user details associated with this task
users {
id
name
avatarUrl
}
createdAt
updatedAt
}
}
}
`;
// Query to get task stages for select
export const TASK_STAGES_SELECT_QUERY = gql`
query TaskStagesSelect(
$filter: TaskStageFilter!
$sorting: [TaskStageSort!]
$paging: OffsetPaging!
) {
taskStages(filter: $filter, sorting: $sorting, paging: $paging) {
totalCount
nodes {
id
title
}
}
}
`;
text.tsx
import React from "react";
import { ConfigProvider, Typography } from "antd";
export type TextProps = {
size?:
| "xs"
| "sm"
| "md"
| "lg"
| "xl"
| "xxl"
| "xxxl"
| "huge"
| "xhuge"
| "xxhuge";
} & React.ComponentProps<typeof Typography.Text>;
// define the font sizes and line heights
const sizes = {
xs: {
fontSize: 12,
lineHeight: 20 / 12,
},
sm: {
fontSize: 14,
lineHeight: 22 / 14,
},
md: {
fontSize: 16,
lineHeight: 24 / 16,
},
lg: {
fontSize: 20,
lineHeight: 28 / 20,
},
xl: {
fontSize: 24,
lineHeight: 32 / 24,
},
xxl: {
fontSize: 30,
lineHeight: 38 / 30,
},
xxxl: {
fontSize: 38,
lineHeight: 46 / 38,
},
huge: {
fontSize: 46,
lineHeight: 54 / 46,
},
xhuge: {
fontSize: 56,
lineHeight: 64 / 56,
},
xxhuge: {
fontSize: 68,
lineHeight: 76 / 68,
},
};
// a custom Text component that wraps/extends the antd Typography.Text component
export const Text = ({ size = "sm", children, ...rest }: TextProps) => {
return (
// config provider is a top-level component that allows us to customize the global properties of antd components. For example, default antd theme
// token is a term used by antd to refer to the design tokens like font size, font weight, color, etc
// https://ant.design/docs/react/customize-theme#customize-design-token
<ConfigProvider
theme={{
token: {
...sizes[size],
},
}}
>
{/**
* Typography.Text is a component from antd that allows us to render text
* Typography has different components like Title, Paragraph, Text, Link, etc
* https://ant.design/components/typography/#Typography.Text
*/}
<Typography.Text {...rest}>{children}</Typography.Text>
</ConfigProvider>
);
};
components/layout/account-settings.tsx
import { SaveButton, useForm } from "@refinedev/antd";
import { HttpError } from "@refinedev/core";
import { GetFields, GetVariables } from "@refinedev/nestjs-query";
import { CloseOutlined } from "@ant-design/icons";
import { Button, Card, Drawer, Form, Input, Spin } from "antd";
import { getNameInitials } from "@/utilities";
import { UPDATE_USER_MUTATION } from "@/graphql/mutations";
import { Text } from "../text";
import CustomAvatar from "../custom-avatar";
import {
UpdateUserMutation,
UpdateUserMutationVariables,
} from "@/graphql/types";
type Props = {
opened: boolean;
setOpened: (opened: boolean) => void;
userId: string;
};
export const AccountSettings = ({ opened, setOpened, userId }: Props) => {
/**
* useForm in Refine is used to manage forms. It provides us with a lot of useful props and methods that we can use to manage forms.
* https://refine.dev/docs/data/hooks/use-form/#usage
*/
/**
* saveButtonProps -> contains all the props needed by the "submit" button. For example, "loading", "disabled", "onClick", etc.
* https://refine.dev/docs/ui-integrations/ant-design/hooks/use-form/#savebuttonprops
*
* formProps -> It's an instance of HTML form that manages form state and actions like onFinish, onValuesChange, etc.
* https://refine.dev/docs/ui-integrations/ant-design/hooks/use-form/#form
*
* queryResult -> contains the result of the query. For example, isLoading, data, error, etc.
* https://refine.dev/docs/packages/react-hook-form/use-form/#queryresult
*/
const { saveButtonProps, formProps, queryResult } = useForm<
/**
* GetFields is used to get the fields of the mutation i.e., in this case, fields are name, email, jobTitle, and phone
* https://refine.dev/docs/data/packages/nestjs-query/#getfields
*/
GetFields<UpdateUserMutation>,
// a type that represents an HTTP error. Used to specify the type of error mutation can throw.
HttpError,
// A third type parameter used to specify the type of variables for the UpdateUserMutation. Meaning that the variables for the UpdateUserMutation should be of type UpdateUserMutationVariables
GetVariables<UpdateUserMutationVariables>
>({
/**
* mutationMode is used to determine how the mutation should be performed. For example, optimistic, pessimistic, undoable etc.
* optimistic -> redirection and UI updates are executed immediately as if the mutation is successful.
* pessimistic -> redirection and UI updates are executed after the mutation is successful.
* https://refine.dev/docs/advanced-tutorials/mutation-mode/#overview
*/
mutationMode: "optimistic",
/**
* specify on which resource the mutation should be performed
* if not specified, Refine will determine the resource name by the current route
*/
resource: "users",
/**
* specify the action that should be performed on the resource. Behind the scenes, Refine calls useOne hook to get the data of the user for edit action.
* https://refine.dev/docs/data/hooks/use-form/#edit
*/
action: "edit",
id: userId,
/**
* used to provide any additional information to the data provider.
* https://refine.dev/docs/data/hooks/use-form/#meta-
*/
meta: {
// gqlMutation is used to specify the mutation that should be performed.
gqlMutation: UPDATE_USER_MUTATION,
},
});
const { avatarUrl, name } = queryResult?.data?.data || {};
const closeModal = () => {
setOpened(false);
};
// if query is processing, show a loading indicator
if (queryResult?.isLoading) {
return (
<Drawer
open={opened}
width={756}
styles={{
body: {
background: "#f5f5f5",
display: "flex",
alignItems: "center",
justifyContent: "center",
},
}}
>
<Spin />
</Drawer>
);
}
return (
<Drawer
onClose={closeModal}
open={opened}
width={756}
styles={{
body: { background: "#f5f5f5", padding: 0 },
header: { display: "none" },
}}
>
<div
style={{
display: "flex",
alignItems: "center",
justifyContent: "space-between",
padding: "16px",
backgroundColor: "#fff",
}}
>
<Text strong>Account Settings</Text>
<Button
type="text"
icon={<CloseOutlined />}
onClick={() => closeModal()}
/>
</div>
<div
style={{
padding: "16px",
}}
>
<Card>
<Form {...formProps} layout="vertical">
<CustomAvatar
shape="square"
src={avatarUrl}
name={getNameInitials(name || "")}
style={{
width: 96,
height: 96,
marginBottom: "24px",
}}
/>
<Form.Item label="Name" name="name">
<Input placeholder="Name" />
</Form.Item>
<Form.Item label="Email" name="email">
<Input placeholder="email" />
</Form.Item>
<Form.Item label="Job title" name="jobTitle">
<Input placeholder="jobTitle" />
</Form.Item>
<Form.Item label="Phone" name="phone">
<Input placeholder="Timezone" />
</Form.Item>
</Form>
<SaveButton
{...saveButtonProps}
style={{
display: "block",
marginLeft: "auto",
}}
/>
</Card>
</div>
</Drawer>
);
};
constants/index.tsx
import { AuditOutlined, ShopOutlined, TeamOutlined } from "@ant-design/icons";
const IconWrapper = ({
color,
children,
}: React.PropsWithChildren<{ color: string }>) => {
return (
<div
style={{
display: "flex",
alignItems: "center",
justifyContent: "center",
width: "32px",
height: "32px",
borderRadius: "50%",
backgroundColor: color,
}}
>
{children}
</div>
);
};
import {
BusinessType,
CompanySize,
Contact,
Industry,
} from "@/graphql/schema.types";
export type TotalCountType = "companies" | "contacts" | "deals";
export const totalCountVariants: {
[key in TotalCountType]: {
primaryColor: string;
secondaryColor?: string;
icon: React.ReactNode;
title: string;
data: { index: string; value: number }[];
};
} = {
companies: {
primaryColor: "#1677FF",
secondaryColor: "#BAE0FF",
icon: (
<IconWrapper color="#E6F4FF">
<ShopOutlined
className="md"
style={{
color: "#1677FF",
}}
/>
</IconWrapper>
),
title: "Number of companies",
data: [
{
index: "1",
value: 3500,
},
{
index: "2",
value: 2750,
},
{
index: "3",
value: 5000,
},
{
index: "4",
value: 4250,
},
{
index: "5",
value: 5000,
},
],
},
contacts: {
primaryColor: "#52C41A",
secondaryColor: "#D9F7BE",
icon: (
<IconWrapper color="#F6FFED">
<TeamOutlined
className="md"
style={{
color: "#52C41A",
}}
/>
</IconWrapper>
),
title: "Number of contacts",
data: [
{
index: "1",
value: 10000,
},
{
index: "2",
value: 19500,
},
{
index: "3",
value: 13000,
},
{
index: "4",
value: 17000,
},
{
index: "5",
value: 13000,
},
{
index: "6",
value: 20000,
},
],
},
deals: {
primaryColor: "#FA541C",
secondaryColor: "#FFD8BF",
icon: (
<IconWrapper color="#FFF2E8">
<AuditOutlined
className="md"
style={{
color: "#FA541C",
}}
/>
</IconWrapper>
),
title: "Total deals in pipeline",
data: [
{
index: "1",
value: 1000,
},
{
index: "2",
value: 1300,
},
{
index: "3",
value: 1200,
},
{
index: "4",
value: 2000,
},
{
index: "5",
value: 800,
},
{
index: "6",
value: 1700,
},
{
index: "7",
value: 1400,
},
{
index: "8",
value: 1800,
},
],
},
};
export const statusOptions: {
label: string;
value: Contact["status"];
}[] = [
{
label: "New",
value: "NEW",
},
{
label: "Qualified",
value: "QUALIFIED",
},
{
label: "Unqualified",
value: "UNQUALIFIED",
},
{
label: "Won",
value: "WON",
},
{
label: "Negotiation",
value: "NEGOTIATION",
},
{
label: "Lost",
value: "LOST",
},
{
label: "Interested",
value: "INTERESTED",
},
{
label: "Contacted",
value: "CONTACTED",
},
{
label: "Churned",
value: "CHURNED",
},
];
export const companySizeOptions: {
label: string;
value: CompanySize;
}[] = [
{
label: "Enterprise",
value: "ENTERPRISE",
},
{
label: "Large",
value: "LARGE",
},
{
label: "Medium",
value: "MEDIUM",
},
{
label: "Small",
value: "SMALL",
},
];
export const industryOptions: {
label: string;
value: Industry;
}[] = [
{ label: "Aerospace", value: "AEROSPACE" },
{ label: "Agriculture", value: "AGRICULTURE" },
{ label: "Automotive", value: "AUTOMOTIVE" },
{ label: "Chemicals", value: "CHEMICALS" },
{ label: "Construction", value: "CONSTRUCTION" },
{ label: "Defense", value: "DEFENSE" },
{ label: "Education", value: "EDUCATION" },
{ label: "Energy", value: "ENERGY" },
{ label: "Financial Services", value: "FINANCIAL_SERVICES" },
{ label: "Food and Beverage", value: "FOOD_AND_BEVERAGE" },
{ label: "Government", value: "GOVERNMENT" },
{ label: "Healthcare", value: "HEALTHCARE" },
{ label: "Hospitality", value: "HOSPITALITY" },
{ label: "Industrial Manufacturing", value: "INDUSTRIAL_MANUFACTURING" },
{ label: "Insurance", value: "INSURANCE" },
{ label: "Life Sciences", value: "LIFE_SCIENCES" },
{ label: "Logistics", value: "LOGISTICS" },
{ label: "Media", value: "MEDIA" },
{ label: "Mining", value: "MINING" },
{ label: "Nonprofit", value: "NONPROFIT" },
{ label: "Other", value: "OTHER" },
{ label: "Pharmaceuticals", value: "PHARMACEUTICALS" },
{ label: "Professional Services", value: "PROFESSIONAL_SERVICES" },
{ label: "Real Estate", value: "REAL_ESTATE" },
{ label: "Retail", value: "RETAIL" },
{ label: "Technology", value: "TECHNOLOGY" },
{ label: "Telecommunications", value: "TELECOMMUNICATIONS" },
{ label: "Transportation", value: "TRANSPORTATION" },
{ label: "Utilities", value: "UTILITIES" },
];
export const businessTypeOptions: {
label: string;
value: BusinessType;
}[] = [
{
label: "B2B",
value: "B2B",
},
{
label: "B2C",
value: "B2C",
},
{
label: "B2G",
value: "B2G",
},
];
pages/company/contacts-table.tsx
import { useParams } from "react-router-dom";
import { FilterDropdown, useTable } from "@refinedev/antd";
import { GetFieldsFromList } from "@refinedev/nestjs-query";
import {
MailOutlined,
PhoneOutlined,
SearchOutlined,
TeamOutlined,
} from "@ant-design/icons";
import { Button, Card, Input, Select, Space, Table } from "antd";
import { Contact } from "@/graphql/schema.types";
import { statusOptions } from "@/constants";
import { COMPANY_CONTACTS_TABLE_QUERY } from "@/graphql/queries";
import { CompanyContactsTableQuery } from "@/graphql/types";
import { Text } from "@/components/text";
import CustomAvatar from "@/components/custom-avatar";
import { ContactStatusTag } from "@/components/tags/contact-status-tag";
export const CompanyContactsTable = () => {
// get params from the url
const params = useParams();
/**
* Refine offers a TanStack Table adapter with @refinedev/react-table that allows us to use the TanStack Table library with Refine.
* All features such as sorting, filtering, and pagination come out of the box
* Under the hood it uses useList hook to fetch the data.
* https://refine.dev/docs/packages/tanstack-table/use-table/#installation
*/
const { tableProps } = useTable<GetFieldsFromList<CompanyContactsTableQuery>>(
{
// specify the resource for which the table is to be used
resource: "contacts",
syncWithLocation: false,
// specify initial sorters
sorters: {
/**
* initial sets the initial value of sorters.
* it's not permanent
* it will be cleared when the user changes the sorting
* https://refine.dev/docs/ui-integrations/ant-design/hooks/use-table/#sortersinitial
*/
initial: [
{
field: "createdAt",
order: "desc",
},
],
},
// specify initial filters
filters: {
/**
* similar to initial in sorters
* https://refine.dev/docs/ui-integrations/ant-design/hooks/use-table/#filtersinitial
*/
initial: [
{
field: "jobTitle",
value: "",
operator: "contains",
},
{
field: "name",
value: "",
operator: "contains",
},
{
field: "status",
value: undefined,
operator: "in",
},
],
/**
* permanent filters are the filters that are always applied
* https://refine.dev/docs/ui-integrations/ant-design/hooks/use-table/#filterspermanent
*/
permanent: [
{
field: "company.id",
operator: "eq",
value: params?.id as string,
},
],
},
/**
* used to provide any additional information to the data provider.
* https://refine.dev/docs/data/hooks/use-form/#meta-
*/
meta: {
// gqlQuery is used to specify the GraphQL query that should be used to fetch the data.
gqlQuery: COMPANY_CONTACTS_TABLE_QUERY,
},
},
);
return (
<Card
headStyle={{
borderBottom: "1px solid #D9D9D9",
marginBottom: "1px",
}}
bodyStyle={{ padding: 0 }}
title={
<Space size="middle">
<TeamOutlined />
<Text>Contacts</Text>
</Space>
}
// property used to render additional content in the top-right corner of the card
extra={
<>
<Text className="tertiary">Total contacts: </Text>
<Text strong>
{/* if pagination is not disabled and total is provided then show the total */}
{tableProps?.pagination !== false && tableProps.pagination?.total}
</Text>
</>
}
>
<Table
{...tableProps}
rowKey="id"
pagination={{
...tableProps.pagination,
showSizeChanger: false, // hide the page size changer
}}
>
<Table.Column<Contact>
title="Name"
dataIndex="name"
render={(_, record) => (
<Space>
<CustomAvatar name={record.name} src={record.avatarUrl} />
<Text
style={{
whiteSpace: "nowrap",
}}
>
{record.name}
</Text>
</Space>
)}
// specify the icon that should be used for filtering
filterIcon={<SearchOutlined />}
// render the filter dropdown
filterDropdown={(props) => (
<FilterDropdown {...props}>
<Input placeholder="Search Name" />
</FilterDropdown>
)}
/>
<Table.Column
title="Title"
dataIndex="jobTitle"
filterIcon={<SearchOutlined />}
filterDropdown={(props) => (
<FilterDropdown {...props}>
<Input placeholder="Search Title" />
</FilterDropdown>
)}
/>
<Table.Column<Contact>
title="Stage"
dataIndex="status"
// render the status tag for each contact
render={(_, record) => <ContactStatusTag status={record.status} />}
// allow filtering by selecting multiple status options
filterDropdown={(props) => (
<FilterDropdown {...props}>
<Select
style={{ width: "200px" }}
mode="multiple" // allow multiple selection
placeholder="Select Stage"
options={statusOptions}
></Select>
</FilterDropdown>
)}
/>
<Table.Column<Contact>
dataIndex="id"
width={112}
render={(_, record) => (
<Space>
<Button
size="small"
href={`mailto:${record.email}`}
icon={<MailOutlined />}
/>
<Button
size="small"
href={`tel:${record.phone}`}
icon={<PhoneOutlined />}
/>
</Space>
)}
/>
</Table>
</Card>
);
};
components/tags/contact-status-tag.tsx
import React from "react";
import {
CheckCircleOutlined,
MinusCircleOutlined,
PlayCircleFilled,
PlayCircleOutlined,
} from "@ant-design/icons";
import { Tag, TagProps } from "antd";
import { ContactStatus } from "@/graphql/schema.types";
type Props = {
status: ContactStatus;
};
/**
* Renders a tag component representing the contact status.
* @param status - The contact status.
*/
export const ContactStatusTag = ({ status }: Props) => {
let icon: React.ReactNode = null;
let color: TagProps["color"] = undefined;
switch (status) {
case "NEW":
case "CONTACTED":
case "INTERESTED":
icon = <PlayCircleOutlined />;
color = "cyan";
break;
case "UNQUALIFIED":
icon = <PlayCircleOutlined />;
color = "red";
break;
case "QUALIFIED":
case "NEGOTIATION":
icon = <PlayCircleFilled />;
color = "green";
break;
case "LOST":
icon = <PlayCircleFilled />;
color = "red";
break;
case "WON":
icon = <CheckCircleOutlined />;
color = "green";
break;
case "CHURNED":
icon = <MinusCircleOutlined />;
color = "red";
break;
default:
break;
}
return (
<Tag color={color} style={{ textTransform: "capitalize" }}>
{icon} {status.toLowerCase()}
</Tag>
);
};
components/text-icon.tsx
import Icon from "@ant-design/icons";
import type { CustomIconComponentProps } from "@ant-design/icons/lib/components/Icon";
export const TextIconSvg = () => (
<svg
xmlns="http://www.w3.org/2000/svg"
width="12"
height="12"
viewBox="0 0 12 12"
fill="none"
>
<path
d="M1.3125 2.25C1.26094 2.25 1.21875 2.29219 1.21875 2.34375V3C1.21875 3.05156 1.26094 3.09375 1.3125 3.09375H10.6875C10.7391 3.09375 10.7812 3.05156 10.7812 3V2.34375C10.7812 2.29219 10.7391 2.25 10.6875 2.25H1.3125Z"
fill="black"
fillOpacity="0.65"
/>
<path
d="M1.3125 5.57812C1.26094 5.57812 1.21875 5.62031 1.21875 5.67188V6.32812C1.21875 6.37969 1.26094 6.42188 1.3125 6.42188H10.6875C10.7391 6.42188 10.7812 6.37969 10.7812 6.32812V5.67188C10.7812 5.62031 10.7391 5.57812 10.6875 5.57812H1.3125Z"
fill="black"
fillOpacity="0.65"
/>
<path
d="M1.3125 8.90625C1.26094 8.90625 1.21875 8.94844 1.21875 9V9.65625C1.21875 9.70781 1.26094 9.75 1.3125 9.75H7.6875C7.73906 9.75 7.78125 9.70781 7.78125 9.65625V9C7.78125 8.94844 7.73906 8.90625 7.6875 8.90625H1.3125Z"
fill="black"
fillOpacity="0.65"
/>
</svg>
);
export const TextIcon = (props: Partial<CustomIconComponentProps>) => (
<Icon component={TextIconSvg} {...props} />
);
components/tasks/kanban/add-card-button.tsx
import React from "react";
import { PlusSquareOutlined } from "@ant-design/icons";
import { Button } from "antd";
import { Text } from "@/components/text";
interface Props {
onClick: () => void;
}
/** Render a button that allows you to add a new card to a column.
*
* @param onClick - a function that is called when the button is clicked.
* @returns a button that allows you to add a new card to a column.
*/
export const KanbanAddCardButton = ({
children,
onClick,
}: React.PropsWithChildren<Props>) => {
return (
<Button
size="large"
icon={<PlusSquareOutlined className="md" />}
style={{
margin: "16px",
backgroundColor: "white",
}}
onClick={onClick}
>
{children ?? (
<Text size="md" type="secondary">
Add new card
</Text>
)}
</Button>
);
};
pages/tasks/create.tsx
import { useSearchParams } from "react-router-dom";
import { useModalForm } from "@refinedev/antd";
import { useNavigation } from "@refinedev/core";
import { Form, Input, Modal } from "antd";
import { CREATE_TASK_MUTATION } from "@/graphql/mutations";
const TasksCreatePage = () => {
// get search params from the url
const [searchParams] = useSearchParams();
/**
* useNavigation is a hook by Refine that allows you to navigate to a page.
* https://refine.dev/docs/routing/hooks/use-navigation/
*
* list method navigates to the list page of the specified resource.
* https://refine.dev/docs/routing/hooks/use-navigation/#list
*/ const { list } = useNavigation();
/**
* useModalForm is a hook by Refine that allows you manage a form inside a modal.
* it extends the useForm hook from the @refinedev/antd package
* https://refine.dev/docs/ui-integrations/ant-design/hooks/use-modal-form/
*
* formProps -> It's an instance of HTML form that manages form state and actions like onFinish, onValuesChange, etc.
* Under the hood, it uses the useForm hook from the @refinedev/antd package
* https://refine.dev/docs/ui-integrations/ant-design/hooks/use-modal-form/#formprops
*
* modalProps -> It's an instance of Modal that manages modal state and actions like onOk, onCancel, etc.
* https://refine.dev/docs/ui-integrations/ant-design/hooks/use-modal-form/#modalprops
*/
const { formProps, modalProps, close } = useModalForm({
// specify the action to perform i.e., create or edit
action: "create",
// specify whether the modal should be visible by default
defaultVisible: true,
// specify the gql mutation to be performed
meta: {
gqlMutation: CREATE_TASK_MUTATION,
},
});
return (
<Modal
{...modalProps}
onCancel={() => {
// close the modal
close();
// navigate to the list page of the tasks resource
list("tasks", "replace");
}}
title="Add new card"
width={512}
>
<Form
{...formProps}
layout="vertical"
onFinish={(values) => {
// on finish, call the onFinish method of useModalForm to perform the mutation
formProps?.onFinish?.({
...values,
stageId: searchParams.get("stageId")
? Number(searchParams.get("stageId"))
: null,
userIds: [],
});
}}
>
<Form.Item label="Title" name="title" rules={[{ required: true }]}>
<Input />
</Form.Item>
</Form>
</Modal>
);
}
export default TasksCreatePage;
pages/tasks/edit.tsx
import { useState } from "react";
import { DeleteButton, useModalForm } from "@refinedev/antd";
import { useNavigation } from "@refinedev/core";
import {
AlignLeftOutlined,
FieldTimeOutlined,
UsergroupAddOutlined,
} from "@ant-design/icons";
import { Modal } from "antd";
import {
Accordion,
DescriptionForm,
DescriptionHeader,
DueDateForm,
DueDateHeader,
StageForm,
TitleForm,
UsersForm,
UsersHeader,
} from "@/components";
import { Task } from "@/graphql/schema.types";
import { UPDATE_TASK_MUTATION } from "@/graphql/mutations";
const TasksEditPage = () => {
const [activeKey, setActiveKey] = useState<string | undefined>();
// use the list method to navigate to the list page of the tasks resource from the navigation hook
const { list } = useNavigation();
// create a modal form to edit a task using the useModalForm hook
// modalProps -> It's an instance of Modal that manages modal state and actions like onOk, onCancel, etc.
// close -> It's a function that closes the modal
// queryResult -> It's an instance of useQuery from react-query
const { modalProps, close, queryResult } = useModalForm<Task>({
// specify the action to perform i.e., create or edit
action: "edit",
// specify whether the modal should be visible by default
defaultVisible: true,
// specify the gql mutation to be performed
meta: {
gqlMutation: UPDATE_TASK_MUTATION,
},
});
// get the data of the task from the queryResult
const { description, dueDate, users, title } = queryResult?.data?.data ?? {};
const isLoading = queryResult?.isLoading ?? true;
return (
<Modal
{...modalProps}
className="kanban-update-modal"
onCancel={() => {
close();
list("tasks", "replace");
}}
title={<TitleForm initialValues={{ title }} isLoading={isLoading} />}
width={586}
footer={
<DeleteButton
type="link"
onSuccess={() => {
list("tasks", "replace");
}}
>
Delete card
</DeleteButton>
}
>
{/* Render the stage form */}
<StageForm isLoading={isLoading} />
{/* Render the description form inside an accordion */}
<Accordion
accordionKey="description"
activeKey={activeKey}
setActive={setActiveKey}
fallback={<DescriptionHeader description={description} />}
isLoading={isLoading}
icon={<AlignLeftOutlined />}
label="Description"
>
<DescriptionForm
initialValues={{ description }}
cancelForm={() => setActiveKey(undefined)}
/>
</Accordion>
{/* Render the due date form inside an accordion */}
<Accordion
accordionKey="due-date"
activeKey={activeKey}
setActive={setActiveKey}
fallback={<DueDateHeader dueData={dueDate} />}
isLoading={isLoading}
icon={<FieldTimeOutlined />}
label="Due date"
>
<DueDateForm
initialValues={{ dueDate: dueDate ?? undefined }}
cancelForm={() => setActiveKey(undefined)}
/>
</Accordion>
{/* Render the users form inside an accordion */}
<Accordion
accordionKey="users"
activeKey={activeKey}
setActive={setActiveKey}
fallback={<UsersHeader users={users} />}
isLoading={isLoading}
icon={<UsergroupAddOutlined />}
label="Users"
>
<UsersForm
initialValues={{
userIds: users?.map((user) => ({
label: user.name,
value: user.id,
})),
}}
cancelForm={() => setActiveKey(undefined)}
/>
</Accordion>
</Modal>
);
};
export default TasksEditPage;
components/accordion.tsx
import { AccordionHeaderSkeleton } from "@/components";
import { Text } from "./text";
type Props = React.PropsWithChildren<{
accordionKey: string;
activeKey?: string;
setActive: (key?: string) => void;
fallback: string | React.ReactNode;
isLoading?: boolean;
icon: React.ReactNode;
label: string;
}>;
/**
* when activeKey is equal to accordionKey, the children will be rendered. Otherwise, the fallback will be rendered
* when isLoading is true, the <AccordionHeaderSkeleton /> will be rendered
* when Accordion is clicked, setActive will be called with the accordionKey
*/
export const Accordion = ({
accordionKey,
activeKey,
setActive,
fallback,
icon,
label,
children,
isLoading,
}: Props) => {
if (isLoading) return <AccordionHeaderSkeleton />;
const isActive = activeKey === accordionKey;
const toggleAccordion = () => {
if (isActive) {
setActive(undefined);
} else {
setActive(accordionKey);
}
};
return (
<div
style={{
display: "flex",
padding: "12px 24px",
gap: "12px",
alignItems: "start",
borderBottom: "1px solid #d9d9d9",
}}
>
<div style={{ marginTop: "1px", flexShrink: 0 }}>{icon}</div>
{isActive ? (
<div
style={{
display: "flex",
flexDirection: "column",
gap: "12px",
flex: 1,
}}
>
<Text strong onClick={toggleAccordion} style={{ cursor: "pointer" }}>
{label}
</Text>
{children}
</div>
) : (
<div onClick={toggleAccordion} style={{ cursor: "pointer", flex: 1 }}>
{fallback}
</div>
)}
</div>
);
};
components/tags/user-tag.tsx
import { Space, Tag } from "antd";
import { User } from "@/graphql/schema.types";
import CustomAvatar from "../custom-avatar";
type Props = {
user: User;
};
// display a user's avatar and name in a tag
export const UserTag = ({ user }: Props) => {
return (
<Tag
key={user.id}
style={{
padding: 2,
paddingRight: 8,
borderRadius: 24,
lineHeight: "unset",
marginRight: "unset",
}}
>
<Space size={4}>
<CustomAvatar
src={user.avatarUrl}
name={user.name}
style={{ display: "inline-flex" }}
/>
{user.name}
</Space>
</Tag>
);
};
Other components (Kanban Edit Forms, Skeletons and utilities) used in the project can be found here