├── .npmrc ├── src ├── vite-env.d.ts ├── utilities │ ├── date │ │ ├── index.ts │ │ └── get-date-colors.ts │ ├── index.ts │ ├── get-name-initials.ts │ ├── currency-number.ts │ ├── get-random-color.ts │ └── helpers.ts ├── providers │ ├── index.ts │ ├── data │ │ ├── index.tsx │ │ └── fetch-wrapper.ts │ └── auth.ts ├── pages │ ├── register │ │ └── index.tsx │ ├── forgotPassword │ │ └── index.tsx │ ├── index.ts │ ├── login │ │ └── index.tsx │ ├── home │ │ └── index.tsx │ ├── company │ │ ├── create.tsx │ │ ├── edit.tsx │ │ ├── list.tsx │ │ └── contacts-table.tsx │ └── tasks │ │ ├── create.tsx │ │ ├── edit.tsx │ │ └── list.tsx ├── index.tsx ├── components │ ├── layout │ │ ├── index.tsx │ │ ├── header.tsx │ │ ├── current-user.tsx │ │ └── account-settings.tsx │ ├── skeleton │ │ ├── accordion-header.tsx │ │ ├── upcoming-events.tsx │ │ ├── project-card.tsx │ │ ├── latest-activities.tsx │ │ └── kanban.tsx │ ├── select-option-with-avatar.tsx │ ├── custom-avatar.tsx │ ├── tags │ │ ├── user-tag.tsx │ │ └── contact-status-tag.tsx │ ├── tasks │ │ ├── kanban │ │ │ ├── add-card-button.tsx │ │ │ ├── item.tsx │ │ │ ├── board.tsx │ │ │ ├── column.tsx │ │ │ └── card.tsx │ │ └── form │ │ │ ├── header.tsx │ │ │ ├── description.tsx │ │ │ ├── due-date.tsx │ │ │ ├── users.tsx │ │ │ ├── title.tsx │ │ │ └── stage.tsx │ ├── index.ts │ ├── text-icon.tsx │ ├── accordion.tsx │ ├── text.tsx │ └── home │ │ ├── deals-chart.tsx │ │ ├── total-count-card.tsx │ │ ├── upcoming-events.tsx │ │ └── latest-activities.tsx ├── config │ └── resources.tsx ├── graphql │ ├── mutations.ts │ ├── queries.ts │ └── types.ts ├── App.tsx └── constants │ └── index.tsx ├── public └── favicon.ico ├── tsconfig.node.json ├── vite.config.ts ├── .gitignore ├── .eslintrc.cjs ├── tsconfig.json ├── index.html ├── package.json ├── graphql.config.ts └── README.MD /.npmrc: -------------------------------------------------------------------------------- 1 | legacy-peer-deps=true 2 | strict-peer-dependencies=false -------------------------------------------------------------------------------- /src/vite-env.d.ts: -------------------------------------------------------------------------------- 1 | /// 2 | -------------------------------------------------------------------------------- /src/utilities/date/index.ts: -------------------------------------------------------------------------------- 1 | export * from "./get-date-colors"; 2 | -------------------------------------------------------------------------------- /src/providers/index.ts: -------------------------------------------------------------------------------- 1 | export * from './data'; 2 | export * from './auth'; -------------------------------------------------------------------------------- /public/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/adrianhajdin/refine_dashboard/HEAD/public/favicon.ico -------------------------------------------------------------------------------- /src/pages/register/index.tsx: -------------------------------------------------------------------------------- 1 | import { AuthPage } from "@refinedev/antd"; 2 | 3 | export const Register = () => { 4 | return ; 5 | }; 6 | -------------------------------------------------------------------------------- /src/utilities/index.ts: -------------------------------------------------------------------------------- 1 | export * from "./currency-number"; 2 | export * from "./date"; 3 | export * from "./get-name-initials"; 4 | export * from "./get-random-color"; 5 | -------------------------------------------------------------------------------- /src/pages/forgotPassword/index.tsx: -------------------------------------------------------------------------------- 1 | import { AuthPage } from "@refinedev/antd"; 2 | 3 | export const ForgotPassword = () => { 4 | return ; 5 | }; 6 | -------------------------------------------------------------------------------- /tsconfig.node.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "composite": true, 4 | "module": "ESNext", 5 | "moduleResolution": "node" 6 | }, 7 | "include": ["vite.config.ts"] 8 | } 9 | -------------------------------------------------------------------------------- /vite.config.ts: -------------------------------------------------------------------------------- 1 | import react from "@vitejs/plugin-react"; 2 | import { defineConfig } from "vite"; 3 | import tsconfigPaths from "vite-tsconfig-paths"; 4 | 5 | export default defineConfig({ 6 | plugins: [tsconfigPaths(), react()], 7 | }); 8 | -------------------------------------------------------------------------------- /src/pages/index.ts: -------------------------------------------------------------------------------- 1 | export * from './home'; 2 | export * from './forgotPassword'; 3 | export * from './login'; 4 | export * from './register'; 5 | export * from './company/list'; 6 | export * from './company/create'; 7 | export * from './company/edit'; -------------------------------------------------------------------------------- /src/utilities/get-name-initials.ts: -------------------------------------------------------------------------------- 1 | export const getNameInitials = (name: string, count = 2) => { 2 | const initials = name 3 | .split(" ") 4 | .map((n) => n[0]) 5 | .join(""); 6 | const filtered = initials.replace(/[^a-zA-Z]/g, ""); 7 | return filtered.slice(0, count).toUpperCase(); 8 | }; -------------------------------------------------------------------------------- /src/pages/login/index.tsx: -------------------------------------------------------------------------------- 1 | import { AuthPage } from "@refinedev/antd"; 2 | import { authCredentials } from "../../providers"; 3 | 4 | export const Login = () => { 5 | return ( 6 | 12 | ); 13 | }; 14 | -------------------------------------------------------------------------------- /src/index.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import { createRoot } from "react-dom/client"; 3 | 4 | import App from "./App"; 5 | 6 | const container = document.getElementById("root") as HTMLElement; 7 | const root = createRoot(container); 8 | 9 | root.render( 10 | 11 | 12 | 13 | ); 14 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # See https://help.github.com/articles/ignoring-files/ for more about ignoring files. 2 | 3 | # dependencies 4 | /node_modules 5 | /.pnp 6 | .pnp.js 7 | 8 | # testing 9 | /coverage 10 | 11 | # production 12 | /build 13 | 14 | # misc 15 | .DS_Store 16 | .env.local 17 | .env.development.local 18 | .env.test.local 19 | .env.production.local 20 | 21 | npm-debug.log* 22 | yarn-debug.log* 23 | yarn-error.log* -------------------------------------------------------------------------------- /src/components/layout/index.tsx: -------------------------------------------------------------------------------- 1 | import { ThemedLayoutV2, ThemedTitleV2 } from "@refinedev/antd" 2 | import Header from "./header" 3 | 4 | const Layout = ({ children }: React.PropsWithChildren) => { 5 | return ( 6 | } 9 | > 10 | {children} 11 | 12 | ) 13 | } 14 | 15 | export default Layout -------------------------------------------------------------------------------- /src/utilities/currency-number.ts: -------------------------------------------------------------------------------- 1 | export const currencyNumber = ( 2 | value: number, 3 | options?: Intl.NumberFormatOptions, 4 | ) => { 5 | if ( 6 | typeof Intl == "object" && 7 | Intl && 8 | typeof Intl.NumberFormat == "function" 9 | ) { 10 | return new Intl.NumberFormat("en-US", { 11 | style: "currency", 12 | currency: "USD", 13 | ...options, 14 | }).format(value); 15 | } 16 | 17 | return value.toString(); 18 | }; 19 | -------------------------------------------------------------------------------- /.eslintrc.cjs: -------------------------------------------------------------------------------- 1 | /* eslint-env node */ 2 | 3 | module.exports = { 4 | env: { browser: true, es2020: true }, 5 | extends: [ 6 | "eslint:recommended", 7 | "plugin:@typescript-eslint/recommended", 8 | "plugin:react-hooks/recommended", 9 | ], 10 | parser: "@typescript-eslint/parser", 11 | parserOptions: { ecmaVersion: "latest", sourceType: "module" }, 12 | plugins: ["react-refresh"], 13 | rules: { 14 | "react-refresh/only-export-components": "warn", 15 | }, 16 | }; 17 | -------------------------------------------------------------------------------- /src/components/skeleton/accordion-header.tsx: -------------------------------------------------------------------------------- 1 | import { Skeleton } from "antd"; 2 | 3 | // create a skeleton for the accordion header 4 | const AccordionHeaderSkeleton = () => { 5 | return ( 6 |
15 | 16 | 17 |
18 | ); 19 | }; 20 | 21 | export default AccordionHeaderSkeleton; -------------------------------------------------------------------------------- /src/components/select-option-with-avatar.tsx: -------------------------------------------------------------------------------- 1 | import CustomAvatar from "./custom-avatar"; 2 | import { Text } from "./text"; 3 | 4 | type Props = { 5 | name: string, 6 | avatarUrl?: string; 7 | shape?: 'circle' | 'square'; 8 | } 9 | 10 | const SelectOptionWithAvatar = ({ avatarUrl, name, shape }: Props) => { 11 | return ( 12 |
19 | 20 | {name} 21 |
22 | ) 23 | } 24 | 25 | export default SelectOptionWithAvatar -------------------------------------------------------------------------------- /src/components/layout/header.tsx: -------------------------------------------------------------------------------- 1 | import { Layout, Space } from "antd" 2 | import CurrentUser from "./current-user" 3 | 4 | const Header = () => { 5 | 6 | const headerStyles: React.CSSProperties = { 7 | background: '#fff', 8 | display: 'flex', 9 | justifyContent: 'flex-end', 10 | alignItems: 'center', 11 | padding: '0 24px', 12 | position: "sticky", 13 | top: 0, 14 | zIndex: 999, 15 | } 16 | 17 | return ( 18 | 19 | 20 | 21 | 22 | 23 | ) 24 | } 25 | 26 | export default Header -------------------------------------------------------------------------------- /src/components/custom-avatar.tsx: -------------------------------------------------------------------------------- 1 | import { getNameInitials } from '@/utilities'; 2 | import { Avatar as AntdAvatar, AvatarProps } from 'antd' 3 | 4 | type Props = AvatarProps & { 5 | name?: string; 6 | } 7 | 8 | const CustomAvatar = ({ name, style, ...rest }: Props) => { 9 | return ( 10 | 22 | {getNameInitials(name || '')} 23 | 24 | ) 25 | } 26 | 27 | export default CustomAvatar -------------------------------------------------------------------------------- /src/utilities/get-random-color.ts: -------------------------------------------------------------------------------- 1 | /* 2 | * generates random colors from https://ant.design/docs/spec/colors. used. 3 | */ 4 | export const getRandomColorFromString = (text: string) => { 5 | const colors = [ 6 | "#ff9c6e", 7 | "#ff7875", 8 | "#ffc069", 9 | "#ffd666", 10 | "#fadb14", 11 | "#95de64", 12 | "#5cdbd3", 13 | "#69c0ff", 14 | "#85a5ff", 15 | "#b37feb", 16 | "#ff85c0", 17 | ]; 18 | 19 | let hash = 0; 20 | for (let i = 0; i < text.length; i++) { 21 | hash = text.charCodeAt(i) + ((hash << 5) - hash); 22 | hash = hash & hash; 23 | } 24 | hash = ((hash % colors.length) + colors.length) % colors.length; 25 | 26 | return colors[hash]; 27 | }; 28 | -------------------------------------------------------------------------------- /src/utilities/date/get-date-colors.ts: -------------------------------------------------------------------------------- 1 | import dayjs from "dayjs"; 2 | 3 | type DateColors = "success" | "processing" | "error" | "default" | "warning"; 4 | 5 | // returns a color based on the date 6 | export const getDateColor = (args: { 7 | date: string; 8 | defaultColor?: DateColors; 9 | }): DateColors => { 10 | const date = dayjs(args.date); 11 | const today = dayjs(); 12 | 13 | if (date.isBefore(today)) { 14 | return "error"; 15 | } 16 | 17 | if (date.isBefore(today.add(3, "day"))) { 18 | return "warning"; 19 | } 20 | 21 | // ?? is the nullish coalescing operator. It returns the right-hand side operand when the left-hand side is null or undefined. 22 | return args.defaultColor ?? "default"; 23 | }; 24 | -------------------------------------------------------------------------------- /src/components/skeleton/upcoming-events.tsx: -------------------------------------------------------------------------------- 1 | import { Badge, List, Skeleton } from "antd"; 2 | 3 | const UpcomingEventsSkeleton = () => { 4 | return ( 5 | 6 | } 8 | title={ 9 | 15 | } 16 | description={ 17 | 25 | } 26 | /> 27 | 28 | ); 29 | }; 30 | 31 | export default UpcomingEventsSkeleton; -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "ESNext", 4 | "useDefineForClassFields": true, 5 | "lib": ["DOM", "DOM.Iterable", "ESNext"], 6 | "allowJs": false, 7 | "skipLibCheck": true, 8 | "esModuleInterop": false, 9 | "allowSyntheticDefaultImports": true, 10 | "strict": true, 11 | "forceConsistentCasingInFileNames": true, 12 | "module": "ESNext", 13 | "moduleResolution": "Node", 14 | "resolveJsonModule": true, 15 | "isolatedModules": true, 16 | "noEmit": true, 17 | "jsx": "react-jsx", 18 | "baseUrl": "./src", 19 | "paths": { 20 | "@/*": ["./*"] 21 | } 22 | }, 23 | "include": ["src", "vite.config.ts"], 24 | "references": [ 25 | { 26 | "path": "./tsconfig.node.json" 27 | } 28 | ] 29 | } 30 | -------------------------------------------------------------------------------- /src/components/skeleton/project-card.tsx: -------------------------------------------------------------------------------- 1 | import { Card, Skeleton } from "antd"; 2 | 3 | const ProjectCardSkeleton = () => { 4 | return ( 5 | 21 | } 22 | > 23 | 30 | 31 | 32 | ); 33 | }; 34 | 35 | export default ProjectCardSkeleton; -------------------------------------------------------------------------------- /src/components/tags/user-tag.tsx: -------------------------------------------------------------------------------- 1 | import { Space, Tag } from "antd"; 2 | 3 | import { User } from "@/graphql/schema.types"; 4 | import CustomAvatar from "../custom-avatar"; 5 | 6 | type Props = { 7 | user: User; 8 | }; 9 | 10 | // display a user's avatar and name in a tag 11 | export const UserTag = ({ user }: Props) => { 12 | return ( 13 | 23 | 24 | 29 | {user.name} 30 | 31 | 32 | ); 33 | }; 34 | -------------------------------------------------------------------------------- /src/config/resources.tsx: -------------------------------------------------------------------------------- 1 | import { DashboardOutlined, ProjectOutlined, ShopOutlined } from "@ant-design/icons"; 2 | import { IResourceItem } from "@refinedev/core"; 3 | 4 | export const resources: IResourceItem[] = [ 5 | { 6 | name: 'dashboard', 7 | list: '/', 8 | meta: { 9 | label: 'Dashboard', 10 | icon: 11 | } 12 | }, 13 | { 14 | name: 'companies', 15 | list: '/companies', 16 | show: '/companies/:id', 17 | create: '/companies/new', 18 | edit: '/companies/edit/:id', 19 | meta: { 20 | label: 'Companies', 21 | icon: 22 | } 23 | }, 24 | { 25 | name: 'tasks', 26 | list: '/tasks', 27 | create: '/tasks/new', 28 | edit: '/tasks/edit/:id', 29 | meta: { 30 | label: 'Tasks', 31 | icon: 32 | } 33 | } 34 | ] -------------------------------------------------------------------------------- /src/components/skeleton/latest-activities.tsx: -------------------------------------------------------------------------------- 1 | import { List, Skeleton } from "antd"; 2 | 3 | const LatestActivitiesSkeleton = () => { 4 | return ( 5 | 6 | 16 | } 17 | title={ 18 | 24 | } 25 | description={ 26 | 33 | } 34 | /> 35 | 36 | ); 37 | }; 38 | 39 | export default LatestActivitiesSkeleton; -------------------------------------------------------------------------------- /src/components/tasks/kanban/add-card-button.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | 3 | import { PlusSquareOutlined } from "@ant-design/icons"; 4 | import { Button } from "antd"; 5 | import { Text } from "@/components/text"; 6 | 7 | interface Props { 8 | onClick: () => void; 9 | } 10 | 11 | /** Render a button that allows you to add a new card to a column. 12 | * 13 | * @param onClick - a function that is called when the button is clicked. 14 | * @returns a button that allows you to add a new card to a column. 15 | */ 16 | export const KanbanAddCardButton = ({ 17 | children, 18 | onClick, 19 | }: React.PropsWithChildren) => { 20 | return ( 21 | 36 | ); 37 | }; 38 | -------------------------------------------------------------------------------- /src/components/index.ts: -------------------------------------------------------------------------------- 1 | import UpcomingEvents from "./home/upcoming-events"; 2 | import DealsChart from "./home/deals-chart"; 3 | import UpcomingEventsSkeleton from "./skeleton/upcoming-events"; 4 | import AccordionHeaderSkeleton from "./skeleton/accordion-header"; 5 | import KanbanColumnSkeleton from "./skeleton/kanban"; 6 | import ProjectCardSkeleton from "./skeleton/project-card"; 7 | import LatestActivitiesSkeleton from "./skeleton/latest-activities"; 8 | 9 | import DashboardTotalCountCard from "./home/total-count-card"; 10 | import LatestActivities from "./home/latest-activities"; 11 | 12 | export { 13 | UpcomingEvents, 14 | DealsChart, 15 | 16 | UpcomingEventsSkeleton, 17 | AccordionHeaderSkeleton, 18 | KanbanColumnSkeleton, 19 | ProjectCardSkeleton, 20 | LatestActivitiesSkeleton, 21 | 22 | DashboardTotalCountCard, 23 | LatestActivities 24 | }; 25 | export * from './tags/user-tag'; 26 | export * from './text'; 27 | export * from './accordion'; 28 | export * from "./tasks/form/description"; 29 | export * from "./tasks/form/due-date"; 30 | export * from "./tasks/form/stage"; 31 | export * from "./tasks/form/title"; 32 | export * from "./tasks/form/users"; 33 | export * from "./tasks/form/header"; 34 | -------------------------------------------------------------------------------- /src/providers/data/index.tsx: -------------------------------------------------------------------------------- 1 | import graphqlDataProvider, { 2 | GraphQLClient, 3 | liveProvider as graphqlLiveProvider 4 | } from "@refinedev/nestjs-query"; 5 | import { createClient } from 'graphql-ws' 6 | import { fetchWrapper } from "./fetch-wrapper"; 7 | 8 | export const API_BASE_URL = 'https://api.crm.refine.dev' 9 | export const API_URL = `${API_BASE_URL}/graphql` 10 | export const WS_URL = 'wss://api.crm.refine.dev/graphql' 11 | 12 | export const client = new GraphQLClient(API_URL, { 13 | fetch: (url: string, options: RequestInit) => { 14 | try { 15 | return fetchWrapper(url, options); 16 | } catch (error) { 17 | return Promise.reject(error as Error); 18 | } 19 | } 20 | }) 21 | 22 | export const wsClient = typeof window !== "undefined" 23 | ? createClient({ 24 | url: WS_URL, 25 | connectionParams: () => { 26 | const accessToken = localStorage.getItem("access_token"); 27 | 28 | return { 29 | headers: { 30 | Authorization: `Bearer ${accessToken}`, 31 | } 32 | } 33 | } 34 | }) 35 | : undefined 36 | 37 | export const dataProvider = graphqlDataProvider(client); 38 | export const liveProvider = wsClient ? graphqlLiveProvider(wsClient) : undefined; -------------------------------------------------------------------------------- /src/components/tasks/kanban/item.tsx: -------------------------------------------------------------------------------- 1 | import { DragOverlay, UseDraggableArguments, useDraggable } from '@dnd-kit/core' 2 | 3 | interface Props { 4 | id: string; 5 | data?: UseDraggableArguments['data'] 6 | } 7 | 8 | const KanbanItem = ({ children, id, data }: React.PropsWithChildren) => { 9 | const { attributes, listeners, setNodeRef, active } = useDraggable({ 10 | id, 11 | data, 12 | }) 13 | 14 | return ( 15 |
18 |
29 | {active?.id === id && ( 30 | 31 |
36 | {children} 37 |
38 |
39 | )} 40 | {children} 41 |
42 |
43 | ) 44 | } 45 | 46 | export default KanbanItem -------------------------------------------------------------------------------- /src/components/tasks/kanban/board.tsx: -------------------------------------------------------------------------------- 1 | import { DndContext, DragEndEvent, MouseSensor, TouchSensor, useSensor, useSensors } from '@dnd-kit/core' 2 | import React from 'react' 3 | 4 | export const KanbanBoardContainer = ({ children }: React.PropsWithChildren) => { 5 | return ( 6 |
15 |
24 | {children} 25 |
26 |
27 | ) 28 | } 29 | 30 | type Props = { 31 | onDragEnd: (event: DragEndEvent) => void 32 | } 33 | 34 | export const KanbanBoard = ({ children, onDragEnd }: React.PropsWithChildren) => { 35 | const mouseSensor = useSensor(MouseSensor, { 36 | activationConstraint: { 37 | distance: 5, 38 | }, 39 | }) 40 | 41 | const touchSensor = useSensor(TouchSensor, { 42 | activationConstraint: { 43 | distance: 5 44 | } 45 | }) 46 | 47 | const sensors = useSensors(mouseSensor, touchSensor) 48 | 49 | return ( 50 | 51 | {children} 52 | 53 | ) 54 | } -------------------------------------------------------------------------------- /src/components/text-icon.tsx: -------------------------------------------------------------------------------- 1 | import Icon from "@ant-design/icons"; 2 | import type { CustomIconComponentProps } from "@ant-design/icons/lib/components/Icon"; 3 | 4 | export const TextIconSvg = () => ( 5 | 12 | 17 | 22 | 27 | 28 | ); 29 | 30 | export const TextIcon = (props: Partial) => ( 31 | 32 | ); 33 | -------------------------------------------------------------------------------- /index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 12 | 17 | 22 | 23 | refine - Build your React-based CRUD applications, without constraints. 24 | 25 | 26 | 27 | 28 |
29 | 30 | 40 | 41 | 42 | -------------------------------------------------------------------------------- /src/providers/data/fetch-wrapper.ts: -------------------------------------------------------------------------------- 1 | import { GraphQLFormattedError } from "graphql"; 2 | 3 | type Error = { 4 | message: string; 5 | statusCode: string; 6 | } 7 | 8 | const customFetch = async (url: string, options: RequestInit) => { 9 | const accessToken = localStorage.getItem('access_token'); 10 | 11 | const headers = options.headers as Record; 12 | 13 | return await fetch(url,{ 14 | ...options, 15 | headers: { 16 | ...headers, 17 | Authorization: headers?.Authorization || `Bearer ${accessToken}`, 18 | "Content-Type": "application/json", 19 | "Apollo-Require-Preflight": "true", 20 | } 21 | }) 22 | } 23 | 24 | const getGraphQLErrors = (body: Record<"errors", GraphQLFormattedError[] | undefined>): Error | null => { 25 | if(!body) { 26 | return { 27 | message: 'Unknown error', 28 | statusCode: "INTERNAL_SERVER_ERROR" 29 | } 30 | } 31 | 32 | if("errors" in body) { 33 | const errors = body?.errors; 34 | 35 | const messages = errors?.map((error) => error?.message)?.join(""); 36 | const code = errors?.[0]?.extensions?.code; 37 | 38 | return { 39 | message: messages || JSON.stringify(errors), 40 | statusCode: code || 500 41 | } 42 | } 43 | 44 | return null; 45 | } 46 | 47 | export const fetchWrapper = async (url: string, options: RequestInit) => { 48 | const response = await customFetch(url, options); 49 | 50 | const responseClone = response.clone(); 51 | const body = await responseClone.json(); 52 | 53 | const error = getGraphQLErrors(body); 54 | 55 | if(error) { 56 | throw error; 57 | } 58 | 59 | return response; 60 | } -------------------------------------------------------------------------------- /src/components/tags/contact-status-tag.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | 3 | import { 4 | CheckCircleOutlined, 5 | MinusCircleOutlined, 6 | PlayCircleFilled, 7 | PlayCircleOutlined, 8 | } from "@ant-design/icons"; 9 | import { Tag, TagProps } from "antd"; 10 | 11 | import { ContactStatus } from "@/graphql/schema.types"; 12 | 13 | type Props = { 14 | status: ContactStatus; 15 | }; 16 | 17 | /** 18 | * Renders a tag component representing the contact status. 19 | * @param status - The contact status. 20 | */ 21 | export const ContactStatusTag = ({ status }: Props) => { 22 | let icon: React.ReactNode = null; 23 | let color: TagProps["color"] = undefined; 24 | 25 | switch (status) { 26 | case "NEW": 27 | case "CONTACTED": 28 | case "INTERESTED": 29 | icon = ; 30 | color = "cyan"; 31 | break; 32 | 33 | case "UNQUALIFIED": 34 | icon = ; 35 | color = "red"; 36 | break; 37 | 38 | case "QUALIFIED": 39 | case "NEGOTIATION": 40 | icon = ; 41 | color = "green"; 42 | break; 43 | 44 | case "LOST": 45 | icon = ; 46 | color = "red"; 47 | break; 48 | 49 | case "WON": 50 | icon = ; 51 | color = "green"; 52 | break; 53 | 54 | case "CHURNED": 55 | icon = ; 56 | color = "red"; 57 | break; 58 | 59 | default: 60 | break; 61 | } 62 | 63 | return ( 64 | 65 | {icon} {status.toLowerCase()} 66 | 67 | ); 68 | }; 69 | -------------------------------------------------------------------------------- /src/components/skeleton/kanban.tsx: -------------------------------------------------------------------------------- 1 | import { Button, Skeleton, Space } from "antd"; 2 | import { MoreOutlined, PlusOutlined } from "@ant-design/icons"; 3 | 4 | const KanbanColumnSkeleton = ({ children }: React.PropsWithChildren) => { 5 | return ( 6 |
13 |
18 | 24 | 25 |
40 |
47 |
55 | {children} 56 |
57 |
58 |
59 | ); 60 | }; 61 | 62 | export default KanbanColumnSkeleton; -------------------------------------------------------------------------------- /src/components/accordion.tsx: -------------------------------------------------------------------------------- 1 | import { AccordionHeaderSkeleton } from "@/components"; 2 | import { Text } from "./text"; 3 | 4 | type Props = React.PropsWithChildren<{ 5 | accordionKey: string; 6 | activeKey?: string; 7 | setActive: (key?: string) => void; 8 | fallback: string | React.ReactNode; 9 | isLoading?: boolean; 10 | icon: React.ReactNode; 11 | label: string; 12 | }>; 13 | 14 | /** 15 | * when activeKey is equal to accordionKey, the children will be rendered. Otherwise, the fallback will be rendered 16 | * when isLoading is true, the will be rendered 17 | * when Accordion is clicked, setActive will be called with the accordionKey 18 | */ 19 | export const Accordion = ({ 20 | accordionKey, 21 | activeKey, 22 | setActive, 23 | fallback, 24 | icon, 25 | label, 26 | children, 27 | isLoading, 28 | }: Props) => { 29 | if (isLoading) return ; 30 | 31 | const isActive = activeKey === accordionKey; 32 | 33 | const toggleAccordion = () => { 34 | if (isActive) { 35 | setActive(undefined); 36 | } else { 37 | setActive(accordionKey); 38 | } 39 | }; 40 | 41 | return ( 42 |
51 |
{icon}
52 | {isActive ? ( 53 |
61 | 62 | {label} 63 | 64 | {children} 65 |
66 | ) : ( 67 |
68 | {fallback} 69 |
70 | )} 71 |
72 | ); 73 | }; 74 | -------------------------------------------------------------------------------- /src/components/layout/current-user.tsx: -------------------------------------------------------------------------------- 1 | import { Popover, Button } from 'antd' 2 | import CustomAvatar from '../custom-avatar' 3 | import { useGetIdentity } from '@refinedev/core' 4 | 5 | import type { User } from '@/graphql/schema.types' 6 | import { Text } from '../text' 7 | import { SettingOutlined } from '@ant-design/icons' 8 | import { useState } from 'react' 9 | import { AccountSettings } from './account-settings' 10 | 11 | const CurrentUser = () => { 12 | const [isOpen, setIsOpen] = useState(false) 13 | const { data: user } = useGetIdentity() 14 | 15 | const content = ( 16 |
20 | 24 | {user?.name} 25 | 26 |
35 | 44 |
45 |
46 | ) 47 | 48 | return ( 49 | <> 50 | 57 | 63 | 64 | {user && ( 65 | 70 | )} 71 | 72 | ) 73 | } 74 | 75 | export default CurrentUser -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "heavy-baths-sort", 3 | "version": "0.1.0", 4 | "private": true, 5 | "type": "module", 6 | "dependencies": { 7 | "@ant-design/icons": "^5.0.1", 8 | "@ant-design/plots": "^1.2.5", 9 | "@dnd-kit/core": "^6.1.0", 10 | "@refinedev/antd": "^5.37.1", 11 | "@refinedev/cli": "^2.16.21", 12 | "@refinedev/core": "^4.46.1", 13 | "@refinedev/devtools": "^1.1.29", 14 | "@refinedev/inferencer": "^4.5.16", 15 | "@refinedev/kbar": "^1.3.5", 16 | "@refinedev/nestjs-query": "^1.1.1", 17 | "@refinedev/react-router-v6": "^4.5.5", 18 | "@uiw/react-md-editor": "^4.0.3", 19 | "antd": "^5.0.5", 20 | "dayjs": "^1.11.10", 21 | "react": "^18.0.0", 22 | "react-dom": "^18.0.0", 23 | "react-router-dom": "^6.8.1" 24 | }, 25 | "devDependencies": { 26 | "@graphql-codegen/cli": "^5.0.0", 27 | "@graphql-codegen/import-types-preset": "^3.0.0", 28 | "@graphql-codegen/typescript": "^4.0.1", 29 | "@graphql-codegen/typescript-operations": "^4.0.1", 30 | "@types/node": "^18.16.2", 31 | "@types/react": "^18.0.0", 32 | "@types/react-dom": "^18.0.0", 33 | "@typescript-eslint/eslint-plugin": "^5.57.1", 34 | "@typescript-eslint/parser": "^5.57.1", 35 | "@vitejs/plugin-react": "^4.0.0", 36 | "eslint": "^8.38.0", 37 | "eslint-plugin-react-hooks": "^4.6.0", 38 | "eslint-plugin-react-refresh": "^0.3.4", 39 | "prettier": "^3.2.1", 40 | "typescript": "^4.9.5", 41 | "vite": "^4.3.1", 42 | "vite-tsconfig-paths": "^4.2.3" 43 | }, 44 | "scripts": { 45 | "dev": "refine dev", 46 | "build": "tsc && refine build", 47 | "preview": "refine start", 48 | "refine": "refine", 49 | "codegen": "graphql-codegen" 50 | }, 51 | "browserslist": { 52 | "production": [ 53 | ">0.2%", 54 | "not dead", 55 | "not op_mini all" 56 | ], 57 | "development": [ 58 | "last 1 chrome version", 59 | "last 1 firefox version", 60 | "last 1 safari version" 61 | ] 62 | }, 63 | "refine": { 64 | "projectId": "WVyLEd-4karEq-tItoeC" 65 | } 66 | } 67 | -------------------------------------------------------------------------------- /src/components/text.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | 3 | import { ConfigProvider, Typography } from "antd"; 4 | 5 | export type TextProps = { 6 | size?: 7 | | "xs" 8 | | "sm" 9 | | "md" 10 | | "lg" 11 | | "xl" 12 | | "xxl" 13 | | "xxxl" 14 | | "huge" 15 | | "xhuge" 16 | | "xxhuge"; 17 | } & React.ComponentProps; 18 | 19 | // define the font sizes and line heights 20 | const sizes = { 21 | xs: { 22 | fontSize: 12, 23 | lineHeight: 20 / 12, 24 | }, 25 | sm: { 26 | fontSize: 14, 27 | lineHeight: 22 / 14, 28 | }, 29 | md: { 30 | fontSize: 16, 31 | lineHeight: 24 / 16, 32 | }, 33 | lg: { 34 | fontSize: 20, 35 | lineHeight: 28 / 20, 36 | }, 37 | xl: { 38 | fontSize: 24, 39 | lineHeight: 32 / 24, 40 | }, 41 | xxl: { 42 | fontSize: 30, 43 | lineHeight: 38 / 30, 44 | }, 45 | xxxl: { 46 | fontSize: 38, 47 | lineHeight: 46 / 38, 48 | }, 49 | huge: { 50 | fontSize: 46, 51 | lineHeight: 54 / 46, 52 | }, 53 | xhuge: { 54 | fontSize: 56, 55 | lineHeight: 64 / 56, 56 | }, 57 | xxhuge: { 58 | fontSize: 68, 59 | lineHeight: 76 / 68, 60 | }, 61 | }; 62 | 63 | // a custom Text component that wraps/extends the antd Typography.Text component 64 | export const Text = ({ size = "sm", children, ...rest }: TextProps) => { 65 | return ( 66 | // config provider is a top-level component that allows us to customize the global properties of antd components. For example, default antd theme 67 | // token is a term used by antd to refer to the design tokens like font size, font weight, color, etc 68 | // https://ant.design/docs/react/customize-theme#customize-design-token 69 | 76 | {/** 77 | * Typography.Text is a component from antd that allows us to render text 78 | * Typography has different components like Title, Paragraph, Text, Link, etc 79 | * https://ant.design/components/typography/#Typography.Text 80 | */} 81 | {children} 82 | 83 | ); 84 | }; 85 | -------------------------------------------------------------------------------- /src/pages/home/index.tsx: -------------------------------------------------------------------------------- 1 | import { DashboardTotalCountCard, DealsChart, LatestActivities, UpcomingEvents } from "@/components" 2 | import { DASHBOARD_TOTAL_COUNTS_QUERY } from "@/graphql/queries" 3 | import { DashboardTotalCountsQuery } from "@/graphql/types" 4 | import { useCustom } from "@refinedev/core" 5 | import { Col, Row } from "antd" 6 | 7 | export const Home = () => { 8 | const { data, isLoading } = useCustom({ 9 | url: '', 10 | method: 'get', 11 | meta: { 12 | gqlQuery: DASHBOARD_TOTAL_COUNTS_QUERY 13 | } 14 | }) 15 | 16 | return ( 17 |
18 | 19 | 20 | 25 | 26 | 27 | 32 | 33 | 34 | 39 | 40 | 41 | 42 | 48 | 56 | 57 | 58 | 66 | 67 | 68 | 69 | 70 | 76 | 77 | 78 | 79 | 80 |
81 | ) 82 | } -------------------------------------------------------------------------------- /src/components/home/deals-chart.tsx: -------------------------------------------------------------------------------- 1 | import { DollarOutlined } from '@ant-design/icons' 2 | import { Card } from 'antd' 3 | import React from 'react' 4 | import { Text } from '../text' 5 | import { Area, AreaConfig } from '@ant-design/plots' 6 | import { useList } from '@refinedev/core' 7 | import { DASHBOARD_DEALS_CHART_QUERY } from '@/graphql/queries' 8 | import { mapDealsData } from '@/utilities/helpers' 9 | import { GetFieldsFromList } from '@refinedev/nestjs-query' 10 | import { DashboardDealsChartQuery } from '@/graphql/types' 11 | 12 | const DealsChart = () => { 13 | const { data } = useList>({ 14 | resource: 'dealStages', 15 | filters: [ 16 | { 17 | field: 'title', operator: 'in', value: ['WON', 'LOST'] 18 | } 19 | ], 20 | meta: { 21 | gqlQuery: DASHBOARD_DEALS_CHART_QUERY 22 | } 23 | }); 24 | 25 | const dealData = React.useMemo(() => { 26 | return mapDealsData(data?.data); 27 | }, [data?.data]) 28 | 29 | const config: AreaConfig = { 30 | data: dealData, 31 | xField: 'timeText', 32 | yField: 'value', 33 | isStack: false, 34 | seriesField: 'state', 35 | animation: true, 36 | startOnZero: false, 37 | smooth: true, 38 | legend: { 39 | offsetY: -6 40 | }, 41 | yAxis: { 42 | tickCount: 4, 43 | label: { 44 | formatter: (v: string) => { 45 | return `$${Number(v) /1000}k` 46 | } 47 | } 48 | }, 49 | tooltip: { 50 | formatter: (data) => { 51 | return { 52 | name: data.state, 53 | value: `$${Number(data.value) / 1000}k` 54 | } 55 | } 56 | }, 57 | } 58 | 59 | return ( 60 | 72 | 73 | 74 | Deals 75 | 76 | 77 | } 78 | > 79 | 80 | 81 | ) 82 | } 83 | 84 | export default DealsChart -------------------------------------------------------------------------------- /src/components/tasks/kanban/column.tsx: -------------------------------------------------------------------------------- 1 | import { Text } from '@/components/text' 2 | import { PlusOutlined } from '@ant-design/icons' 3 | import { UseDroppableArguments, useDroppable } from '@dnd-kit/core' 4 | import { Badge, Button, Space } from 'antd' 5 | 6 | type Props = { 7 | id: string, 8 | title: string, 9 | description?: React.ReactNode, 10 | count: number, 11 | data?: UseDroppableArguments['data'], 12 | onAddClick?: (args: { id: string }) => void, 13 | } 14 | 15 | const KanbanColumn = ({ 16 | children, 17 | id, 18 | title, 19 | description, 20 | count, 21 | data, 22 | onAddClick 23 | }: React.PropsWithChildren) => { 24 | const { isOver, setNodeRef, active } = useDroppable({ id, data }) 25 | 26 | const onAddClickHandler = () => { 27 | onAddClick?.({ id }) 28 | } 29 | 30 | return ( 31 |
39 |
40 | 41 | 42 | 51 | {title} 52 | 53 | {!!count && } 54 | 55 |
63 |
72 |
80 | {children} 81 |
82 |
83 |
84 | ) 85 | } 86 | 87 | export default KanbanColumn -------------------------------------------------------------------------------- /src/graphql/mutations.ts: -------------------------------------------------------------------------------- 1 | import gql from "graphql-tag"; 2 | 3 | // Mutation to update user 4 | export const UPDATE_USER_MUTATION = gql` 5 | # The ! after the type means that it is required 6 | mutation UpdateUser($input: UpdateOneUserInput!) { 7 | # call the updateOneUser mutation with the input and pass the $input argument 8 | # $variableName is a convention for GraphQL variables 9 | updateOneUser(input: $input) { 10 | id 11 | name 12 | avatarUrl 13 | email 14 | phone 15 | jobTitle 16 | } 17 | } 18 | `; 19 | 20 | // Mutation to create company 21 | export const CREATE_COMPANY_MUTATION = gql` 22 | mutation CreateCompany($input: CreateOneCompanyInput!) { 23 | createOneCompany(input: $input) { 24 | id 25 | salesOwner { 26 | id 27 | } 28 | } 29 | } 30 | `; 31 | 32 | // Mutation to update company details 33 | export const UPDATE_COMPANY_MUTATION = gql` 34 | mutation UpdateCompany($input: UpdateOneCompanyInput!) { 35 | updateOneCompany(input: $input) { 36 | id 37 | name 38 | totalRevenue 39 | industry 40 | companySize 41 | businessType 42 | country 43 | website 44 | avatarUrl 45 | salesOwner { 46 | id 47 | name 48 | avatarUrl 49 | } 50 | } 51 | } 52 | `; 53 | 54 | // Mutation to update task stage of a task 55 | export const UPDATE_TASK_STAGE_MUTATION = gql` 56 | mutation UpdateTaskStage($input: UpdateOneTaskInput!) { 57 | updateOneTask(input: $input) { 58 | id 59 | } 60 | } 61 | `; 62 | 63 | // Mutation to create a new task 64 | export const CREATE_TASK_MUTATION = gql` 65 | mutation CreateTask($input: CreateOneTaskInput!) { 66 | createOneTask(input: $input) { 67 | id 68 | title 69 | stage { 70 | id 71 | title 72 | } 73 | } 74 | } 75 | `; 76 | 77 | // Mutation to update a task details 78 | export const UPDATE_TASK_MUTATION = gql` 79 | mutation UpdateTask($input: UpdateOneTaskInput!) { 80 | updateOneTask(input: $input) { 81 | id 82 | title 83 | completed 84 | description 85 | dueDate 86 | stage { 87 | id 88 | title 89 | } 90 | users { 91 | id 92 | name 93 | avatarUrl 94 | } 95 | checklist { 96 | title 97 | checked 98 | } 99 | } 100 | } 101 | `; 102 | -------------------------------------------------------------------------------- /src/utilities/helpers.ts: -------------------------------------------------------------------------------- 1 | import dayjs from "dayjs"; 2 | import { GetFieldsFromList } from "@refinedev/nestjs-query"; 3 | 4 | import { DashboardDealsChartQuery } from "@/graphql/types"; 5 | 6 | type DealStage = GetFieldsFromList; 7 | 8 | type DealAggregate = DealStage["dealsAggregate"][0]; 9 | 10 | interface MappedDealData { 11 | timeUnix: number; 12 | timeText: string; 13 | value: number; 14 | state: string; 15 | } 16 | 17 | // Get the date in the format "MMM DD, YYYY - HH:mm" 18 | export const getDate = (startDate: string, endDate: string) => { 19 | const start = dayjs(startDate).format("MMM DD, YYYY - HH:mm"); 20 | const end = dayjs(endDate).format("MMM DD, YYYY - HH:mm"); 21 | 22 | return `${start} - ${end}`; 23 | }; 24 | 25 | // Filter out deals that don't have a closeDateMonth or closeDateYear 26 | const filterDeal = (deal?: DealAggregate) => 27 | deal?.groupBy && deal.groupBy?.closeDateMonth && deal.groupBy?.closeDateYear; 28 | 29 | const mapDeals = ( 30 | deals: DealAggregate[] = [], 31 | state: string 32 | ): MappedDealData[] => { 33 | // filter out deals that don't have a closeDateMonth or closeDateYear 34 | return deals.filter(filterDeal).map((deal) => { 35 | // Get the closeDateMonth and closeDateYear from the deal 36 | const { closeDateMonth, closeDateYear } = deal.groupBy as NonNullable< 37 | DealAggregate["groupBy"] 38 | >; 39 | 40 | // Create a date object from the closeDateMonth and closeDateYear 41 | const date = dayjs(`${closeDateYear}-${closeDateMonth}-01`); 42 | 43 | // Return the mapped deal data 44 | return { 45 | // Convert the date to a unix timestamp i.e., 1622505600000 46 | timeUnix: date.unix(), 47 | // Convert the date to a string i.e., "May 2021" 48 | timeText: date.format("MMM YYYY"), 49 | // Get the sum of all deals in this stage 50 | value: deal.sum?.value ?? 0, 51 | state, 52 | }; 53 | }); 54 | }; 55 | 56 | // Map deals data to the format required by the chart 57 | export const mapDealsData = ( 58 | dealStages: DealStage[] = [] 59 | ): MappedDealData[] => { 60 | // Get the deal stage with the title "WON" 61 | const won = dealStages.find((stage) => stage.title === "WON"); 62 | const wonDeals = mapDeals(won?.dealsAggregate, "Won"); 63 | 64 | // Get the deal stage with the title "LOST" 65 | const lost = dealStages.find((stage) => stage.title === "LOST"); 66 | const lostDeals = mapDeals(lost?.dealsAggregate, "Lost"); 67 | 68 | // Combine the won and lost deals and sort them by time 69 | return [...wonDeals, ...lostDeals].sort((a, b) => a.timeUnix - b.timeUnix); 70 | }; 71 | -------------------------------------------------------------------------------- /src/components/tasks/form/header.tsx: -------------------------------------------------------------------------------- 1 | import { MarkdownField } from "@refinedev/antd"; 2 | 3 | import { Typography, Space, Tag } from "antd"; 4 | 5 | import dayjs from "dayjs"; 6 | 7 | import { Text, UserTag } from "@/components"; 8 | import { getDateColor } from "@/utilities"; 9 | 10 | import { Task } from "@/graphql/schema.types"; 11 | 12 | type DescriptionProps = { 13 | description?: Task["description"]; 14 | }; 15 | 16 | type DueDateProps = { 17 | dueData?: Task["dueDate"]; 18 | }; 19 | 20 | type UserProps = { 21 | users?: Task["users"]; 22 | }; 23 | 24 | // display a task's descriptio if it exists, otherwise display a link to add one 25 | export const DescriptionHeader = ({ description }: DescriptionProps) => { 26 | if (description) { 27 | return ( 28 | 29 | 30 | 31 | ); 32 | } 33 | 34 | // if the task doesn't have a description, display a link to add one 35 | return Add task description; 36 | }; 37 | 38 | // display a task's due date if it exists, otherwise display a link to add one 39 | export const DueDateHeader = ({ dueData }: DueDateProps) => { 40 | if (dueData) { 41 | // get the color of the due date 42 | const color = getDateColor({ 43 | date: dueData, 44 | defaultColor: "processing", 45 | }); 46 | 47 | // depending on the due date, display a different color and text 48 | const getTagText = () => { 49 | switch (color) { 50 | case "error": 51 | return "Overdue"; 52 | 53 | case "warning": 54 | return "Due soon"; 55 | 56 | default: 57 | return "Processing"; 58 | } 59 | }; 60 | 61 | return ( 62 | 63 | {getTagText()} 64 | {dayjs(dueData).format("MMMM D, YYYY - h:ma")} 65 | 66 | ); 67 | } 68 | 69 | // if the task doesn't have a due date, display a link to add one 70 | return Add due date; 71 | }; 72 | 73 | // display a task's users if it exists, otherwise display a link to add one 74 | export const UsersHeader = ({ users = [] }: UserProps) => { 75 | if (users.length > 0) { 76 | return ( 77 | 78 | {users.map((user) => ( 79 | 80 | ))} 81 | 82 | ); 83 | } 84 | 85 | // if the task doesn't have users, display a link to add one 86 | return Assign to users; 87 | }; 88 | -------------------------------------------------------------------------------- /src/pages/company/create.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import { CompanyList } from './list' 3 | import { Form, Input, Modal, Select } from 'antd' 4 | import { useModalForm, useSelect } from '@refinedev/antd' 5 | import { useGo } from '@refinedev/core' 6 | import { CREATE_COMPANY_MUTATION } from '@/graphql/mutations' 7 | import { USERS_SELECT_QUERY } from '@/graphql/queries' 8 | import SelectOptionWithAvatar from '@/components/select-option-with-avatar' 9 | import { GetFieldsFromList } from '@refinedev/nestjs-query' 10 | import { UsersSelectQuery } from '@/graphql/types' 11 | 12 | const Create = () => { 13 | const go = useGo(); 14 | 15 | const goToListPage = () => { 16 | go({ 17 | to: { resource: 'companies', action: 'list' }, 18 | options: { keepQuery: true }, 19 | type: 'replace', 20 | }) 21 | } 22 | 23 | const { formProps, modalProps } = useModalForm({ 24 | action: 'create', 25 | defaultVisible: true, 26 | resource: 'companies', 27 | redirect: false, 28 | mutationMode: 'pessimistic', 29 | onMutationSuccess: goToListPage, 30 | meta: { 31 | gqlMutation: CREATE_COMPANY_MUTATION 32 | } 33 | }) 34 | 35 | const { selectProps, queryResult } = useSelect>({ 36 | resource: 'users', 37 | optionLabel: 'name', 38 | meta: { 39 | gqlQuery: USERS_SELECT_QUERY 40 | } 41 | }) 42 | 43 | return ( 44 | 45 | 52 |
53 | 58 | 59 | 60 | 65 | 74 | 75 |
76 |
77 | ); 78 | }; 79 | 80 | export default TasksCreatePage; -------------------------------------------------------------------------------- /src/components/tasks/form/due-date.tsx: -------------------------------------------------------------------------------- 1 | import { useForm } from "@refinedev/antd"; 2 | import { HttpError } from "@refinedev/core"; 3 | import { GetFields, GetVariables } from "@refinedev/nestjs-query"; 4 | 5 | import { Button, DatePicker, Form, Space } from "antd"; 6 | import dayjs from "dayjs"; 7 | 8 | import { Task } from "@/graphql/schema.types"; 9 | import { 10 | UpdateTaskMutation, 11 | UpdateTaskMutationVariables, 12 | } from "@/graphql/types"; 13 | 14 | import { UPDATE_TASK_MUTATION } from "@/graphql/mutations"; 15 | 16 | type Props = { 17 | initialValues: { 18 | dueDate?: Task["dueDate"]; 19 | }; 20 | cancelForm: () => void; 21 | }; 22 | 23 | export const DueDateForm = ({ initialValues, cancelForm }: Props) => { 24 | // use the useForm hook to manage the form 25 | // formProps contains all the props that we need to pass to the form (initialValues, onSubmit, etc.) 26 | // saveButtonProps contains all the props that we need to pass to the save button 27 | const { formProps, saveButtonProps } = useForm< 28 | GetFields, 29 | HttpError, 30 | /** 31 | * Pick is a utility type from typescript that allows you to create a new type from an existing type by picking some properties from it. 32 | * https://www.typescriptlang.org/docs/handbook/utility-types.html#picktype-keys 33 | * 34 | * Pick 35 | * Type -> the type from which we want to pick the properties 36 | * Keys -> the properties that we want to pick 37 | */ 38 | Pick, "dueDate"> 39 | >({ 40 | queryOptions: { 41 | // disable the query to prevent fetching data on component mount 42 | enabled: false, 43 | }, 44 | redirect: false, // disable redirection 45 | // when the mutation is successful, call the cancelForm function to close the form 46 | onMutationSuccess: () => { 47 | cancelForm(); 48 | }, 49 | // specify the mutation that should be performed 50 | meta: { 51 | gqlMutation: UPDATE_TASK_MUTATION, 52 | }, 53 | }); 54 | 55 | return ( 56 |
63 |
64 | { 68 | if (!value) return { value: undefined }; 69 | return { value: dayjs(value) }; 70 | }} 71 | > 72 | 80 | 81 |
82 | 83 | 86 | 89 | 90 |
91 | ); 92 | }; 93 | -------------------------------------------------------------------------------- /graphql.config.ts: -------------------------------------------------------------------------------- 1 | import type { IGraphQLConfig } from "graphql-config"; 2 | 3 | const config: IGraphQLConfig = { 4 | // define graphQL schema provided by Refine 5 | schema: "https://api.crm.refine.dev/graphql", 6 | extensions: { 7 | // codegen is a plugin that generates typescript types from GraphQL schema 8 | // https://the-guild.dev/graphql/codegen 9 | codegen: { 10 | // hooks are commands that are executed after a certain event 11 | hooks: { 12 | afterOneFileWrite: ["eslint --fix", "prettier --write"], 13 | }, 14 | // generates typescript types from GraphQL schema 15 | generates: { 16 | // specify the output path of the generated types 17 | "src/graphql/schema.types.ts": { 18 | // use typescript plugin 19 | plugins: ["typescript"], 20 | // set the config of the typescript plugin 21 | // this defines how the generated types will look like 22 | config: { 23 | skipTypename: true, // skipTypename is used to remove __typename from the generated types 24 | enumsAsTypes: true, // enumsAsTypes is used to generate enums as types instead of enums. 25 | // scalars is used to define how the scalars i.e. DateTime, JSON, etc. will be generated 26 | // scalar is a type that is not a list and does not have fields. Meaning it is a primitive type. 27 | scalars: { 28 | // DateTime is a scalar type that is used to represent date and time 29 | DateTime: { 30 | input: "string", 31 | output: "string", 32 | format: "date-time", 33 | }, 34 | }, 35 | }, 36 | }, 37 | // generates typescript types from GraphQL operations 38 | // graphql operations are queries, mutations, and subscriptions we write in our code to communicate with the GraphQL API 39 | "src/graphql/types.ts": { 40 | // preset is a plugin that is used to generate typescript types from GraphQL operations 41 | // import-types suggests to import types from schema.types.ts or other files 42 | // this is used to avoid duplication of types 43 | // https://the-guild.dev/graphql/codegen/plugins/presets/import-types-preset 44 | preset: "import-types", 45 | // documents is used to define the path of the files that contain GraphQL operations 46 | documents: ["src/**/*.{ts,tsx}"], 47 | // plugins is used to define the plugins that will be used to generate typescript types from GraphQL operations 48 | plugins: ["typescript-operations"], 49 | config: { 50 | skipTypename: true, 51 | enumsAsTypes: true, 52 | // determine whether the generated types should be resolved ahead of time or not. 53 | // When preResolveTypes is set to false, the code generator will not try to resolve the types ahead of time. 54 | // Instead, it will generate more generic types, and the actual types will be resolved at runtime. 55 | preResolveTypes: false, 56 | // useTypeImports is used to import types using import type instead of import. 57 | useTypeImports: true, 58 | }, 59 | // presetConfig is used to define the config of the preset 60 | presetConfig: { 61 | typesPath: "./schema.types", 62 | }, 63 | }, 64 | }, 65 | }, 66 | }, 67 | }; 68 | 69 | export default config; -------------------------------------------------------------------------------- /src/components/tasks/form/users.tsx: -------------------------------------------------------------------------------- 1 | import { useForm, useSelect } from "@refinedev/antd"; 2 | import { HttpError } from "@refinedev/core"; 3 | import { 4 | GetFields, 5 | GetFieldsFromList, 6 | GetVariables, 7 | } from "@refinedev/nestjs-query"; 8 | 9 | import { Button, Form, Select, Space } from "antd"; 10 | 11 | import { 12 | UpdateTaskMutation, 13 | UpdateTaskMutationVariables, 14 | UsersSelectQuery, 15 | } from "@/graphql/types"; 16 | 17 | import { USERS_SELECT_QUERY } from "@/graphql/queries"; 18 | import { UPDATE_TASK_MUTATION } from "@/graphql/mutations"; 19 | 20 | type Props = { 21 | initialValues: { 22 | userIds?: { label: string; value: string }[]; 23 | }; 24 | cancelForm: () => void; 25 | }; 26 | 27 | export const UsersForm = ({ initialValues, cancelForm }: Props) => { 28 | // use the useForm hook to manage the form to add users to a task (assign task to users) 29 | const { formProps, saveButtonProps } = useForm< 30 | GetFields, 31 | HttpError, 32 | /** 33 | * Pick is a utility type from typescript that allows you to create a new type from an existing type by picking some properties from it. 34 | * https://www.typescriptlang.org/docs/handbook/utility-types.html#picktype-keys 35 | * 36 | * Pick 37 | * Type -> the type from which we want to pick the properties 38 | * Keys -> the properties that we want to pick 39 | */ 40 | Pick, "userIds"> 41 | >({ 42 | queryOptions: { 43 | // disable the query to prevent fetching data on component mount 44 | enabled: false, 45 | }, 46 | redirect: false, // disable redirection 47 | onMutationSuccess: () => { 48 | // when the mutation is successful, call the cancelForm function to close the form 49 | cancelForm(); 50 | }, 51 | // perform the mutation when the form is submitted 52 | meta: { 53 | gqlMutation: UPDATE_TASK_MUTATION, 54 | }, 55 | }); 56 | 57 | // use the useSelect hook to fetch the list of users from the server and display them in a select component 58 | const { selectProps } = useSelect>({ 59 | // specify the resource from which we want to fetch the data 60 | resource: "users", 61 | // specify the query that should be performed 62 | meta: { 63 | gqlQuery: USERS_SELECT_QUERY, 64 | }, 65 | // specify the label for the select component 66 | optionLabel: "name", 67 | }); 68 | 69 | return ( 70 |
78 |
83 | 84 | ({ 55 | value: user.id, 56 | label: ( 57 | 61 | ) 62 | })) ?? [] 63 | } 64 | /> 65 | 66 | 67 | 79 | 80 | 81 | 85 | 86 | 87 | 88 | 89 |
90 | 91 | 92 | 93 | 94 | 95 | 96 |
97 | ) 98 | } 99 | 100 | export default EditPage -------------------------------------------------------------------------------- /src/components/home/latest-activities.tsx: -------------------------------------------------------------------------------- 1 | import { UnorderedListOutlined } from '@ant-design/icons' 2 | import { Card, List, Space } from 'antd' 3 | import React from 'react' 4 | import { Text } from '../text' 5 | import LatestActivitiesSkeleton from '../skeleton/latest-activities' 6 | import { useList } from '@refinedev/core' 7 | import { DASHBOARD_LATEST_ACTIVITIES_AUDITS_QUERY, DASHBOARD_LATEST_ACTIVITIES_DEALS_QUERY } from '@/graphql/queries' 8 | import dayjs from 'dayjs' 9 | import CustomAvatar from '../custom-avatar' 10 | 11 | const LatestActivities = () => { 12 | const { data: audit, isLoading: isLoadingAudit, isError, error } = useList({ 13 | resource: 'audits', 14 | meta: { 15 | gqlQuery: DASHBOARD_LATEST_ACTIVITIES_AUDITS_QUERY 16 | } 17 | }) 18 | 19 | const dealIds = audit?.data?.map((audit) => audit?.targetId); 20 | 21 | const { data: deals, isLoading: isLoadingDeals } = useList({ 22 | resource: 'deals', 23 | queryOptions: { enabled: !!dealIds?.length }, 24 | pagination: { 25 | mode: 'off' 26 | }, 27 | filters: [{ field: 'id', operator: 'in', value: dealIds }], 28 | meta: { 29 | gqlQuery: DASHBOARD_LATEST_ACTIVITIES_DEALS_QUERY 30 | } 31 | }) 32 | 33 | if(isError) { 34 | console.log(error); 35 | return null; 36 | } 37 | 38 | const isLoading = isLoadingAudit || isLoadingDeals; 39 | 40 | return ( 41 | 46 | 47 | 48 | Latest Activities 49 | 50 | 51 | )} 52 | > 53 | {isLoading ? ( 54 | ({ id: i}))} 58 | renderItem={(_, index) => ( 59 | 60 | )} 61 | /> 62 | ): ( 63 | { 67 | const deal = deals?.data.find( 68 | (deal) => deal.id === String(item.targetId) 69 | ) || undefined; 70 | 71 | return ( 72 | 73 | 82 | } 83 | description={ 84 | 85 | {item.user?.name} 86 | 87 | {item.action === 'CREATE' ? 'created' : 'moved'} 88 | 89 | {deal?.title} 90 | deal 91 | {item.action === 'CREATE' ? 'in' : 'to'} 92 | 93 | {deal?.stage?.title} 94 | 95 | 96 | } 97 | /> 98 | 99 | ) 100 | }} 101 | /> 102 | )} 103 | 104 | ) 105 | } 106 | 107 | export default LatestActivities -------------------------------------------------------------------------------- /src/pages/company/list.tsx: -------------------------------------------------------------------------------- 1 | import CustomAvatar from '@/components/custom-avatar'; 2 | import { Text } from '@/components/text'; 3 | import { COMPANIES_LIST_QUERY } from '@/graphql/queries'; 4 | import { Company } from '@/graphql/schema.types'; 5 | import { CompaniesListQuery } from '@/graphql/types'; 6 | import { currencyNumber } from '@/utilities'; 7 | import { SearchOutlined } from '@ant-design/icons'; 8 | import { CreateButton, DeleteButton, EditButton, FilterDropdown, List, useTable } from '@refinedev/antd' 9 | import { HttpError, getDefaultFilter, useGo } from '@refinedev/core' 10 | import { GetFieldsFromList } from '@refinedev/nestjs-query'; 11 | import { Input, Space, Table } from 'antd'; 12 | 13 | export const CompanyList = ({ children }: React.PropsWithChildren) => { 14 | const go = useGo(); 15 | const { tableProps, filters } = useTable< 16 | GetFieldsFromList, 17 | HttpError, 18 | GetFieldsFromList 19 | >({ 20 | resource: 'companies', 21 | onSearch: (values) => { 22 | return [ 23 | { 24 | field: 'name', 25 | operator: 'contains', 26 | value: values.name 27 | } 28 | ] 29 | }, 30 | pagination: { 31 | pageSize: 12, 32 | }, 33 | sorters: { 34 | initial: [ 35 | { 36 | field: 'createdAt', 37 | order: 'desc' 38 | } 39 | ] 40 | }, 41 | filters: { 42 | initial: [ 43 | { 44 | field: 'name', 45 | operator: 'contains', 46 | value: undefined 47 | } 48 | ] 49 | }, 50 | meta: { 51 | gqlQuery: COMPANIES_LIST_QUERY 52 | } 53 | }) 54 | 55 | return ( 56 |
57 | ( 60 | { 62 | go({ 63 | to: { 64 | resource: 'companies', 65 | action: 'create' 66 | }, 67 | options: { 68 | keepQuery: true 69 | }, 70 | type: 'replace' 71 | }) 72 | }} 73 | /> 74 | )} 75 | > 76 | 82 | 83 | dataIndex="name" 84 | title="Company Title" 85 | defaultFilteredValue={getDefaultFilter('id', filters)} 86 | filterIcon={} 87 | filterDropdown={(props) => ( 88 | 89 | 90 | 91 | )} 92 | render={(value, record) => ( 93 | 94 | 95 | 96 | {record.name} 97 | 98 | 99 | )} 100 | /> 101 | 102 | dataIndex="totalRevenue" 103 | title="Open deals amount" 104 | render={(value, company) => ( 105 | 106 | {currencyNumber(company?.dealsAggregate?.[0].sum?.value || 0)} 107 | 108 | )} 109 | /> 110 | 111 | dataIndex="id" 112 | title="Actions" 113 | fixed="right" 114 | render={(value) => ( 115 | 116 | 117 | 118 | 119 | )} 120 | /> 121 |
122 |
123 | {children} 124 |
125 | ) 126 | } 127 | -------------------------------------------------------------------------------- /src/components/tasks/form/title.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | 3 | import { useForm } from "@refinedev/antd"; 4 | import { HttpError, useInvalidate } from "@refinedev/core"; 5 | import { GetFields, GetVariables } from "@refinedev/nestjs-query"; 6 | 7 | import { Form, Skeleton } from "antd"; 8 | 9 | import { Text } from "@/components"; 10 | import { Task } from "@/graphql/schema.types"; 11 | import { 12 | UpdateTaskMutation, 13 | UpdateTaskMutationVariables, 14 | } from "@/graphql/types"; 15 | 16 | import { UPDATE_TASK_MUTATION } from "@/graphql/mutations"; 17 | 18 | const TitleInput = ({ 19 | value, 20 | onChange, 21 | }: { 22 | value?: string; 23 | onChange?: (value: string) => void; 24 | }) => { 25 | const onTitleChange = (newTitle: string) => { 26 | onChange?.(newTitle); 27 | }; 28 | 29 | return ( 30 | 36 | {value} 37 | 38 | ); 39 | }; 40 | 41 | type Props = { 42 | initialValues: { 43 | title?: Task["title"]; 44 | }; 45 | isLoading?: boolean; 46 | }; 47 | 48 | export const TitleForm = ({ initialValues, isLoading }: Props) => { 49 | /** 50 | * useInvalidate is used to invalidate the state of a particular resource or dataProvider 51 | * Means, it will refetch the data from the server and update the state of the resource or dataProvider. We can also specify which part of the state we want to invalidate. 52 | * We typically use this hook when we want to refetch the data from the server after a mutation is successful. 53 | * 54 | * https://refine.dev/docs/data/hooks/use-invalidate/ 55 | */ 56 | const invalidate = useInvalidate(); 57 | 58 | // use the useForm hook to manage the form for adding a title to a task 59 | const { formProps } = useForm< 60 | GetFields, 61 | HttpError, 62 | /** 63 | * Pick is a utility type from typescript that allows you to create a new type from an existing type by picking some properties from it. 64 | * https://www.typescriptlang.org/docs/handbook/utility-types.html#picktype-keys 65 | * 66 | * Pick 67 | * Type -> the type from which we want to pick the properties 68 | * Keys -> the properties that we want to pick 69 | */ 70 | Pick, "title"> 71 | >({ 72 | queryOptions: { 73 | // disable the query to prevent fetching data on component mount 74 | enabled: false, 75 | }, 76 | redirect: false, // disable redirection 77 | warnWhenUnsavedChanges: false, // disable warning when there are unsaved changes 78 | /** 79 | * autoSave is used to automatically save the form when the value of the form changes. It accepts an object with 1 property: 80 | * enabled: boolean - whether to enable autoSave or not 81 | * 82 | * https://refine.dev/docs/ui-integrations/ant-design/hooks/use-form/#autosave 83 | * 84 | * In this case, we are enabling autoSave. 85 | */ 86 | autoSave: { 87 | enabled: true, 88 | }, 89 | // invalidate the list page of the tasks resource when the mutation is successful 90 | onMutationSuccess: () => { 91 | // refetch the list page of the tasks resource 92 | invalidate({ invalidates: ["list"], resource: "tasks" }); 93 | }, 94 | meta: { 95 | gqlMutation: UPDATE_TASK_MUTATION, 96 | }, 97 | }); 98 | 99 | // set the title of the form to the title of the task 100 | React.useEffect(() => { 101 | formProps.form?.setFieldsValue(initialValues); 102 | }, [initialValues.title]); 103 | 104 | if (isLoading) { 105 | return ( 106 | 111 | ); 112 | } 113 | 114 | return ( 115 |
116 | 117 | 118 | 119 |
120 | ); 121 | }; 122 | -------------------------------------------------------------------------------- /src/pages/tasks/edit.tsx: -------------------------------------------------------------------------------- 1 | import { useState } from "react"; 2 | 3 | import { DeleteButton, useModalForm } from "@refinedev/antd"; 4 | import { useNavigation } from "@refinedev/core"; 5 | 6 | import { 7 | AlignLeftOutlined, 8 | FieldTimeOutlined, 9 | UsergroupAddOutlined, 10 | } from "@ant-design/icons"; 11 | import { Modal } from "antd"; 12 | 13 | import { 14 | Accordion, 15 | DescriptionForm, 16 | DescriptionHeader, 17 | DueDateForm, 18 | DueDateHeader, 19 | StageForm, 20 | TitleForm, 21 | UsersForm, 22 | UsersHeader, 23 | } from "@/components"; 24 | import { Task } from "@/graphql/schema.types"; 25 | 26 | import { UPDATE_TASK_MUTATION } from "@/graphql/mutations"; 27 | 28 | const TasksEditPage = () => { 29 | const [activeKey, setActiveKey] = useState(); 30 | 31 | // use the list method to navigate to the list page of the tasks resource from the navigation hook 32 | const { list } = useNavigation(); 33 | 34 | // create a modal form to edit a task using the useModalForm hook 35 | // modalProps -> It's an instance of Modal that manages modal state and actions like onOk, onCancel, etc. 36 | // close -> It's a function that closes the modal 37 | // queryResult -> It's an instance of useQuery from react-query 38 | const { modalProps, close, queryResult } = useModalForm({ 39 | // specify the action to perform i.e., create or edit 40 | action: "edit", 41 | // specify whether the modal should be visible by default 42 | defaultVisible: true, 43 | // specify the gql mutation to be performed 44 | meta: { 45 | gqlMutation: UPDATE_TASK_MUTATION, 46 | }, 47 | }); 48 | 49 | // get the data of the task from the queryResult 50 | const { description, dueDate, users, title } = queryResult?.data?.data ?? {}; 51 | 52 | const isLoading = queryResult?.isLoading ?? true; 53 | 54 | return ( 55 | { 59 | close(); 60 | list("tasks", "replace"); 61 | }} 62 | title={} 63 | width={586} 64 | footer={ 65 | { 68 | list("tasks", "replace"); 69 | }} 70 | > 71 | Delete card 72 | 73 | } 74 | > 75 | {/* Render the stage form */} 76 | 77 | 78 | {/* Render the description form inside an accordion */} 79 | } 84 | isLoading={isLoading} 85 | icon={} 86 | label="Description" 87 | > 88 | setActiveKey(undefined)} 91 | /> 92 | 93 | 94 | {/* Render the due date form inside an accordion */} 95 | } 100 | isLoading={isLoading} 101 | icon={} 102 | label="Due date" 103 | > 104 | setActiveKey(undefined)} 107 | /> 108 | 109 | 110 | {/* Render the users form inside an accordion */} 111 | } 116 | isLoading={isLoading} 117 | icon={} 118 | label="Users" 119 | > 120 | ({ 123 | label: user.name, 124 | value: user.id, 125 | })), 126 | }} 127 | cancelForm={() => setActiveKey(undefined)} 128 | /> 129 | 130 | 131 | ); 132 | }; 133 | 134 | export default TasksEditPage; -------------------------------------------------------------------------------- /src/providers/auth.ts: -------------------------------------------------------------------------------- 1 | import { AuthBindings } from "@refinedev/core"; 2 | 3 | import { API_URL, dataProvider } from "./data"; 4 | 5 | // For demo purposes and to make it easier to test the app, you can use the following credentials 6 | export const authCredentials = { 7 | email: "michael.scott@dundermifflin.com", 8 | password: "demodemo", 9 | }; 10 | 11 | export const authProvider: AuthBindings = { 12 | login: async ({ email }) => { 13 | try { 14 | // call the login mutation 15 | // dataProvider.custom is used to make a custom request to the GraphQL API 16 | // this will call dataProvider which will go through the fetchWrapper function 17 | const { data } = await dataProvider.custom({ 18 | url: API_URL, 19 | method: "post", 20 | headers: {}, 21 | meta: { 22 | variables: { email }, 23 | // pass the email to see if the user exists and if so, return the accessToken 24 | rawQuery: ` 25 | mutation Login($email: String!) { 26 | login(loginInput: { email: $email }) { 27 | accessToken 28 | } 29 | } 30 | `, 31 | }, 32 | }); 33 | 34 | // save the accessToken in localStorage 35 | localStorage.setItem("access_token", data.login.accessToken); 36 | 37 | return { 38 | success: true, 39 | redirectTo: "/", 40 | }; 41 | } catch (e) { 42 | const error = e as Error; 43 | 44 | return { 45 | success: false, 46 | error: { 47 | message: "message" in error ? error.message : "Login failed", 48 | name: "name" in error ? error.name : "Invalid email or password", 49 | }, 50 | }; 51 | } 52 | }, 53 | 54 | // simply remove the accessToken from localStorage for the logout 55 | logout: async () => { 56 | localStorage.removeItem("access_token"); 57 | 58 | return { 59 | success: true, 60 | redirectTo: "/login", 61 | }; 62 | }, 63 | 64 | onError: async (error) => { 65 | // a check to see if the error is an authentication error 66 | // if so, set logout to true 67 | if (error.statusCode === "UNAUTHENTICATED") { 68 | return { 69 | logout: true, 70 | ...error, 71 | }; 72 | } 73 | 74 | return { error }; 75 | }, 76 | 77 | check: async () => { 78 | try { 79 | // get the identity of the user 80 | // this is to know if the user is authenticated or not 81 | await dataProvider.custom({ 82 | url: API_URL, 83 | method: "post", 84 | headers: {}, 85 | meta: { 86 | rawQuery: ` 87 | query Me { 88 | me { 89 | name 90 | } 91 | } 92 | `, 93 | }, 94 | }); 95 | 96 | // if the user is authenticated, redirect to the home page 97 | return { 98 | authenticated: true, 99 | redirectTo: "/", 100 | }; 101 | } catch (error) { 102 | // for any other error, redirect to the login page 103 | return { 104 | authenticated: false, 105 | redirectTo: "/login", 106 | }; 107 | } 108 | }, 109 | 110 | // get the user information 111 | getIdentity: async () => { 112 | const accessToken = localStorage.getItem("access_token"); 113 | 114 | try { 115 | // call the GraphQL API to get the user information 116 | // we're using me:any because the GraphQL API doesn't have a type for the me query yet. 117 | // we'll add some queries and mutations later and change this to User which will be generated by codegen. 118 | const { data } = await dataProvider.custom<{ me: any }>({ 119 | url: API_URL, 120 | method: "post", 121 | headers: accessToken 122 | ? { 123 | // send the accessToken in the Authorization header 124 | Authorization: `Bearer ${accessToken}`, 125 | } 126 | : {}, 127 | meta: { 128 | // get the user information such as name, email, etc. 129 | rawQuery: ` 130 | query Me { 131 | me { 132 | id 133 | name 134 | email 135 | phone 136 | jobTitle 137 | timezone 138 | avatarUrl 139 | } 140 | } 141 | `, 142 | }, 143 | }); 144 | 145 | return data.me; 146 | } catch (error) { 147 | return undefined; 148 | } 149 | }, 150 | }; -------------------------------------------------------------------------------- /src/components/tasks/form/stage.tsx: -------------------------------------------------------------------------------- 1 | import { useForm, useSelect } from "@refinedev/antd"; 2 | import { HttpError } from "@refinedev/core"; 3 | import { 4 | GetFields, 5 | GetFieldsFromList, 6 | GetVariables, 7 | } from "@refinedev/nestjs-query"; 8 | 9 | import { FlagOutlined } from "@ant-design/icons"; 10 | import { Checkbox, Form, Select, Space } from "antd"; 11 | 12 | import { AccordionHeaderSkeleton } from "@/components"; 13 | import { 14 | TaskStagesSelectQuery, 15 | UpdateTaskMutation, 16 | UpdateTaskMutationVariables, 17 | } from "@/graphql/types"; 18 | 19 | import { UPDATE_TASK_MUTATION } from "@/graphql/mutations"; 20 | import { TASK_STAGES_SELECT_QUERY } from "@/graphql/queries"; 21 | 22 | type Props = { 23 | isLoading?: boolean; 24 | }; 25 | 26 | export const StageForm = ({ isLoading }: Props) => { 27 | // use the useForm hook to manage the form for adding a stage to a task 28 | const { formProps } = useForm< 29 | GetFields, 30 | HttpError, 31 | /** 32 | * Pick is a utility type from typescript that allows you to create a new type from an existing type by picking some properties from it. 33 | * https://www.typescriptlang.org/docs/handbook/utility-types.html#picktype-keys 34 | * 35 | * Pick 36 | * Type -> the type from which we want to pick the properties 37 | * Keys -> the properties that we want to pick 38 | */ 39 | Pick, "stageId" | "completed"> 40 | >({ 41 | queryOptions: { 42 | // disable the query to prevent fetching data on component mount 43 | enabled: false, 44 | }, 45 | 46 | /** 47 | * autoSave is used to automatically save the form when the value of the form changes. It accepts an object with 2 properties: 48 | * enabled: boolean - whether to enable autoSave or not 49 | * debounce: number - the debounce time in milliseconds 50 | * 51 | * https://refine.dev/docs/ui-integrations/ant-design/hooks/use-form/#autosave 52 | * 53 | * In this case, we are enabling autoSave and setting the debounce time to 0. Means immediately save the form when the value changes. 54 | */ 55 | autoSave: { 56 | enabled: true, 57 | debounce: 0, 58 | }, 59 | // specify the mutation that should be performed 60 | meta: { 61 | gqlMutation: UPDATE_TASK_MUTATION, 62 | }, 63 | }); 64 | 65 | // use the useSelect hook to fetch the task stages and pass it to the select component. This will allow us to select a stage for the task. 66 | // https://refine.dev/docs/ui-integrations/ant-design/hooks/use-select/ 67 | const { selectProps } = useSelect>({ 68 | // specify the resource that we want to fetch 69 | resource: "taskStages", 70 | // specify a filter to only fetch the stages with the title "TODO", "IN PROGRESS", "IN REVIEW", "DONE" 71 | filters: [ 72 | { 73 | field: "title", 74 | operator: "in", 75 | value: ["TODO", "IN PROGRESS", "IN REVIEW", "DONE"], 76 | }, 77 | ], 78 | // specify a sorter to sort the stages by createdAt in ascending order 79 | sorters: [ 80 | { 81 | field: "createdAt", 82 | order: "asc", 83 | }, 84 | ], 85 | // specify the gqlQuery that should be performed 86 | meta: { 87 | gqlQuery: TASK_STAGES_SELECT_QUERY, 88 | }, 89 | }); 90 | 91 | if (isLoading) return ; 92 | 93 | return ( 94 |
95 |
103 | 104 | 105 | 110 | 150 | 151 | 152 | 153 | 154 | 155 | 156 | 157 | 158 | 159 | 160 | 161 | 168 | 169 |
170 | 171 | ); 172 | }; 173 | -------------------------------------------------------------------------------- /src/constants/index.tsx: -------------------------------------------------------------------------------- 1 | import { AuditOutlined, ShopOutlined, TeamOutlined } from "@ant-design/icons"; 2 | 3 | const IconWrapper = ({ 4 | color, 5 | children, 6 | }: React.PropsWithChildren<{ color: string }>) => { 7 | return ( 8 |
19 | {children} 20 |
21 | ); 22 | }; 23 | 24 | import { 25 | BusinessType, 26 | CompanySize, 27 | Contact, 28 | Industry, 29 | } from "@/graphql/schema.types"; 30 | 31 | export type TotalCountType = "companies" | "contacts" | "deals"; 32 | 33 | export const totalCountVariants: { 34 | [key in TotalCountType]: { 35 | primaryColor: string; 36 | secondaryColor?: string; 37 | icon: React.ReactNode; 38 | title: string; 39 | data: { index: string; value: number }[]; 40 | }; 41 | } = { 42 | companies: { 43 | primaryColor: "#1677FF", 44 | secondaryColor: "#BAE0FF", 45 | icon: ( 46 | 47 | 53 | 54 | ), 55 | title: "Number of companies", 56 | data: [ 57 | { 58 | index: "1", 59 | value: 3500, 60 | }, 61 | { 62 | index: "2", 63 | value: 2750, 64 | }, 65 | { 66 | index: "3", 67 | value: 5000, 68 | }, 69 | { 70 | index: "4", 71 | value: 4250, 72 | }, 73 | { 74 | index: "5", 75 | value: 5000, 76 | }, 77 | ], 78 | }, 79 | contacts: { 80 | primaryColor: "#52C41A", 81 | secondaryColor: "#D9F7BE", 82 | icon: ( 83 | 84 | 90 | 91 | ), 92 | title: "Number of contacts", 93 | data: [ 94 | { 95 | index: "1", 96 | value: 10000, 97 | }, 98 | { 99 | index: "2", 100 | value: 19500, 101 | }, 102 | { 103 | index: "3", 104 | value: 13000, 105 | }, 106 | { 107 | index: "4", 108 | value: 17000, 109 | }, 110 | { 111 | index: "5", 112 | value: 13000, 113 | }, 114 | { 115 | index: "6", 116 | value: 20000, 117 | }, 118 | ], 119 | }, 120 | deals: { 121 | primaryColor: "#FA541C", 122 | secondaryColor: "#FFD8BF", 123 | icon: ( 124 | 125 | 131 | 132 | ), 133 | title: "Total deals in pipeline", 134 | data: [ 135 | { 136 | index: "1", 137 | value: 1000, 138 | }, 139 | { 140 | index: "2", 141 | value: 1300, 142 | }, 143 | { 144 | index: "3", 145 | value: 1200, 146 | }, 147 | { 148 | index: "4", 149 | value: 2000, 150 | }, 151 | { 152 | index: "5", 153 | value: 800, 154 | }, 155 | { 156 | index: "6", 157 | value: 1700, 158 | }, 159 | { 160 | index: "7", 161 | value: 1400, 162 | }, 163 | { 164 | index: "8", 165 | value: 1800, 166 | }, 167 | ], 168 | }, 169 | }; 170 | 171 | export const statusOptions: { 172 | label: string; 173 | value: Contact["status"]; 174 | }[] = [ 175 | { 176 | label: "New", 177 | value: "NEW", 178 | }, 179 | { 180 | label: "Qualified", 181 | value: "QUALIFIED", 182 | }, 183 | { 184 | label: "Unqualified", 185 | value: "UNQUALIFIED", 186 | }, 187 | { 188 | label: "Won", 189 | value: "WON", 190 | }, 191 | { 192 | label: "Negotiation", 193 | value: "NEGOTIATION", 194 | }, 195 | { 196 | label: "Lost", 197 | value: "LOST", 198 | }, 199 | { 200 | label: "Interested", 201 | value: "INTERESTED", 202 | }, 203 | { 204 | label: "Contacted", 205 | value: "CONTACTED", 206 | }, 207 | { 208 | label: "Churned", 209 | value: "CHURNED", 210 | }, 211 | ]; 212 | 213 | export const companySizeOptions: { 214 | label: string; 215 | value: CompanySize; 216 | }[] = [ 217 | { 218 | label: "Enterprise", 219 | value: "ENTERPRISE", 220 | }, 221 | { 222 | label: "Large", 223 | value: "LARGE", 224 | }, 225 | { 226 | label: "Medium", 227 | value: "MEDIUM", 228 | }, 229 | { 230 | label: "Small", 231 | value: "SMALL", 232 | }, 233 | ]; 234 | 235 | export const industryOptions: { 236 | label: string; 237 | value: Industry; 238 | }[] = [ 239 | { label: "Aerospace", value: "AEROSPACE" }, 240 | { label: "Agriculture", value: "AGRICULTURE" }, 241 | { label: "Automotive", value: "AUTOMOTIVE" }, 242 | { label: "Chemicals", value: "CHEMICALS" }, 243 | { label: "Construction", value: "CONSTRUCTION" }, 244 | { label: "Defense", value: "DEFENSE" }, 245 | { label: "Education", value: "EDUCATION" }, 246 | { label: "Energy", value: "ENERGY" }, 247 | { label: "Financial Services", value: "FINANCIAL_SERVICES" }, 248 | { label: "Food and Beverage", value: "FOOD_AND_BEVERAGE" }, 249 | { label: "Government", value: "GOVERNMENT" }, 250 | { label: "Healthcare", value: "HEALTHCARE" }, 251 | { label: "Hospitality", value: "HOSPITALITY" }, 252 | { label: "Industrial Manufacturing", value: "INDUSTRIAL_MANUFACTURING" }, 253 | { label: "Insurance", value: "INSURANCE" }, 254 | { label: "Life Sciences", value: "LIFE_SCIENCES" }, 255 | { label: "Logistics", value: "LOGISTICS" }, 256 | { label: "Media", value: "MEDIA" }, 257 | { label: "Mining", value: "MINING" }, 258 | { label: "Nonprofit", value: "NONPROFIT" }, 259 | { label: "Other", value: "OTHER" }, 260 | { label: "Pharmaceuticals", value: "PHARMACEUTICALS" }, 261 | { label: "Professional Services", value: "PROFESSIONAL_SERVICES" }, 262 | { label: "Real Estate", value: "REAL_ESTATE" }, 263 | { label: "Retail", value: "RETAIL" }, 264 | { label: "Technology", value: "TECHNOLOGY" }, 265 | { label: "Telecommunications", value: "TELECOMMUNICATIONS" }, 266 | { label: "Transportation", value: "TRANSPORTATION" }, 267 | { label: "Utilities", value: "UTILITIES" }, 268 | ]; 269 | 270 | export const businessTypeOptions: { 271 | label: string; 272 | value: BusinessType; 273 | }[] = [ 274 | { 275 | label: "B2B", 276 | value: "B2B", 277 | }, 278 | { 279 | label: "B2C", 280 | value: "B2C", 281 | }, 282 | { 283 | label: "B2G", 284 | value: "B2G", 285 | }, 286 | ]; 287 | -------------------------------------------------------------------------------- /src/pages/company/contacts-table.tsx: -------------------------------------------------------------------------------- 1 | import { useParams } from "react-router-dom"; 2 | 3 | import { FilterDropdown, useTable } from "@refinedev/antd"; 4 | import { GetFieldsFromList } from "@refinedev/nestjs-query"; 5 | 6 | import { 7 | MailOutlined, 8 | PhoneOutlined, 9 | SearchOutlined, 10 | TeamOutlined, 11 | } from "@ant-design/icons"; 12 | import { Button, Card, Input, Select, Space, Table } from "antd"; 13 | 14 | import { Contact } from "@/graphql/schema.types"; 15 | 16 | import { statusOptions } from "@/constants"; 17 | import { COMPANY_CONTACTS_TABLE_QUERY } from "@/graphql/queries"; 18 | 19 | import { CompanyContactsTableQuery } from "@/graphql/types"; 20 | import { Text } from "@/components/text"; 21 | import CustomAvatar from "@/components/custom-avatar"; 22 | import { ContactStatusTag } from "@/components/tags/contact-status-tag"; 23 | 24 | export const CompanyContactsTable = () => { 25 | // get params from the url 26 | const params = useParams(); 27 | 28 | /** 29 | * Refine offers a TanStack Table adapter with @refinedev/react-table that allows us to use the TanStack Table library with Refine. 30 | * All features such as sorting, filtering, and pagination come out of the box 31 | * Under the hood it uses useList hook to fetch the data. 32 | * https://refine.dev/docs/packages/tanstack-table/use-table/#installation 33 | */ 34 | const { tableProps } = useTable>( 35 | { 36 | // specify the resource for which the table is to be used 37 | resource: "contacts", 38 | syncWithLocation: false, 39 | // specify initial sorters 40 | sorters: { 41 | /** 42 | * initial sets the initial value of sorters. 43 | * it's not permanent 44 | * it will be cleared when the user changes the sorting 45 | * https://refine.dev/docs/ui-integrations/ant-design/hooks/use-table/#sortersinitial 46 | */ 47 | initial: [ 48 | { 49 | field: "createdAt", 50 | order: "desc", 51 | }, 52 | ], 53 | }, 54 | // specify initial filters 55 | filters: { 56 | /** 57 | * similar to initial in sorters 58 | * https://refine.dev/docs/ui-integrations/ant-design/hooks/use-table/#filtersinitial 59 | */ 60 | initial: [ 61 | { 62 | field: "jobTitle", 63 | value: "", 64 | operator: "contains", 65 | }, 66 | { 67 | field: "name", 68 | value: "", 69 | operator: "contains", 70 | }, 71 | { 72 | field: "status", 73 | value: undefined, 74 | operator: "in", 75 | }, 76 | ], 77 | /** 78 | * permanent filters are the filters that are always applied 79 | * https://refine.dev/docs/ui-integrations/ant-design/hooks/use-table/#filterspermanent 80 | */ 81 | permanent: [ 82 | { 83 | field: "company.id", 84 | operator: "eq", 85 | value: params?.id as string, 86 | }, 87 | ], 88 | }, 89 | /** 90 | * used to provide any additional information to the data provider. 91 | * https://refine.dev/docs/data/hooks/use-form/#meta- 92 | */ 93 | meta: { 94 | // gqlQuery is used to specify the GraphQL query that should be used to fetch the data. 95 | gqlQuery: COMPANY_CONTACTS_TABLE_QUERY, 96 | }, 97 | }, 98 | ); 99 | 100 | return ( 101 | 109 | 110 | Contacts 111 | 112 | } 113 | // property used to render additional content in the top-right corner of the card 114 | extra={ 115 | <> 116 | Total contacts: 117 | 118 | {/* if pagination is not disabled and total is provided then show the total */} 119 | {tableProps?.pagination !== false && tableProps.pagination?.total} 120 | 121 | 122 | } 123 | > 124 | 132 | 133 | title="Name" 134 | dataIndex="name" 135 | render={(_, record) => ( 136 | 137 | 138 | 143 | {record.name} 144 | 145 | 146 | )} 147 | // specify the icon that should be used for filtering 148 | filterIcon={} 149 | // render the filter dropdown 150 | filterDropdown={(props) => ( 151 | 152 | 153 | 154 | )} 155 | /> 156 | } 160 | filterDropdown={(props) => ( 161 | 162 | 163 | 164 | )} 165 | /> 166 | 167 | title="Stage" 168 | dataIndex="status" 169 | // render the status tag for each contact 170 | render={(_, record) => } 171 | // allow filtering by selecting multiple status options 172 | filterDropdown={(props) => ( 173 | 174 | 180 | 181 | )} 182 | /> 183 | 184 | dataIndex="id" 185 | width={112} 186 | render={(_, record) => ( 187 | 188 |
202 |
203 | ); 204 | }; 205 | 206 | -------------------------------------------------------------------------------- /src/graphql/types.ts: -------------------------------------------------------------------------------- 1 | import type * as Types from "./schema.types"; 2 | 3 | export type UpdateUserMutationVariables = Types.Exact<{ 4 | input: Types.UpdateOneUserInput; 5 | }>; 6 | 7 | export type UpdateUserMutation = { 8 | updateOneUser: Pick< 9 | Types.User, 10 | "id" | "name" | "avatarUrl" | "email" | "phone" | "jobTitle" 11 | >; 12 | }; 13 | 14 | export type CreateCompanyMutationVariables = Types.Exact<{ 15 | input: Types.CreateOneCompanyInput; 16 | }>; 17 | 18 | export type CreateCompanyMutation = { 19 | createOneCompany: Pick & { 20 | salesOwner: Pick; 21 | }; 22 | }; 23 | 24 | export type UpdateCompanyMutationVariables = Types.Exact<{ 25 | input: Types.UpdateOneCompanyInput; 26 | }>; 27 | 28 | export type UpdateCompanyMutation = { 29 | updateOneCompany: Pick< 30 | Types.Company, 31 | | "id" 32 | | "name" 33 | | "totalRevenue" 34 | | "industry" 35 | | "companySize" 36 | | "businessType" 37 | | "country" 38 | | "website" 39 | | "avatarUrl" 40 | > & { salesOwner: Pick }; 41 | }; 42 | 43 | export type UpdateTaskStageMutationVariables = Types.Exact<{ 44 | input: Types.UpdateOneTaskInput; 45 | }>; 46 | 47 | export type UpdateTaskStageMutation = { updateOneTask: Pick }; 48 | 49 | export type CreateTaskMutationVariables = Types.Exact<{ 50 | input: Types.CreateOneTaskInput; 51 | }>; 52 | 53 | export type CreateTaskMutation = { 54 | createOneTask: Pick & { 55 | stage?: Types.Maybe>; 56 | }; 57 | }; 58 | 59 | export type UpdateTaskMutationVariables = Types.Exact<{ 60 | input: Types.UpdateOneTaskInput; 61 | }>; 62 | 63 | export type UpdateTaskMutation = { 64 | updateOneTask: Pick< 65 | Types.Task, 66 | "id" | "title" | "completed" | "description" | "dueDate" 67 | > & { 68 | stage?: Types.Maybe>; 69 | users: Array>; 70 | checklist: Array>; 71 | }; 72 | }; 73 | 74 | export type DashboardTotalCountsQueryVariables = Types.Exact<{ 75 | [key: string]: never; 76 | }>; 77 | 78 | export type DashboardTotalCountsQuery = { 79 | companies: Pick; 80 | contacts: Pick; 81 | deals: Pick; 82 | }; 83 | 84 | export type DashboardCalendarUpcomingEventsQueryVariables = Types.Exact<{ 85 | filter: Types.EventFilter; 86 | sorting?: Types.InputMaybe | Types.EventSort>; 87 | paging: Types.OffsetPaging; 88 | }>; 89 | 90 | export type DashboardCalendarUpcomingEventsQuery = { 91 | events: Pick & { 92 | nodes: Array< 93 | Pick 94 | >; 95 | }; 96 | }; 97 | 98 | export type DashboardDealsChartQueryVariables = Types.Exact<{ 99 | filter: Types.DealStageFilter; 100 | sorting?: Types.InputMaybe | Types.DealStageSort>; 101 | paging?: Types.InputMaybe; 102 | }>; 103 | 104 | export type DashboardDealsChartQuery = { 105 | dealStages: Pick & { 106 | nodes: Array< 107 | Pick & { 108 | dealsAggregate: Array<{ 109 | groupBy?: Types.Maybe< 110 | Pick< 111 | Types.DealStageDealsAggregateGroupBy, 112 | "closeDateMonth" | "closeDateYear" 113 | > 114 | >; 115 | sum?: Types.Maybe>; 116 | }>; 117 | } 118 | >; 119 | }; 120 | }; 121 | 122 | export type DashboardLatestActivitiesDealsQueryVariables = Types.Exact<{ 123 | filter: Types.DealFilter; 124 | sorting?: Types.InputMaybe | Types.DealSort>; 125 | paging?: Types.InputMaybe; 126 | }>; 127 | 128 | export type DashboardLatestActivitiesDealsQuery = { 129 | deals: Pick & { 130 | nodes: Array< 131 | Pick & { 132 | stage?: Types.Maybe>; 133 | company: Pick; 134 | } 135 | >; 136 | }; 137 | }; 138 | 139 | export type DashboardLatestActivitiesAuditsQueryVariables = Types.Exact<{ 140 | filter: Types.AuditFilter; 141 | sorting?: Types.InputMaybe | Types.AuditSort>; 142 | paging?: Types.InputMaybe; 143 | }>; 144 | 145 | export type DashboardLatestActivitiesAuditsQuery = { 146 | audits: Pick & { 147 | nodes: Array< 148 | Pick< 149 | Types.Audit, 150 | "id" | "action" | "targetEntity" | "targetId" | "createdAt" 151 | > & { 152 | changes: Array>; 153 | user?: Types.Maybe>; 154 | } 155 | >; 156 | }; 157 | }; 158 | 159 | export type CompaniesListQueryVariables = Types.Exact<{ 160 | filter: Types.CompanyFilter; 161 | sorting?: Types.InputMaybe | Types.CompanySort>; 162 | paging: Types.OffsetPaging; 163 | }>; 164 | 165 | export type CompaniesListQuery = { 166 | companies: Pick & { 167 | nodes: Array< 168 | Pick & { 169 | dealsAggregate: Array<{ 170 | sum?: Types.Maybe>; 171 | }>; 172 | } 173 | >; 174 | }; 175 | }; 176 | 177 | export type UsersSelectQueryVariables = Types.Exact<{ 178 | filter: Types.UserFilter; 179 | sorting?: Types.InputMaybe | Types.UserSort>; 180 | paging: Types.OffsetPaging; 181 | }>; 182 | 183 | export type UsersSelectQuery = { 184 | users: Pick & { 185 | nodes: Array>; 186 | }; 187 | }; 188 | 189 | export type CompanyContactsTableQueryVariables = Types.Exact<{ 190 | filter: Types.ContactFilter; 191 | sorting?: Types.InputMaybe | Types.ContactSort>; 192 | paging: Types.OffsetPaging; 193 | }>; 194 | 195 | export type CompanyContactsTableQuery = { 196 | contacts: Pick & { 197 | nodes: Array< 198 | Pick< 199 | Types.Contact, 200 | "id" | "name" | "avatarUrl" | "jobTitle" | "email" | "phone" | "status" 201 | > 202 | >; 203 | }; 204 | }; 205 | 206 | export type TaskStagesQueryVariables = Types.Exact<{ 207 | filter: Types.TaskStageFilter; 208 | sorting?: Types.InputMaybe | Types.TaskStageSort>; 209 | paging: Types.OffsetPaging; 210 | }>; 211 | 212 | export type TaskStagesQuery = { 213 | taskStages: Pick & { 214 | nodes: Array>; 215 | }; 216 | }; 217 | 218 | export type TasksQueryVariables = Types.Exact<{ 219 | filter: Types.TaskFilter; 220 | sorting?: Types.InputMaybe | Types.TaskSort>; 221 | paging: Types.OffsetPaging; 222 | }>; 223 | 224 | export type TasksQuery = { 225 | tasks: Pick & { 226 | nodes: Array< 227 | Pick< 228 | Types.Task, 229 | | "id" 230 | | "title" 231 | | "description" 232 | | "dueDate" 233 | | "completed" 234 | | "stageId" 235 | | "createdAt" 236 | | "updatedAt" 237 | > & { users: Array> } 238 | >; 239 | }; 240 | }; 241 | 242 | export type TaskStagesSelectQueryVariables = Types.Exact<{ 243 | filter: Types.TaskStageFilter; 244 | sorting?: Types.InputMaybe | Types.TaskStageSort>; 245 | paging: Types.OffsetPaging; 246 | }>; 247 | 248 | export type TaskStagesSelectQuery = { 249 | taskStages: Pick & { 250 | nodes: Array>; 251 | }; 252 | }; 253 | -------------------------------------------------------------------------------- /README.MD: -------------------------------------------------------------------------------- 1 |
2 |
3 | 4 | Project Banner 5 | 6 |
7 | 8 |
9 | react.js 10 | refine 11 | antd 12 |
13 | 14 |

