An open-source, headless React meta-framework, developed with a commitment to best practices, flexibility, minimal tech debt, and team alignment, could be a perfect fit for dynamic environments.
⭐ ABOUT
⭐ Checkout the live demo of the application here.
⚙️ TECH STACK
- Refine
- TypeScript
- GraphQL
- Ant Design
- Codegen
- Vite
⚡ FEATURES
👉 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.
⭐ Demo video
npm create refine-app@latest -- --example app-crm-minimal
This will download the files and install the necessary dependencies automatically.
Once it's done, go to the directory and run the following command to start 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: "michael.scott@dundermifflin.com",
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 /