├── .eslintrc.cjs ├── .gitignore ├── .npmrc ├── README.MD ├── graphql.config.ts ├── index.html ├── package-lock.json ├── package.json ├── public └── favicon.ico ├── src ├── App.tsx ├── components │ ├── accordion.tsx │ ├── custom-avatar.tsx │ ├── home │ │ ├── deals-chart.tsx │ │ ├── latest-activities.tsx │ │ ├── total-count-card.tsx │ │ └── upcoming-events.tsx │ ├── index.ts │ ├── layout │ │ ├── account-settings.tsx │ │ ├── current-user.tsx │ │ ├── header.tsx │ │ └── index.tsx │ ├── select-option-with-avatar.tsx │ ├── skeleton │ │ ├── accordion-header.tsx │ │ ├── kanban.tsx │ │ ├── latest-activities.tsx │ │ ├── project-card.tsx │ │ └── upcoming-events.tsx │ ├── tags │ │ ├── contact-status-tag.tsx │ │ └── user-tag.tsx │ ├── tasks │ │ ├── form │ │ │ ├── description.tsx │ │ │ ├── due-date.tsx │ │ │ ├── header.tsx │ │ │ ├── stage.tsx │ │ │ ├── title.tsx │ │ │ └── users.tsx │ │ └── kanban │ │ │ ├── add-card-button.tsx │ │ │ ├── board.tsx │ │ │ ├── card.tsx │ │ │ ├── column.tsx │ │ │ └── item.tsx │ ├── text-icon.tsx │ └── text.tsx ├── config │ └── resources.tsx ├── constants │ └── index.tsx ├── graphql │ ├── mutations.ts │ ├── queries.ts │ ├── schema.types.ts │ └── types.ts ├── index.tsx ├── pages │ ├── company │ │ ├── contacts-table.tsx │ │ ├── create.tsx │ │ ├── edit.tsx │ │ └── list.tsx │ ├── forgotPassword │ │ └── index.tsx │ ├── home │ │ └── index.tsx │ ├── index.ts │ ├── login │ │ └── index.tsx │ ├── register │ │ └── index.tsx │ └── tasks │ │ ├── create.tsx │ │ ├── edit.tsx │ │ └── list.tsx ├── providers │ ├── auth.ts │ ├── data │ │ ├── fetch-wrapper.ts │ │ └── index.tsx │ └── index.ts ├── utilities │ ├── currency-number.ts │ ├── date │ │ ├── get-date-colors.ts │ │ └── index.ts │ ├── get-name-initials.ts │ ├── get-random-color.ts │ ├── helpers.ts │ └── index.ts └── vite-env.d.ts ├── tsconfig.json ├── tsconfig.node.json └── vite.config.ts /.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 | -------------------------------------------------------------------------------- /.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* -------------------------------------------------------------------------------- /.npmrc: -------------------------------------------------------------------------------- 1 | legacy-peer-deps=true 2 | strict-peer-dependencies=false -------------------------------------------------------------------------------- /README.MD: -------------------------------------------------------------------------------- 1 |
2 |
3 | 4 | Project Banner 5 | 6 |
7 | 8 |
9 | react.js 10 | typescript 11 | refine 12 | antd 13 |
14 | 15 |

A CRM Dashboard

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