A CRM Dashboard

15 | 16 |
17 | Build this project step by step with our detailed tutorial on JavaScript Mastery YouTube. Join the JSM family! 18 |
19 |
20 | 21 | ## 📋 Table of Contents 22 | 23 | 1. 🤖 [Introduction](#introduction) 24 | 2. ⚙️ [Tech Stack](#tech-stack) 25 | 3. 🔋 [Features](#features) 26 | 4. 🤸 [Quick Start](#quick-start) 27 | 5. 🕸️ [Snippets](#snippets) 28 | 6. 🔗 [Links](#links) 29 | 7. 🚀 [More](#more) 30 | 31 | ## 🚨 Tutorial 32 | 33 | This repository contains the code corresponding to an in-depth tutorial available on our YouTube channel, JavaScript Mastery. 34 | 35 | If you prefer visual learning, this is the perfect resource for you. Follow our tutorial to learn how to build projects like these step-by-step in a beginner-friendly manner! 36 | 37 | 38 | 39 | ## 🤖 Introduction 40 | 41 | 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. 42 | 43 | If you're getting started and need assistance or face any bugs, join our active Discord community with over 27k+ members. It's a place where people help each other out. 44 | 45 | 46 | 47 | ## ⚙️ Tech Stack 48 | 49 | - React.js 50 | - TypeScript 51 | - GraphQL 52 | - Ant Design 53 | - Refine 54 | - Codegen 55 | - Vite 56 | 57 | ## 🔋 Features 58 | 59 | 👉 **Authentication**: Seamless onboarding with secure login and signup functionalities; robust password recovery ensures a smooth authentication experience. 60 | 61 | 👉 **Authorization**: Granular access control regulates user actions, maintaining data security and user permissions. 62 | 63 | 👉 **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. 64 | 65 | 👉 **Companies Page**: Complete CRUD for company management and sales processes; detailed profiles with add/edit functions, associated contacts/leads, pagination, and field-specific search. 66 | 67 | 👉 **Kanban Board**: Collaborative board with real-time task updates; customization options include due dates, markdown descriptions, and multi-assignees, dynamically shifting tasks across dashboards. 68 | 69 | 👉 **Account Settings**: Personalized user account settings for profile management; streamlined configuration options for a tailored application experience. 70 | 71 | 👉 **Responsive**: Full responsiveness across devices for consistent user experience; fluid design adapts seamlessly to various screen sizes, ensuring accessibility. 72 | 73 | and many more, including code architecture and reusability 74 | 75 | ## 🤸 Quick Start 76 | 77 | Follow these steps to set up the project locally on your machine. 78 | 79 | **Prerequisites** 80 | 81 | Make sure you have the following installed on your machine: 82 | 83 | - [Git](https://git-scm.com/) 84 | - [Node.js](https://nodejs.org/en) 85 | - [npm](https://www.npmjs.com/) (Node Package Manager) 86 | 87 | **Cloning the Repository** 88 | 89 | ```bash 90 | git clone https://github.com/adrianhajdin/refine_dashboard.git 91 | cd refine_dashboard 92 | ``` 93 | 94 | **Installation** 95 | 96 | Install the project dependencies using npm: 97 | 98 | ```bash 99 | npm install 100 | ``` 101 | 102 | 103 | **Running the Project** 104 | 105 | ```bash 106 | npm run dev 107 | ``` 108 | 109 | Open [http://localhost:5173](http://localhost:5173) in your browser to view the project. 110 | 111 | ## 🕸️ Snippets 112 | 113 | # Code Snippets 114 | 115 |
116 | providers/auth.ts 117 | 118 | ```typescript 119 | import { AuthBindings } from "@refinedev/core"; 120 | 121 | import { API_URL, dataProvider } from "./data"; 122 | 123 | // For demo purposes and to make it easier to test the app, you can use the following credentials 124 | export const authCredentials = { 125 | email: "michael.scott@dundermifflin.com", 126 | password: "demodemo", 127 | }; 128 | 129 | export const authProvider: AuthBindings = { 130 | login: async ({ email }) => { 131 | try { 132 | // call the login mutation 133 | // dataProvider.custom is used to make a custom request to the GraphQL API 134 | // this will call dataProvider which will go through the fetchWrapper function 135 | const { data } = await dataProvider.custom({ 136 | url: API_URL, 137 | method: "post", 138 | headers: {}, 139 | meta: { 140 | variables: { email }, 141 | // pass the email to see if the user exists and if so, return the accessToken 142 | rawQuery: ` 143 | mutation Login($email: String!) { 144 | login(loginInput: { email: $email }) { 145 | accessToken 146 | } 147 | } 148 | `, 149 | }, 150 | }); 151 | 152 | // save the accessToken in localStorage 153 | localStorage.setItem("access_token", data.login.accessToken); 154 | 155 | return { 156 | success: true, 157 | redirectTo: "/", 158 | }; 159 | } catch (e) { 160 | const error = e as Error; 161 | 162 | return { 163 | success: false, 164 | error: { 165 | message: "message" in error ? error.message : "Login failed", 166 | name: "name" in error ? error.name : "Invalid email or password", 167 | }, 168 | }; 169 | } 170 | }, 171 | 172 | // simply remove the accessToken from localStorage for the logout 173 | logout: async () => { 174 | localStorage.removeItem("access_token"); 175 | 176 | return { 177 | success: true, 178 | redirectTo: "/login", 179 | }; 180 | }, 181 | 182 | onError: async (error) => { 183 | // a check to see if the error is an authentication error 184 | // if so, set logout to true 185 | if (error.statusCode === "UNAUTHENTICATED") { 186 | return { 187 | logout: true, 188 | ...error, 189 | }; 190 | } 191 | 192 | return { error }; 193 | }, 194 | 195 | check: async () => { 196 | try { 197 | // get the identity of the user 198 | // this is to know if the user is authenticated or not 199 | await dataProvider.custom({ 200 | url: API_URL, 201 | method: "post", 202 | headers: {}, 203 | meta: { 204 | rawQuery: ` 205 | query Me { 206 | me { 207 | name 208 | } 209 | } 210 | `, 211 | }, 212 | }); 213 | 214 | // if the user is authenticated, redirect to the home page 215 | return { 216 | authenticated: true, 217 | redirectTo: "/", 218 | }; 219 | } catch (error) { 220 | // for any other error, redirect to the login page 221 | return { 222 | authenticated: false, 223 | redirectTo: "/login", 224 | }; 225 | } 226 | }, 227 | 228 | // get the user information 229 | getIdentity: async () => { 230 | const accessToken = localStorage.getItem("access_token"); 231 | 232 | try { 233 | // call the GraphQL API to get the user information 234 | // we're using me:any because the GraphQL API doesn't have a type for the me query yet. 235 | // we'll add some queries and mutations later and change this to User which will be generated by codegen. 236 | const { data } = await dataProvider.custom<{ me: any }>({ 237 | url: API_URL, 238 | method: "post", 239 | headers: accessToken 240 | ? { 241 | // send the accessToken in the Authorization header 242 | Authorization: `Bearer ${accessToken}`, 243 | } 244 | : {}, 245 | meta: { 246 | // get the user information such as name, email, etc. 247 | rawQuery: ` 248 | query Me { 249 | me { 250 | id 251 | name 252 | email 253 | phone 254 | jobTitle 255 | timezone 256 | avatarUrl 257 | } 258 | } 259 | `, 260 | }, 261 | }); 262 | 263 | return data.me; 264 | } catch (error) { 265 | return undefined; 266 | } 267 | }, 268 | }; 269 | ``` 270 | 271 |
272 | 273 |
274 | GraphQl and Codegen Setup 275 | 276 | ```bash 277 | npm i -D @graphql-codegen/cli @graphql-codegen/typescript @graphql-codegen/typescript-operations @graphql-codegen/import-types-preset prettier vite-tsconfig-paths 278 | ``` 279 | 280 |
281 | 282 |
283 | graphql.config.ts 284 | 285 | ```typescript 286 | import type { IGraphQLConfig } from "graphql-config"; 287 | 288 | const config: IGraphQLConfig = { 289 | // define graphQL schema provided by Refine 290 | schema: "https://api.crm.refine.dev/graphql", 291 | extensions: { 292 | // codegen is a plugin that generates typescript types from GraphQL schema 293 | // https://the-guild.dev/graphql/codegen 294 | codegen: { 295 | // hooks are commands that are executed after a certain event 296 | hooks: { 297 | afterOneFileWrite: ["eslint --fix", "prettier --write"], 298 | }, 299 | // generates typescript types from GraphQL schema 300 | generates: { 301 | // specify the output path of the generated types 302 | "src/graphql/schema.types.ts": { 303 | // use typescript plugin 304 | plugins: ["typescript"], 305 | // set the config of the typescript plugin 306 | // this defines how the generated types will look like 307 | config: { 308 | skipTypename: true, // skipTypename is used to remove __typename from the generated types 309 | enumsAsTypes: true, // enumsAsTypes is used to generate enums as types instead of enums. 310 | // scalars is used to define how the scalars i.e. DateTime, JSON, etc. will be generated 311 | // scalar is a type that is not a list and does not have fields. Meaning it is a primitive type. 312 | scalars: { 313 | // DateTime is a scalar type that is used to represent date and time 314 | DateTime: { 315 | input: "string", 316 | output: "string", 317 | format: "date-time", 318 | }, 319 | }, 320 | }, 321 | }, 322 | // generates typescript types from GraphQL operations 323 | // graphql operations are queries, mutations, and subscriptions we write in our code to communicate with the GraphQL API 324 | "src/graphql/types.ts": { 325 | // preset is a plugin that is used to generate typescript types from GraphQL operations 326 | // import-types suggests to import types from schema.types.ts or other files 327 | // this is used to avoid duplication of types 328 | // https://the-guild.dev/graphql/codegen/plugins/presets/import-types-preset 329 | preset: "import-types", 330 | // documents is used to define the path of the files that contain GraphQL operations 331 | documents: ["src/**/*.{ts,tsx}"], 332 | // plugins is used to define the plugins that will be used to generate typescript types from GraphQL operations 333 | plugins: ["typescript-operations"], 334 | config: { 335 | skipTypename: true, 336 | enumsAsTypes: true, 337 | // determine whether the generated types should be resolved ahead of time or not. 338 | // When preResolveTypes is set to false, the code generator will not try to resolve the types ahead of time. 339 | // Instead, it will generate more generic types, and the actual types will be resolved at runtime. 340 | preResolveTypes: false, 341 | // useTypeImports is used to import types using import type instead of import. 342 | useTypeImports: true, 343 | }, 344 | // presetConfig is used to define the config of the preset 345 | presetConfig: { 346 | typesPath: "./schema.types", 347 | }, 348 | }, 349 | }, 350 | }, 351 | }, 352 | }; 353 | 354 | export default config; 355 | ``` 356 | 357 |
358 | 359 |
360 | graphql/mutations.ts 361 | 362 | ```typescript 363 | import gql from "graphql-tag"; 364 | 365 | // Mutation to update user 366 | export const UPDATE_USER_MUTATION = gql` 367 | # The ! after the type means that it is required 368 | mutation UpdateUser($input: UpdateOneUserInput!) { 369 | # call the updateOneUser mutation with the input and pass the $input argument 370 | # $variableName is a convention for GraphQL variables 371 | updateOneUser(input: $input) { 372 | id 373 | name 374 | avatarUrl 375 | email 376 | phone 377 | jobTitle 378 | } 379 | } 380 | `; 381 | 382 | // Mutation to create company 383 | export const CREATE_COMPANY_MUTATION = gql` 384 | mutation CreateCompany($input: CreateOneCompanyInput!) { 385 | createOneCompany(input: $input) { 386 | id 387 | salesOwner { 388 | id 389 | } 390 | } 391 | } 392 | `; 393 | 394 | // Mutation to update company details 395 | export const UPDATE_COMPANY_MUTATION = gql` 396 | mutation UpdateCompany($input: UpdateOneCompanyInput!) { 397 | updateOneCompany(input: $input) { 398 | id 399 | name 400 | totalRevenue 401 | industry 402 | companySize 403 | businessType 404 | country 405 | website 406 | avatarUrl 407 | salesOwner { 408 | id 409 | name 410 | avatarUrl 411 | } 412 | } 413 | } 414 | `; 415 | 416 | // Mutation to update task stage of a task 417 | export const UPDATE_TASK_STAGE_MUTATION = gql` 418 | mutation UpdateTaskStage($input: UpdateOneTaskInput!) { 419 | updateOneTask(input: $input) { 420 | id 421 | } 422 | } 423 | `; 424 | 425 | // Mutation to create a new task 426 | export const CREATE_TASK_MUTATION = gql` 427 | mutation CreateTask($input: CreateOneTaskInput!) { 428 | createOneTask(input: $input) { 429 | id 430 | title 431 | stage { 432 | id 433 | title 434 | } 435 | } 436 | } 437 | `; 438 | 439 | // Mutation to update a task details 440 | export const UPDATE_TASK_MUTATION = gql` 441 | mutation UpdateTask($input: UpdateOneTaskInput!) { 442 | updateOneTask(input: $input) { 443 | id 444 | title 445 | completed 446 | description 447 | dueDate 448 | stage { 449 | id 450 | title 451 | } 452 | users { 453 | id 454 | name 455 | avatarUrl 456 | } 457 | checklist { 458 | title 459 | checked 460 | } 461 | } 462 | } 463 | `; 464 | ``` 465 | 466 |
467 | 468 |
469 | graphql/queries.ts 470 | 471 | ```typescript 472 | import gql from "graphql-tag"; 473 | 474 | // Query to get Total Company, Contact and Deal Counts 475 | export const DASHBOARD_TOTAL_COUNTS_QUERY = gql` 476 | query DashboardTotalCounts { 477 | companies { 478 | totalCount 479 | } 480 | contacts { 481 | totalCount 482 | } 483 | deals { 484 | totalCount 485 | } 486 | } 487 | `; 488 | 489 | // Query to get upcoming events 490 | export const DASHBORAD_CALENDAR_UPCOMING_EVENTS_QUERY = gql` 491 | query DashboardCalendarUpcomingEvents( 492 | $filter: EventFilter! 493 | $sorting: [EventSort!] 494 | $paging: OffsetPaging! 495 | ) { 496 | events(filter: $filter, sorting: $sorting, paging: $paging) { 497 | totalCount 498 | nodes { 499 | id 500 | title 501 | color 502 | startDate 503 | endDate 504 | } 505 | } 506 | } 507 | `; 508 | 509 | // Query to get deals chart 510 | export const DASHBOARD_DEALS_CHART_QUERY = gql` 511 | query DashboardDealsChart( 512 | $filter: DealStageFilter! 513 | $sorting: [DealStageSort!] 514 | $paging: OffsetPaging 515 | ) { 516 | dealStages(filter: $filter, sorting: $sorting, paging: $paging) { 517 | # Get all deal stages 518 | nodes { 519 | id 520 | title 521 | # Get the sum of all deals in this stage and group by closeDateMonth and closeDateYear 522 | dealsAggregate { 523 | groupBy { 524 | closeDateMonth 525 | closeDateYear 526 | } 527 | sum { 528 | value 529 | } 530 | } 531 | } 532 | # Get the total count of all deals in this stage 533 | totalCount 534 | } 535 | } 536 | `; 537 | 538 | // Query to get latest activities deals 539 | export const DASHBOARD_LATEST_ACTIVITIES_DEALS_QUERY = gql` 540 | query DashboardLatestActivitiesDeals( 541 | $filter: DealFilter! 542 | $sorting: [DealSort!] 543 | $paging: OffsetPaging 544 | ) { 545 | deals(filter: $filter, sorting: $sorting, paging: $paging) { 546 | totalCount 547 | nodes { 548 | id 549 | title 550 | stage { 551 | id 552 | title 553 | } 554 | company { 555 | id 556 | name 557 | avatarUrl 558 | } 559 | createdAt 560 | } 561 | } 562 | } 563 | `; 564 | 565 | // Query to get latest activities audits 566 | export const DASHBOARD_LATEST_ACTIVITIES_AUDITS_QUERY = gql` 567 | query DashboardLatestActivitiesAudits( 568 | $filter: AuditFilter! 569 | $sorting: [AuditSort!] 570 | $paging: OffsetPaging 571 | ) { 572 | audits(filter: $filter, sorting: $sorting, paging: $paging) { 573 | totalCount 574 | nodes { 575 | id 576 | action 577 | targetEntity 578 | targetId 579 | changes { 580 | field 581 | from 582 | to 583 | } 584 | createdAt 585 | user { 586 | id 587 | name 588 | avatarUrl 589 | } 590 | } 591 | } 592 | } 593 | `; 594 | 595 | // Query to get companies list 596 | export const COMPANIES_LIST_QUERY = gql` 597 | query CompaniesList( 598 | $filter: CompanyFilter! 599 | $sorting: [CompanySort!] 600 | $paging: OffsetPaging! 601 | ) { 602 | companies(filter: $filter, sorting: $sorting, paging: $paging) { 603 | totalCount 604 | nodes { 605 | id 606 | name 607 | avatarUrl 608 | # Get the sum of all deals in this company 609 | dealsAggregate { 610 | sum { 611 | value 612 | } 613 | } 614 | } 615 | } 616 | } 617 | `; 618 | 619 | // Query to get users list 620 | export const USERS_SELECT_QUERY = gql` 621 | query UsersSelect( 622 | $filter: UserFilter! 623 | $sorting: [UserSort!] 624 | $paging: OffsetPaging! 625 | ) { 626 | # Get all users 627 | users(filter: $filter, sorting: $sorting, paging: $paging) { 628 | totalCount # Get the total count of users 629 | # Get specific fields for each user 630 | nodes { 631 | id 632 | name 633 | avatarUrl 634 | } 635 | } 636 | } 637 | `; 638 | 639 | // Query to get contacts associated with a company 640 | export const COMPANY_CONTACTS_TABLE_QUERY = gql` 641 | query CompanyContactsTable( 642 | $filter: ContactFilter! 643 | $sorting: [ContactSort!] 644 | $paging: OffsetPaging! 645 | ) { 646 | contacts(filter: $filter, sorting: $sorting, paging: $paging) { 647 | totalCount 648 | nodes { 649 | id 650 | name 651 | avatarUrl 652 | jobTitle 653 | email 654 | phone 655 | status 656 | } 657 | } 658 | } 659 | `; 660 | 661 | // Query to get task stages list 662 | export const TASK_STAGES_QUERY = gql` 663 | query TaskStages( 664 | $filter: TaskStageFilter! 665 | $sorting: [TaskStageSort!] 666 | $paging: OffsetPaging! 667 | ) { 668 | taskStages(filter: $filter, sorting: $sorting, paging: $paging) { 669 | totalCount # Get the total count of task stages 670 | nodes { 671 | id 672 | title 673 | } 674 | } 675 | } 676 | `; 677 | 678 | // Query to get tasks list 679 | export const TASKS_QUERY = gql` 680 | query Tasks( 681 | $filter: TaskFilter! 682 | $sorting: [TaskSort!] 683 | $paging: OffsetPaging! 684 | ) { 685 | tasks(filter: $filter, sorting: $sorting, paging: $paging) { 686 | totalCount # Get the total count of tasks 687 | nodes { 688 | id 689 | title 690 | description 691 | dueDate 692 | completed 693 | stageId 694 | # Get user details associated with this task 695 | users { 696 | id 697 | name 698 | avatarUrl 699 | } 700 | createdAt 701 | updatedAt 702 | } 703 | } 704 | } 705 | `; 706 | 707 | // Query to get task stages for select 708 | export const TASK_STAGES_SELECT_QUERY = gql` 709 | query TaskStagesSelect( 710 | $filter: TaskStageFilter! 711 | $sorting: [TaskStageSort!] 712 | $paging: OffsetPaging! 713 | ) { 714 | taskStages(filter: $filter, sorting: $sorting, paging: $paging) { 715 | totalCount 716 | nodes { 717 | id 718 | title 719 | } 720 | } 721 | } 722 | `; 723 | ``` 724 | 725 |
726 | 727 |
728 | text.tsx 729 | 730 | ```typescript 731 | import React from "react"; 732 | 733 | import { ConfigProvider, Typography } from "antd"; 734 | 735 | export type TextProps = { 736 | size?: 737 | | "xs" 738 | | "sm" 739 | | "md" 740 | | "lg" 741 | | "xl" 742 | | "xxl" 743 | | "xxxl" 744 | | "huge" 745 | | "xhuge" 746 | | "xxhuge"; 747 | } & React.ComponentProps; 748 | 749 | // define the font sizes and line heights 750 | const sizes = { 751 | xs: { 752 | fontSize: 12, 753 | lineHeight: 20 / 12, 754 | }, 755 | sm: { 756 | fontSize: 14, 757 | lineHeight: 22 / 14, 758 | }, 759 | md: { 760 | fontSize: 16, 761 | lineHeight: 24 / 16, 762 | }, 763 | lg: { 764 | fontSize: 20, 765 | lineHeight: 28 / 20, 766 | }, 767 | xl: { 768 | fontSize: 24, 769 | lineHeight: 32 / 24, 770 | }, 771 | xxl: { 772 | fontSize: 30, 773 | lineHeight: 38 / 30, 774 | }, 775 | xxxl: { 776 | fontSize: 38, 777 | lineHeight: 46 / 38, 778 | }, 779 | huge: { 780 | fontSize: 46, 781 | lineHeight: 54 / 46, 782 | }, 783 | xhuge: { 784 | fontSize: 56, 785 | lineHeight: 64 / 56, 786 | }, 787 | xxhuge: { 788 | fontSize: 68, 789 | lineHeight: 76 / 68, 790 | }, 791 | }; 792 | 793 | // a custom Text component that wraps/extends the antd Typography.Text component 794 | export const Text = ({ size = "sm", children, ...rest }: TextProps) => { 795 | return ( 796 | // config provider is a top-level component that allows us to customize the global properties of antd components. For example, default antd theme 797 | // token is a term used by antd to refer to the design tokens like font size, font weight, color, etc 798 | // https://ant.design/docs/react/customize-theme#customize-design-token 799 | 806 | {/** 807 | * Typography.Text is a component from antd that allows us to render text 808 | * Typography has different components like Title, Paragraph, Text, Link, etc 809 | * https://ant.design/components/typography/#Typography.Text 810 | */} 811 | {children} 812 | 813 | ); 814 | }; 815 | ``` 816 | 817 |
818 | 819 |
820 | components/layout/account-settings.tsx 821 | 822 | ```typescript 823 | import { SaveButton, useForm } from "@refinedev/antd"; 824 | import { HttpError } from "@refinedev/core"; 825 | import { GetFields, GetVariables } from "@refinedev/nestjs-query"; 826 | 827 | import { CloseOutlined } from "@ant-design/icons"; 828 | import { Button, Card, Drawer, Form, Input, Spin } from "antd"; 829 | 830 | import { getNameInitials } from "@/utilities"; 831 | import { UPDATE_USER_MUTATION } from "@/graphql/mutations"; 832 | 833 | import { Text } from "../text"; 834 | import CustomAvatar from "../custom-avatar"; 835 | 836 | import { 837 | UpdateUserMutation, 838 | UpdateUserMutationVariables, 839 | } from "@/graphql/types"; 840 | 841 | type Props = { 842 | opened: boolean; 843 | setOpened: (opened: boolean) => void; 844 | userId: string; 845 | }; 846 | 847 | export const AccountSettings = ({ opened, setOpened, userId }: Props) => { 848 | /** 849 | * 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. 850 | * https://refine.dev/docs/data/hooks/use-form/#usage 851 | */ 852 | 853 | /** 854 | * saveButtonProps -> contains all the props needed by the "submit" button. For example, "loading", "disabled", "onClick", etc. 855 | * https://refine.dev/docs/ui-integrations/ant-design/hooks/use-form/#savebuttonprops 856 | * 857 | * formProps -> It's an instance of HTML form that manages form state and actions like onFinish, onValuesChange, etc. 858 | * https://refine.dev/docs/ui-integrations/ant-design/hooks/use-form/#form 859 | * 860 | * queryResult -> contains the result of the query. For example, isLoading, data, error, etc. 861 | * https://refine.dev/docs/packages/react-hook-form/use-form/#queryresult 862 | */ 863 | const { saveButtonProps, formProps, queryResult } = useForm< 864 | /** 865 | * GetFields is used to get the fields of the mutation i.e., in this case, fields are name, email, jobTitle, and phone 866 | * https://refine.dev/docs/data/packages/nestjs-query/#getfields 867 | */ 868 | GetFields, 869 | // a type that represents an HTTP error. Used to specify the type of error mutation can throw. 870 | HttpError, 871 | // 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 872 | GetVariables 873 | >({ 874 | /** 875 | * mutationMode is used to determine how the mutation should be performed. For example, optimistic, pessimistic, undoable etc. 876 | * optimistic -> redirection and UI updates are executed immediately as if the mutation is successful. 877 | * pessimistic -> redirection and UI updates are executed after the mutation is successful. 878 | * https://refine.dev/docs/advanced-tutorials/mutation-mode/#overview 879 | */ 880 | mutationMode: "optimistic", 881 | /** 882 | * specify on which resource the mutation should be performed 883 | * if not specified, Refine will determine the resource name by the current route 884 | */ 885 | resource: "users", 886 | /** 887 | * 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. 888 | * https://refine.dev/docs/data/hooks/use-form/#edit 889 | */ 890 | action: "edit", 891 | id: userId, 892 | /** 893 | * used to provide any additional information to the data provider. 894 | * https://refine.dev/docs/data/hooks/use-form/#meta- 895 | */ 896 | meta: { 897 | // gqlMutation is used to specify the mutation that should be performed. 898 | gqlMutation: UPDATE_USER_MUTATION, 899 | }, 900 | }); 901 | const { avatarUrl, name } = queryResult?.data?.data || {}; 902 | 903 | const closeModal = () => { 904 | setOpened(false); 905 | }; 906 | 907 | // if query is processing, show a loading indicator 908 | if (queryResult?.isLoading) { 909 | return ( 910 | 922 | 923 | 924 | ); 925 | } 926 | 927 | return ( 928 | 937 |
946 | Account Settings 947 |
953 |
958 | 959 |
960 | 970 | 971 | 972 | 973 | 974 | 975 | 976 | 977 | 978 | 979 | 980 | 981 | 982 | 983 | 990 |
991 |
992 |
993 | ); 994 | }; 995 | ``` 996 | 997 |
998 | 999 |
1000 | constants/index.tsx 1001 | 1002 | ```typescript 1003 | import { AuditOutlined, ShopOutlined, TeamOutlined } from "@ant-design/icons"; 1004 | 1005 | const IconWrapper = ({ 1006 | color, 1007 | children, 1008 | }: React.PropsWithChildren<{ color: string }>) => { 1009 | return ( 1010 |
1021 | {children} 1022 |
1023 | ); 1024 | }; 1025 | 1026 | import { 1027 | BusinessType, 1028 | CompanySize, 1029 | Contact, 1030 | Industry, 1031 | } from "@/graphql/schema.types"; 1032 | 1033 | export type TotalCountType = "companies" | "contacts" | "deals"; 1034 | 1035 | export const totalCountVariants: { 1036 | [key in TotalCountType]: { 1037 | primaryColor: string; 1038 | secondaryColor?: string; 1039 | icon: React.ReactNode; 1040 | title: string; 1041 | data: { index: string; value: number }[]; 1042 | }; 1043 | } = { 1044 | companies: { 1045 | primaryColor: "#1677FF", 1046 | secondaryColor: "#BAE0FF", 1047 | icon: ( 1048 | 1049 | 1055 | 1056 | ), 1057 | title: "Number of companies", 1058 | data: [ 1059 | { 1060 | index: "1", 1061 | value: 3500, 1062 | }, 1063 | { 1064 | index: "2", 1065 | value: 2750, 1066 | }, 1067 | { 1068 | index: "3", 1069 | value: 5000, 1070 | }, 1071 | { 1072 | index: "4", 1073 | value: 4250, 1074 | }, 1075 | { 1076 | index: "5", 1077 | value: 5000, 1078 | }, 1079 | ], 1080 | }, 1081 | contacts: { 1082 | primaryColor: "#52C41A", 1083 | secondaryColor: "#D9F7BE", 1084 | icon: ( 1085 | 1086 | 1092 | 1093 | ), 1094 | title: "Number of contacts", 1095 | data: [ 1096 | { 1097 | index: "1", 1098 | value: 10000, 1099 | }, 1100 | { 1101 | index: "2", 1102 | value: 19500, 1103 | }, 1104 | { 1105 | index: "3", 1106 | value: 13000, 1107 | }, 1108 | { 1109 | index: "4", 1110 | value: 17000, 1111 | }, 1112 | { 1113 | index: "5", 1114 | value: 13000, 1115 | }, 1116 | { 1117 | index: "6", 1118 | value: 20000, 1119 | }, 1120 | ], 1121 | }, 1122 | deals: { 1123 | primaryColor: "#FA541C", 1124 | secondaryColor: "#FFD8BF", 1125 | icon: ( 1126 | 1127 | 1133 | 1134 | ), 1135 | title: "Total deals in pipeline", 1136 | data: [ 1137 | { 1138 | index: "1", 1139 | value: 1000, 1140 | }, 1141 | { 1142 | index: "2", 1143 | value: 1300, 1144 | }, 1145 | { 1146 | index: "3", 1147 | value: 1200, 1148 | }, 1149 | { 1150 | index: "4", 1151 | value: 2000, 1152 | }, 1153 | { 1154 | index: "5", 1155 | value: 800, 1156 | }, 1157 | { 1158 | index: "6", 1159 | value: 1700, 1160 | }, 1161 | { 1162 | index: "7", 1163 | value: 1400, 1164 | }, 1165 | { 1166 | index: "8", 1167 | value: 1800, 1168 | }, 1169 | ], 1170 | }, 1171 | }; 1172 | 1173 | export const statusOptions: { 1174 | label: string; 1175 | value: Contact["status"]; 1176 | }[] = [ 1177 | { 1178 | label: "New", 1179 | value: "NEW", 1180 | }, 1181 | { 1182 | label: "Qualified", 1183 | value: "QUALIFIED", 1184 | }, 1185 | { 1186 | label: "Unqualified", 1187 | value: "UNQUALIFIED", 1188 | }, 1189 | { 1190 | label: "Won", 1191 | value: "WON", 1192 | }, 1193 | { 1194 | label: "Negotiation", 1195 | value: "NEGOTIATION", 1196 | }, 1197 | { 1198 | label: "Lost", 1199 | value: "LOST", 1200 | }, 1201 | { 1202 | label: "Interested", 1203 | value: "INTERESTED", 1204 | }, 1205 | { 1206 | label: "Contacted", 1207 | value: "CONTACTED", 1208 | }, 1209 | { 1210 | label: "Churned", 1211 | value: "CHURNED", 1212 | }, 1213 | ]; 1214 | 1215 | export const companySizeOptions: { 1216 | label: string; 1217 | value: CompanySize; 1218 | }[] = [ 1219 | { 1220 | label: "Enterprise", 1221 | value: "ENTERPRISE", 1222 | }, 1223 | { 1224 | label: "Large", 1225 | value: "LARGE", 1226 | }, 1227 | { 1228 | label: "Medium", 1229 | value: "MEDIUM", 1230 | }, 1231 | { 1232 | label: "Small", 1233 | value: "SMALL", 1234 | }, 1235 | ]; 1236 | 1237 | export const industryOptions: { 1238 | label: string; 1239 | value: Industry; 1240 | }[] = [ 1241 | { label: "Aerospace", value: "AEROSPACE" }, 1242 | { label: "Agriculture", value: "AGRICULTURE" }, 1243 | { label: "Automotive", value: "AUTOMOTIVE" }, 1244 | { label: "Chemicals", value: "CHEMICALS" }, 1245 | { label: "Construction", value: "CONSTRUCTION" }, 1246 | { label: "Defense", value: "DEFENSE" }, 1247 | { label: "Education", value: "EDUCATION" }, 1248 | { label: "Energy", value: "ENERGY" }, 1249 | { label: "Financial Services", value: "FINANCIAL_SERVICES" }, 1250 | { label: "Food and Beverage", value: "FOOD_AND_BEVERAGE" }, 1251 | { label: "Government", value: "GOVERNMENT" }, 1252 | { label: "Healthcare", value: "HEALTHCARE" }, 1253 | { label: "Hospitality", value: "HOSPITALITY" }, 1254 | { label: "Industrial Manufacturing", value: "INDUSTRIAL_MANUFACTURING" }, 1255 | { label: "Insurance", value: "INSURANCE" }, 1256 | { label: "Life Sciences", value: "LIFE_SCIENCES" }, 1257 | { label: "Logistics", value: "LOGISTICS" }, 1258 | { label: "Media", value: "MEDIA" }, 1259 | { label: "Mining", value: "MINING" }, 1260 | { label: "Nonprofit", value: "NONPROFIT" }, 1261 | { label: "Other", value: "OTHER" }, 1262 | { label: "Pharmaceuticals", value: "PHARMACEUTICALS" }, 1263 | { label: "Professional Services", value: "PROFESSIONAL_SERVICES" }, 1264 | { label: "Real Estate", value: "REAL_ESTATE" }, 1265 | { label: "Retail", value: "RETAIL" }, 1266 | { label: "Technology", value: "TECHNOLOGY" }, 1267 | { label: "Telecommunications", value: "TELECOMMUNICATIONS" }, 1268 | { label: "Transportation", value: "TRANSPORTATION" }, 1269 | { label: "Utilities", value: "UTILITIES" }, 1270 | ]; 1271 | 1272 | export const businessTypeOptions: { 1273 | label: string; 1274 | value: BusinessType; 1275 | }[] = [ 1276 | { 1277 | label: "B2B", 1278 | value: "B2B", 1279 | }, 1280 | { 1281 | label: "B2C", 1282 | value: "B2C", 1283 | }, 1284 | { 1285 | label: "B2G", 1286 | value: "B2G", 1287 | }, 1288 | ]; 1289 | ``` 1290 | 1291 |
1292 | 1293 |
1294 | pages/company/contacts-table.tsx 1295 | 1296 | ```typescript 1297 | import { useParams } from "react-router-dom"; 1298 | 1299 | import { FilterDropdown, useTable } from "@refinedev/antd"; 1300 | import { GetFieldsFromList } from "@refinedev/nestjs-query"; 1301 | 1302 | import { 1303 | MailOutlined, 1304 | PhoneOutlined, 1305 | SearchOutlined, 1306 | TeamOutlined, 1307 | } from "@ant-design/icons"; 1308 | import { Button, Card, Input, Select, Space, Table } from "antd"; 1309 | 1310 | import { Contact } from "@/graphql/schema.types"; 1311 | 1312 | import { statusOptions } from "@/constants"; 1313 | import { COMPANY_CONTACTS_TABLE_QUERY } from "@/graphql/queries"; 1314 | 1315 | import { CompanyContactsTableQuery } from "@/graphql/types"; 1316 | import { Text } from "@/components/text"; 1317 | import CustomAvatar from "@/components/custom-avatar"; 1318 | import { ContactStatusTag } from "@/components/tags/contact-status-tag"; 1319 | 1320 | export const CompanyContactsTable = () => { 1321 | // get params from the url 1322 | const params = useParams(); 1323 | 1324 | /** 1325 | * Refine offers a TanStack Table adapter with @refinedev/react-table that allows us to use the TanStack Table library with Refine. 1326 | * All features such as sorting, filtering, and pagination come out of the box 1327 | * Under the hood it uses useList hook to fetch the data. 1328 | * https://refine.dev/docs/packages/tanstack-table/use-table/#installation 1329 | */ 1330 | const { tableProps } = useTable>( 1331 | { 1332 | // specify the resource for which the table is to be used 1333 | resource: "contacts", 1334 | syncWithLocation: false, 1335 | // specify initial sorters 1336 | sorters: { 1337 | /** 1338 | * initial sets the initial value of sorters. 1339 | * it's not permanent 1340 | * it will be cleared when the user changes the sorting 1341 | * https://refine.dev/docs/ui-integrations/ant-design/hooks/use-table/#sortersinitial 1342 | */ 1343 | initial: [ 1344 | { 1345 | field: "createdAt", 1346 | order: "desc", 1347 | }, 1348 | ], 1349 | }, 1350 | // specify initial filters 1351 | filters: { 1352 | /** 1353 | * similar to initial in sorters 1354 | * https://refine.dev/docs/ui-integrations/ant-design/hooks/use-table/#filtersinitial 1355 | */ 1356 | initial: [ 1357 | { 1358 | field: "jobTitle", 1359 | value: "", 1360 | operator: "contains", 1361 | }, 1362 | { 1363 | field: "name", 1364 | value: "", 1365 | operator: "contains", 1366 | }, 1367 | { 1368 | field: "status", 1369 | value: undefined, 1370 | operator: "in", 1371 | }, 1372 | ], 1373 | /** 1374 | * permanent filters are the filters that are always applied 1375 | * https://refine.dev/docs/ui-integrations/ant-design/hooks/use-table/#filterspermanent 1376 | */ 1377 | permanent: [ 1378 | { 1379 | field: "company.id", 1380 | operator: "eq", 1381 | value: params?.id as string, 1382 | }, 1383 | ], 1384 | }, 1385 | /** 1386 | * used to provide any additional information to the data provider. 1387 | * https://refine.dev/docs/data/hooks/use-form/#meta- 1388 | */ 1389 | meta: { 1390 | // gqlQuery is used to specify the GraphQL query that should be used to fetch the data. 1391 | gqlQuery: COMPANY_CONTACTS_TABLE_QUERY, 1392 | }, 1393 | }, 1394 | ); 1395 | 1396 | return ( 1397 | 1405 | 1406 | Contacts 1407 | 1408 | } 1409 | // property used to render additional content in the top-right corner of the card 1410 | extra={ 1411 | <> 1412 | Total contacts: 1413 | 1414 | {/* if pagination is not disabled and total is provided then show the total */} 1415 | {tableProps?.pagination !== false && tableProps.pagination?.total} 1416 | 1417 | 1418 | } 1419 | > 1420 | 1428 | 1429 | title="Name" 1430 | dataIndex="name" 1431 | render={(_, record) => ( 1432 | 1433 | 1434 | 1439 | {record.name} 1440 | 1441 | 1442 | )} 1443 | // specify the icon that should be used for filtering 1444 | filterIcon={} 1445 | // render the filter dropdown 1446 | filterDropdown={(props) => ( 1447 | 1448 | 1449 | 1450 | )} 1451 | /> 1452 | } 1456 | filterDropdown={(props) => ( 1457 | 1458 | 1459 | 1460 | )} 1461 | /> 1462 | 1463 | title="Stage" 1464 | dataIndex="status" 1465 | // render the status tag for each contact 1466 | render={(_, record) => } 1467 | // allow filtering by selecting multiple status options 1468 | filterDropdown={(props) => ( 1469 | 1470 | 1476 | 1477 | )} 1478 | /> 1479 | 1480 | dataIndex="id" 1481 | width={112} 1482 | render={(_, record) => ( 1483 | 1484 |
1498 |
1499 | ); 1500 | }; 1501 | ``` 1502 | 1503 |
1504 | 1505 |
1506 | components/tags/contact-status-tag.tsx 1507 | 1508 | ```typescript 1509 | import React from "react"; 1510 | 1511 | import { 1512 | CheckCircleOutlined, 1513 | MinusCircleOutlined, 1514 | PlayCircleFilled, 1515 | PlayCircleOutlined, 1516 | } from "@ant-design/icons"; 1517 | import { Tag, TagProps } from "antd"; 1518 | 1519 | import { ContactStatus } from "@/graphql/schema.types"; 1520 | 1521 | type Props = { 1522 | status: ContactStatus; 1523 | }; 1524 | 1525 | /** 1526 | * Renders a tag component representing the contact status. 1527 | * @param status - The contact status. 1528 | */ 1529 | export const ContactStatusTag = ({ status }: Props) => { 1530 | let icon: React.ReactNode = null; 1531 | let color: TagProps["color"] = undefined; 1532 | 1533 | switch (status) { 1534 | case "NEW": 1535 | case "CONTACTED": 1536 | case "INTERESTED": 1537 | icon = ; 1538 | color = "cyan"; 1539 | break; 1540 | 1541 | case "UNQUALIFIED": 1542 | icon = ; 1543 | color = "red"; 1544 | break; 1545 | 1546 | case "QUALIFIED": 1547 | case "NEGOTIATION": 1548 | icon = ; 1549 | color = "green"; 1550 | break; 1551 | 1552 | case "LOST": 1553 | icon = ; 1554 | color = "red"; 1555 | break; 1556 | 1557 | case "WON": 1558 | icon = ; 1559 | color = "green"; 1560 | break; 1561 | 1562 | case "CHURNED": 1563 | icon = ; 1564 | color = "red"; 1565 | break; 1566 | 1567 | default: 1568 | break; 1569 | } 1570 | 1571 | return ( 1572 | 1573 | {icon} {status.toLowerCase()} 1574 | 1575 | ); 1576 | }; 1577 | ``` 1578 | 1579 |
1580 | 1581 | 1582 |
1583 | components/text-icon.tsx 1584 | 1585 | ```typescript 1586 | import Icon from "@ant-design/icons"; 1587 | import type { CustomIconComponentProps } from "@ant-design/icons/lib/components/Icon"; 1588 | 1589 | export const TextIconSvg = () => ( 1590 | 1597 | 1602 | 1607 | 1612 | 1613 | ); 1614 | 1615 | export const TextIcon = (props: Partial) => ( 1616 | 1617 | ); 1618 | ``` 1619 | 1620 |
1621 | 1622 |
1623 | components/tasks/kanban/add-card-button.tsx 1624 | 1625 | ```typescript 1626 | import React from "react"; 1627 | 1628 | import { PlusSquareOutlined } from "@ant-design/icons"; 1629 | import { Button } from "antd"; 1630 | import { Text } from "@/components/text"; 1631 | 1632 | interface Props { 1633 | onClick: () => void; 1634 | } 1635 | 1636 | /** Render a button that allows you to add a new card to a column. 1637 | * 1638 | * @param onClick - a function that is called when the button is clicked. 1639 | * @returns a button that allows you to add a new card to a column. 1640 | */ 1641 | export const KanbanAddCardButton = ({ 1642 | children, 1643 | onClick, 1644 | }: React.PropsWithChildren) => { 1645 | return ( 1646 | 1661 | ); 1662 | }; 1663 | ``` 1664 | 1665 |
1666 | 1667 |
1668 | pages/tasks/create.tsx 1669 | 1670 | ```typescript 1671 | import { useSearchParams } from "react-router-dom"; 1672 | 1673 | import { useModalForm } from "@refinedev/antd"; 1674 | import { useNavigation } from "@refinedev/core"; 1675 | 1676 | import { Form, Input, Modal } from "antd"; 1677 | 1678 | import { CREATE_TASK_MUTATION } from "@/graphql/mutations"; 1679 | 1680 | const TasksCreatePage = () => { 1681 | // get search params from the url 1682 | const [searchParams] = useSearchParams(); 1683 | 1684 | /** 1685 | * useNavigation is a hook by Refine that allows you to navigate to a page. 1686 | * https://refine.dev/docs/routing/hooks/use-navigation/ 1687 | * 1688 | * list method navigates to the list page of the specified resource. 1689 | * https://refine.dev/docs/routing/hooks/use-navigation/#list 1690 | */ const { list } = useNavigation(); 1691 | 1692 | /** 1693 | * useModalForm is a hook by Refine that allows you manage a form inside a modal. 1694 | * it extends the useForm hook from the @refinedev/antd package 1695 | * https://refine.dev/docs/ui-integrations/ant-design/hooks/use-modal-form/ 1696 | * 1697 | * formProps -> It's an instance of HTML form that manages form state and actions like onFinish, onValuesChange, etc. 1698 | * Under the hood, it uses the useForm hook from the @refinedev/antd package 1699 | * https://refine.dev/docs/ui-integrations/ant-design/hooks/use-modal-form/#formprops 1700 | * 1701 | * modalProps -> It's an instance of Modal that manages modal state and actions like onOk, onCancel, etc. 1702 | * https://refine.dev/docs/ui-integrations/ant-design/hooks/use-modal-form/#modalprops 1703 | */ 1704 | const { formProps, modalProps, close } = useModalForm({ 1705 | // specify the action to perform i.e., create or edit 1706 | action: "create", 1707 | // specify whether the modal should be visible by default 1708 | defaultVisible: true, 1709 | // specify the gql mutation to be performed 1710 | meta: { 1711 | gqlMutation: CREATE_TASK_MUTATION, 1712 | }, 1713 | }); 1714 | 1715 | return ( 1716 | { 1719 | // close the modal 1720 | close(); 1721 | 1722 | // navigate to the list page of the tasks resource 1723 | list("tasks", "replace"); 1724 | }} 1725 | title="Add new card" 1726 | width={512} 1727 | > 1728 |
{ 1732 | // on finish, call the onFinish method of useModalForm to perform the mutation 1733 | formProps?.onFinish?.({ 1734 | ...values, 1735 | stageId: searchParams.get("stageId") 1736 | ? Number(searchParams.get("stageId")) 1737 | : null, 1738 | userIds: [], 1739 | }); 1740 | }} 1741 | > 1742 | 1743 | 1744 | 1745 |
1746 |
1747 | ); 1748 | } 1749 | 1750 | export default TasksCreatePage; 1751 | ``` 1752 | 1753 |
1754 | 1755 |
1756 | pages/tasks/edit.tsx 1757 | 1758 | ```typescript 1759 | import { useState } from "react"; 1760 | 1761 | import { DeleteButton, useModalForm } from "@refinedev/antd"; 1762 | import { useNavigation } from "@refinedev/core"; 1763 | 1764 | import { 1765 | AlignLeftOutlined, 1766 | FieldTimeOutlined, 1767 | UsergroupAddOutlined, 1768 | } from "@ant-design/icons"; 1769 | import { Modal } from "antd"; 1770 | 1771 | import { 1772 | Accordion, 1773 | DescriptionForm, 1774 | DescriptionHeader, 1775 | DueDateForm, 1776 | DueDateHeader, 1777 | StageForm, 1778 | TitleForm, 1779 | UsersForm, 1780 | UsersHeader, 1781 | } from "@/components"; 1782 | import { Task } from "@/graphql/schema.types"; 1783 | 1784 | import { UPDATE_TASK_MUTATION } from "@/graphql/mutations"; 1785 | 1786 | const TasksEditPage = () => { 1787 | const [activeKey, setActiveKey] = useState(); 1788 | 1789 | // use the list method to navigate to the list page of the tasks resource from the navigation hook 1790 | const { list } = useNavigation(); 1791 | 1792 | // create a modal form to edit a task using the useModalForm hook 1793 | // modalProps -> It's an instance of Modal that manages modal state and actions like onOk, onCancel, etc. 1794 | // close -> It's a function that closes the modal 1795 | // queryResult -> It's an instance of useQuery from react-query 1796 | const { modalProps, close, queryResult } = useModalForm({ 1797 | // specify the action to perform i.e., create or edit 1798 | action: "edit", 1799 | // specify whether the modal should be visible by default 1800 | defaultVisible: true, 1801 | // specify the gql mutation to be performed 1802 | meta: { 1803 | gqlMutation: UPDATE_TASK_MUTATION, 1804 | }, 1805 | }); 1806 | 1807 | // get the data of the task from the queryResult 1808 | const { description, dueDate, users, title } = queryResult?.data?.data ?? {}; 1809 | 1810 | const isLoading = queryResult?.isLoading ?? true; 1811 | 1812 | return ( 1813 | { 1817 | close(); 1818 | list("tasks", "replace"); 1819 | }} 1820 | title={} 1821 | width={586} 1822 | footer={ 1823 | { 1826 | list("tasks", "replace"); 1827 | }} 1828 | > 1829 | Delete card 1830 | 1831 | } 1832 | > 1833 | {/* Render the stage form */} 1834 | 1835 | 1836 | {/* Render the description form inside an accordion */} 1837 | } 1842 | isLoading={isLoading} 1843 | icon={} 1844 | label="Description" 1845 | > 1846 | setActiveKey(undefined)} 1849 | /> 1850 | 1851 | 1852 | {/* Render the due date form inside an accordion */} 1853 | } 1858 | isLoading={isLoading} 1859 | icon={} 1860 | label="Due date" 1861 | > 1862 | setActiveKey(undefined)} 1865 | /> 1866 | 1867 | 1868 | {/* Render the users form inside an accordion */} 1869 | } 1874 | isLoading={isLoading} 1875 | icon={} 1876 | label="Users" 1877 | > 1878 | ({ 1881 | label: user.name, 1882 | value: user.id, 1883 | })), 1884 | }} 1885 | cancelForm={() => setActiveKey(undefined)} 1886 | /> 1887 | 1888 | 1889 | ); 1890 | }; 1891 | 1892 | export default TasksEditPage; 1893 | ``` 1894 | 1895 |
1896 | 1897 |
1898 | components/accordion.tsx 1899 | 1900 | ```typescript 1901 | import { AccordionHeaderSkeleton } from "@/components"; 1902 | import { Text } from "./text"; 1903 | 1904 | type Props = React.PropsWithChildren<{ 1905 | accordionKey: string; 1906 | activeKey?: string; 1907 | setActive: (key?: string) => void; 1908 | fallback: string | React.ReactNode; 1909 | isLoading?: boolean; 1910 | icon: React.ReactNode; 1911 | label: string; 1912 | }>; 1913 | 1914 | /** 1915 | * when activeKey is equal to accordionKey, the children will be rendered. Otherwise, the fallback will be rendered 1916 | * when isLoading is true, the will be rendered 1917 | * when Accordion is clicked, setActive will be called with the accordionKey 1918 | */ 1919 | export const Accordion = ({ 1920 | accordionKey, 1921 | activeKey, 1922 | setActive, 1923 | fallback, 1924 | icon, 1925 | label, 1926 | children, 1927 | isLoading, 1928 | }: Props) => { 1929 | if (isLoading) return ; 1930 | 1931 | const isActive = activeKey === accordionKey; 1932 | 1933 | const toggleAccordion = () => { 1934 | if (isActive) { 1935 | setActive(undefined); 1936 | } else { 1937 | setActive(accordionKey); 1938 | } 1939 | }; 1940 | 1941 | return ( 1942 |
1951 |
{icon}
1952 | {isActive ? ( 1953 |
1961 | 1962 | {label} 1963 | 1964 | {children} 1965 |
1966 | ) : ( 1967 |
1968 | {fallback} 1969 |
1970 | )} 1971 |
1972 | ); 1973 | }; 1974 | ``` 1975 | 1976 |
1977 | 1978 |
1979 | components/tags/user-tag.tsx 1980 | 1981 | ```typescript 1982 | import { Space, Tag } from "antd"; 1983 | 1984 | import { User } from "@/graphql/schema.types"; 1985 | import CustomAvatar from "../custom-avatar"; 1986 | 1987 | type Props = { 1988 | user: User; 1989 | }; 1990 | 1991 | // display a user's avatar and name in a tag 1992 | export const UserTag = ({ user }: Props) => { 1993 | return ( 1994 | 2004 | 2005 | 2010 | {user.name} 2011 | 2012 | 2013 | ); 2014 | }; 2015 | ``` 2016 | 2017 |
2018 | 2019 | ## 🔗 Links 2020 | 2021 | Other components (Kanban Edit Forms, Skeletons and utilities) used in the project can be found [here](https://drive.google.com/drive/folders/1oDFoI-a8qSJqHde6doUtM9NCHlYpWdNW?usp=sharing) 2022 | 2023 | ## 🚀 More 2024 | 2025 | **Advance your skills with Next.js 14 Pro Course** 2026 | 2027 | Enjoyed creating this project? Dive deeper into our PRO courses for a richer learning adventure. They're packed with detailed explanations, cool features, and exercises to boost your skills. Give it a go! 2028 | 2029 | 2030 | Project Banner 2031 | 2032 | 2033 |
2034 |
2035 | 2036 | **Accelerate your professional journey with the Expert Training program** 2037 | 2038 | And if you're hungry for more than just a course and want to understand how we learn and tackle tech challenges, hop into our personalized masterclass. We cover best practices, different web skills, and offer mentorship to boost your confidence. Let's learn and grow together! 2039 | 2040 | 2041 | Project Banner 2042 | 2043 | 2044 | # 2045 | --------------------------------------------------------------------------------