├── .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/emredkyc/react_admin_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'; 8 | 9 | -------------------------------------------------------------------------------- /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 { authCredentials } from "@/providers"; 2 | import { AuthPage } from "@refinedev/antd"; 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 | -------------------------------------------------------------------------------- /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 | } -------------------------------------------------------------------------------- /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; -------------------------------------------------------------------------------- /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 | }; -------------------------------------------------------------------------------- /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 | }; -------------------------------------------------------------------------------- /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"; -------------------------------------------------------------------------------- /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/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/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 | ); -------------------------------------------------------------------------------- /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 | }; -------------------------------------------------------------------------------- /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 | }; -------------------------------------------------------------------------------- /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": "react_admin_dashboard", 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.4", 11 | "@refinedev/cli": "^2.16.21", 12 | "@refinedev/core": "^4.47.1", 13 | "@refinedev/devtools": "^1.1.32", 14 | "@refinedev/kbar": "^1.3.6", 15 | "@refinedev/nestjs-query": "^1.1.1", 16 | "@refinedev/react-router-v6": "^4.5.5", 17 | "@uiw/react-md-editor": "^4.0.3", 18 | "antd": "^5.0.5", 19 | "graphql-tag": "^2.12.6", 20 | "graphql-ws": "^5.9.1", 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.2", 27 | "@graphql-codegen/import-types-preset": "^3.0.0", 28 | "@graphql-codegen/typescript": "^4.0.6", 29 | "@graphql-codegen/typescript-operations": "^4.2.0", 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.5", 40 | "typescript": "^4.7.4", 41 | "vite": "^4.3.1", 42 | "vite-tsconfig-paths": "^4.3.1" 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": "Gjpn4O-Y1QOhP-ugQq19" 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 | }; -------------------------------------------------------------------------------- /src/pages/home/index.tsx: -------------------------------------------------------------------------------- 1 | import { UpcomingEvents, DealsChart, DashboardTotalCountCard, LatestActivities } 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 | return ( 16 |
17 | 18 | 19 | 24 | 25 | 26 | 31 | 32 | 33 | 38 | 39 | 40 | 46 | 54 | 55 | 56 | 64 | 65 | 66 | 67 | 73 | 74 | 75 | 76 | 77 | 78 |
79 | ) 80 | } -------------------------------------------------------------------------------- /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 | `; -------------------------------------------------------------------------------- /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 { Text } from '../text' 4 | import LatestActivitiesSkeleton from '../skeleton/latest-activities' 5 | import { useList } from '@refinedev/core' 6 | import { DASHBOARD_LATEST_ACTIVITIES_AUDITS_QUERY, DASHBOARD_LATEST_ACTIVITIES_DEALS_QUERY } from '@/graphql/queries' 7 | import dayjs from 'dayjs' 8 | import CustomAvatar from '../custom-avatar' 9 | 10 | const LatestActivities = () => { 11 | const { data: audit, isLoading: isLoadingAudit, isError, error } = useList({ 12 | resource: 'audits', 13 | meta: { 14 | gqlQuery: DASHBOARD_LATEST_ACTIVITIES_AUDITS_QUERY 15 | } 16 | }) 17 | 18 | const dealIds = audit?.data?.map((audit) => audit?.targetId); 19 | 20 | const { data: deals, isLoading: isLoadingDeals } = useList({ 21 | resource: 'deals', 22 | queryOptions: { enabled: !!dealIds?.length }, 23 | pagination: { 24 | mode: 'off' 25 | }, 26 | filters: [{ field: 'id', operator: 'in', value: dealIds }], 27 | meta: { 28 | gqlQuery: DASHBOARD_LATEST_ACTIVITIES_DEALS_QUERY 29 | } 30 | }) 31 | 32 | if(isError) { 33 | console.log(error); 34 | return null; 35 | } 36 | 37 | const isLoading = isLoadingAudit || isLoadingDeals; 38 | 39 | return ( 40 | 45 | 46 | 47 | Latest Activities 48 | 49 | 50 | )} 51 | > 52 | {isLoading ? ( 53 | ({ id: i}))} 57 | renderItem={(_, index) => ( 58 | 59 | )} 60 | /> 61 | ): ( 62 | { 66 | const deal = deals?.data.find( 67 | (deal) => deal.id === String(item.targetId) 68 | ) || undefined; 69 | 70 | return ( 71 | 72 | 81 | } 82 | description={ 83 | 84 | {item.user?.name} 85 | 86 | {item.action === 'CREATE' ? 'created' : 'moved'} 87 | 88 | {deal?.title} 89 | deal 90 | {item.action === 'CREATE' ? 'in' : 'to'} 91 | 92 | {deal?.stage?.title} 93 | 94 | 95 | } 96 | /> 97 | 98 | ) 99 | }} 100 | /> 101 | )} 102 | 103 | ) 104 | } 105 | 106 | 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 | } -------------------------------------------------------------------------------- /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 | }; -------------------------------------------------------------------------------- /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 | ]; -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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 | typescript 11 | refine 12 | antd 13 |
14 | 15 |

A CRM Dashboard

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