├── .npmrc
├── src
├── vite-env.d.ts
├── utilities
│ ├── date
│ │ ├── index.ts
│ │ └── get-date-colors.ts
│ ├── index.ts
│ ├── get-name-initials.ts
│ ├── currency-number.ts
│ ├── get-random-color.ts
│ └── helpers.ts
├── providers
│ ├── index.ts
│ ├── data
│ │ ├── index.tsx
│ │ └── fetch-wrapper.ts
│ └── auth.ts
├── pages
│ ├── register
│ │ └── index.tsx
│ ├── forgotPassword
│ │ └── index.tsx
│ ├── index.ts
│ ├── login
│ │ └── index.tsx
│ ├── home
│ │ └── index.tsx
│ ├── company
│ │ ├── create.tsx
│ │ ├── edit.tsx
│ │ ├── list.tsx
│ │ └── contacts-table.tsx
│ └── tasks
│ │ ├── create.tsx
│ │ ├── edit.tsx
│ │ └── list.tsx
├── index.tsx
├── components
│ ├── layout
│ │ ├── index.tsx
│ │ ├── header.tsx
│ │ ├── current-user.tsx
│ │ └── account-settings.tsx
│ ├── skeleton
│ │ ├── accordion-header.tsx
│ │ ├── upcoming-events.tsx
│ │ ├── project-card.tsx
│ │ ├── latest-activities.tsx
│ │ └── kanban.tsx
│ ├── select-option-with-avatar.tsx
│ ├── custom-avatar.tsx
│ ├── tags
│ │ ├── user-tag.tsx
│ │ └── contact-status-tag.tsx
│ ├── tasks
│ │ ├── kanban
│ │ │ ├── add-card-button.tsx
│ │ │ ├── item.tsx
│ │ │ ├── board.tsx
│ │ │ ├── column.tsx
│ │ │ └── card.tsx
│ │ └── form
│ │ │ ├── header.tsx
│ │ │ ├── description.tsx
│ │ │ ├── due-date.tsx
│ │ │ ├── users.tsx
│ │ │ ├── title.tsx
│ │ │ └── stage.tsx
│ ├── index.ts
│ ├── text-icon.tsx
│ ├── accordion.tsx
│ ├── text.tsx
│ └── home
│ │ ├── deals-chart.tsx
│ │ ├── total-count-card.tsx
│ │ ├── upcoming-events.tsx
│ │ └── latest-activities.tsx
├── config
│ └── resources.tsx
├── graphql
│ ├── mutations.ts
│ ├── queries.ts
│ └── types.ts
├── App.tsx
└── constants
│ └── index.tsx
├── public
└── favicon.ico
├── tsconfig.node.json
├── vite.config.ts
├── .gitignore
├── .eslintrc.cjs
├── tsconfig.json
├── index.html
├── package.json
├── graphql.config.ts
└── README.MD
/.npmrc:
--------------------------------------------------------------------------------
1 | legacy-peer-deps=true
2 | strict-peer-dependencies=false
--------------------------------------------------------------------------------
/src/vite-env.d.ts:
--------------------------------------------------------------------------------
1 | ///
2 |
--------------------------------------------------------------------------------
/src/utilities/date/index.ts:
--------------------------------------------------------------------------------
1 | export * from "./get-date-colors";
2 |
--------------------------------------------------------------------------------
/src/providers/index.ts:
--------------------------------------------------------------------------------
1 | export * from './data';
2 | export * from './auth';
--------------------------------------------------------------------------------
/public/favicon.ico:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/adrianhajdin/refine_dashboard/HEAD/public/favicon.ico
--------------------------------------------------------------------------------
/src/pages/register/index.tsx:
--------------------------------------------------------------------------------
1 | import { AuthPage } from "@refinedev/antd";
2 |
3 | export const Register = () => {
4 | return ;
5 | };
6 |
--------------------------------------------------------------------------------
/src/utilities/index.ts:
--------------------------------------------------------------------------------
1 | export * from "./currency-number";
2 | export * from "./date";
3 | export * from "./get-name-initials";
4 | export * from "./get-random-color";
5 |
--------------------------------------------------------------------------------
/src/pages/forgotPassword/index.tsx:
--------------------------------------------------------------------------------
1 | import { AuthPage } from "@refinedev/antd";
2 |
3 | export const ForgotPassword = () => {
4 | return ;
5 | };
6 |
--------------------------------------------------------------------------------
/tsconfig.node.json:
--------------------------------------------------------------------------------
1 | {
2 | "compilerOptions": {
3 | "composite": true,
4 | "module": "ESNext",
5 | "moduleResolution": "node"
6 | },
7 | "include": ["vite.config.ts"]
8 | }
9 |
--------------------------------------------------------------------------------
/vite.config.ts:
--------------------------------------------------------------------------------
1 | import react from "@vitejs/plugin-react";
2 | import { defineConfig } from "vite";
3 | import tsconfigPaths from "vite-tsconfig-paths";
4 |
5 | export default defineConfig({
6 | plugins: [tsconfigPaths(), react()],
7 | });
8 |
--------------------------------------------------------------------------------
/src/pages/index.ts:
--------------------------------------------------------------------------------
1 | export * from './home';
2 | export * from './forgotPassword';
3 | export * from './login';
4 | export * from './register';
5 | export * from './company/list';
6 | export * from './company/create';
7 | export * from './company/edit';
--------------------------------------------------------------------------------
/src/utilities/get-name-initials.ts:
--------------------------------------------------------------------------------
1 | export const getNameInitials = (name: string, count = 2) => {
2 | const initials = name
3 | .split(" ")
4 | .map((n) => n[0])
5 | .join("");
6 | const filtered = initials.replace(/[^a-zA-Z]/g, "");
7 | return filtered.slice(0, count).toUpperCase();
8 | };
--------------------------------------------------------------------------------
/src/pages/login/index.tsx:
--------------------------------------------------------------------------------
1 | import { AuthPage } from "@refinedev/antd";
2 | import { authCredentials } from "../../providers";
3 |
4 | export const Login = () => {
5 | return (
6 |
12 | );
13 | };
14 |
--------------------------------------------------------------------------------
/src/index.tsx:
--------------------------------------------------------------------------------
1 | import React from "react";
2 | import { createRoot } from "react-dom/client";
3 |
4 | import App from "./App";
5 |
6 | const container = document.getElementById("root") as HTMLElement;
7 | const root = createRoot(container);
8 |
9 | root.render(
10 |
11 |
12 |
13 | );
14 |
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
1 | # See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
2 |
3 | # dependencies
4 | /node_modules
5 | /.pnp
6 | .pnp.js
7 |
8 | # testing
9 | /coverage
10 |
11 | # production
12 | /build
13 |
14 | # misc
15 | .DS_Store
16 | .env.local
17 | .env.development.local
18 | .env.test.local
19 | .env.production.local
20 |
21 | npm-debug.log*
22 | yarn-debug.log*
23 | yarn-error.log*
--------------------------------------------------------------------------------
/src/components/layout/index.tsx:
--------------------------------------------------------------------------------
1 | import { ThemedLayoutV2, ThemedTitleV2 } from "@refinedev/antd"
2 | import Header from "./header"
3 |
4 | const Layout = ({ children }: React.PropsWithChildren) => {
5 | return (
6 | }
9 | >
10 | {children}
11 |
12 | )
13 | }
14 |
15 | export default Layout
--------------------------------------------------------------------------------
/src/utilities/currency-number.ts:
--------------------------------------------------------------------------------
1 | export const currencyNumber = (
2 | value: number,
3 | options?: Intl.NumberFormatOptions,
4 | ) => {
5 | if (
6 | typeof Intl == "object" &&
7 | Intl &&
8 | typeof Intl.NumberFormat == "function"
9 | ) {
10 | return new Intl.NumberFormat("en-US", {
11 | style: "currency",
12 | currency: "USD",
13 | ...options,
14 | }).format(value);
15 | }
16 |
17 | return value.toString();
18 | };
19 |
--------------------------------------------------------------------------------
/.eslintrc.cjs:
--------------------------------------------------------------------------------
1 | /* eslint-env node */
2 |
3 | module.exports = {
4 | env: { browser: true, es2020: true },
5 | extends: [
6 | "eslint:recommended",
7 | "plugin:@typescript-eslint/recommended",
8 | "plugin:react-hooks/recommended",
9 | ],
10 | parser: "@typescript-eslint/parser",
11 | parserOptions: { ecmaVersion: "latest", sourceType: "module" },
12 | plugins: ["react-refresh"],
13 | rules: {
14 | "react-refresh/only-export-components": "warn",
15 | },
16 | };
17 |
--------------------------------------------------------------------------------
/src/components/skeleton/accordion-header.tsx:
--------------------------------------------------------------------------------
1 | import { Skeleton } from "antd";
2 |
3 | // create a skeleton for the accordion header
4 | const AccordionHeaderSkeleton = () => {
5 | return (
6 |
15 |
16 |
17 |
18 | );
19 | };
20 |
21 | export default AccordionHeaderSkeleton;
--------------------------------------------------------------------------------
/src/components/select-option-with-avatar.tsx:
--------------------------------------------------------------------------------
1 | import CustomAvatar from "./custom-avatar";
2 | import { Text } from "./text";
3 |
4 | type Props = {
5 | name: string,
6 | avatarUrl?: string;
7 | shape?: 'circle' | 'square';
8 | }
9 |
10 | const SelectOptionWithAvatar = ({ avatarUrl, name, shape }: Props) => {
11 | return (
12 |
19 |
20 | {name}
21 |
22 | )
23 | }
24 |
25 | export default SelectOptionWithAvatar
--------------------------------------------------------------------------------
/src/components/layout/header.tsx:
--------------------------------------------------------------------------------
1 | import { Layout, Space } from "antd"
2 | import CurrentUser from "./current-user"
3 |
4 | const Header = () => {
5 |
6 | const headerStyles: React.CSSProperties = {
7 | background: '#fff',
8 | display: 'flex',
9 | justifyContent: 'flex-end',
10 | alignItems: 'center',
11 | padding: '0 24px',
12 | position: "sticky",
13 | top: 0,
14 | zIndex: 999,
15 | }
16 |
17 | return (
18 |
19 |
20 |
21 |
22 |
23 | )
24 | }
25 |
26 | export default Header
--------------------------------------------------------------------------------
/src/components/custom-avatar.tsx:
--------------------------------------------------------------------------------
1 | import { getNameInitials } from '@/utilities';
2 | import { Avatar as AntdAvatar, AvatarProps } from 'antd'
3 |
4 | type Props = AvatarProps & {
5 | name?: string;
6 | }
7 |
8 | const CustomAvatar = ({ name, style, ...rest }: Props) => {
9 | return (
10 |
22 | {getNameInitials(name || '')}
23 |
24 | )
25 | }
26 |
27 | export default CustomAvatar
--------------------------------------------------------------------------------
/src/utilities/get-random-color.ts:
--------------------------------------------------------------------------------
1 | /*
2 | * generates random colors from https://ant.design/docs/spec/colors. used.
3 | */
4 | export const getRandomColorFromString = (text: string) => {
5 | const colors = [
6 | "#ff9c6e",
7 | "#ff7875",
8 | "#ffc069",
9 | "#ffd666",
10 | "#fadb14",
11 | "#95de64",
12 | "#5cdbd3",
13 | "#69c0ff",
14 | "#85a5ff",
15 | "#b37feb",
16 | "#ff85c0",
17 | ];
18 |
19 | let hash = 0;
20 | for (let i = 0; i < text.length; i++) {
21 | hash = text.charCodeAt(i) + ((hash << 5) - hash);
22 | hash = hash & hash;
23 | }
24 | hash = ((hash % colors.length) + colors.length) % colors.length;
25 |
26 | return colors[hash];
27 | };
28 |
--------------------------------------------------------------------------------
/src/utilities/date/get-date-colors.ts:
--------------------------------------------------------------------------------
1 | import dayjs from "dayjs";
2 |
3 | type DateColors = "success" | "processing" | "error" | "default" | "warning";
4 |
5 | // returns a color based on the date
6 | export const getDateColor = (args: {
7 | date: string;
8 | defaultColor?: DateColors;
9 | }): DateColors => {
10 | const date = dayjs(args.date);
11 | const today = dayjs();
12 |
13 | if (date.isBefore(today)) {
14 | return "error";
15 | }
16 |
17 | if (date.isBefore(today.add(3, "day"))) {
18 | return "warning";
19 | }
20 |
21 | // ?? is the nullish coalescing operator. It returns the right-hand side operand when the left-hand side is null or undefined.
22 | return args.defaultColor ?? "default";
23 | };
24 |
--------------------------------------------------------------------------------
/src/components/skeleton/upcoming-events.tsx:
--------------------------------------------------------------------------------
1 | import { Badge, List, Skeleton } from "antd";
2 |
3 | const UpcomingEventsSkeleton = () => {
4 | return (
5 |
6 | }
8 | title={
9 |
15 | }
16 | description={
17 |
25 | }
26 | />
27 |
28 | );
29 | };
30 |
31 | export default UpcomingEventsSkeleton;
--------------------------------------------------------------------------------
/tsconfig.json:
--------------------------------------------------------------------------------
1 | {
2 | "compilerOptions": {
3 | "target": "ESNext",
4 | "useDefineForClassFields": true,
5 | "lib": ["DOM", "DOM.Iterable", "ESNext"],
6 | "allowJs": false,
7 | "skipLibCheck": true,
8 | "esModuleInterop": false,
9 | "allowSyntheticDefaultImports": true,
10 | "strict": true,
11 | "forceConsistentCasingInFileNames": true,
12 | "module": "ESNext",
13 | "moduleResolution": "Node",
14 | "resolveJsonModule": true,
15 | "isolatedModules": true,
16 | "noEmit": true,
17 | "jsx": "react-jsx",
18 | "baseUrl": "./src",
19 | "paths": {
20 | "@/*": ["./*"]
21 | }
22 | },
23 | "include": ["src", "vite.config.ts"],
24 | "references": [
25 | {
26 | "path": "./tsconfig.node.json"
27 | }
28 | ]
29 | }
30 |
--------------------------------------------------------------------------------
/src/components/skeleton/project-card.tsx:
--------------------------------------------------------------------------------
1 | import { Card, Skeleton } from "antd";
2 |
3 | const ProjectCardSkeleton = () => {
4 | return (
5 |
21 | }
22 | >
23 |
30 |
31 |
32 | );
33 | };
34 |
35 | export default ProjectCardSkeleton;
--------------------------------------------------------------------------------
/src/components/tags/user-tag.tsx:
--------------------------------------------------------------------------------
1 | import { Space, Tag } from "antd";
2 |
3 | import { User } from "@/graphql/schema.types";
4 | import CustomAvatar from "../custom-avatar";
5 |
6 | type Props = {
7 | user: User;
8 | };
9 |
10 | // display a user's avatar and name in a tag
11 | export const UserTag = ({ user }: Props) => {
12 | return (
13 |
23 |
24 |
29 | {user.name}
30 |
31 |
32 | );
33 | };
34 |
--------------------------------------------------------------------------------
/src/config/resources.tsx:
--------------------------------------------------------------------------------
1 | import { DashboardOutlined, ProjectOutlined, ShopOutlined } from "@ant-design/icons";
2 | import { IResourceItem } from "@refinedev/core";
3 |
4 | export const resources: IResourceItem[] = [
5 | {
6 | name: 'dashboard',
7 | list: '/',
8 | meta: {
9 | label: 'Dashboard',
10 | icon:
11 | }
12 | },
13 | {
14 | name: 'companies',
15 | list: '/companies',
16 | show: '/companies/:id',
17 | create: '/companies/new',
18 | edit: '/companies/edit/:id',
19 | meta: {
20 | label: 'Companies',
21 | icon:
22 | }
23 | },
24 | {
25 | name: 'tasks',
26 | list: '/tasks',
27 | create: '/tasks/new',
28 | edit: '/tasks/edit/:id',
29 | meta: {
30 | label: 'Tasks',
31 | icon:
32 | }
33 | }
34 | ]
--------------------------------------------------------------------------------
/src/components/skeleton/latest-activities.tsx:
--------------------------------------------------------------------------------
1 | import { List, Skeleton } from "antd";
2 |
3 | const LatestActivitiesSkeleton = () => {
4 | return (
5 |
6 |
16 | }
17 | title={
18 |
24 | }
25 | description={
26 |
33 | }
34 | />
35 |
36 | );
37 | };
38 |
39 | export default LatestActivitiesSkeleton;
--------------------------------------------------------------------------------
/src/components/tasks/kanban/add-card-button.tsx:
--------------------------------------------------------------------------------
1 | import React from "react";
2 |
3 | import { PlusSquareOutlined } from "@ant-design/icons";
4 | import { Button } from "antd";
5 | import { Text } from "@/components/text";
6 |
7 | interface Props {
8 | onClick: () => void;
9 | }
10 |
11 | /** Render a button that allows you to add a new card to a column.
12 | *
13 | * @param onClick - a function that is called when the button is clicked.
14 | * @returns a button that allows you to add a new card to a column.
15 | */
16 | export const KanbanAddCardButton = ({
17 | children,
18 | onClick,
19 | }: React.PropsWithChildren) => {
20 | return (
21 | }
24 | style={{
25 | margin: "16px",
26 | backgroundColor: "white",
27 | }}
28 | onClick={onClick}
29 | >
30 | {children ?? (
31 |
32 | Add new card
33 |
34 | )}
35 |
36 | );
37 | };
38 |
--------------------------------------------------------------------------------
/src/components/index.ts:
--------------------------------------------------------------------------------
1 | import UpcomingEvents from "./home/upcoming-events";
2 | import DealsChart from "./home/deals-chart";
3 | import UpcomingEventsSkeleton from "./skeleton/upcoming-events";
4 | import AccordionHeaderSkeleton from "./skeleton/accordion-header";
5 | import KanbanColumnSkeleton from "./skeleton/kanban";
6 | import ProjectCardSkeleton from "./skeleton/project-card";
7 | import LatestActivitiesSkeleton from "./skeleton/latest-activities";
8 |
9 | import DashboardTotalCountCard from "./home/total-count-card";
10 | import LatestActivities from "./home/latest-activities";
11 |
12 | export {
13 | UpcomingEvents,
14 | DealsChart,
15 |
16 | UpcomingEventsSkeleton,
17 | AccordionHeaderSkeleton,
18 | KanbanColumnSkeleton,
19 | ProjectCardSkeleton,
20 | LatestActivitiesSkeleton,
21 |
22 | DashboardTotalCountCard,
23 | LatestActivities
24 | };
25 | export * from './tags/user-tag';
26 | export * from './text';
27 | export * from './accordion';
28 | export * from "./tasks/form/description";
29 | export * from "./tasks/form/due-date";
30 | export * from "./tasks/form/stage";
31 | export * from "./tasks/form/title";
32 | export * from "./tasks/form/users";
33 | export * from "./tasks/form/header";
34 |
--------------------------------------------------------------------------------
/src/providers/data/index.tsx:
--------------------------------------------------------------------------------
1 | import graphqlDataProvider, {
2 | GraphQLClient,
3 | liveProvider as graphqlLiveProvider
4 | } from "@refinedev/nestjs-query";
5 | import { createClient } from 'graphql-ws'
6 | import { fetchWrapper } from "./fetch-wrapper";
7 |
8 | export const API_BASE_URL = 'https://api.crm.refine.dev'
9 | export const API_URL = `${API_BASE_URL}/graphql`
10 | export const WS_URL = 'wss://api.crm.refine.dev/graphql'
11 |
12 | export const client = new GraphQLClient(API_URL, {
13 | fetch: (url: string, options: RequestInit) => {
14 | try {
15 | return fetchWrapper(url, options);
16 | } catch (error) {
17 | return Promise.reject(error as Error);
18 | }
19 | }
20 | })
21 |
22 | export const wsClient = typeof window !== "undefined"
23 | ? createClient({
24 | url: WS_URL,
25 | connectionParams: () => {
26 | const accessToken = localStorage.getItem("access_token");
27 |
28 | return {
29 | headers: {
30 | Authorization: `Bearer ${accessToken}`,
31 | }
32 | }
33 | }
34 | })
35 | : undefined
36 |
37 | export const dataProvider = graphqlDataProvider(client);
38 | export const liveProvider = wsClient ? graphqlLiveProvider(wsClient) : undefined;
--------------------------------------------------------------------------------
/src/components/tasks/kanban/item.tsx:
--------------------------------------------------------------------------------
1 | import { DragOverlay, UseDraggableArguments, useDraggable } from '@dnd-kit/core'
2 |
3 | interface Props {
4 | id: string;
5 | data?: UseDraggableArguments['data']
6 | }
7 |
8 | const KanbanItem = ({ children, id, data }: React.PropsWithChildren) => {
9 | const { attributes, listeners, setNodeRef, active } = useDraggable({
10 | id,
11 | data,
12 | })
13 |
14 | return (
15 |
18 |
29 | {active?.id === id && (
30 |
31 |
36 | {children}
37 |
38 |
39 | )}
40 | {children}
41 |
42 |
43 | )
44 | }
45 |
46 | export default KanbanItem
--------------------------------------------------------------------------------
/src/components/tasks/kanban/board.tsx:
--------------------------------------------------------------------------------
1 | import { DndContext, DragEndEvent, MouseSensor, TouchSensor, useSensor, useSensors } from '@dnd-kit/core'
2 | import React from 'react'
3 |
4 | export const KanbanBoardContainer = ({ children }: React.PropsWithChildren) => {
5 | return (
6 |
15 |
24 | {children}
25 |
26 |
27 | )
28 | }
29 |
30 | type Props = {
31 | onDragEnd: (event: DragEndEvent) => void
32 | }
33 |
34 | export const KanbanBoard = ({ children, onDragEnd }: React.PropsWithChildren) => {
35 | const mouseSensor = useSensor(MouseSensor, {
36 | activationConstraint: {
37 | distance: 5,
38 | },
39 | })
40 |
41 | const touchSensor = useSensor(TouchSensor, {
42 | activationConstraint: {
43 | distance: 5
44 | }
45 | })
46 |
47 | const sensors = useSensors(mouseSensor, touchSensor)
48 |
49 | return (
50 |
51 | {children}
52 |
53 | )
54 | }
--------------------------------------------------------------------------------
/src/components/text-icon.tsx:
--------------------------------------------------------------------------------
1 | import Icon from "@ant-design/icons";
2 | import type { CustomIconComponentProps } from "@ant-design/icons/lib/components/Icon";
3 |
4 | export const TextIconSvg = () => (
5 |
12 |
17 |
22 |
27 |
28 | );
29 |
30 | export const TextIcon = (props: Partial) => (
31 |
32 | );
33 |
--------------------------------------------------------------------------------
/index.html:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
7 |
8 |
12 |
17 |
22 |
23 | refine - Build your React-based CRUD applications, without constraints.
24 |
25 |
26 |
27 | You need to enable JavaScript to run this app.
28 |
29 |
30 |
40 |
41 |
42 |
--------------------------------------------------------------------------------
/src/providers/data/fetch-wrapper.ts:
--------------------------------------------------------------------------------
1 | import { GraphQLFormattedError } from "graphql";
2 |
3 | type Error = {
4 | message: string;
5 | statusCode: string;
6 | }
7 |
8 | const customFetch = async (url: string, options: RequestInit) => {
9 | const accessToken = localStorage.getItem('access_token');
10 |
11 | const headers = options.headers as Record;
12 |
13 | return await fetch(url,{
14 | ...options,
15 | headers: {
16 | ...headers,
17 | Authorization: headers?.Authorization || `Bearer ${accessToken}`,
18 | "Content-Type": "application/json",
19 | "Apollo-Require-Preflight": "true",
20 | }
21 | })
22 | }
23 |
24 | const getGraphQLErrors = (body: Record<"errors", GraphQLFormattedError[] | undefined>): Error | null => {
25 | if(!body) {
26 | return {
27 | message: 'Unknown error',
28 | statusCode: "INTERNAL_SERVER_ERROR"
29 | }
30 | }
31 |
32 | if("errors" in body) {
33 | const errors = body?.errors;
34 |
35 | const messages = errors?.map((error) => error?.message)?.join("");
36 | const code = errors?.[0]?.extensions?.code;
37 |
38 | return {
39 | message: messages || JSON.stringify(errors),
40 | statusCode: code || 500
41 | }
42 | }
43 |
44 | return null;
45 | }
46 |
47 | export const fetchWrapper = async (url: string, options: RequestInit) => {
48 | const response = await customFetch(url, options);
49 |
50 | const responseClone = response.clone();
51 | const body = await responseClone.json();
52 |
53 | const error = getGraphQLErrors(body);
54 |
55 | if(error) {
56 | throw error;
57 | }
58 |
59 | return response;
60 | }
--------------------------------------------------------------------------------
/src/components/tags/contact-status-tag.tsx:
--------------------------------------------------------------------------------
1 | import React from "react";
2 |
3 | import {
4 | CheckCircleOutlined,
5 | MinusCircleOutlined,
6 | PlayCircleFilled,
7 | PlayCircleOutlined,
8 | } from "@ant-design/icons";
9 | import { Tag, TagProps } from "antd";
10 |
11 | import { ContactStatus } from "@/graphql/schema.types";
12 |
13 | type Props = {
14 | status: ContactStatus;
15 | };
16 |
17 | /**
18 | * Renders a tag component representing the contact status.
19 | * @param status - The contact status.
20 | */
21 | export const ContactStatusTag = ({ status }: Props) => {
22 | let icon: React.ReactNode = null;
23 | let color: TagProps["color"] = undefined;
24 |
25 | switch (status) {
26 | case "NEW":
27 | case "CONTACTED":
28 | case "INTERESTED":
29 | icon = ;
30 | color = "cyan";
31 | break;
32 |
33 | case "UNQUALIFIED":
34 | icon = ;
35 | color = "red";
36 | break;
37 |
38 | case "QUALIFIED":
39 | case "NEGOTIATION":
40 | icon = ;
41 | color = "green";
42 | break;
43 |
44 | case "LOST":
45 | icon = ;
46 | color = "red";
47 | break;
48 |
49 | case "WON":
50 | icon = ;
51 | color = "green";
52 | break;
53 |
54 | case "CHURNED":
55 | icon = ;
56 | color = "red";
57 | break;
58 |
59 | default:
60 | break;
61 | }
62 |
63 | return (
64 |
65 | {icon} {status.toLowerCase()}
66 |
67 | );
68 | };
69 |
--------------------------------------------------------------------------------
/src/components/skeleton/kanban.tsx:
--------------------------------------------------------------------------------
1 | import { Button, Skeleton, Space } from "antd";
2 | import { MoreOutlined, PlusOutlined } from "@ant-design/icons";
3 |
4 | const KanbanColumnSkeleton = ({ children }: React.PropsWithChildren) => {
5 | return (
6 |
13 |
18 |
24 |
25 |
35 | }
36 | />
37 | } />
38 |
39 |
40 |
47 |
55 | {children}
56 |
57 |
58 |
59 | );
60 | };
61 |
62 | export default KanbanColumnSkeleton;
--------------------------------------------------------------------------------
/src/components/accordion.tsx:
--------------------------------------------------------------------------------
1 | import { AccordionHeaderSkeleton } from "@/components";
2 | import { Text } from "./text";
3 |
4 | type Props = React.PropsWithChildren<{
5 | accordionKey: string;
6 | activeKey?: string;
7 | setActive: (key?: string) => void;
8 | fallback: string | React.ReactNode;
9 | isLoading?: boolean;
10 | icon: React.ReactNode;
11 | label: string;
12 | }>;
13 |
14 | /**
15 | * when activeKey is equal to accordionKey, the children will be rendered. Otherwise, the fallback will be rendered
16 | * when isLoading is true, the will be rendered
17 | * when Accordion is clicked, setActive will be called with the accordionKey
18 | */
19 | export const Accordion = ({
20 | accordionKey,
21 | activeKey,
22 | setActive,
23 | fallback,
24 | icon,
25 | label,
26 | children,
27 | isLoading,
28 | }: Props) => {
29 | if (isLoading) return ;
30 |
31 | const isActive = activeKey === accordionKey;
32 |
33 | const toggleAccordion = () => {
34 | if (isActive) {
35 | setActive(undefined);
36 | } else {
37 | setActive(accordionKey);
38 | }
39 | };
40 |
41 | return (
42 |
51 |
{icon}
52 | {isActive ? (
53 |
61 |
62 | {label}
63 |
64 | {children}
65 |
66 | ) : (
67 |
68 | {fallback}
69 |
70 | )}
71 |
72 | );
73 | };
74 |
--------------------------------------------------------------------------------
/src/components/layout/current-user.tsx:
--------------------------------------------------------------------------------
1 | import { Popover, Button } from 'antd'
2 | import CustomAvatar from '../custom-avatar'
3 | import { useGetIdentity } from '@refinedev/core'
4 |
5 | import type { User } from '@/graphql/schema.types'
6 | import { Text } from '../text'
7 | import { SettingOutlined } from '@ant-design/icons'
8 | import { useState } from 'react'
9 | import { AccountSettings } from './account-settings'
10 |
11 | const CurrentUser = () => {
12 | const [isOpen, setIsOpen] = useState(false)
13 | const { data: user } = useGetIdentity()
14 |
15 | const content = (
16 |
20 |
24 | {user?.name}
25 |
26 |
35 | }
38 | type="text"
39 | block
40 | onClick={() => setIsOpen(true)}
41 | >
42 | Account Settings
43 |
44 |
45 |
46 | )
47 |
48 | return (
49 | <>
50 |
57 |
63 |
64 | {user && (
65 |
70 | )}
71 | >
72 | )
73 | }
74 |
75 | export default CurrentUser
--------------------------------------------------------------------------------
/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "heavy-baths-sort",
3 | "version": "0.1.0",
4 | "private": true,
5 | "type": "module",
6 | "dependencies": {
7 | "@ant-design/icons": "^5.0.1",
8 | "@ant-design/plots": "^1.2.5",
9 | "@dnd-kit/core": "^6.1.0",
10 | "@refinedev/antd": "^5.37.1",
11 | "@refinedev/cli": "^2.16.21",
12 | "@refinedev/core": "^4.46.1",
13 | "@refinedev/devtools": "^1.1.29",
14 | "@refinedev/inferencer": "^4.5.16",
15 | "@refinedev/kbar": "^1.3.5",
16 | "@refinedev/nestjs-query": "^1.1.1",
17 | "@refinedev/react-router-v6": "^4.5.5",
18 | "@uiw/react-md-editor": "^4.0.3",
19 | "antd": "^5.0.5",
20 | "dayjs": "^1.11.10",
21 | "react": "^18.0.0",
22 | "react-dom": "^18.0.0",
23 | "react-router-dom": "^6.8.1"
24 | },
25 | "devDependencies": {
26 | "@graphql-codegen/cli": "^5.0.0",
27 | "@graphql-codegen/import-types-preset": "^3.0.0",
28 | "@graphql-codegen/typescript": "^4.0.1",
29 | "@graphql-codegen/typescript-operations": "^4.0.1",
30 | "@types/node": "^18.16.2",
31 | "@types/react": "^18.0.0",
32 | "@types/react-dom": "^18.0.0",
33 | "@typescript-eslint/eslint-plugin": "^5.57.1",
34 | "@typescript-eslint/parser": "^5.57.1",
35 | "@vitejs/plugin-react": "^4.0.0",
36 | "eslint": "^8.38.0",
37 | "eslint-plugin-react-hooks": "^4.6.0",
38 | "eslint-plugin-react-refresh": "^0.3.4",
39 | "prettier": "^3.2.1",
40 | "typescript": "^4.9.5",
41 | "vite": "^4.3.1",
42 | "vite-tsconfig-paths": "^4.2.3"
43 | },
44 | "scripts": {
45 | "dev": "refine dev",
46 | "build": "tsc && refine build",
47 | "preview": "refine start",
48 | "refine": "refine",
49 | "codegen": "graphql-codegen"
50 | },
51 | "browserslist": {
52 | "production": [
53 | ">0.2%",
54 | "not dead",
55 | "not op_mini all"
56 | ],
57 | "development": [
58 | "last 1 chrome version",
59 | "last 1 firefox version",
60 | "last 1 safari version"
61 | ]
62 | },
63 | "refine": {
64 | "projectId": "WVyLEd-4karEq-tItoeC"
65 | }
66 | }
67 |
--------------------------------------------------------------------------------
/src/components/text.tsx:
--------------------------------------------------------------------------------
1 | import React from "react";
2 |
3 | import { ConfigProvider, Typography } from "antd";
4 |
5 | export type TextProps = {
6 | size?:
7 | | "xs"
8 | | "sm"
9 | | "md"
10 | | "lg"
11 | | "xl"
12 | | "xxl"
13 | | "xxxl"
14 | | "huge"
15 | | "xhuge"
16 | | "xxhuge";
17 | } & React.ComponentProps;
18 |
19 | // define the font sizes and line heights
20 | const sizes = {
21 | xs: {
22 | fontSize: 12,
23 | lineHeight: 20 / 12,
24 | },
25 | sm: {
26 | fontSize: 14,
27 | lineHeight: 22 / 14,
28 | },
29 | md: {
30 | fontSize: 16,
31 | lineHeight: 24 / 16,
32 | },
33 | lg: {
34 | fontSize: 20,
35 | lineHeight: 28 / 20,
36 | },
37 | xl: {
38 | fontSize: 24,
39 | lineHeight: 32 / 24,
40 | },
41 | xxl: {
42 | fontSize: 30,
43 | lineHeight: 38 / 30,
44 | },
45 | xxxl: {
46 | fontSize: 38,
47 | lineHeight: 46 / 38,
48 | },
49 | huge: {
50 | fontSize: 46,
51 | lineHeight: 54 / 46,
52 | },
53 | xhuge: {
54 | fontSize: 56,
55 | lineHeight: 64 / 56,
56 | },
57 | xxhuge: {
58 | fontSize: 68,
59 | lineHeight: 76 / 68,
60 | },
61 | };
62 |
63 | // a custom Text component that wraps/extends the antd Typography.Text component
64 | export const Text = ({ size = "sm", children, ...rest }: TextProps) => {
65 | return (
66 | // config provider is a top-level component that allows us to customize the global properties of antd components. For example, default antd theme
67 | // token is a term used by antd to refer to the design tokens like font size, font weight, color, etc
68 | // https://ant.design/docs/react/customize-theme#customize-design-token
69 |
76 | {/**
77 | * Typography.Text is a component from antd that allows us to render text
78 | * Typography has different components like Title, Paragraph, Text, Link, etc
79 | * https://ant.design/components/typography/#Typography.Text
80 | */}
81 | {children}
82 |
83 | );
84 | };
85 |
--------------------------------------------------------------------------------
/src/pages/home/index.tsx:
--------------------------------------------------------------------------------
1 | import { DashboardTotalCountCard, DealsChart, LatestActivities, UpcomingEvents } from "@/components"
2 | import { DASHBOARD_TOTAL_COUNTS_QUERY } from "@/graphql/queries"
3 | import { DashboardTotalCountsQuery } from "@/graphql/types"
4 | import { useCustom } from "@refinedev/core"
5 | import { Col, Row } from "antd"
6 |
7 | export const Home = () => {
8 | const { data, isLoading } = useCustom({
9 | url: '',
10 | method: 'get',
11 | meta: {
12 | gqlQuery: DASHBOARD_TOTAL_COUNTS_QUERY
13 | }
14 | })
15 |
16 | return (
17 |
18 |
19 |
20 |
25 |
26 |
27 |
32 |
33 |
34 |
39 |
40 |
41 |
42 |
48 |
56 |
57 |
58 |
66 |
67 |
68 |
69 |
70 |
76 |
77 |
78 |
79 |
80 |
81 | )
82 | }
--------------------------------------------------------------------------------
/src/components/home/deals-chart.tsx:
--------------------------------------------------------------------------------
1 | import { DollarOutlined } from '@ant-design/icons'
2 | import { Card } from 'antd'
3 | import React from 'react'
4 | import { Text } from '../text'
5 | import { Area, AreaConfig } from '@ant-design/plots'
6 | import { useList } from '@refinedev/core'
7 | import { DASHBOARD_DEALS_CHART_QUERY } from '@/graphql/queries'
8 | import { mapDealsData } from '@/utilities/helpers'
9 | import { GetFieldsFromList } from '@refinedev/nestjs-query'
10 | import { DashboardDealsChartQuery } from '@/graphql/types'
11 |
12 | const DealsChart = () => {
13 | const { data } = useList>({
14 | resource: 'dealStages',
15 | filters: [
16 | {
17 | field: 'title', operator: 'in', value: ['WON', 'LOST']
18 | }
19 | ],
20 | meta: {
21 | gqlQuery: DASHBOARD_DEALS_CHART_QUERY
22 | }
23 | });
24 |
25 | const dealData = React.useMemo(() => {
26 | return mapDealsData(data?.data);
27 | }, [data?.data])
28 |
29 | const config: AreaConfig = {
30 | data: dealData,
31 | xField: 'timeText',
32 | yField: 'value',
33 | isStack: false,
34 | seriesField: 'state',
35 | animation: true,
36 | startOnZero: false,
37 | smooth: true,
38 | legend: {
39 | offsetY: -6
40 | },
41 | yAxis: {
42 | tickCount: 4,
43 | label: {
44 | formatter: (v: string) => {
45 | return `$${Number(v) /1000}k`
46 | }
47 | }
48 | },
49 | tooltip: {
50 | formatter: (data) => {
51 | return {
52 | name: data.state,
53 | value: `$${Number(data.value) / 1000}k`
54 | }
55 | }
56 | },
57 | }
58 |
59 | return (
60 |
72 |
73 |
74 | Deals
75 |
76 |
77 | }
78 | >
79 |
80 |
81 | )
82 | }
83 |
84 | export default DealsChart
--------------------------------------------------------------------------------
/src/components/tasks/kanban/column.tsx:
--------------------------------------------------------------------------------
1 | import { Text } from '@/components/text'
2 | import { PlusOutlined } from '@ant-design/icons'
3 | import { UseDroppableArguments, useDroppable } from '@dnd-kit/core'
4 | import { Badge, Button, Space } from 'antd'
5 |
6 | type Props = {
7 | id: string,
8 | title: string,
9 | description?: React.ReactNode,
10 | count: number,
11 | data?: UseDroppableArguments['data'],
12 | onAddClick?: (args: { id: string }) => void,
13 | }
14 |
15 | const KanbanColumn = ({
16 | children,
17 | id,
18 | title,
19 | description,
20 | count,
21 | data,
22 | onAddClick
23 | }: React.PropsWithChildren) => {
24 | const { isOver, setNodeRef, active } = useDroppable({ id, data })
25 |
26 | const onAddClickHandler = () => {
27 | onAddClick?.({ id })
28 | }
29 |
30 | return (
31 |
39 |
40 |
41 |
42 |
51 | {title}
52 |
53 | {!!count && }
54 |
55 | }
58 | onClick={onAddClickHandler}
59 | />
60 |
61 | {description}
62 |
63 |
72 |
80 | {children}
81 |
82 |
83 |
84 | )
85 | }
86 |
87 | export default KanbanColumn
--------------------------------------------------------------------------------
/src/graphql/mutations.ts:
--------------------------------------------------------------------------------
1 | import gql from "graphql-tag";
2 |
3 | // Mutation to update user
4 | export const UPDATE_USER_MUTATION = gql`
5 | # The ! after the type means that it is required
6 | mutation UpdateUser($input: UpdateOneUserInput!) {
7 | # call the updateOneUser mutation with the input and pass the $input argument
8 | # $variableName is a convention for GraphQL variables
9 | updateOneUser(input: $input) {
10 | id
11 | name
12 | avatarUrl
13 | email
14 | phone
15 | jobTitle
16 | }
17 | }
18 | `;
19 |
20 | // Mutation to create company
21 | export const CREATE_COMPANY_MUTATION = gql`
22 | mutation CreateCompany($input: CreateOneCompanyInput!) {
23 | createOneCompany(input: $input) {
24 | id
25 | salesOwner {
26 | id
27 | }
28 | }
29 | }
30 | `;
31 |
32 | // Mutation to update company details
33 | export const UPDATE_COMPANY_MUTATION = gql`
34 | mutation UpdateCompany($input: UpdateOneCompanyInput!) {
35 | updateOneCompany(input: $input) {
36 | id
37 | name
38 | totalRevenue
39 | industry
40 | companySize
41 | businessType
42 | country
43 | website
44 | avatarUrl
45 | salesOwner {
46 | id
47 | name
48 | avatarUrl
49 | }
50 | }
51 | }
52 | `;
53 |
54 | // Mutation to update task stage of a task
55 | export const UPDATE_TASK_STAGE_MUTATION = gql`
56 | mutation UpdateTaskStage($input: UpdateOneTaskInput!) {
57 | updateOneTask(input: $input) {
58 | id
59 | }
60 | }
61 | `;
62 |
63 | // Mutation to create a new task
64 | export const CREATE_TASK_MUTATION = gql`
65 | mutation CreateTask($input: CreateOneTaskInput!) {
66 | createOneTask(input: $input) {
67 | id
68 | title
69 | stage {
70 | id
71 | title
72 | }
73 | }
74 | }
75 | `;
76 |
77 | // Mutation to update a task details
78 | export const UPDATE_TASK_MUTATION = gql`
79 | mutation UpdateTask($input: UpdateOneTaskInput!) {
80 | updateOneTask(input: $input) {
81 | id
82 | title
83 | completed
84 | description
85 | dueDate
86 | stage {
87 | id
88 | title
89 | }
90 | users {
91 | id
92 | name
93 | avatarUrl
94 | }
95 | checklist {
96 | title
97 | checked
98 | }
99 | }
100 | }
101 | `;
102 |
--------------------------------------------------------------------------------
/src/utilities/helpers.ts:
--------------------------------------------------------------------------------
1 | import dayjs from "dayjs";
2 | import { GetFieldsFromList } from "@refinedev/nestjs-query";
3 |
4 | import { DashboardDealsChartQuery } from "@/graphql/types";
5 |
6 | type DealStage = GetFieldsFromList;
7 |
8 | type DealAggregate = DealStage["dealsAggregate"][0];
9 |
10 | interface MappedDealData {
11 | timeUnix: number;
12 | timeText: string;
13 | value: number;
14 | state: string;
15 | }
16 |
17 | // Get the date in the format "MMM DD, YYYY - HH:mm"
18 | export const getDate = (startDate: string, endDate: string) => {
19 | const start = dayjs(startDate).format("MMM DD, YYYY - HH:mm");
20 | const end = dayjs(endDate).format("MMM DD, YYYY - HH:mm");
21 |
22 | return `${start} - ${end}`;
23 | };
24 |
25 | // Filter out deals that don't have a closeDateMonth or closeDateYear
26 | const filterDeal = (deal?: DealAggregate) =>
27 | deal?.groupBy && deal.groupBy?.closeDateMonth && deal.groupBy?.closeDateYear;
28 |
29 | const mapDeals = (
30 | deals: DealAggregate[] = [],
31 | state: string
32 | ): MappedDealData[] => {
33 | // filter out deals that don't have a closeDateMonth or closeDateYear
34 | return deals.filter(filterDeal).map((deal) => {
35 | // Get the closeDateMonth and closeDateYear from the deal
36 | const { closeDateMonth, closeDateYear } = deal.groupBy as NonNullable<
37 | DealAggregate["groupBy"]
38 | >;
39 |
40 | // Create a date object from the closeDateMonth and closeDateYear
41 | const date = dayjs(`${closeDateYear}-${closeDateMonth}-01`);
42 |
43 | // Return the mapped deal data
44 | return {
45 | // Convert the date to a unix timestamp i.e., 1622505600000
46 | timeUnix: date.unix(),
47 | // Convert the date to a string i.e., "May 2021"
48 | timeText: date.format("MMM YYYY"),
49 | // Get the sum of all deals in this stage
50 | value: deal.sum?.value ?? 0,
51 | state,
52 | };
53 | });
54 | };
55 |
56 | // Map deals data to the format required by the chart
57 | export const mapDealsData = (
58 | dealStages: DealStage[] = []
59 | ): MappedDealData[] => {
60 | // Get the deal stage with the title "WON"
61 | const won = dealStages.find((stage) => stage.title === "WON");
62 | const wonDeals = mapDeals(won?.dealsAggregate, "Won");
63 |
64 | // Get the deal stage with the title "LOST"
65 | const lost = dealStages.find((stage) => stage.title === "LOST");
66 | const lostDeals = mapDeals(lost?.dealsAggregate, "Lost");
67 |
68 | // Combine the won and lost deals and sort them by time
69 | return [...wonDeals, ...lostDeals].sort((a, b) => a.timeUnix - b.timeUnix);
70 | };
71 |
--------------------------------------------------------------------------------
/src/components/tasks/form/header.tsx:
--------------------------------------------------------------------------------
1 | import { MarkdownField } from "@refinedev/antd";
2 |
3 | import { Typography, Space, Tag } from "antd";
4 |
5 | import dayjs from "dayjs";
6 |
7 | import { Text, UserTag } from "@/components";
8 | import { getDateColor } from "@/utilities";
9 |
10 | import { Task } from "@/graphql/schema.types";
11 |
12 | type DescriptionProps = {
13 | description?: Task["description"];
14 | };
15 |
16 | type DueDateProps = {
17 | dueData?: Task["dueDate"];
18 | };
19 |
20 | type UserProps = {
21 | users?: Task["users"];
22 | };
23 |
24 | // display a task's descriptio if it exists, otherwise display a link to add one
25 | export const DescriptionHeader = ({ description }: DescriptionProps) => {
26 | if (description) {
27 | return (
28 |
29 |
30 |
31 | );
32 | }
33 |
34 | // if the task doesn't have a description, display a link to add one
35 | return Add task description ;
36 | };
37 |
38 | // display a task's due date if it exists, otherwise display a link to add one
39 | export const DueDateHeader = ({ dueData }: DueDateProps) => {
40 | if (dueData) {
41 | // get the color of the due date
42 | const color = getDateColor({
43 | date: dueData,
44 | defaultColor: "processing",
45 | });
46 |
47 | // depending on the due date, display a different color and text
48 | const getTagText = () => {
49 | switch (color) {
50 | case "error":
51 | return "Overdue";
52 |
53 | case "warning":
54 | return "Due soon";
55 |
56 | default:
57 | return "Processing";
58 | }
59 | };
60 |
61 | return (
62 |
63 | {getTagText()}
64 | {dayjs(dueData).format("MMMM D, YYYY - h:ma")}
65 |
66 | );
67 | }
68 |
69 | // if the task doesn't have a due date, display a link to add one
70 | return Add due date ;
71 | };
72 |
73 | // display a task's users if it exists, otherwise display a link to add one
74 | export const UsersHeader = ({ users = [] }: UserProps) => {
75 | if (users.length > 0) {
76 | return (
77 |
78 | {users.map((user) => (
79 |
80 | ))}
81 |
82 | );
83 | }
84 |
85 | // if the task doesn't have users, display a link to add one
86 | return Assign to users ;
87 | };
88 |
--------------------------------------------------------------------------------
/src/pages/company/create.tsx:
--------------------------------------------------------------------------------
1 | import React from 'react'
2 | import { CompanyList } from './list'
3 | import { Form, Input, Modal, Select } from 'antd'
4 | import { useModalForm, useSelect } from '@refinedev/antd'
5 | import { useGo } from '@refinedev/core'
6 | import { CREATE_COMPANY_MUTATION } from '@/graphql/mutations'
7 | import { USERS_SELECT_QUERY } from '@/graphql/queries'
8 | import SelectOptionWithAvatar from '@/components/select-option-with-avatar'
9 | import { GetFieldsFromList } from '@refinedev/nestjs-query'
10 | import { UsersSelectQuery } from '@/graphql/types'
11 |
12 | const Create = () => {
13 | const go = useGo();
14 |
15 | const goToListPage = () => {
16 | go({
17 | to: { resource: 'companies', action: 'list' },
18 | options: { keepQuery: true },
19 | type: 'replace',
20 | })
21 | }
22 |
23 | const { formProps, modalProps } = useModalForm({
24 | action: 'create',
25 | defaultVisible: true,
26 | resource: 'companies',
27 | redirect: false,
28 | mutationMode: 'pessimistic',
29 | onMutationSuccess: goToListPage,
30 | meta: {
31 | gqlMutation: CREATE_COMPANY_MUTATION
32 | }
33 | })
34 |
35 | const { selectProps, queryResult } = useSelect>({
36 | resource: 'users',
37 | optionLabel: 'name',
38 | meta: {
39 | gqlQuery: USERS_SELECT_QUERY
40 | }
41 | })
42 |
43 | return (
44 |
45 |
52 |
58 |
59 |
60 |
65 | ({
70 | value: user.id,
71 | label: (
72 |
76 | )
77 | })) ?? []
78 | }
79 | />
80 |
81 |
82 |
83 |
84 | )
85 | }
86 |
87 | export default Create
--------------------------------------------------------------------------------
/src/components/tasks/form/description.tsx:
--------------------------------------------------------------------------------
1 | import { useForm } from "@refinedev/antd";
2 | import { HttpError } from "@refinedev/core";
3 | import { GetFields, GetVariables } from "@refinedev/nestjs-query";
4 |
5 | import MDEditor from "@uiw/react-md-editor";
6 | import { Button, Form, Space } from "antd";
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 | description?: Task["description"];
19 | };
20 | cancelForm: () => void;
21 | };
22 |
23 | export const DescriptionForm = ({ 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, "description">
39 | >({
40 | queryOptions: {
41 | // we are disabling the query because we don't want to fetch the data on component mount.
42 | enabled: false, // disable the query
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 | <>
57 |
59 |
60 |
61 |
62 |
70 |
71 |
72 | Cancel
73 |
74 |
75 | Save
76 |
77 |
78 |
79 | >
80 | );
81 | };
82 |
--------------------------------------------------------------------------------
/src/components/home/total-count-card.tsx:
--------------------------------------------------------------------------------
1 | import { totalCountVariants } from "@/constants"
2 | import { Card, Skeleton } from "antd"
3 | import { Text } from "../text"
4 | import { Area, AreaConfig } from "@ant-design/plots"
5 |
6 | type Props = {
7 | resource: "companies" | "contacts" | "deals",
8 | isLoading: boolean,
9 | totalCount?: number
10 | }
11 |
12 | const DashboardTotalCountCard = ({
13 | resource,
14 | isLoading,
15 | totalCount
16 | }: Props) => {
17 | const { primaryColor, secondaryColor, icon, title } = totalCountVariants[resource];
18 |
19 | const config: AreaConfig = {
20 | data: totalCountVariants[resource].data,
21 | xField: 'index',
22 | yField: 'value',
23 | appendPadding: [1, 0, 0, 0],
24 | padding: 0,
25 | syncViewPadding: true,
26 | autoFit: true,
27 | tooltip: false,
28 | animation: false,
29 | xAxis: false,
30 | yAxis: {
31 | tickCount: 12,
32 | label: {
33 | style: {
34 | stroke: 'transparent'
35 | }
36 | },
37 | grid: {
38 | line: {
39 | style: {
40 | stroke: 'transparent'
41 | }
42 | }
43 | }
44 | },
45 | smooth: true,
46 | line: {
47 | color: primaryColor,
48 | },
49 | areaStyle: () => {
50 | return {
51 | fill: `l(270) 0:#fff 0.2${secondaryColor} 1:${primaryColor}`
52 | }
53 | }
54 | }
55 |
56 | return (
57 |
62 |
70 | {icon}
71 |
72 | {title}
73 |
74 |
75 |
78 |
90 | {isLoading ? (
91 |
97 | ) : (
98 | totalCount
99 | )}
100 |
101 |
102 |
103 |
104 | )
105 | }
106 |
107 | export default DashboardTotalCountCard
--------------------------------------------------------------------------------
/src/components/home/upcoming-events.tsx:
--------------------------------------------------------------------------------
1 | import { CalendarOutlined } from '@ant-design/icons'
2 | import { Badge, Card, List } from 'antd'
3 | import { Text } from '../text'
4 | import UpcomingEventsSkeleton from '../skeleton/upcoming-events';
5 | import { getDate } from '@/utilities/helpers';
6 | import { useList } from '@refinedev/core';
7 | import { DASHBORAD_CALENDAR_UPCOMING_EVENTS_QUERY } from '@/graphql/queries';
8 | import dayjs from 'dayjs';
9 |
10 | const UpcomingEvents = () => {
11 | const { data, isLoading } = useList({
12 | resource: 'events',
13 | pagination: { pageSize: 5},
14 | sorters: [
15 | {
16 | field: 'startDate',
17 | order: 'asc'
18 | }
19 | ],
20 | filters: [
21 | {
22 | field: 'startDate',
23 | operator: 'gte',
24 | value: dayjs().format('YYYY-MM-DD')
25 | }
26 | ],
27 | meta: {
28 | gqlQuery: DASHBORAD_CALENDAR_UPCOMING_EVENTS_QUERY
29 | }
30 | });
31 |
32 | return (
33 |
43 |
44 |
45 | Upcoming Events
46 |
47 |
48 | }
49 | >
50 | {isLoading ? (
51 | ({
54 | id: index,
55 | }))}
56 | renderItem={() => }
57 | />
58 | ) : (
59 | {
63 | const renderDate = getDate(item.startDate, item.endDate)
64 |
65 | return (
66 |
67 | }
69 | title={{renderDate} }
70 | description={
71 | {item.title}
72 | }
73 | />
74 |
75 | )
76 | }}
77 | />
78 | )}
79 |
80 | {!isLoading && data?.data.length === 0 && (
81 |
89 | No upcoming events
90 |
91 | )}
92 |
93 | )
94 | }
95 |
96 | export default UpcomingEvents
--------------------------------------------------------------------------------
/src/pages/tasks/create.tsx:
--------------------------------------------------------------------------------
1 | import { useSearchParams } from "react-router-dom";
2 |
3 | import { useModalForm } from "@refinedev/antd";
4 | import { useNavigation } from "@refinedev/core";
5 |
6 | import { Form, Input, Modal } from "antd";
7 |
8 | import { CREATE_TASK_MUTATION } from "@/graphql/mutations";
9 |
10 | const TasksCreatePage = () => {
11 | // get search params from the url
12 | const [searchParams] = useSearchParams();
13 |
14 | /**
15 | * useNavigation is a hook by Refine that allows you to navigate to a page.
16 | * https://refine.dev/docs/routing/hooks/use-navigation/
17 | *
18 | * list method navigates to the list page of the specified resource.
19 | * https://refine.dev/docs/routing/hooks/use-navigation/#list
20 | */ const { list } = useNavigation();
21 |
22 | /**
23 | * useModalForm is a hook by Refine that allows you manage a form inside a modal.
24 | * it extends the useForm hook from the @refinedev/antd package
25 | * https://refine.dev/docs/ui-integrations/ant-design/hooks/use-modal-form/
26 | *
27 | * formProps -> It's an instance of HTML form that manages form state and actions like onFinish, onValuesChange, etc.
28 | * Under the hood, it uses the useForm hook from the @refinedev/antd package
29 | * https://refine.dev/docs/ui-integrations/ant-design/hooks/use-modal-form/#formprops
30 | *
31 | * modalProps -> It's an instance of Modal that manages modal state and actions like onOk, onCancel, etc.
32 | * https://refine.dev/docs/ui-integrations/ant-design/hooks/use-modal-form/#modalprops
33 | */
34 | const { formProps, modalProps, close } = useModalForm({
35 | // specify the action to perform i.e., create or edit
36 | action: "create",
37 | // specify whether the modal should be visible by default
38 | defaultVisible: true,
39 | // specify the gql mutation to be performed
40 | meta: {
41 | gqlMutation: CREATE_TASK_MUTATION,
42 | },
43 | });
44 |
45 | return (
46 | {
49 | // close the modal
50 | close();
51 |
52 | // navigate to the list page of the tasks resource
53 | list("tasks", "replace");
54 | }}
55 | title="Add new card"
56 | width={512}
57 | >
58 |
73 |
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 |
{
68 | if (!value) return { value: undefined };
69 | return { value: dayjs(value) };
70 | }}
71 | >
72 |
80 |
81 |
82 |
83 |
84 | Cancel
85 |
86 |
87 | Save
88 |
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 |
84 |
91 |
92 |
93 |
94 |
95 | Cancel
96 |
97 |
98 | Save
99 |
100 |
101 |
102 | );
103 | };
104 |
--------------------------------------------------------------------------------
/src/App.tsx:
--------------------------------------------------------------------------------
1 | import { Authenticated, GitHubBanner, Refine, WelcomePage } from "@refinedev/core";
2 | import { DevtoolsPanel, DevtoolsProvider } from "@refinedev/devtools";
3 | import { RefineKbar, RefineKbarProvider } from "@refinedev/kbar";
4 |
5 | import { useNotificationProvider } from "@refinedev/antd";
6 | import "@refinedev/antd/dist/reset.css";
7 |
8 | import { authProvider, dataProvider, liveProvider } from "./providers";
9 | import { Home, ForgotPassword, Login, Register, CompanyList } from "./pages";
10 |
11 | import routerBindings, {
12 | CatchAllNavigate,
13 | DocumentTitleHandler,
14 | UnsavedChangesNotifier,
15 | } from "@refinedev/react-router-v6";
16 | import { App as AntdApp } from "antd";
17 | import { BrowserRouter, Outlet, Route, Routes } from "react-router-dom";
18 | import Layout from "./components/layout";
19 | import { resources } from "./config/resources";
20 | import Create from "./pages/company/create";
21 | import Edit from "./pages/company/edit";
22 | import List from "./pages/tasks/list";
23 | import EditTask from "./pages/tasks/edit";
24 | import CreateTask from "./pages/tasks/create";
25 |
26 | function App() {
27 | return (
28 |
29 |
30 |
31 |
32 |
47 |
48 | } />
49 | } />
50 | } />
51 | }
56 | >
57 |
58 |
59 |
60 |
61 | }>
62 | } />
63 |
64 | } />
65 | } />
66 | } />
67 |
68 |
70 |
71 |
72 | }>
73 | } />
74 | } />
75 |
76 |
77 |
78 |
79 |
80 |
81 |
82 |
83 |
84 |
85 |
86 |
87 | );
88 | }
89 |
90 | export default App;
91 |
--------------------------------------------------------------------------------
/src/pages/company/edit.tsx:
--------------------------------------------------------------------------------
1 | import { Col, Form, Input, InputNumber, Row, Select } from 'antd'
2 | import { Edit, useForm, useSelect } from '@refinedev/antd'
3 | import { UPDATE_COMPANY_MUTATION } from '@/graphql/mutations';
4 | import CustomAvatar from '@/components/custom-avatar';
5 | import { getNameInitials } from '@/utilities';
6 | import { GetFieldsFromList } from '@refinedev/nestjs-query';
7 | import { UsersSelectQuery } from '@/graphql/types';
8 | import { USERS_SELECT_QUERY } from '@/graphql/queries';
9 | import SelectOptionWithAvatar from '@/components/select-option-with-avatar';
10 | import { businessTypeOptions, companySizeOptions, industryOptions } from '@/constants';
11 | import { CompanyContactsTable } from './contacts-table';
12 |
13 | const EditPage = () => {
14 | const { saveButtonProps, formProps, formLoading, queryResult } = useForm({
15 | redirect: false,
16 | meta: {
17 | gqlMutation: UPDATE_COMPANY_MUTATION
18 | }
19 | });
20 |
21 | const { avatarUrl, name } = queryResult?.data?.data || {}
22 |
23 | const { selectProps, queryResult: queryResultUsers } = useSelect>({
24 | resource: 'users',
25 | optionLabel: 'name',
26 | pagination: {
27 | mode: 'off'
28 | },
29 | meta: {
30 | gqlQuery: USERS_SELECT_QUERY
31 | }
32 | })
33 |
34 | return (
35 |
36 |
37 |
38 |
43 |
50 | ({
55 | value: user.id,
56 | label: (
57 |
61 | )
62 | })) ?? []
63 | }
64 | />
65 |
66 |
67 |
68 |
69 |
70 |
76 |
77 |
78 |
79 |
80 |
81 |
82 |
83 |
84 |
85 |
86 |
87 |
88 |
89 |
90 |
91 |
92 |
93 |
94 |
95 |
96 |
97 | )
98 | }
99 |
100 | export default EditPage
--------------------------------------------------------------------------------
/src/components/home/latest-activities.tsx:
--------------------------------------------------------------------------------
1 | import { UnorderedListOutlined } from '@ant-design/icons'
2 | import { Card, List, Space } from 'antd'
3 | import React from 'react'
4 | import { Text } from '../text'
5 | import LatestActivitiesSkeleton from '../skeleton/latest-activities'
6 | import { useList } from '@refinedev/core'
7 | import { DASHBOARD_LATEST_ACTIVITIES_AUDITS_QUERY, DASHBOARD_LATEST_ACTIVITIES_DEALS_QUERY } from '@/graphql/queries'
8 | import dayjs from 'dayjs'
9 | import CustomAvatar from '../custom-avatar'
10 |
11 | const LatestActivities = () => {
12 | const { data: audit, isLoading: isLoadingAudit, isError, error } = useList({
13 | resource: 'audits',
14 | meta: {
15 | gqlQuery: DASHBOARD_LATEST_ACTIVITIES_AUDITS_QUERY
16 | }
17 | })
18 |
19 | const dealIds = audit?.data?.map((audit) => audit?.targetId);
20 |
21 | const { data: deals, isLoading: isLoadingDeals } = useList({
22 | resource: 'deals',
23 | queryOptions: { enabled: !!dealIds?.length },
24 | pagination: {
25 | mode: 'off'
26 | },
27 | filters: [{ field: 'id', operator: 'in', value: dealIds }],
28 | meta: {
29 | gqlQuery: DASHBOARD_LATEST_ACTIVITIES_DEALS_QUERY
30 | }
31 | })
32 |
33 | if(isError) {
34 | console.log(error);
35 | return null;
36 | }
37 |
38 | const isLoading = isLoadingAudit || isLoadingDeals;
39 |
40 | return (
41 |
46 |
47 |
48 | Latest Activities
49 |
50 |
51 | )}
52 | >
53 | {isLoading ? (
54 | ({ id: i}))}
58 | renderItem={(_, index) => (
59 |
60 | )}
61 | />
62 | ): (
63 | {
67 | const deal = deals?.data.find(
68 | (deal) => deal.id === String(item.targetId)
69 | ) || undefined;
70 |
71 | return (
72 |
73 |
82 | }
83 | description={
84 |
85 | {item.user?.name}
86 |
87 | {item.action === 'CREATE' ? 'created' : 'moved'}
88 |
89 | {deal?.title}
90 | deal
91 | {item.action === 'CREATE' ? 'in' : 'to'}
92 |
93 | {deal?.stage?.title}
94 |
95 |
96 | }
97 | />
98 |
99 | )
100 | }}
101 | />
102 | )}
103 |
104 | )
105 | }
106 |
107 | export default LatestActivities
--------------------------------------------------------------------------------
/src/pages/company/list.tsx:
--------------------------------------------------------------------------------
1 | import CustomAvatar from '@/components/custom-avatar';
2 | import { Text } from '@/components/text';
3 | import { COMPANIES_LIST_QUERY } from '@/graphql/queries';
4 | import { Company } from '@/graphql/schema.types';
5 | import { CompaniesListQuery } from '@/graphql/types';
6 | import { currencyNumber } from '@/utilities';
7 | import { SearchOutlined } from '@ant-design/icons';
8 | import { CreateButton, DeleteButton, EditButton, FilterDropdown, List, useTable } from '@refinedev/antd'
9 | import { HttpError, getDefaultFilter, useGo } from '@refinedev/core'
10 | import { GetFieldsFromList } from '@refinedev/nestjs-query';
11 | import { Input, Space, Table } from 'antd';
12 |
13 | export const CompanyList = ({ children }: React.PropsWithChildren) => {
14 | const go = useGo();
15 | const { tableProps, filters } = useTable<
16 | GetFieldsFromList,
17 | HttpError,
18 | GetFieldsFromList
19 | >({
20 | resource: 'companies',
21 | onSearch: (values) => {
22 | return [
23 | {
24 | field: 'name',
25 | operator: 'contains',
26 | value: values.name
27 | }
28 | ]
29 | },
30 | pagination: {
31 | pageSize: 12,
32 | },
33 | sorters: {
34 | initial: [
35 | {
36 | field: 'createdAt',
37 | order: 'desc'
38 | }
39 | ]
40 | },
41 | filters: {
42 | initial: [
43 | {
44 | field: 'name',
45 | operator: 'contains',
46 | value: undefined
47 | }
48 | ]
49 | },
50 | meta: {
51 | gqlQuery: COMPANIES_LIST_QUERY
52 | }
53 | })
54 |
55 | return (
56 |
57 |
(
60 | {
62 | go({
63 | to: {
64 | resource: 'companies',
65 | action: 'create'
66 | },
67 | options: {
68 | keepQuery: true
69 | },
70 | type: 'replace'
71 | })
72 | }}
73 | />
74 | )}
75 | >
76 |
122 |
123 | {children}
124 |
125 | )
126 | }
127 |
--------------------------------------------------------------------------------
/src/components/tasks/form/title.tsx:
--------------------------------------------------------------------------------
1 | import React from "react";
2 |
3 | import { useForm } from "@refinedev/antd";
4 | import { HttpError, useInvalidate } from "@refinedev/core";
5 | import { GetFields, GetVariables } from "@refinedev/nestjs-query";
6 |
7 | import { Form, Skeleton } from "antd";
8 |
9 | import { Text } from "@/components";
10 | import { Task } from "@/graphql/schema.types";
11 | import {
12 | UpdateTaskMutation,
13 | UpdateTaskMutationVariables,
14 | } from "@/graphql/types";
15 |
16 | import { UPDATE_TASK_MUTATION } from "@/graphql/mutations";
17 |
18 | const TitleInput = ({
19 | value,
20 | onChange,
21 | }: {
22 | value?: string;
23 | onChange?: (value: string) => void;
24 | }) => {
25 | const onTitleChange = (newTitle: string) => {
26 | onChange?.(newTitle);
27 | };
28 |
29 | return (
30 |
36 | {value}
37 |
38 | );
39 | };
40 |
41 | type Props = {
42 | initialValues: {
43 | title?: Task["title"];
44 | };
45 | isLoading?: boolean;
46 | };
47 |
48 | export const TitleForm = ({ initialValues, isLoading }: Props) => {
49 | /**
50 | * useInvalidate is used to invalidate the state of a particular resource or dataProvider
51 | * Means, it will refetch the data from the server and update the state of the resource or dataProvider. We can also specify which part of the state we want to invalidate.
52 | * We typically use this hook when we want to refetch the data from the server after a mutation is successful.
53 | *
54 | * https://refine.dev/docs/data/hooks/use-invalidate/
55 | */
56 | const invalidate = useInvalidate();
57 |
58 | // use the useForm hook to manage the form for adding a title to a task
59 | const { formProps } = useForm<
60 | GetFields,
61 | HttpError,
62 | /**
63 | * Pick is a utility type from typescript that allows you to create a new type from an existing type by picking some properties from it.
64 | * https://www.typescriptlang.org/docs/handbook/utility-types.html#picktype-keys
65 | *
66 | * Pick
67 | * Type -> the type from which we want to pick the properties
68 | * Keys -> the properties that we want to pick
69 | */
70 | Pick, "title">
71 | >({
72 | queryOptions: {
73 | // disable the query to prevent fetching data on component mount
74 | enabled: false,
75 | },
76 | redirect: false, // disable redirection
77 | warnWhenUnsavedChanges: false, // disable warning when there are unsaved changes
78 | /**
79 | * autoSave is used to automatically save the form when the value of the form changes. It accepts an object with 1 property:
80 | * enabled: boolean - whether to enable autoSave or not
81 | *
82 | * https://refine.dev/docs/ui-integrations/ant-design/hooks/use-form/#autosave
83 | *
84 | * In this case, we are enabling autoSave.
85 | */
86 | autoSave: {
87 | enabled: true,
88 | },
89 | // invalidate the list page of the tasks resource when the mutation is successful
90 | onMutationSuccess: () => {
91 | // refetch the list page of the tasks resource
92 | invalidate({ invalidates: ["list"], resource: "tasks" });
93 | },
94 | meta: {
95 | gqlMutation: UPDATE_TASK_MUTATION,
96 | },
97 | });
98 |
99 | // set the title of the form to the title of the task
100 | React.useEffect(() => {
101 | formProps.form?.setFieldsValue(initialValues);
102 | }, [initialValues.title]);
103 |
104 | if (isLoading) {
105 | return (
106 |
111 | );
112 | }
113 |
114 | return (
115 |
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 |
130 | Mark as complete
131 |
132 |
133 |
134 | );
135 | };
136 |
--------------------------------------------------------------------------------
/src/components/tasks/kanban/card.tsx:
--------------------------------------------------------------------------------
1 | import CustomAvatar from '@/components/custom-avatar'
2 | import { Text } from '@/components/text'
3 | import { TextIcon } from '@/components/text-icon'
4 | import { User } from '@/graphql/schema.types'
5 | import { getDateColor } from '@/utilities'
6 | import { ClockCircleOutlined, DeleteOutlined, EyeOutlined, MoreOutlined } from '@ant-design/icons'
7 | import { useDelete, useNavigation } from '@refinedev/core'
8 | import { Button, Card, ConfigProvider, Dropdown, MenuProps, Space, Tag, Tooltip, theme } from 'antd'
9 | import dayjs from 'dayjs'
10 | import React, { memo, useMemo } from 'react'
11 |
12 | type ProjectCardProps = {
13 | id: string,
14 | title: string,
15 | updatedAt: string,
16 | dueDate?: string,
17 | users?: {
18 | id: string,
19 | name: string,
20 | avatarUrl?: User['avatarUrl']
21 | }[]
22 | }
23 |
24 | const ProjectCard = ({ id, title, dueDate, users }: ProjectCardProps) => {
25 | const { token } = theme.useToken();
26 |
27 | const { edit } = useNavigation();
28 | const { mutate } = useDelete();
29 |
30 | const dropdownItems = useMemo(() => {
31 | const dropdownItems: MenuProps['items'] = [
32 | {
33 | label: 'View card',
34 | key: '1',
35 | icon: ,
36 | onClick: () => {
37 | edit('tasks', id, 'replace')
38 | }
39 | },
40 | {
41 | danger: true,
42 | label: 'Delete card',
43 | key: '2',
44 | icon: ,
45 | onClick: () => {
46 | mutate({
47 | resource: 'tasks',
48 | id,
49 | meta: {
50 | operation: 'task'
51 | }
52 | })
53 | }
54 | }
55 | ]
56 |
57 | return dropdownItems
58 | }, [])
59 |
60 | const dueDateOptions = useMemo(() => {
61 | if(!dueDate) return null;
62 |
63 | const date = dayjs(dueDate);
64 |
65 | return {
66 | color: getDateColor({ date: dueDate}) as string,
67 | text: date.format('MMM DD')
68 | }
69 | }, [dueDate]);
70 |
71 | return (
72 |
84 | {title}}
87 | onClick={() => edit('tasks', id, 'replace')}
88 | extra={
89 | {
94 | e.stopPropagation()
95 | },
96 | onClick: (e) => {
97 | e.domEvent.stopPropagation()
98 | }
99 | }}
100 | placement='bottom'
101 | arrow={{ pointAtCenter: true}}
102 | >
103 |
112 | }
113 | onPointerDown={(e) => {
114 | e.stopPropagation()
115 | }}
116 | onClick={(e) => {
117 | e.stopPropagation()
118 | }}
119 | />
120 |
121 | }
122 | >
123 |
131 |
132 | {dueDateOptions && (
133 |
136 | }
137 | style={{
138 | padding: '0 4px',
139 | marginInlineEnd: '0',
140 | backgroundColor: dueDateOptions.color === 'default' ? 'transparent' : 'unset',
141 | }}
142 | color={dueDateOptions.color}
143 | bordered={dueDateOptions.color !== 'default'}
144 | >
145 | {dueDateOptions.text}
146 |
147 | )}
148 | {!!users?.length && (
149 |
161 | {users.map((user) => (
162 |
163 |
164 |
165 | ))}
166 |
167 | )}
168 |
169 |
170 |
171 | )
172 | }
173 |
174 | export default ProjectCard
175 |
176 | export const ProjectCardMemo = memo(ProjectCard, (prev, next) => {
177 | return (
178 | prev.id === next.id &&
179 | prev.title === next.title &&
180 | prev.dueDate === next.dueDate &&
181 | prev.users?.length === next.users?.length &&
182 | prev.updatedAt === next.updatedAt
183 | )
184 | })
--------------------------------------------------------------------------------
/src/graphql/queries.ts:
--------------------------------------------------------------------------------
1 | import gql from "graphql-tag";
2 |
3 | // Query to get Total Company, Contact and Deal Counts
4 | export const DASHBOARD_TOTAL_COUNTS_QUERY = gql`
5 | query DashboardTotalCounts {
6 | companies {
7 | totalCount
8 | }
9 | contacts {
10 | totalCount
11 | }
12 | deals {
13 | totalCount
14 | }
15 | }
16 | `;
17 |
18 | // Query to get upcoming events
19 | export const DASHBORAD_CALENDAR_UPCOMING_EVENTS_QUERY = gql`
20 | query DashboardCalendarUpcomingEvents(
21 | $filter: EventFilter!
22 | $sorting: [EventSort!]
23 | $paging: OffsetPaging!
24 | ) {
25 | events(filter: $filter, sorting: $sorting, paging: $paging) {
26 | totalCount
27 | nodes {
28 | id
29 | title
30 | color
31 | startDate
32 | endDate
33 | }
34 | }
35 | }
36 | `;
37 |
38 | // Query to get deals chart
39 | export const DASHBOARD_DEALS_CHART_QUERY = gql`
40 | query DashboardDealsChart(
41 | $filter: DealStageFilter!
42 | $sorting: [DealStageSort!]
43 | $paging: OffsetPaging
44 | ) {
45 | dealStages(filter: $filter, sorting: $sorting, paging: $paging) {
46 | # Get all deal stages
47 | nodes {
48 | id
49 | title
50 | # Get the sum of all deals in this stage and group by closeDateMonth and closeDateYear
51 | dealsAggregate {
52 | groupBy {
53 | closeDateMonth
54 | closeDateYear
55 | }
56 | sum {
57 | value
58 | }
59 | }
60 | }
61 | # Get the total count of all deals in this stage
62 | totalCount
63 | }
64 | }
65 | `;
66 |
67 | // Query to get latest activities deals
68 | export const DASHBOARD_LATEST_ACTIVITIES_DEALS_QUERY = gql`
69 | query DashboardLatestActivitiesDeals(
70 | $filter: DealFilter!
71 | $sorting: [DealSort!]
72 | $paging: OffsetPaging
73 | ) {
74 | deals(filter: $filter, sorting: $sorting, paging: $paging) {
75 | totalCount
76 | nodes {
77 | id
78 | title
79 | stage {
80 | id
81 | title
82 | }
83 | company {
84 | id
85 | name
86 | avatarUrl
87 | }
88 | createdAt
89 | }
90 | }
91 | }
92 | `;
93 |
94 | // Query to get latest activities audits
95 | export const DASHBOARD_LATEST_ACTIVITIES_AUDITS_QUERY = gql`
96 | query DashboardLatestActivitiesAudits(
97 | $filter: AuditFilter!
98 | $sorting: [AuditSort!]
99 | $paging: OffsetPaging
100 | ) {
101 | audits(filter: $filter, sorting: $sorting, paging: $paging) {
102 | totalCount
103 | nodes {
104 | id
105 | action
106 | targetEntity
107 | targetId
108 | changes {
109 | field
110 | from
111 | to
112 | }
113 | createdAt
114 | user {
115 | id
116 | name
117 | avatarUrl
118 | }
119 | }
120 | }
121 | }
122 | `;
123 |
124 | // Query to get companies list
125 | export const COMPANIES_LIST_QUERY = gql`
126 | query CompaniesList(
127 | $filter: CompanyFilter!
128 | $sorting: [CompanySort!]
129 | $paging: OffsetPaging!
130 | ) {
131 | companies(filter: $filter, sorting: $sorting, paging: $paging) {
132 | totalCount
133 | nodes {
134 | id
135 | name
136 | avatarUrl
137 | # Get the sum of all deals in this company
138 | dealsAggregate {
139 | sum {
140 | value
141 | }
142 | }
143 | }
144 | }
145 | }
146 | `;
147 |
148 | // Query to get users list
149 | export const USERS_SELECT_QUERY = gql`
150 | query UsersSelect(
151 | $filter: UserFilter!
152 | $sorting: [UserSort!]
153 | $paging: OffsetPaging!
154 | ) {
155 | # Get all users
156 | users(filter: $filter, sorting: $sorting, paging: $paging) {
157 | totalCount # Get the total count of users
158 | # Get specific fields for each user
159 | nodes {
160 | id
161 | name
162 | avatarUrl
163 | }
164 | }
165 | }
166 | `;
167 |
168 | // Query to get contacts associated with a company
169 | export const COMPANY_CONTACTS_TABLE_QUERY = gql`
170 | query CompanyContactsTable(
171 | $filter: ContactFilter!
172 | $sorting: [ContactSort!]
173 | $paging: OffsetPaging!
174 | ) {
175 | contacts(filter: $filter, sorting: $sorting, paging: $paging) {
176 | totalCount
177 | nodes {
178 | id
179 | name
180 | avatarUrl
181 | jobTitle
182 | email
183 | phone
184 | status
185 | }
186 | }
187 | }
188 | `;
189 |
190 | // Query to get task stages list
191 | export const TASK_STAGES_QUERY = gql`
192 | query TaskStages(
193 | $filter: TaskStageFilter!
194 | $sorting: [TaskStageSort!]
195 | $paging: OffsetPaging!
196 | ) {
197 | taskStages(filter: $filter, sorting: $sorting, paging: $paging) {
198 | totalCount # Get the total count of task stages
199 | nodes {
200 | id
201 | title
202 | }
203 | }
204 | }
205 | `;
206 |
207 | // Query to get tasks list
208 | export const TASKS_QUERY = gql`
209 | query Tasks(
210 | $filter: TaskFilter!
211 | $sorting: [TaskSort!]
212 | $paging: OffsetPaging!
213 | ) {
214 | tasks(filter: $filter, sorting: $sorting, paging: $paging) {
215 | totalCount # Get the total count of tasks
216 | nodes {
217 | id
218 | title
219 | description
220 | dueDate
221 | completed
222 | stageId
223 | # Get user details associated with this task
224 | users {
225 | id
226 | name
227 | avatarUrl
228 | }
229 | createdAt
230 | updatedAt
231 | }
232 | }
233 | }
234 | `;
235 |
236 | // Query to get task stages for select
237 | export const TASK_STAGES_SELECT_QUERY = gql`
238 | query TaskStagesSelect(
239 | $filter: TaskStageFilter!
240 | $sorting: [TaskStageSort!]
241 | $paging: OffsetPaging!
242 | ) {
243 | taskStages(filter: $filter, sorting: $sorting, paging: $paging) {
244 | totalCount
245 | nodes {
246 | id
247 | title
248 | }
249 | }
250 | }
251 | `;
252 |
--------------------------------------------------------------------------------
/src/pages/tasks/list.tsx:
--------------------------------------------------------------------------------
1 | import { KanbanColumnSkeleton, ProjectCardSkeleton } from '@/components'
2 | import { KanbanAddCardButton } from '@/components/tasks/kanban/add-card-button'
3 | import { KanbanBoardContainer, KanbanBoard } from '@/components/tasks/kanban/board'
4 | import { ProjectCardMemo } from '@/components/tasks/kanban/card'
5 | import KanbanColumn from '@/components/tasks/kanban/column'
6 | import KanbanItem from '@/components/tasks/kanban/item'
7 | import { UPDATE_TASK_STAGE_MUTATION } from '@/graphql/mutations'
8 | import { TASKS_QUERY, TASK_STAGES_QUERY } from '@/graphql/queries'
9 | import { TaskStagesQuery, TasksQuery } from '@/graphql/types'
10 | import { DragEndEvent } from '@dnd-kit/core'
11 | import { useList, useNavigation, useUpdate } from '@refinedev/core'
12 | import { GetFieldsFromList } from '@refinedev/nestjs-query'
13 | import React from 'react'
14 |
15 | type Task = GetFieldsFromList
16 | type TaskStage = GetFieldsFromList & { tasks: Task[] }
17 |
18 | const List = ({ children }: React.PropsWithChildren) => {
19 | const { replace } = useNavigation()
20 |
21 | const { data: stages, isLoading: isLoadingStages } = useList({
22 | resource: 'taskStages',
23 | filters: [
24 | {
25 | field: 'title',
26 | operator: 'in',
27 | value: ['TODO', 'IN PROGRESS', 'IN REVIEW', 'DONE']
28 | }
29 | ],
30 | sorters: [
31 | {
32 | field: 'createdAt',
33 | order: 'asc'
34 | }
35 | ],
36 | meta: {
37 | gqlQuery: TASK_STAGES_QUERY
38 | }
39 | })
40 | const { data: tasks, isLoading: isLoadingTasks } = useList>({
41 | resource: 'tasks',
42 | sorters: [
43 | {
44 | field: 'dueDate',
45 | order: 'asc',
46 | }
47 | ],
48 | queryOptions: {
49 | enabled: !!stages,
50 | },
51 | pagination: {
52 | mode: 'off'
53 | },
54 | meta: {
55 | gqlQuery: TASKS_QUERY
56 | }
57 | })
58 |
59 | const { mutate: updateTask } = useUpdate();
60 |
61 | const taskStages = React.useMemo(() => {
62 | if (!tasks?.data || !stages?.data) {
63 | return {
64 | unassignedStage: [],
65 | stages: []
66 | }
67 | }
68 |
69 | const unassignedStage = tasks.data.filter((task) => task.stageId === null)
70 |
71 | const grouped: TaskStage[] = stages.data.map((stage) => ({
72 | ...stage,
73 | tasks: tasks.data.filter((task) => task.stageId?.toString() === stage.id)
74 | }))
75 |
76 | return {
77 | unassignedStage,
78 | columns: grouped
79 | }
80 | }, [stages, tasks])
81 |
82 | const handleAddCard = (args: { stageId: string}) => {
83 | const path = args.stageId === 'unassigned'
84 | ? '/tasks/new'
85 | : `/tasks/new?stageId=${args.stageId}`
86 |
87 | replace(path);
88 | }
89 |
90 | const handleOnDragEnd = (event: DragEndEvent) => {
91 | let stageId = event.over?.id as undefined | string | null
92 | const taskId = event.active.id as string
93 | const taskStageId = event.active.data.current?.stageId
94 |
95 | if(taskStageId === stageId) return;
96 |
97 | if(stageId === 'unassigned') {
98 | stageId = null
99 | }
100 |
101 | updateTask({
102 | resource: 'tasks',
103 | id: taskId,
104 | values: {
105 | stageId: stageId,
106 | },
107 | successNotification: false,
108 | mutationMode: 'optimistic',
109 | meta: {
110 | gqlMutation: UPDATE_TASK_STAGE_MUTATION
111 | }
112 | })
113 | }
114 |
115 | const isLoading = isLoadingStages || isLoadingTasks
116 |
117 | if(isLoading) return
118 |
119 | return (
120 | <>
121 |
122 |
123 | handleAddCard({ stageId: 'unassigned' })}
128 | >
129 | {taskStages.unassignedStage.map((task) => (
130 |
133 |
137 |
138 | ))}
139 |
140 | {!taskStages.unassignedStage.length && (
141 | handleAddCard({ stageId: 'unassigned' })}
143 | />
144 | )}
145 |
146 |
147 | {taskStages.columns?.map((column) => (
148 | handleAddCard({ stageId: column.id })}
154 | >
155 | {!isLoading && column.tasks.map((task) => (
156 |
157 |
161 |
162 | ))}
163 | {!column.tasks.length && (
164 | handleAddCard({ stageId: column.id })}
166 | />
167 | )}
168 |
169 | ))}
170 |
171 |
172 | {children}
173 | >
174 | )
175 | }
176 |
177 | export default List
178 |
179 | const PageSkeleton = () => {
180 | const columnCount = 6;
181 | const itemCount = 4;
182 |
183 | return (
184 |
185 | {Array.from({ length: columnCount }).map((_, index) => (
186 |
187 | {Array.from({length: itemCount}).map((_, index)=> (
188 |
189 | ))}
190 |
191 | ))}
192 |
193 | )
194 | }
--------------------------------------------------------------------------------
/src/components/layout/account-settings.tsx:
--------------------------------------------------------------------------------
1 | import { SaveButton, useForm } from "@refinedev/antd";
2 | import { HttpError } from "@refinedev/core";
3 | import { GetFields, GetVariables } from "@refinedev/nestjs-query";
4 |
5 | import { CloseOutlined } from "@ant-design/icons";
6 | import { Button, Card, Drawer, Form, Input, Spin } from "antd";
7 |
8 | import { getNameInitials } from "@/utilities";
9 | import { UPDATE_USER_MUTATION } from "@/graphql/mutations";
10 |
11 | import { Text } from "../text";
12 | import CustomAvatar from "../custom-avatar";
13 |
14 | import {
15 | UpdateUserMutation,
16 | UpdateUserMutationVariables,
17 | } from "@/graphql/types";
18 |
19 | type Props = {
20 | opened: boolean;
21 | setOpened: (opened: boolean) => void;
22 | userId: string;
23 | };
24 |
25 | export const AccountSettings = ({ opened, setOpened, userId }: Props) => {
26 | /**
27 | * 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.
28 | * https://refine.dev/docs/data/hooks/use-form/#usage
29 | */
30 |
31 | /**
32 | * saveButtonProps -> contains all the props needed by the "submit" button. For example, "loading", "disabled", "onClick", etc.
33 | * https://refine.dev/docs/ui-integrations/ant-design/hooks/use-form/#savebuttonprops
34 | *
35 | * formProps -> It's an instance of HTML form that manages form state and actions like onFinish, onValuesChange, etc.
36 | * https://refine.dev/docs/ui-integrations/ant-design/hooks/use-form/#form
37 | *
38 | * queryResult -> contains the result of the query. For example, isLoading, data, error, etc.
39 | * https://refine.dev/docs/packages/react-hook-form/use-form/#queryresult
40 | */
41 | const { saveButtonProps, formProps, queryResult } = useForm<
42 | /**
43 | * GetFields is used to get the fields of the mutation i.e., in this case, fields are name, email, jobTitle, and phone
44 | * https://refine.dev/docs/data/packages/nestjs-query/#getfields
45 | */
46 | GetFields,
47 | // a type that represents an HTTP error. Used to specify the type of error mutation can throw.
48 | HttpError,
49 | // 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
50 | GetVariables
51 | >({
52 | /**
53 | * mutationMode is used to determine how the mutation should be performed. For example, optimistic, pessimistic, undoable etc.
54 | * optimistic -> redirection and UI updates are executed immediately as if the mutation is successful.
55 | * pessimistic -> redirection and UI updates are executed after the mutation is successful.
56 | * https://refine.dev/docs/advanced-tutorials/mutation-mode/#overview
57 | */
58 | mutationMode: "optimistic",
59 | /**
60 | * specify on which resource the mutation should be performed
61 | * if not specified, Refine will determine the resource name by the current route
62 | */
63 | resource: "users",
64 | /**
65 | * 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.
66 | * https://refine.dev/docs/data/hooks/use-form/#edit
67 | */
68 | action: "edit",
69 | id: userId,
70 | /**
71 | * used to provide any additional information to the data provider.
72 | * https://refine.dev/docs/data/hooks/use-form/#meta-
73 | */
74 | meta: {
75 | // gqlMutation is used to specify the mutation that should be performed.
76 | gqlMutation: UPDATE_USER_MUTATION,
77 | },
78 | });
79 | const { avatarUrl, name } = queryResult?.data?.data || {};
80 |
81 | const closeModal = () => {
82 | setOpened(false);
83 | };
84 |
85 | // if query is processing, show a loading indicator
86 | if (queryResult?.isLoading) {
87 | return (
88 |
100 |
101 |
102 | );
103 | }
104 |
105 | return (
106 |
115 |
124 | Account Settings
125 | }
128 | onClick={() => closeModal()}
129 | />
130 |
131 |
136 |
137 |
149 |
150 |
151 |
152 |
153 |
154 |
155 |
156 |
157 |
158 |
159 |
160 |
161 |
168 |
169 |
170 |
171 | );
172 | };
173 |
--------------------------------------------------------------------------------
/src/constants/index.tsx:
--------------------------------------------------------------------------------
1 | import { AuditOutlined, ShopOutlined, TeamOutlined } from "@ant-design/icons";
2 |
3 | const IconWrapper = ({
4 | color,
5 | children,
6 | }: React.PropsWithChildren<{ color: string }>) => {
7 | return (
8 |
19 | {children}
20 |
21 | );
22 | };
23 |
24 | import {
25 | BusinessType,
26 | CompanySize,
27 | Contact,
28 | Industry,
29 | } from "@/graphql/schema.types";
30 |
31 | export type TotalCountType = "companies" | "contacts" | "deals";
32 |
33 | export const totalCountVariants: {
34 | [key in TotalCountType]: {
35 | primaryColor: string;
36 | secondaryColor?: string;
37 | icon: React.ReactNode;
38 | title: string;
39 | data: { index: string; value: number }[];
40 | };
41 | } = {
42 | companies: {
43 | primaryColor: "#1677FF",
44 | secondaryColor: "#BAE0FF",
45 | icon: (
46 |
47 |
53 |
54 | ),
55 | title: "Number of companies",
56 | data: [
57 | {
58 | index: "1",
59 | value: 3500,
60 | },
61 | {
62 | index: "2",
63 | value: 2750,
64 | },
65 | {
66 | index: "3",
67 | value: 5000,
68 | },
69 | {
70 | index: "4",
71 | value: 4250,
72 | },
73 | {
74 | index: "5",
75 | value: 5000,
76 | },
77 | ],
78 | },
79 | contacts: {
80 | primaryColor: "#52C41A",
81 | secondaryColor: "#D9F7BE",
82 | icon: (
83 |
84 |
90 |
91 | ),
92 | title: "Number of contacts",
93 | data: [
94 | {
95 | index: "1",
96 | value: 10000,
97 | },
98 | {
99 | index: "2",
100 | value: 19500,
101 | },
102 | {
103 | index: "3",
104 | value: 13000,
105 | },
106 | {
107 | index: "4",
108 | value: 17000,
109 | },
110 | {
111 | index: "5",
112 | value: 13000,
113 | },
114 | {
115 | index: "6",
116 | value: 20000,
117 | },
118 | ],
119 | },
120 | deals: {
121 | primaryColor: "#FA541C",
122 | secondaryColor: "#FFD8BF",
123 | icon: (
124 |
125 |
131 |
132 | ),
133 | title: "Total deals in pipeline",
134 | data: [
135 | {
136 | index: "1",
137 | value: 1000,
138 | },
139 | {
140 | index: "2",
141 | value: 1300,
142 | },
143 | {
144 | index: "3",
145 | value: 1200,
146 | },
147 | {
148 | index: "4",
149 | value: 2000,
150 | },
151 | {
152 | index: "5",
153 | value: 800,
154 | },
155 | {
156 | index: "6",
157 | value: 1700,
158 | },
159 | {
160 | index: "7",
161 | value: 1400,
162 | },
163 | {
164 | index: "8",
165 | value: 1800,
166 | },
167 | ],
168 | },
169 | };
170 |
171 | export const statusOptions: {
172 | label: string;
173 | value: Contact["status"];
174 | }[] = [
175 | {
176 | label: "New",
177 | value: "NEW",
178 | },
179 | {
180 | label: "Qualified",
181 | value: "QUALIFIED",
182 | },
183 | {
184 | label: "Unqualified",
185 | value: "UNQUALIFIED",
186 | },
187 | {
188 | label: "Won",
189 | value: "WON",
190 | },
191 | {
192 | label: "Negotiation",
193 | value: "NEGOTIATION",
194 | },
195 | {
196 | label: "Lost",
197 | value: "LOST",
198 | },
199 | {
200 | label: "Interested",
201 | value: "INTERESTED",
202 | },
203 | {
204 | label: "Contacted",
205 | value: "CONTACTED",
206 | },
207 | {
208 | label: "Churned",
209 | value: "CHURNED",
210 | },
211 | ];
212 |
213 | export const companySizeOptions: {
214 | label: string;
215 | value: CompanySize;
216 | }[] = [
217 | {
218 | label: "Enterprise",
219 | value: "ENTERPRISE",
220 | },
221 | {
222 | label: "Large",
223 | value: "LARGE",
224 | },
225 | {
226 | label: "Medium",
227 | value: "MEDIUM",
228 | },
229 | {
230 | label: "Small",
231 | value: "SMALL",
232 | },
233 | ];
234 |
235 | export const industryOptions: {
236 | label: string;
237 | value: Industry;
238 | }[] = [
239 | { label: "Aerospace", value: "AEROSPACE" },
240 | { label: "Agriculture", value: "AGRICULTURE" },
241 | { label: "Automotive", value: "AUTOMOTIVE" },
242 | { label: "Chemicals", value: "CHEMICALS" },
243 | { label: "Construction", value: "CONSTRUCTION" },
244 | { label: "Defense", value: "DEFENSE" },
245 | { label: "Education", value: "EDUCATION" },
246 | { label: "Energy", value: "ENERGY" },
247 | { label: "Financial Services", value: "FINANCIAL_SERVICES" },
248 | { label: "Food and Beverage", value: "FOOD_AND_BEVERAGE" },
249 | { label: "Government", value: "GOVERNMENT" },
250 | { label: "Healthcare", value: "HEALTHCARE" },
251 | { label: "Hospitality", value: "HOSPITALITY" },
252 | { label: "Industrial Manufacturing", value: "INDUSTRIAL_MANUFACTURING" },
253 | { label: "Insurance", value: "INSURANCE" },
254 | { label: "Life Sciences", value: "LIFE_SCIENCES" },
255 | { label: "Logistics", value: "LOGISTICS" },
256 | { label: "Media", value: "MEDIA" },
257 | { label: "Mining", value: "MINING" },
258 | { label: "Nonprofit", value: "NONPROFIT" },
259 | { label: "Other", value: "OTHER" },
260 | { label: "Pharmaceuticals", value: "PHARMACEUTICALS" },
261 | { label: "Professional Services", value: "PROFESSIONAL_SERVICES" },
262 | { label: "Real Estate", value: "REAL_ESTATE" },
263 | { label: "Retail", value: "RETAIL" },
264 | { label: "Technology", value: "TECHNOLOGY" },
265 | { label: "Telecommunications", value: "TELECOMMUNICATIONS" },
266 | { label: "Transportation", value: "TRANSPORTATION" },
267 | { label: "Utilities", value: "UTILITIES" },
268 | ];
269 |
270 | export const businessTypeOptions: {
271 | label: string;
272 | value: BusinessType;
273 | }[] = [
274 | {
275 | label: "B2B",
276 | value: "B2B",
277 | },
278 | {
279 | label: "B2C",
280 | value: "B2C",
281 | },
282 | {
283 | label: "B2G",
284 | value: "B2G",
285 | },
286 | ];
287 |
--------------------------------------------------------------------------------
/src/pages/company/contacts-table.tsx:
--------------------------------------------------------------------------------
1 | import { useParams } from "react-router-dom";
2 |
3 | import { FilterDropdown, useTable } from "@refinedev/antd";
4 | import { GetFieldsFromList } from "@refinedev/nestjs-query";
5 |
6 | import {
7 | MailOutlined,
8 | PhoneOutlined,
9 | SearchOutlined,
10 | TeamOutlined,
11 | } from "@ant-design/icons";
12 | import { Button, Card, Input, Select, Space, Table } from "antd";
13 |
14 | import { Contact } from "@/graphql/schema.types";
15 |
16 | import { statusOptions } from "@/constants";
17 | import { COMPANY_CONTACTS_TABLE_QUERY } from "@/graphql/queries";
18 |
19 | import { CompanyContactsTableQuery } from "@/graphql/types";
20 | import { Text } from "@/components/text";
21 | import CustomAvatar from "@/components/custom-avatar";
22 | import { ContactStatusTag } from "@/components/tags/contact-status-tag";
23 |
24 | export const CompanyContactsTable = () => {
25 | // get params from the url
26 | const params = useParams();
27 |
28 | /**
29 | * Refine offers a TanStack Table adapter with @refinedev/react-table that allows us to use the TanStack Table library with Refine.
30 | * All features such as sorting, filtering, and pagination come out of the box
31 | * Under the hood it uses useList hook to fetch the data.
32 | * https://refine.dev/docs/packages/tanstack-table/use-table/#installation
33 | */
34 | const { tableProps } = useTable>(
35 | {
36 | // specify the resource for which the table is to be used
37 | resource: "contacts",
38 | syncWithLocation: false,
39 | // specify initial sorters
40 | sorters: {
41 | /**
42 | * initial sets the initial value of sorters.
43 | * it's not permanent
44 | * it will be cleared when the user changes the sorting
45 | * https://refine.dev/docs/ui-integrations/ant-design/hooks/use-table/#sortersinitial
46 | */
47 | initial: [
48 | {
49 | field: "createdAt",
50 | order: "desc",
51 | },
52 | ],
53 | },
54 | // specify initial filters
55 | filters: {
56 | /**
57 | * similar to initial in sorters
58 | * https://refine.dev/docs/ui-integrations/ant-design/hooks/use-table/#filtersinitial
59 | */
60 | initial: [
61 | {
62 | field: "jobTitle",
63 | value: "",
64 | operator: "contains",
65 | },
66 | {
67 | field: "name",
68 | value: "",
69 | operator: "contains",
70 | },
71 | {
72 | field: "status",
73 | value: undefined,
74 | operator: "in",
75 | },
76 | ],
77 | /**
78 | * permanent filters are the filters that are always applied
79 | * https://refine.dev/docs/ui-integrations/ant-design/hooks/use-table/#filterspermanent
80 | */
81 | permanent: [
82 | {
83 | field: "company.id",
84 | operator: "eq",
85 | value: params?.id as string,
86 | },
87 | ],
88 | },
89 | /**
90 | * used to provide any additional information to the data provider.
91 | * https://refine.dev/docs/data/hooks/use-form/#meta-
92 | */
93 | meta: {
94 | // gqlQuery is used to specify the GraphQL query that should be used to fetch the data.
95 | gqlQuery: COMPANY_CONTACTS_TABLE_QUERY,
96 | },
97 | },
98 | );
99 |
100 | return (
101 |
109 |
110 | Contacts
111 |
112 | }
113 | // property used to render additional content in the top-right corner of the card
114 | extra={
115 | <>
116 | Total contacts:
117 |
118 | {/* if pagination is not disabled and total is provided then show the total */}
119 | {tableProps?.pagination !== false && tableProps.pagination?.total}
120 |
121 | >
122 | }
123 | >
124 |
202 |
203 | );
204 | };
205 |
206 |
--------------------------------------------------------------------------------
/src/graphql/types.ts:
--------------------------------------------------------------------------------
1 | import type * as Types from "./schema.types";
2 |
3 | export type UpdateUserMutationVariables = Types.Exact<{
4 | input: Types.UpdateOneUserInput;
5 | }>;
6 |
7 | export type UpdateUserMutation = {
8 | updateOneUser: Pick<
9 | Types.User,
10 | "id" | "name" | "avatarUrl" | "email" | "phone" | "jobTitle"
11 | >;
12 | };
13 |
14 | export type CreateCompanyMutationVariables = Types.Exact<{
15 | input: Types.CreateOneCompanyInput;
16 | }>;
17 |
18 | export type CreateCompanyMutation = {
19 | createOneCompany: Pick & {
20 | salesOwner: Pick;
21 | };
22 | };
23 |
24 | export type UpdateCompanyMutationVariables = Types.Exact<{
25 | input: Types.UpdateOneCompanyInput;
26 | }>;
27 |
28 | export type UpdateCompanyMutation = {
29 | updateOneCompany: Pick<
30 | Types.Company,
31 | | "id"
32 | | "name"
33 | | "totalRevenue"
34 | | "industry"
35 | | "companySize"
36 | | "businessType"
37 | | "country"
38 | | "website"
39 | | "avatarUrl"
40 | > & { salesOwner: Pick };
41 | };
42 |
43 | export type UpdateTaskStageMutationVariables = Types.Exact<{
44 | input: Types.UpdateOneTaskInput;
45 | }>;
46 |
47 | export type UpdateTaskStageMutation = { updateOneTask: Pick };
48 |
49 | export type CreateTaskMutationVariables = Types.Exact<{
50 | input: Types.CreateOneTaskInput;
51 | }>;
52 |
53 | export type CreateTaskMutation = {
54 | createOneTask: Pick & {
55 | stage?: Types.Maybe>;
56 | };
57 | };
58 |
59 | export type UpdateTaskMutationVariables = Types.Exact<{
60 | input: Types.UpdateOneTaskInput;
61 | }>;
62 |
63 | export type UpdateTaskMutation = {
64 | updateOneTask: Pick<
65 | Types.Task,
66 | "id" | "title" | "completed" | "description" | "dueDate"
67 | > & {
68 | stage?: Types.Maybe>;
69 | users: Array>;
70 | checklist: Array>;
71 | };
72 | };
73 |
74 | export type DashboardTotalCountsQueryVariables = Types.Exact<{
75 | [key: string]: never;
76 | }>;
77 |
78 | export type DashboardTotalCountsQuery = {
79 | companies: Pick;
80 | contacts: Pick;
81 | deals: Pick;
82 | };
83 |
84 | export type DashboardCalendarUpcomingEventsQueryVariables = Types.Exact<{
85 | filter: Types.EventFilter;
86 | sorting?: Types.InputMaybe | Types.EventSort>;
87 | paging: Types.OffsetPaging;
88 | }>;
89 |
90 | export type DashboardCalendarUpcomingEventsQuery = {
91 | events: Pick & {
92 | nodes: Array<
93 | Pick
94 | >;
95 | };
96 | };
97 |
98 | export type DashboardDealsChartQueryVariables = Types.Exact<{
99 | filter: Types.DealStageFilter;
100 | sorting?: Types.InputMaybe | Types.DealStageSort>;
101 | paging?: Types.InputMaybe;
102 | }>;
103 |
104 | export type DashboardDealsChartQuery = {
105 | dealStages: Pick & {
106 | nodes: Array<
107 | Pick & {
108 | dealsAggregate: Array<{
109 | groupBy?: Types.Maybe<
110 | Pick<
111 | Types.DealStageDealsAggregateGroupBy,
112 | "closeDateMonth" | "closeDateYear"
113 | >
114 | >;
115 | sum?: Types.Maybe>;
116 | }>;
117 | }
118 | >;
119 | };
120 | };
121 |
122 | export type DashboardLatestActivitiesDealsQueryVariables = Types.Exact<{
123 | filter: Types.DealFilter;
124 | sorting?: Types.InputMaybe | Types.DealSort>;
125 | paging?: Types.InputMaybe;
126 | }>;
127 |
128 | export type DashboardLatestActivitiesDealsQuery = {
129 | deals: Pick & {
130 | nodes: Array<
131 | Pick & {
132 | stage?: Types.Maybe>;
133 | company: Pick;
134 | }
135 | >;
136 | };
137 | };
138 |
139 | export type DashboardLatestActivitiesAuditsQueryVariables = Types.Exact<{
140 | filter: Types.AuditFilter;
141 | sorting?: Types.InputMaybe | Types.AuditSort>;
142 | paging?: Types.InputMaybe;
143 | }>;
144 |
145 | export type DashboardLatestActivitiesAuditsQuery = {
146 | audits: Pick & {
147 | nodes: Array<
148 | Pick<
149 | Types.Audit,
150 | "id" | "action" | "targetEntity" | "targetId" | "createdAt"
151 | > & {
152 | changes: Array>;
153 | user?: Types.Maybe>;
154 | }
155 | >;
156 | };
157 | };
158 |
159 | export type CompaniesListQueryVariables = Types.Exact<{
160 | filter: Types.CompanyFilter;
161 | sorting?: Types.InputMaybe | Types.CompanySort>;
162 | paging: Types.OffsetPaging;
163 | }>;
164 |
165 | export type CompaniesListQuery = {
166 | companies: Pick & {
167 | nodes: Array<
168 | Pick & {
169 | dealsAggregate: Array<{
170 | sum?: Types.Maybe>;
171 | }>;
172 | }
173 | >;
174 | };
175 | };
176 |
177 | export type UsersSelectQueryVariables = Types.Exact<{
178 | filter: Types.UserFilter;
179 | sorting?: Types.InputMaybe | Types.UserSort>;
180 | paging: Types.OffsetPaging;
181 | }>;
182 |
183 | export type UsersSelectQuery = {
184 | users: Pick & {
185 | nodes: Array>;
186 | };
187 | };
188 |
189 | export type CompanyContactsTableQueryVariables = Types.Exact<{
190 | filter: Types.ContactFilter;
191 | sorting?: Types.InputMaybe | Types.ContactSort>;
192 | paging: Types.OffsetPaging;
193 | }>;
194 |
195 | export type CompanyContactsTableQuery = {
196 | contacts: Pick & {
197 | nodes: Array<
198 | Pick<
199 | Types.Contact,
200 | "id" | "name" | "avatarUrl" | "jobTitle" | "email" | "phone" | "status"
201 | >
202 | >;
203 | };
204 | };
205 |
206 | export type TaskStagesQueryVariables = Types.Exact<{
207 | filter: Types.TaskStageFilter;
208 | sorting?: Types.InputMaybe | Types.TaskStageSort>;
209 | paging: Types.OffsetPaging;
210 | }>;
211 |
212 | export type TaskStagesQuery = {
213 | taskStages: Pick & {
214 | nodes: Array>;
215 | };
216 | };
217 |
218 | export type TasksQueryVariables = Types.Exact<{
219 | filter: Types.TaskFilter;
220 | sorting?: Types.InputMaybe | Types.TaskSort>;
221 | paging: Types.OffsetPaging;
222 | }>;
223 |
224 | export type TasksQuery = {
225 | tasks: Pick & {
226 | nodes: Array<
227 | Pick<
228 | Types.Task,
229 | | "id"
230 | | "title"
231 | | "description"
232 | | "dueDate"
233 | | "completed"
234 | | "stageId"
235 | | "createdAt"
236 | | "updatedAt"
237 | > & { users: Array> }
238 | >;
239 | };
240 | };
241 |
242 | export type TaskStagesSelectQueryVariables = Types.Exact<{
243 | filter: Types.TaskStageFilter;
244 | sorting?: Types.InputMaybe | Types.TaskStageSort>;
245 | paging: Types.OffsetPaging;
246 | }>;
247 |
248 | export type TaskStagesSelectQuery = {
249 | taskStages: Pick & {
250 | nodes: Array>;
251 | };
252 | };
253 |
--------------------------------------------------------------------------------
/README.MD:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
7 |
8 |
13 |
14 |
A CRM Dashboard
15 |
16 |
17 | Build this project step by step with our detailed tutorial on
JavaScript Mastery YouTube. Join the JSM family!
18 |
19 |
20 |
21 | ## 📋 Table of Contents
22 |
23 | 1. 🤖 [Introduction](#introduction)
24 | 2. ⚙️ [Tech Stack](#tech-stack)
25 | 3. 🔋 [Features](#features)
26 | 4. 🤸 [Quick Start](#quick-start)
27 | 5. 🕸️ [Snippets](#snippets)
28 | 6. 🔗 [Links](#links)
29 | 7. 🚀 [More](#more)
30 |
31 | ## 🚨 Tutorial
32 |
33 | This repository contains the code corresponding to an in-depth tutorial available on our YouTube channel, JavaScript Mastery .
34 |
35 | If you prefer visual learning, this is the perfect resource for you. Follow our tutorial to learn how to build projects like these step-by-step in a beginner-friendly manner!
36 |
37 |
38 |
39 | ## 🤖 Introduction
40 |
41 | React-based CRM dashboard featuring comprehensive authentication, antd charts, sales management, and a fully operational kanban board with live updates for real-time actions across all devices.
42 |
43 | If you're getting started and need assistance or face any bugs, join our active Discord community with over 27k+ members. It's a place where people help each other out.
44 |
45 |
46 |
47 | ## ⚙️ Tech Stack
48 |
49 | - React.js
50 | - TypeScript
51 | - GraphQL
52 | - Ant Design
53 | - Refine
54 | - Codegen
55 | - Vite
56 |
57 | ## 🔋 Features
58 |
59 | 👉 **Authentication**: Seamless onboarding with secure login and signup functionalities; robust password recovery ensures a smooth authentication experience.
60 |
61 | 👉 **Authorization**: Granular access control regulates user actions, maintaining data security and user permissions.
62 |
63 | 👉 **Home Page**: Dynamic home page showcases interactive charts for key metrics; real-time updates on activities, upcoming events, and a deals chart for business insights.
64 |
65 | 👉 **Companies Page**: Complete CRUD for company management and sales processes; detailed profiles with add/edit functions, associated contacts/leads, pagination, and field-specific search.
66 |
67 | 👉 **Kanban Board**: Collaborative board with real-time task updates; customization options include due dates, markdown descriptions, and multi-assignees, dynamically shifting tasks across dashboards.
68 |
69 | 👉 **Account Settings**: Personalized user account settings for profile management; streamlined configuration options for a tailored application experience.
70 |
71 | 👉 **Responsive**: Full responsiveness across devices for consistent user experience; fluid design adapts seamlessly to various screen sizes, ensuring accessibility.
72 |
73 | and many more, including code architecture and reusability
74 |
75 | ## 🤸 Quick Start
76 |
77 | Follow these steps to set up the project locally on your machine.
78 |
79 | **Prerequisites**
80 |
81 | Make sure you have the following installed on your machine:
82 |
83 | - [Git](https://git-scm.com/)
84 | - [Node.js](https://nodejs.org/en)
85 | - [npm](https://www.npmjs.com/) (Node Package Manager)
86 |
87 | **Cloning the Repository**
88 |
89 | ```bash
90 | git clone https://github.com/adrianhajdin/refine_dashboard.git
91 | cd refine_dashboard
92 | ```
93 |
94 | **Installation**
95 |
96 | Install the project dependencies using npm:
97 |
98 | ```bash
99 | npm install
100 | ```
101 |
102 |
103 | **Running the Project**
104 |
105 | ```bash
106 | npm run dev
107 | ```
108 |
109 | Open [http://localhost:5173](http://localhost:5173) in your browser to view the project.
110 |
111 | ## 🕸️ Snippets
112 |
113 | # Code Snippets
114 |
115 |
116 | providers/auth.ts
117 |
118 | ```typescript
119 | import { AuthBindings } from "@refinedev/core";
120 |
121 | import { API_URL, dataProvider } from "./data";
122 |
123 | // For demo purposes and to make it easier to test the app, you can use the following credentials
124 | export const authCredentials = {
125 | email: "michael.scott@dundermifflin.com",
126 | password: "demodemo",
127 | };
128 |
129 | export const authProvider: AuthBindings = {
130 | login: async ({ email }) => {
131 | try {
132 | // call the login mutation
133 | // dataProvider.custom is used to make a custom request to the GraphQL API
134 | // this will call dataProvider which will go through the fetchWrapper function
135 | const { data } = await dataProvider.custom({
136 | url: API_URL,
137 | method: "post",
138 | headers: {},
139 | meta: {
140 | variables: { email },
141 | // pass the email to see if the user exists and if so, return the accessToken
142 | rawQuery: `
143 | mutation Login($email: String!) {
144 | login(loginInput: { email: $email }) {
145 | accessToken
146 | }
147 | }
148 | `,
149 | },
150 | });
151 |
152 | // save the accessToken in localStorage
153 | localStorage.setItem("access_token", data.login.accessToken);
154 |
155 | return {
156 | success: true,
157 | redirectTo: "/",
158 | };
159 | } catch (e) {
160 | const error = e as Error;
161 |
162 | return {
163 | success: false,
164 | error: {
165 | message: "message" in error ? error.message : "Login failed",
166 | name: "name" in error ? error.name : "Invalid email or password",
167 | },
168 | };
169 | }
170 | },
171 |
172 | // simply remove the accessToken from localStorage for the logout
173 | logout: async () => {
174 | localStorage.removeItem("access_token");
175 |
176 | return {
177 | success: true,
178 | redirectTo: "/login",
179 | };
180 | },
181 |
182 | onError: async (error) => {
183 | // a check to see if the error is an authentication error
184 | // if so, set logout to true
185 | if (error.statusCode === "UNAUTHENTICATED") {
186 | return {
187 | logout: true,
188 | ...error,
189 | };
190 | }
191 |
192 | return { error };
193 | },
194 |
195 | check: async () => {
196 | try {
197 | // get the identity of the user
198 | // this is to know if the user is authenticated or not
199 | await dataProvider.custom({
200 | url: API_URL,
201 | method: "post",
202 | headers: {},
203 | meta: {
204 | rawQuery: `
205 | query Me {
206 | me {
207 | name
208 | }
209 | }
210 | `,
211 | },
212 | });
213 |
214 | // if the user is authenticated, redirect to the home page
215 | return {
216 | authenticated: true,
217 | redirectTo: "/",
218 | };
219 | } catch (error) {
220 | // for any other error, redirect to the login page
221 | return {
222 | authenticated: false,
223 | redirectTo: "/login",
224 | };
225 | }
226 | },
227 |
228 | // get the user information
229 | getIdentity: async () => {
230 | const accessToken = localStorage.getItem("access_token");
231 |
232 | try {
233 | // call the GraphQL API to get the user information
234 | // we're using me:any because the GraphQL API doesn't have a type for the me query yet.
235 | // we'll add some queries and mutations later and change this to User which will be generated by codegen.
236 | const { data } = await dataProvider.custom<{ me: any }>({
237 | url: API_URL,
238 | method: "post",
239 | headers: accessToken
240 | ? {
241 | // send the accessToken in the Authorization header
242 | Authorization: `Bearer ${accessToken}`,
243 | }
244 | : {},
245 | meta: {
246 | // get the user information such as name, email, etc.
247 | rawQuery: `
248 | query Me {
249 | me {
250 | id
251 | name
252 | email
253 | phone
254 | jobTitle
255 | timezone
256 | avatarUrl
257 | }
258 | }
259 | `,
260 | },
261 | });
262 |
263 | return data.me;
264 | } catch (error) {
265 | return undefined;
266 | }
267 | },
268 | };
269 | ```
270 |
271 |
272 |
273 |
274 | GraphQl and Codegen Setup
275 |
276 | ```bash
277 | npm i -D @graphql-codegen/cli @graphql-codegen/typescript @graphql-codegen/typescript-operations @graphql-codegen/import-types-preset prettier vite-tsconfig-paths
278 | ```
279 |
280 |
281 |
282 |
283 | graphql.config.ts
284 |
285 | ```typescript
286 | import type { IGraphQLConfig } from "graphql-config";
287 |
288 | const config: IGraphQLConfig = {
289 | // define graphQL schema provided by Refine
290 | schema: "https://api.crm.refine.dev/graphql",
291 | extensions: {
292 | // codegen is a plugin that generates typescript types from GraphQL schema
293 | // https://the-guild.dev/graphql/codegen
294 | codegen: {
295 | // hooks are commands that are executed after a certain event
296 | hooks: {
297 | afterOneFileWrite: ["eslint --fix", "prettier --write"],
298 | },
299 | // generates typescript types from GraphQL schema
300 | generates: {
301 | // specify the output path of the generated types
302 | "src/graphql/schema.types.ts": {
303 | // use typescript plugin
304 | plugins: ["typescript"],
305 | // set the config of the typescript plugin
306 | // this defines how the generated types will look like
307 | config: {
308 | skipTypename: true, // skipTypename is used to remove __typename from the generated types
309 | enumsAsTypes: true, // enumsAsTypes is used to generate enums as types instead of enums.
310 | // scalars is used to define how the scalars i.e. DateTime, JSON, etc. will be generated
311 | // scalar is a type that is not a list and does not have fields. Meaning it is a primitive type.
312 | scalars: {
313 | // DateTime is a scalar type that is used to represent date and time
314 | DateTime: {
315 | input: "string",
316 | output: "string",
317 | format: "date-time",
318 | },
319 | },
320 | },
321 | },
322 | // generates typescript types from GraphQL operations
323 | // graphql operations are queries, mutations, and subscriptions we write in our code to communicate with the GraphQL API
324 | "src/graphql/types.ts": {
325 | // preset is a plugin that is used to generate typescript types from GraphQL operations
326 | // import-types suggests to import types from schema.types.ts or other files
327 | // this is used to avoid duplication of types
328 | // https://the-guild.dev/graphql/codegen/plugins/presets/import-types-preset
329 | preset: "import-types",
330 | // documents is used to define the path of the files that contain GraphQL operations
331 | documents: ["src/**/*.{ts,tsx}"],
332 | // plugins is used to define the plugins that will be used to generate typescript types from GraphQL operations
333 | plugins: ["typescript-operations"],
334 | config: {
335 | skipTypename: true,
336 | enumsAsTypes: true,
337 | // determine whether the generated types should be resolved ahead of time or not.
338 | // When preResolveTypes is set to false, the code generator will not try to resolve the types ahead of time.
339 | // Instead, it will generate more generic types, and the actual types will be resolved at runtime.
340 | preResolveTypes: false,
341 | // useTypeImports is used to import types using import type instead of import.
342 | useTypeImports: true,
343 | },
344 | // presetConfig is used to define the config of the preset
345 | presetConfig: {
346 | typesPath: "./schema.types",
347 | },
348 | },
349 | },
350 | },
351 | },
352 | };
353 |
354 | export default config;
355 | ```
356 |
357 |
358 |
359 |
360 | graphql/mutations.ts
361 |
362 | ```typescript
363 | import gql from "graphql-tag";
364 |
365 | // Mutation to update user
366 | export const UPDATE_USER_MUTATION = gql`
367 | # The ! after the type means that it is required
368 | mutation UpdateUser($input: UpdateOneUserInput!) {
369 | # call the updateOneUser mutation with the input and pass the $input argument
370 | # $variableName is a convention for GraphQL variables
371 | updateOneUser(input: $input) {
372 | id
373 | name
374 | avatarUrl
375 | email
376 | phone
377 | jobTitle
378 | }
379 | }
380 | `;
381 |
382 | // Mutation to create company
383 | export const CREATE_COMPANY_MUTATION = gql`
384 | mutation CreateCompany($input: CreateOneCompanyInput!) {
385 | createOneCompany(input: $input) {
386 | id
387 | salesOwner {
388 | id
389 | }
390 | }
391 | }
392 | `;
393 |
394 | // Mutation to update company details
395 | export const UPDATE_COMPANY_MUTATION = gql`
396 | mutation UpdateCompany($input: UpdateOneCompanyInput!) {
397 | updateOneCompany(input: $input) {
398 | id
399 | name
400 | totalRevenue
401 | industry
402 | companySize
403 | businessType
404 | country
405 | website
406 | avatarUrl
407 | salesOwner {
408 | id
409 | name
410 | avatarUrl
411 | }
412 | }
413 | }
414 | `;
415 |
416 | // Mutation to update task stage of a task
417 | export const UPDATE_TASK_STAGE_MUTATION = gql`
418 | mutation UpdateTaskStage($input: UpdateOneTaskInput!) {
419 | updateOneTask(input: $input) {
420 | id
421 | }
422 | }
423 | `;
424 |
425 | // Mutation to create a new task
426 | export const CREATE_TASK_MUTATION = gql`
427 | mutation CreateTask($input: CreateOneTaskInput!) {
428 | createOneTask(input: $input) {
429 | id
430 | title
431 | stage {
432 | id
433 | title
434 | }
435 | }
436 | }
437 | `;
438 |
439 | // Mutation to update a task details
440 | export const UPDATE_TASK_MUTATION = gql`
441 | mutation UpdateTask($input: UpdateOneTaskInput!) {
442 | updateOneTask(input: $input) {
443 | id
444 | title
445 | completed
446 | description
447 | dueDate
448 | stage {
449 | id
450 | title
451 | }
452 | users {
453 | id
454 | name
455 | avatarUrl
456 | }
457 | checklist {
458 | title
459 | checked
460 | }
461 | }
462 | }
463 | `;
464 | ```
465 |
466 |
467 |
468 |
469 | graphql/queries.ts
470 |
471 | ```typescript
472 | import gql from "graphql-tag";
473 |
474 | // Query to get Total Company, Contact and Deal Counts
475 | export const DASHBOARD_TOTAL_COUNTS_QUERY = gql`
476 | query DashboardTotalCounts {
477 | companies {
478 | totalCount
479 | }
480 | contacts {
481 | totalCount
482 | }
483 | deals {
484 | totalCount
485 | }
486 | }
487 | `;
488 |
489 | // Query to get upcoming events
490 | export const DASHBORAD_CALENDAR_UPCOMING_EVENTS_QUERY = gql`
491 | query DashboardCalendarUpcomingEvents(
492 | $filter: EventFilter!
493 | $sorting: [EventSort!]
494 | $paging: OffsetPaging!
495 | ) {
496 | events(filter: $filter, sorting: $sorting, paging: $paging) {
497 | totalCount
498 | nodes {
499 | id
500 | title
501 | color
502 | startDate
503 | endDate
504 | }
505 | }
506 | }
507 | `;
508 |
509 | // Query to get deals chart
510 | export const DASHBOARD_DEALS_CHART_QUERY = gql`
511 | query DashboardDealsChart(
512 | $filter: DealStageFilter!
513 | $sorting: [DealStageSort!]
514 | $paging: OffsetPaging
515 | ) {
516 | dealStages(filter: $filter, sorting: $sorting, paging: $paging) {
517 | # Get all deal stages
518 | nodes {
519 | id
520 | title
521 | # Get the sum of all deals in this stage and group by closeDateMonth and closeDateYear
522 | dealsAggregate {
523 | groupBy {
524 | closeDateMonth
525 | closeDateYear
526 | }
527 | sum {
528 | value
529 | }
530 | }
531 | }
532 | # Get the total count of all deals in this stage
533 | totalCount
534 | }
535 | }
536 | `;
537 |
538 | // Query to get latest activities deals
539 | export const DASHBOARD_LATEST_ACTIVITIES_DEALS_QUERY = gql`
540 | query DashboardLatestActivitiesDeals(
541 | $filter: DealFilter!
542 | $sorting: [DealSort!]
543 | $paging: OffsetPaging
544 | ) {
545 | deals(filter: $filter, sorting: $sorting, paging: $paging) {
546 | totalCount
547 | nodes {
548 | id
549 | title
550 | stage {
551 | id
552 | title
553 | }
554 | company {
555 | id
556 | name
557 | avatarUrl
558 | }
559 | createdAt
560 | }
561 | }
562 | }
563 | `;
564 |
565 | // Query to get latest activities audits
566 | export const DASHBOARD_LATEST_ACTIVITIES_AUDITS_QUERY = gql`
567 | query DashboardLatestActivitiesAudits(
568 | $filter: AuditFilter!
569 | $sorting: [AuditSort!]
570 | $paging: OffsetPaging
571 | ) {
572 | audits(filter: $filter, sorting: $sorting, paging: $paging) {
573 | totalCount
574 | nodes {
575 | id
576 | action
577 | targetEntity
578 | targetId
579 | changes {
580 | field
581 | from
582 | to
583 | }
584 | createdAt
585 | user {
586 | id
587 | name
588 | avatarUrl
589 | }
590 | }
591 | }
592 | }
593 | `;
594 |
595 | // Query to get companies list
596 | export const COMPANIES_LIST_QUERY = gql`
597 | query CompaniesList(
598 | $filter: CompanyFilter!
599 | $sorting: [CompanySort!]
600 | $paging: OffsetPaging!
601 | ) {
602 | companies(filter: $filter, sorting: $sorting, paging: $paging) {
603 | totalCount
604 | nodes {
605 | id
606 | name
607 | avatarUrl
608 | # Get the sum of all deals in this company
609 | dealsAggregate {
610 | sum {
611 | value
612 | }
613 | }
614 | }
615 | }
616 | }
617 | `;
618 |
619 | // Query to get users list
620 | export const USERS_SELECT_QUERY = gql`
621 | query UsersSelect(
622 | $filter: UserFilter!
623 | $sorting: [UserSort!]
624 | $paging: OffsetPaging!
625 | ) {
626 | # Get all users
627 | users(filter: $filter, sorting: $sorting, paging: $paging) {
628 | totalCount # Get the total count of users
629 | # Get specific fields for each user
630 | nodes {
631 | id
632 | name
633 | avatarUrl
634 | }
635 | }
636 | }
637 | `;
638 |
639 | // Query to get contacts associated with a company
640 | export const COMPANY_CONTACTS_TABLE_QUERY = gql`
641 | query CompanyContactsTable(
642 | $filter: ContactFilter!
643 | $sorting: [ContactSort!]
644 | $paging: OffsetPaging!
645 | ) {
646 | contacts(filter: $filter, sorting: $sorting, paging: $paging) {
647 | totalCount
648 | nodes {
649 | id
650 | name
651 | avatarUrl
652 | jobTitle
653 | email
654 | phone
655 | status
656 | }
657 | }
658 | }
659 | `;
660 |
661 | // Query to get task stages list
662 | export const TASK_STAGES_QUERY = gql`
663 | query TaskStages(
664 | $filter: TaskStageFilter!
665 | $sorting: [TaskStageSort!]
666 | $paging: OffsetPaging!
667 | ) {
668 | taskStages(filter: $filter, sorting: $sorting, paging: $paging) {
669 | totalCount # Get the total count of task stages
670 | nodes {
671 | id
672 | title
673 | }
674 | }
675 | }
676 | `;
677 |
678 | // Query to get tasks list
679 | export const TASKS_QUERY = gql`
680 | query Tasks(
681 | $filter: TaskFilter!
682 | $sorting: [TaskSort!]
683 | $paging: OffsetPaging!
684 | ) {
685 | tasks(filter: $filter, sorting: $sorting, paging: $paging) {
686 | totalCount # Get the total count of tasks
687 | nodes {
688 | id
689 | title
690 | description
691 | dueDate
692 | completed
693 | stageId
694 | # Get user details associated with this task
695 | users {
696 | id
697 | name
698 | avatarUrl
699 | }
700 | createdAt
701 | updatedAt
702 | }
703 | }
704 | }
705 | `;
706 |
707 | // Query to get task stages for select
708 | export const TASK_STAGES_SELECT_QUERY = gql`
709 | query TaskStagesSelect(
710 | $filter: TaskStageFilter!
711 | $sorting: [TaskStageSort!]
712 | $paging: OffsetPaging!
713 | ) {
714 | taskStages(filter: $filter, sorting: $sorting, paging: $paging) {
715 | totalCount
716 | nodes {
717 | id
718 | title
719 | }
720 | }
721 | }
722 | `;
723 | ```
724 |
725 |
726 |
727 |
728 | text.tsx
729 |
730 | ```typescript
731 | import React from "react";
732 |
733 | import { ConfigProvider, Typography } from "antd";
734 |
735 | export type TextProps = {
736 | size?:
737 | | "xs"
738 | | "sm"
739 | | "md"
740 | | "lg"
741 | | "xl"
742 | | "xxl"
743 | | "xxxl"
744 | | "huge"
745 | | "xhuge"
746 | | "xxhuge";
747 | } & React.ComponentProps;
748 |
749 | // define the font sizes and line heights
750 | const sizes = {
751 | xs: {
752 | fontSize: 12,
753 | lineHeight: 20 / 12,
754 | },
755 | sm: {
756 | fontSize: 14,
757 | lineHeight: 22 / 14,
758 | },
759 | md: {
760 | fontSize: 16,
761 | lineHeight: 24 / 16,
762 | },
763 | lg: {
764 | fontSize: 20,
765 | lineHeight: 28 / 20,
766 | },
767 | xl: {
768 | fontSize: 24,
769 | lineHeight: 32 / 24,
770 | },
771 | xxl: {
772 | fontSize: 30,
773 | lineHeight: 38 / 30,
774 | },
775 | xxxl: {
776 | fontSize: 38,
777 | lineHeight: 46 / 38,
778 | },
779 | huge: {
780 | fontSize: 46,
781 | lineHeight: 54 / 46,
782 | },
783 | xhuge: {
784 | fontSize: 56,
785 | lineHeight: 64 / 56,
786 | },
787 | xxhuge: {
788 | fontSize: 68,
789 | lineHeight: 76 / 68,
790 | },
791 | };
792 |
793 | // a custom Text component that wraps/extends the antd Typography.Text component
794 | export const Text = ({ size = "sm", children, ...rest }: TextProps) => {
795 | return (
796 | // config provider is a top-level component that allows us to customize the global properties of antd components. For example, default antd theme
797 | // token is a term used by antd to refer to the design tokens like font size, font weight, color, etc
798 | // https://ant.design/docs/react/customize-theme#customize-design-token
799 |
806 | {/**
807 | * Typography.Text is a component from antd that allows us to render text
808 | * Typography has different components like Title, Paragraph, Text, Link, etc
809 | * https://ant.design/components/typography/#Typography.Text
810 | */}
811 | {children}
812 |
813 | );
814 | };
815 | ```
816 |
817 |
818 |
819 |
820 | components/layout/account-settings.tsx
821 |
822 | ```typescript
823 | import { SaveButton, useForm } from "@refinedev/antd";
824 | import { HttpError } from "@refinedev/core";
825 | import { GetFields, GetVariables } from "@refinedev/nestjs-query";
826 |
827 | import { CloseOutlined } from "@ant-design/icons";
828 | import { Button, Card, Drawer, Form, Input, Spin } from "antd";
829 |
830 | import { getNameInitials } from "@/utilities";
831 | import { UPDATE_USER_MUTATION } from "@/graphql/mutations";
832 |
833 | import { Text } from "../text";
834 | import CustomAvatar from "../custom-avatar";
835 |
836 | import {
837 | UpdateUserMutation,
838 | UpdateUserMutationVariables,
839 | } from "@/graphql/types";
840 |
841 | type Props = {
842 | opened: boolean;
843 | setOpened: (opened: boolean) => void;
844 | userId: string;
845 | };
846 |
847 | export const AccountSettings = ({ opened, setOpened, userId }: Props) => {
848 | /**
849 | * useForm in Refine is used to manage forms. It provides us with a lot of useful props and methods that we can use to manage forms.
850 | * https://refine.dev/docs/data/hooks/use-form/#usage
851 | */
852 |
853 | /**
854 | * saveButtonProps -> contains all the props needed by the "submit" button. For example, "loading", "disabled", "onClick", etc.
855 | * https://refine.dev/docs/ui-integrations/ant-design/hooks/use-form/#savebuttonprops
856 | *
857 | * formProps -> It's an instance of HTML form that manages form state and actions like onFinish, onValuesChange, etc.
858 | * https://refine.dev/docs/ui-integrations/ant-design/hooks/use-form/#form
859 | *
860 | * queryResult -> contains the result of the query. For example, isLoading, data, error, etc.
861 | * https://refine.dev/docs/packages/react-hook-form/use-form/#queryresult
862 | */
863 | const { saveButtonProps, formProps, queryResult } = useForm<
864 | /**
865 | * GetFields is used to get the fields of the mutation i.e., in this case, fields are name, email, jobTitle, and phone
866 | * https://refine.dev/docs/data/packages/nestjs-query/#getfields
867 | */
868 | GetFields,
869 | // a type that represents an HTTP error. Used to specify the type of error mutation can throw.
870 | HttpError,
871 | // A third type parameter used to specify the type of variables for the UpdateUserMutation. Meaning that the variables for the UpdateUserMutation should be of type UpdateUserMutationVariables
872 | GetVariables
873 | >({
874 | /**
875 | * mutationMode is used to determine how the mutation should be performed. For example, optimistic, pessimistic, undoable etc.
876 | * optimistic -> redirection and UI updates are executed immediately as if the mutation is successful.
877 | * pessimistic -> redirection and UI updates are executed after the mutation is successful.
878 | * https://refine.dev/docs/advanced-tutorials/mutation-mode/#overview
879 | */
880 | mutationMode: "optimistic",
881 | /**
882 | * specify on which resource the mutation should be performed
883 | * if not specified, Refine will determine the resource name by the current route
884 | */
885 | resource: "users",
886 | /**
887 | * specify the action that should be performed on the resource. Behind the scenes, Refine calls useOne hook to get the data of the user for edit action.
888 | * https://refine.dev/docs/data/hooks/use-form/#edit
889 | */
890 | action: "edit",
891 | id: userId,
892 | /**
893 | * used to provide any additional information to the data provider.
894 | * https://refine.dev/docs/data/hooks/use-form/#meta-
895 | */
896 | meta: {
897 | // gqlMutation is used to specify the mutation that should be performed.
898 | gqlMutation: UPDATE_USER_MUTATION,
899 | },
900 | });
901 | const { avatarUrl, name } = queryResult?.data?.data || {};
902 |
903 | const closeModal = () => {
904 | setOpened(false);
905 | };
906 |
907 | // if query is processing, show a loading indicator
908 | if (queryResult?.isLoading) {
909 | return (
910 |
922 |
923 |
924 | );
925 | }
926 |
927 | return (
928 |
937 |
946 | Account Settings
947 | }
950 | onClick={() => closeModal()}
951 | />
952 |
953 |
958 |
959 |
971 |
972 |
973 |
974 |
975 |
976 |
977 |
978 |
979 |
980 |
981 |
982 |
983 |
990 |
991 |
992 |
993 | );
994 | };
995 | ```
996 |
997 |
998 |
999 |
1000 | constants/index.tsx
1001 |
1002 | ```typescript
1003 | import { AuditOutlined, ShopOutlined, TeamOutlined } from "@ant-design/icons";
1004 |
1005 | const IconWrapper = ({
1006 | color,
1007 | children,
1008 | }: React.PropsWithChildren<{ color: string }>) => {
1009 | return (
1010 |
1021 | {children}
1022 |
1023 | );
1024 | };
1025 |
1026 | import {
1027 | BusinessType,
1028 | CompanySize,
1029 | Contact,
1030 | Industry,
1031 | } from "@/graphql/schema.types";
1032 |
1033 | export type TotalCountType = "companies" | "contacts" | "deals";
1034 |
1035 | export const totalCountVariants: {
1036 | [key in TotalCountType]: {
1037 | primaryColor: string;
1038 | secondaryColor?: string;
1039 | icon: React.ReactNode;
1040 | title: string;
1041 | data: { index: string; value: number }[];
1042 | };
1043 | } = {
1044 | companies: {
1045 | primaryColor: "#1677FF",
1046 | secondaryColor: "#BAE0FF",
1047 | icon: (
1048 |
1049 |
1055 |
1056 | ),
1057 | title: "Number of companies",
1058 | data: [
1059 | {
1060 | index: "1",
1061 | value: 3500,
1062 | },
1063 | {
1064 | index: "2",
1065 | value: 2750,
1066 | },
1067 | {
1068 | index: "3",
1069 | value: 5000,
1070 | },
1071 | {
1072 | index: "4",
1073 | value: 4250,
1074 | },
1075 | {
1076 | index: "5",
1077 | value: 5000,
1078 | },
1079 | ],
1080 | },
1081 | contacts: {
1082 | primaryColor: "#52C41A",
1083 | secondaryColor: "#D9F7BE",
1084 | icon: (
1085 |
1086 |
1092 |
1093 | ),
1094 | title: "Number of contacts",
1095 | data: [
1096 | {
1097 | index: "1",
1098 | value: 10000,
1099 | },
1100 | {
1101 | index: "2",
1102 | value: 19500,
1103 | },
1104 | {
1105 | index: "3",
1106 | value: 13000,
1107 | },
1108 | {
1109 | index: "4",
1110 | value: 17000,
1111 | },
1112 | {
1113 | index: "5",
1114 | value: 13000,
1115 | },
1116 | {
1117 | index: "6",
1118 | value: 20000,
1119 | },
1120 | ],
1121 | },
1122 | deals: {
1123 | primaryColor: "#FA541C",
1124 | secondaryColor: "#FFD8BF",
1125 | icon: (
1126 |
1127 |
1133 |
1134 | ),
1135 | title: "Total deals in pipeline",
1136 | data: [
1137 | {
1138 | index: "1",
1139 | value: 1000,
1140 | },
1141 | {
1142 | index: "2",
1143 | value: 1300,
1144 | },
1145 | {
1146 | index: "3",
1147 | value: 1200,
1148 | },
1149 | {
1150 | index: "4",
1151 | value: 2000,
1152 | },
1153 | {
1154 | index: "5",
1155 | value: 800,
1156 | },
1157 | {
1158 | index: "6",
1159 | value: 1700,
1160 | },
1161 | {
1162 | index: "7",
1163 | value: 1400,
1164 | },
1165 | {
1166 | index: "8",
1167 | value: 1800,
1168 | },
1169 | ],
1170 | },
1171 | };
1172 |
1173 | export const statusOptions: {
1174 | label: string;
1175 | value: Contact["status"];
1176 | }[] = [
1177 | {
1178 | label: "New",
1179 | value: "NEW",
1180 | },
1181 | {
1182 | label: "Qualified",
1183 | value: "QUALIFIED",
1184 | },
1185 | {
1186 | label: "Unqualified",
1187 | value: "UNQUALIFIED",
1188 | },
1189 | {
1190 | label: "Won",
1191 | value: "WON",
1192 | },
1193 | {
1194 | label: "Negotiation",
1195 | value: "NEGOTIATION",
1196 | },
1197 | {
1198 | label: "Lost",
1199 | value: "LOST",
1200 | },
1201 | {
1202 | label: "Interested",
1203 | value: "INTERESTED",
1204 | },
1205 | {
1206 | label: "Contacted",
1207 | value: "CONTACTED",
1208 | },
1209 | {
1210 | label: "Churned",
1211 | value: "CHURNED",
1212 | },
1213 | ];
1214 |
1215 | export const companySizeOptions: {
1216 | label: string;
1217 | value: CompanySize;
1218 | }[] = [
1219 | {
1220 | label: "Enterprise",
1221 | value: "ENTERPRISE",
1222 | },
1223 | {
1224 | label: "Large",
1225 | value: "LARGE",
1226 | },
1227 | {
1228 | label: "Medium",
1229 | value: "MEDIUM",
1230 | },
1231 | {
1232 | label: "Small",
1233 | value: "SMALL",
1234 | },
1235 | ];
1236 |
1237 | export const industryOptions: {
1238 | label: string;
1239 | value: Industry;
1240 | }[] = [
1241 | { label: "Aerospace", value: "AEROSPACE" },
1242 | { label: "Agriculture", value: "AGRICULTURE" },
1243 | { label: "Automotive", value: "AUTOMOTIVE" },
1244 | { label: "Chemicals", value: "CHEMICALS" },
1245 | { label: "Construction", value: "CONSTRUCTION" },
1246 | { label: "Defense", value: "DEFENSE" },
1247 | { label: "Education", value: "EDUCATION" },
1248 | { label: "Energy", value: "ENERGY" },
1249 | { label: "Financial Services", value: "FINANCIAL_SERVICES" },
1250 | { label: "Food and Beverage", value: "FOOD_AND_BEVERAGE" },
1251 | { label: "Government", value: "GOVERNMENT" },
1252 | { label: "Healthcare", value: "HEALTHCARE" },
1253 | { label: "Hospitality", value: "HOSPITALITY" },
1254 | { label: "Industrial Manufacturing", value: "INDUSTRIAL_MANUFACTURING" },
1255 | { label: "Insurance", value: "INSURANCE" },
1256 | { label: "Life Sciences", value: "LIFE_SCIENCES" },
1257 | { label: "Logistics", value: "LOGISTICS" },
1258 | { label: "Media", value: "MEDIA" },
1259 | { label: "Mining", value: "MINING" },
1260 | { label: "Nonprofit", value: "NONPROFIT" },
1261 | { label: "Other", value: "OTHER" },
1262 | { label: "Pharmaceuticals", value: "PHARMACEUTICALS" },
1263 | { label: "Professional Services", value: "PROFESSIONAL_SERVICES" },
1264 | { label: "Real Estate", value: "REAL_ESTATE" },
1265 | { label: "Retail", value: "RETAIL" },
1266 | { label: "Technology", value: "TECHNOLOGY" },
1267 | { label: "Telecommunications", value: "TELECOMMUNICATIONS" },
1268 | { label: "Transportation", value: "TRANSPORTATION" },
1269 | { label: "Utilities", value: "UTILITIES" },
1270 | ];
1271 |
1272 | export const businessTypeOptions: {
1273 | label: string;
1274 | value: BusinessType;
1275 | }[] = [
1276 | {
1277 | label: "B2B",
1278 | value: "B2B",
1279 | },
1280 | {
1281 | label: "B2C",
1282 | value: "B2C",
1283 | },
1284 | {
1285 | label: "B2G",
1286 | value: "B2G",
1287 | },
1288 | ];
1289 | ```
1290 |
1291 |
1292 |
1293 |
1294 | pages/company/contacts-table.tsx
1295 |
1296 | ```typescript
1297 | import { useParams } from "react-router-dom";
1298 |
1299 | import { FilterDropdown, useTable } from "@refinedev/antd";
1300 | import { GetFieldsFromList } from "@refinedev/nestjs-query";
1301 |
1302 | import {
1303 | MailOutlined,
1304 | PhoneOutlined,
1305 | SearchOutlined,
1306 | TeamOutlined,
1307 | } from "@ant-design/icons";
1308 | import { Button, Card, Input, Select, Space, Table } from "antd";
1309 |
1310 | import { Contact } from "@/graphql/schema.types";
1311 |
1312 | import { statusOptions } from "@/constants";
1313 | import { COMPANY_CONTACTS_TABLE_QUERY } from "@/graphql/queries";
1314 |
1315 | import { CompanyContactsTableQuery } from "@/graphql/types";
1316 | import { Text } from "@/components/text";
1317 | import CustomAvatar from "@/components/custom-avatar";
1318 | import { ContactStatusTag } from "@/components/tags/contact-status-tag";
1319 |
1320 | export const CompanyContactsTable = () => {
1321 | // get params from the url
1322 | const params = useParams();
1323 |
1324 | /**
1325 | * Refine offers a TanStack Table adapter with @refinedev/react-table that allows us to use the TanStack Table library with Refine.
1326 | * All features such as sorting, filtering, and pagination come out of the box
1327 | * Under the hood it uses useList hook to fetch the data.
1328 | * https://refine.dev/docs/packages/tanstack-table/use-table/#installation
1329 | */
1330 | const { tableProps } = useTable>(
1331 | {
1332 | // specify the resource for which the table is to be used
1333 | resource: "contacts",
1334 | syncWithLocation: false,
1335 | // specify initial sorters
1336 | sorters: {
1337 | /**
1338 | * initial sets the initial value of sorters.
1339 | * it's not permanent
1340 | * it will be cleared when the user changes the sorting
1341 | * https://refine.dev/docs/ui-integrations/ant-design/hooks/use-table/#sortersinitial
1342 | */
1343 | initial: [
1344 | {
1345 | field: "createdAt",
1346 | order: "desc",
1347 | },
1348 | ],
1349 | },
1350 | // specify initial filters
1351 | filters: {
1352 | /**
1353 | * similar to initial in sorters
1354 | * https://refine.dev/docs/ui-integrations/ant-design/hooks/use-table/#filtersinitial
1355 | */
1356 | initial: [
1357 | {
1358 | field: "jobTitle",
1359 | value: "",
1360 | operator: "contains",
1361 | },
1362 | {
1363 | field: "name",
1364 | value: "",
1365 | operator: "contains",
1366 | },
1367 | {
1368 | field: "status",
1369 | value: undefined,
1370 | operator: "in",
1371 | },
1372 | ],
1373 | /**
1374 | * permanent filters are the filters that are always applied
1375 | * https://refine.dev/docs/ui-integrations/ant-design/hooks/use-table/#filterspermanent
1376 | */
1377 | permanent: [
1378 | {
1379 | field: "company.id",
1380 | operator: "eq",
1381 | value: params?.id as string,
1382 | },
1383 | ],
1384 | },
1385 | /**
1386 | * used to provide any additional information to the data provider.
1387 | * https://refine.dev/docs/data/hooks/use-form/#meta-
1388 | */
1389 | meta: {
1390 | // gqlQuery is used to specify the GraphQL query that should be used to fetch the data.
1391 | gqlQuery: COMPANY_CONTACTS_TABLE_QUERY,
1392 | },
1393 | },
1394 | );
1395 |
1396 | return (
1397 |
1405 |
1406 | Contacts
1407 |
1408 | }
1409 | // property used to render additional content in the top-right corner of the card
1410 | extra={
1411 | <>
1412 | Total contacts:
1413 |
1414 | {/* if pagination is not disabled and total is provided then show the total */}
1415 | {tableProps?.pagination !== false && tableProps.pagination?.total}
1416 |
1417 | >
1418 | }
1419 | >
1420 |
1498 |
1499 | );
1500 | };
1501 | ```
1502 |
1503 |
1504 |
1505 |
1506 | components/tags/contact-status-tag.tsx
1507 |
1508 | ```typescript
1509 | import React from "react";
1510 |
1511 | import {
1512 | CheckCircleOutlined,
1513 | MinusCircleOutlined,
1514 | PlayCircleFilled,
1515 | PlayCircleOutlined,
1516 | } from "@ant-design/icons";
1517 | import { Tag, TagProps } from "antd";
1518 |
1519 | import { ContactStatus } from "@/graphql/schema.types";
1520 |
1521 | type Props = {
1522 | status: ContactStatus;
1523 | };
1524 |
1525 | /**
1526 | * Renders a tag component representing the contact status.
1527 | * @param status - The contact status.
1528 | */
1529 | export const ContactStatusTag = ({ status }: Props) => {
1530 | let icon: React.ReactNode = null;
1531 | let color: TagProps["color"] = undefined;
1532 |
1533 | switch (status) {
1534 | case "NEW":
1535 | case "CONTACTED":
1536 | case "INTERESTED":
1537 | icon = ;
1538 | color = "cyan";
1539 | break;
1540 |
1541 | case "UNQUALIFIED":
1542 | icon = ;
1543 | color = "red";
1544 | break;
1545 |
1546 | case "QUALIFIED":
1547 | case "NEGOTIATION":
1548 | icon = ;
1549 | color = "green";
1550 | break;
1551 |
1552 | case "LOST":
1553 | icon = ;
1554 | color = "red";
1555 | break;
1556 |
1557 | case "WON":
1558 | icon = ;
1559 | color = "green";
1560 | break;
1561 |
1562 | case "CHURNED":
1563 | icon = ;
1564 | color = "red";
1565 | break;
1566 |
1567 | default:
1568 | break;
1569 | }
1570 |
1571 | return (
1572 |
1573 | {icon} {status.toLowerCase()}
1574 |
1575 | );
1576 | };
1577 | ```
1578 |
1579 |
1580 |
1581 |
1582 |
1583 | components/text-icon.tsx
1584 |
1585 | ```typescript
1586 | import Icon from "@ant-design/icons";
1587 | import type { CustomIconComponentProps } from "@ant-design/icons/lib/components/Icon";
1588 |
1589 | export const TextIconSvg = () => (
1590 |
1597 |
1602 |
1607 |
1612 |
1613 | );
1614 |
1615 | export const TextIcon = (props: Partial) => (
1616 |
1617 | );
1618 | ```
1619 |
1620 |
1621 |
1622 |
1623 | components/tasks/kanban/add-card-button.tsx
1624 |
1625 | ```typescript
1626 | import React from "react";
1627 |
1628 | import { PlusSquareOutlined } from "@ant-design/icons";
1629 | import { Button } from "antd";
1630 | import { Text } from "@/components/text";
1631 |
1632 | interface Props {
1633 | onClick: () => void;
1634 | }
1635 |
1636 | /** Render a button that allows you to add a new card to a column.
1637 | *
1638 | * @param onClick - a function that is called when the button is clicked.
1639 | * @returns a button that allows you to add a new card to a column.
1640 | */
1641 | export const KanbanAddCardButton = ({
1642 | children,
1643 | onClick,
1644 | }: React.PropsWithChildren) => {
1645 | return (
1646 | }
1649 | style={{
1650 | margin: "16px",
1651 | backgroundColor: "white",
1652 | }}
1653 | onClick={onClick}
1654 | >
1655 | {children ?? (
1656 |
1657 | Add new card
1658 |
1659 | )}
1660 |
1661 | );
1662 | };
1663 | ```
1664 |
1665 |
1666 |
1667 |
1668 | pages/tasks/create.tsx
1669 |
1670 | ```typescript
1671 | import { useSearchParams } from "react-router-dom";
1672 |
1673 | import { useModalForm } from "@refinedev/antd";
1674 | import { useNavigation } from "@refinedev/core";
1675 |
1676 | import { Form, Input, Modal } from "antd";
1677 |
1678 | import { CREATE_TASK_MUTATION } from "@/graphql/mutations";
1679 |
1680 | const TasksCreatePage = () => {
1681 | // get search params from the url
1682 | const [searchParams] = useSearchParams();
1683 |
1684 | /**
1685 | * useNavigation is a hook by Refine that allows you to navigate to a page.
1686 | * https://refine.dev/docs/routing/hooks/use-navigation/
1687 | *
1688 | * list method navigates to the list page of the specified resource.
1689 | * https://refine.dev/docs/routing/hooks/use-navigation/#list
1690 | */ const { list } = useNavigation();
1691 |
1692 | /**
1693 | * useModalForm is a hook by Refine that allows you manage a form inside a modal.
1694 | * it extends the useForm hook from the @refinedev/antd package
1695 | * https://refine.dev/docs/ui-integrations/ant-design/hooks/use-modal-form/
1696 | *
1697 | * formProps -> It's an instance of HTML form that manages form state and actions like onFinish, onValuesChange, etc.
1698 | * Under the hood, it uses the useForm hook from the @refinedev/antd package
1699 | * https://refine.dev/docs/ui-integrations/ant-design/hooks/use-modal-form/#formprops
1700 | *
1701 | * modalProps -> It's an instance of Modal that manages modal state and actions like onOk, onCancel, etc.
1702 | * https://refine.dev/docs/ui-integrations/ant-design/hooks/use-modal-form/#modalprops
1703 | */
1704 | const { formProps, modalProps, close } = useModalForm({
1705 | // specify the action to perform i.e., create or edit
1706 | action: "create",
1707 | // specify whether the modal should be visible by default
1708 | defaultVisible: true,
1709 | // specify the gql mutation to be performed
1710 | meta: {
1711 | gqlMutation: CREATE_TASK_MUTATION,
1712 | },
1713 | });
1714 |
1715 | return (
1716 | {
1719 | // close the modal
1720 | close();
1721 |
1722 | // navigate to the list page of the tasks resource
1723 | list("tasks", "replace");
1724 | }}
1725 | title="Add new card"
1726 | width={512}
1727 | >
1728 |
1743 |
1744 |
1745 |
1746 |
1747 | );
1748 | }
1749 |
1750 | export default TasksCreatePage;
1751 | ```
1752 |
1753 |
1754 |
1755 |
1756 | pages/tasks/edit.tsx
1757 |
1758 | ```typescript
1759 | import { useState } from "react";
1760 |
1761 | import { DeleteButton, useModalForm } from "@refinedev/antd";
1762 | import { useNavigation } from "@refinedev/core";
1763 |
1764 | import {
1765 | AlignLeftOutlined,
1766 | FieldTimeOutlined,
1767 | UsergroupAddOutlined,
1768 | } from "@ant-design/icons";
1769 | import { Modal } from "antd";
1770 |
1771 | import {
1772 | Accordion,
1773 | DescriptionForm,
1774 | DescriptionHeader,
1775 | DueDateForm,
1776 | DueDateHeader,
1777 | StageForm,
1778 | TitleForm,
1779 | UsersForm,
1780 | UsersHeader,
1781 | } from "@/components";
1782 | import { Task } from "@/graphql/schema.types";
1783 |
1784 | import { UPDATE_TASK_MUTATION } from "@/graphql/mutations";
1785 |
1786 | const TasksEditPage = () => {
1787 | const [activeKey, setActiveKey] = useState();
1788 |
1789 | // use the list method to navigate to the list page of the tasks resource from the navigation hook
1790 | const { list } = useNavigation();
1791 |
1792 | // create a modal form to edit a task using the useModalForm hook
1793 | // modalProps -> It's an instance of Modal that manages modal state and actions like onOk, onCancel, etc.
1794 | // close -> It's a function that closes the modal
1795 | // queryResult -> It's an instance of useQuery from react-query
1796 | const { modalProps, close, queryResult } = useModalForm({
1797 | // specify the action to perform i.e., create or edit
1798 | action: "edit",
1799 | // specify whether the modal should be visible by default
1800 | defaultVisible: true,
1801 | // specify the gql mutation to be performed
1802 | meta: {
1803 | gqlMutation: UPDATE_TASK_MUTATION,
1804 | },
1805 | });
1806 |
1807 | // get the data of the task from the queryResult
1808 | const { description, dueDate, users, title } = queryResult?.data?.data ?? {};
1809 |
1810 | const isLoading = queryResult?.isLoading ?? true;
1811 |
1812 | return (
1813 | {
1817 | close();
1818 | list("tasks", "replace");
1819 | }}
1820 | title={ }
1821 | width={586}
1822 | footer={
1823 | {
1826 | list("tasks", "replace");
1827 | }}
1828 | >
1829 | Delete card
1830 |
1831 | }
1832 | >
1833 | {/* Render the stage form */}
1834 |
1835 |
1836 | {/* Render the description form inside an accordion */}
1837 | }
1842 | isLoading={isLoading}
1843 | icon={ }
1844 | label="Description"
1845 | >
1846 | setActiveKey(undefined)}
1849 | />
1850 |
1851 |
1852 | {/* Render the due date form inside an accordion */}
1853 | }
1858 | isLoading={isLoading}
1859 | icon={ }
1860 | label="Due date"
1861 | >
1862 | setActiveKey(undefined)}
1865 | />
1866 |
1867 |
1868 | {/* Render the users form inside an accordion */}
1869 | }
1874 | isLoading={isLoading}
1875 | icon={ }
1876 | label="Users"
1877 | >
1878 | ({
1881 | label: user.name,
1882 | value: user.id,
1883 | })),
1884 | }}
1885 | cancelForm={() => setActiveKey(undefined)}
1886 | />
1887 |
1888 |
1889 | );
1890 | };
1891 |
1892 | export default TasksEditPage;
1893 | ```
1894 |
1895 |
1896 |
1897 |
1898 | components/accordion.tsx
1899 |
1900 | ```typescript
1901 | import { AccordionHeaderSkeleton } from "@/components";
1902 | import { Text } from "./text";
1903 |
1904 | type Props = React.PropsWithChildren<{
1905 | accordionKey: string;
1906 | activeKey?: string;
1907 | setActive: (key?: string) => void;
1908 | fallback: string | React.ReactNode;
1909 | isLoading?: boolean;
1910 | icon: React.ReactNode;
1911 | label: string;
1912 | }>;
1913 |
1914 | /**
1915 | * when activeKey is equal to accordionKey, the children will be rendered. Otherwise, the fallback will be rendered
1916 | * when isLoading is true, the will be rendered
1917 | * when Accordion is clicked, setActive will be called with the accordionKey
1918 | */
1919 | export const Accordion = ({
1920 | accordionKey,
1921 | activeKey,
1922 | setActive,
1923 | fallback,
1924 | icon,
1925 | label,
1926 | children,
1927 | isLoading,
1928 | }: Props) => {
1929 | if (isLoading) return ;
1930 |
1931 | const isActive = activeKey === accordionKey;
1932 |
1933 | const toggleAccordion = () => {
1934 | if (isActive) {
1935 | setActive(undefined);
1936 | } else {
1937 | setActive(accordionKey);
1938 | }
1939 | };
1940 |
1941 | return (
1942 |
1951 |
{icon}
1952 | {isActive ? (
1953 |
1961 |
1962 | {label}
1963 |
1964 | {children}
1965 |
1966 | ) : (
1967 |
1968 | {fallback}
1969 |
1970 | )}
1971 |
1972 | );
1973 | };
1974 | ```
1975 |
1976 |
1977 |
1978 |
1979 | components/tags/user-tag.tsx
1980 |
1981 | ```typescript
1982 | import { Space, Tag } from "antd";
1983 |
1984 | import { User } from "@/graphql/schema.types";
1985 | import CustomAvatar from "../custom-avatar";
1986 |
1987 | type Props = {
1988 | user: User;
1989 | };
1990 |
1991 | // display a user's avatar and name in a tag
1992 | export const UserTag = ({ user }: Props) => {
1993 | return (
1994 |
2004 |
2005 |
2010 | {user.name}
2011 |
2012 |
2013 | );
2014 | };
2015 | ```
2016 |
2017 |
2018 |
2019 | ## 🔗 Links
2020 |
2021 | Other components (Kanban Edit Forms, Skeletons and utilities) used in the project can be found [here](https://drive.google.com/drive/folders/1oDFoI-a8qSJqHde6doUtM9NCHlYpWdNW?usp=sharing)
2022 |
2023 | ## 🚀 More
2024 |
2025 | **Advance your skills with Next.js 14 Pro Course**
2026 |
2027 | Enjoyed creating this project? Dive deeper into our PRO courses for a richer learning adventure. They're packed with detailed explanations, cool features, and exercises to boost your skills. Give it a go!
2028 |
2029 |
2030 |
2031 |
2032 |
2033 |
2034 |
2035 |
2036 | **Accelerate your professional journey with the Expert Training program**
2037 |
2038 | And if you're hungry for more than just a course and want to understand how we learn and tackle tech challenges, hop into our personalized masterclass. We cover best practices, different web skills, and offer mentorship to boost your confidence. Let's learn and grow together!
2039 |
2040 |
2041 |
2042 |
2043 |
2044 | #
2045 |
--------------------------------------------------------------------------------