├── .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 |
5 |
6 |
7 |
8 |
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 | }
947 | onClick={() => closeModal()}
948 | />
949 |
950 |
955 |
956 |
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 |
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 | }
1646 | style={{
1647 | margin: "16px",
1648 | backgroundColor: "white",
1649 | }}
1650 | onClick={onClick}
1651 | >
1652 | {children ?? (
1653 |
1654 | Add new card
1655 |
1656 | )}
1657 |
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 |
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 | You need to enable JavaScript to run this app.
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 | }
128 | onClick={() => closeModal()}
129 | />
130 |
131 |
136 |
137 |
149 |
150 |
151 |
152 |
153 |
154 |
155 |
156 |
157 |
158 |
159 |
160 |
161 |
168 |
169 |
170 |
171 | );
172 | };
--------------------------------------------------------------------------------
/src/components/layout/current-user.tsx:
--------------------------------------------------------------------------------
1 | import { Popover, Button } from 'antd'
2 | import CustomAvatar from '../custom-avatar'
3 | import { useGetIdentity } from '@refinedev/core'
4 |
5 | import type { User } from '@/graphql/schema.types'
6 | import { Text } from '../text'
7 | import { SettingOutlined } from '@ant-design/icons'
8 | import { useState } from 'react'
9 | import { AccountSettings } from './account-settings'
10 |
11 | const CurrentUser = () => {
12 | const [isOpen, setIsOpen] = useState(false)
13 | const { data: user } = useGetIdentity()
14 |
15 | const content = (
16 |
20 |
24 | {user?.name}
25 |
26 |
35 | }
38 | type="text"
39 | block
40 | onClick={() => setIsOpen(true)}
41 | >
42 | Account Settings
43 |
44 |
45 |
46 | )
47 |
48 | return (
49 | <>
50 |
57 |
63 |
64 | {user && (
65 |
70 | )}
71 | >
72 | )
73 | }
74 |
75 | export default CurrentUser
--------------------------------------------------------------------------------
/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 |
35 | }
36 | />
37 | } />
38 |
39 |
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 |
59 |
60 |
61 |
62 |
70 |
71 |
72 | Cancel
73 |
74 |
75 | Save
76 |
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 |
{
68 | if (!value) return { value: undefined };
69 | return { value: dayjs(value) };
70 | }}
71 | >
72 |
80 |
81 |
82 |
83 |
84 | Cancel
85 |
86 |
87 | Save
88 |
89 |
90 |
91 | );
92 | };
93 |
--------------------------------------------------------------------------------
/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 |
130 | Mark as complete
131 |
132 |
133 |
134 | );
135 | };
136 |
--------------------------------------------------------------------------------
/src/components/tasks/form/title.tsx:
--------------------------------------------------------------------------------
1 | import React from "react";
2 |
3 | import { useForm } from "@refinedev/antd";
4 | import { HttpError, useInvalidate } from "@refinedev/core";
5 | import { GetFields, GetVariables } from "@refinedev/nestjs-query";
6 |
7 | import { Form, Skeleton } from "antd";
8 |
9 | import { Text } from "@/components";
10 | import { Task } from "@/graphql/schema.types";
11 | import {
12 | UpdateTaskMutation,
13 | UpdateTaskMutationVariables,
14 | } from "@/graphql/types";
15 |
16 | import { UPDATE_TASK_MUTATION } from "@/graphql/mutations";
17 |
18 | const TitleInput = ({
19 | value,
20 | onChange,
21 | }: {
22 | value?: string;
23 | onChange?: (value: string) => void;
24 | }) => {
25 | const onTitleChange = (newTitle: string) => {
26 | onChange?.(newTitle);
27 | };
28 |
29 | return (
30 |
36 | {value}
37 |
38 | );
39 | };
40 |
41 | type Props = {
42 | initialValues: {
43 | title?: Task["title"];
44 | };
45 | isLoading?: boolean;
46 | };
47 |
48 | export const TitleForm = ({ initialValues, isLoading }: Props) => {
49 | /**
50 | * useInvalidate is used to invalidate the state of a particular resource or dataProvider
51 | * Means, it will refetch the data from the server and update the state of the resource or dataProvider. We can also specify which part of the state we want to invalidate.
52 | * We typically use this hook when we want to refetch the data from the server after a mutation is successful.
53 | *
54 | * https://refine.dev/docs/data/hooks/use-invalidate/
55 | */
56 | const invalidate = useInvalidate();
57 |
58 | // use the useForm hook to manage the form for adding a title to a task
59 | const { formProps } = useForm<
60 | GetFields,
61 | HttpError,
62 | /**
63 | * Pick is a utility type from typescript that allows you to create a new type from an existing type by picking some properties from it.
64 | * https://www.typescriptlang.org/docs/handbook/utility-types.html#picktype-keys
65 | *
66 | * Pick
67 | * Type -> the type from which we want to pick the properties
68 | * Keys -> the properties that we want to pick
69 | */
70 | Pick, "title">
71 | >({
72 | queryOptions: {
73 | // disable the query to prevent fetching data on component mount
74 | enabled: false,
75 | },
76 | redirect: false, // disable redirection
77 | warnWhenUnsavedChanges: false, // disable warning when there are unsaved changes
78 | /**
79 | * autoSave is used to automatically save the form when the value of the form changes. It accepts an object with 1 property:
80 | * enabled: boolean - whether to enable autoSave or not
81 | *
82 | * https://refine.dev/docs/ui-integrations/ant-design/hooks/use-form/#autosave
83 | *
84 | * In this case, we are enabling autoSave.
85 | */
86 | autoSave: {
87 | enabled: true,
88 | },
89 | // invalidate the list page of the tasks resource when the mutation is successful
90 | onMutationSuccess: () => {
91 | // refetch the list page of the tasks resource
92 | invalidate({ invalidates: ["list"], resource: "tasks" });
93 | },
94 | meta: {
95 | gqlMutation: UPDATE_TASK_MUTATION,
96 | },
97 | });
98 |
99 | // set the title of the form to the title of the task
100 | React.useEffect(() => {
101 | formProps.form?.setFieldsValue(initialValues);
102 | }, [initialValues.title]);
103 |
104 | if (isLoading) {
105 | return (
106 |
111 | );
112 | }
113 |
114 | return (
115 |
117 |
118 |
119 |
120 | );
121 | };
122 |
--------------------------------------------------------------------------------
/src/components/tasks/form/users.tsx:
--------------------------------------------------------------------------------
1 | import { useForm, useSelect } from "@refinedev/antd";
2 | import { HttpError } from "@refinedev/core";
3 | import {
4 | GetFields,
5 | GetFieldsFromList,
6 | GetVariables,
7 | } from "@refinedev/nestjs-query";
8 |
9 | import { Button, Form, Select, Space } from "antd";
10 |
11 | import {
12 | UpdateTaskMutation,
13 | UpdateTaskMutationVariables,
14 | UsersSelectQuery,
15 | } from "@/graphql/types";
16 |
17 | import { USERS_SELECT_QUERY } from "@/graphql/queries";
18 | import { UPDATE_TASK_MUTATION } from "@/graphql/mutations";
19 |
20 | type Props = {
21 | initialValues: {
22 | userIds?: { label: string; value: string }[];
23 | };
24 | cancelForm: () => void;
25 | };
26 |
27 | export const UsersForm = ({ initialValues, cancelForm }: Props) => {
28 | // use the useForm hook to manage the form to add users to a task (assign task to users)
29 | const { formProps, saveButtonProps } = useForm<
30 | GetFields,
31 | HttpError,
32 | /**
33 | * Pick is a utility type from typescript that allows you to create a new type from an existing type by picking some properties from it.
34 | * https://www.typescriptlang.org/docs/handbook/utility-types.html#picktype-keys
35 | *
36 | * Pick
37 | * Type -> the type from which we want to pick the properties
38 | * Keys -> the properties that we want to pick
39 | */
40 | Pick, "userIds">
41 | >({
42 | queryOptions: {
43 | // disable the query to prevent fetching data on component mount
44 | enabled: false,
45 | },
46 | redirect: false, // disable redirection
47 | onMutationSuccess: () => {
48 | // when the mutation is successful, call the cancelForm function to close the form
49 | cancelForm();
50 | },
51 | // perform the mutation when the form is submitted
52 | meta: {
53 | gqlMutation: UPDATE_TASK_MUTATION,
54 | },
55 | });
56 |
57 | // use the useSelect hook to fetch the list of users from the server and display them in a select component
58 | const { selectProps } = useSelect>({
59 | // specify the resource from which we want to fetch the data
60 | resource: "users",
61 | // specify the query that should be performed
62 | meta: {
63 | gqlQuery: USERS_SELECT_QUERY,
64 | },
65 | // specify the label for the select component
66 | optionLabel: "name",
67 | });
68 |
69 | return (
70 |
78 |
84 |
91 |
92 |
93 |
94 |
95 | Cancel
96 |
97 |
98 | Save
99 |
100 |
101 |
102 | );
103 | };
104 |
--------------------------------------------------------------------------------
/src/components/tasks/kanban/add-card-button.tsx:
--------------------------------------------------------------------------------
1 | import React from "react";
2 |
3 | import { PlusSquareOutlined } from "@ant-design/icons";
4 | import { Button } from "antd";
5 | import { Text } from "@/components/text";
6 |
7 | interface Props {
8 | onClick: () => void;
9 | }
10 |
11 | /** Render a button that allows you to add a new card to a column.
12 | *
13 | * @param onClick - a function that is called when the button is clicked.
14 | * @returns a button that allows you to add a new card to a column.
15 | */
16 | export const KanbanAddCardButton = ({
17 | children,
18 | onClick,
19 | }: React.PropsWithChildren) => {
20 | return (
21 | }
24 | style={{
25 | margin: "16px",
26 | backgroundColor: "white",
27 | }}
28 | onClick={onClick}
29 | >
30 | {children ?? (
31 |
32 | Add new card
33 |
34 | )}
35 |
36 | );
37 | };
--------------------------------------------------------------------------------
/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 |
112 | }
113 | onPointerDown={(e) => {
114 | e.stopPropagation()
115 | }}
116 | onClick={(e) => {
117 | e.stopPropagation()
118 | }}
119 | />
120 |
121 | }
122 | >
123 |
131 |
132 | {dueDateOptions && (
133 |
136 | }
137 | style={{
138 | padding: '0 4px',
139 | marginInlineEnd: '0',
140 | backgroundColor: dueDateOptions.color === 'default' ? 'transparent' : 'unset',
141 | }}
142 | color={dueDateOptions.color}
143 | bordered={dueDateOptions.color !== 'default'}
144 | >
145 | {dueDateOptions.text}
146 |
147 | )}
148 | {!!users?.length && (
149 |
161 | {users.map((user) => (
162 |
163 |
164 |
165 | ))}
166 |
167 | )}
168 |
169 |
170 |
171 | )
172 | }
173 |
174 | export default ProjectCard
175 |
176 | export const ProjectCardMemo = memo(ProjectCard, (prev, next) => {
177 | return (
178 | prev.id === next.id &&
179 | prev.title === next.title &&
180 | prev.dueDate === next.dueDate &&
181 | prev.users?.length === next.users?.length &&
182 | prev.updatedAt === next.updatedAt
183 | )
184 | })
--------------------------------------------------------------------------------
/src/components/tasks/kanban/column.tsx:
--------------------------------------------------------------------------------
1 | import { Text } from '@/components/text'
2 | import { PlusOutlined } from '@ant-design/icons'
3 | import { UseDroppableArguments, useDroppable } from '@dnd-kit/core'
4 | import { Badge, Button, Space } from 'antd'
5 |
6 | type Props = {
7 | id: string,
8 | title: string,
9 | description?: React.ReactNode,
10 | count: number,
11 | data?: UseDroppableArguments['data'],
12 | onAddClick?: (args: { id: string }) => void,
13 | }
14 |
15 | const KanbanColumn = ({
16 | children,
17 | id,
18 | title,
19 | description,
20 | count,
21 | data,
22 | onAddClick
23 | }: React.PropsWithChildren) => {
24 | const { isOver, setNodeRef, active } = useDroppable({ id, data })
25 |
26 | const onAddClickHandler = () => {
27 | onAddClick?.({ id })
28 | }
29 |
30 | return (
31 |
39 |
40 |
41 |
42 |
51 | {title}
52 |
53 | {!!count && }
54 |
55 | }
58 | onClick={onAddClickHandler}
59 | />
60 |
61 | {description}
62 |
63 |
72 |
80 | {children}
81 |
82 |
83 |
84 | )
85 | }
86 |
87 | export default KanbanColumn
--------------------------------------------------------------------------------
/src/components/tasks/kanban/item.tsx:
--------------------------------------------------------------------------------
1 | import { DragOverlay, UseDraggableArguments, useDraggable } from '@dnd-kit/core'
2 |
3 | interface Props {
4 | id: string;
5 | data?: UseDraggableArguments['data']
6 | }
7 |
8 | const KanbanItem = ({ children, id, data }: React.PropsWithChildren) => {
9 | const { attributes, listeners, setNodeRef, active } = useDraggable({
10 | id,
11 | data,
12 | })
13 |
14 | return (
15 |
18 |
29 | {active?.id === id && (
30 |
31 |
36 | {children}
37 |
38 |
39 | )}
40 | {children}
41 |
42 |
43 | )
44 | }
45 |
46 | export default KanbanItem
--------------------------------------------------------------------------------
/src/components/text-icon.tsx:
--------------------------------------------------------------------------------
1 | import Icon from "@ant-design/icons";
2 | import type { CustomIconComponentProps } from "@ant-design/icons/lib/components/Icon";
3 |
4 | export const TextIconSvg = () => (
5 |
12 |
17 |
22 |
27 |
28 | );
29 |
30 | export const TextIcon = (props: Partial) => (
31 |
32 | );
--------------------------------------------------------------------------------
/src/components/text.tsx:
--------------------------------------------------------------------------------
1 | import React from "react";
2 |
3 | import { ConfigProvider, Typography } from "antd";
4 |
5 | export type TextProps = {
6 | size?:
7 | | "xs"
8 | | "sm"
9 | | "md"
10 | | "lg"
11 | | "xl"
12 | | "xxl"
13 | | "xxxl"
14 | | "huge"
15 | | "xhuge"
16 | | "xxhuge";
17 | } & React.ComponentProps;
18 |
19 | // define the font sizes and line heights
20 | const sizes = {
21 | xs: {
22 | fontSize: 12,
23 | lineHeight: 20 / 12,
24 | },
25 | sm: {
26 | fontSize: 14,
27 | lineHeight: 22 / 14,
28 | },
29 | md: {
30 | fontSize: 16,
31 | lineHeight: 24 / 16,
32 | },
33 | lg: {
34 | fontSize: 20,
35 | lineHeight: 28 / 20,
36 | },
37 | xl: {
38 | fontSize: 24,
39 | lineHeight: 32 / 24,
40 | },
41 | xxl: {
42 | fontSize: 30,
43 | lineHeight: 38 / 30,
44 | },
45 | xxxl: {
46 | fontSize: 38,
47 | lineHeight: 46 / 38,
48 | },
49 | huge: {
50 | fontSize: 46,
51 | lineHeight: 54 / 46,
52 | },
53 | xhuge: {
54 | fontSize: 56,
55 | lineHeight: 64 / 56,
56 | },
57 | xxhuge: {
58 | fontSize: 68,
59 | lineHeight: 76 / 68,
60 | },
61 | };
62 |
63 | // a custom Text component that wraps/extends the antd Typography.Text component
64 | export const Text = ({ size = "sm", children, ...rest }: TextProps) => {
65 | return (
66 | // config provider is a top-level component that allows us to customize the global properties of antd components. For example, default antd theme
67 | // token is a term used by antd to refer to the design tokens like font size, font weight, color, etc
68 | // https://ant.design/docs/react/customize-theme#customize-design-token
69 |
76 | {/**
77 | * Typography.Text is a component from antd that allows us to render text
78 | * Typography has different components like Title, Paragraph, Text, Link, etc
79 | * https://ant.design/components/typography/#Typography.Text
80 | */}
81 | {children}
82 |
83 | );
84 | };
--------------------------------------------------------------------------------
/src/config/resources.tsx:
--------------------------------------------------------------------------------
1 | import { DashboardOutlined, ProjectOutlined, ShopOutlined } from "@ant-design/icons";
2 | import { IResourceItem } from "@refinedev/core";
3 |
4 | export const resources: IResourceItem[] = [
5 | {
6 | name: 'dashboard',
7 | list: '/',
8 | meta: {
9 | label: 'Dashboard',
10 | icon:
11 | }
12 | },
13 | {
14 | name: 'companies',
15 | list: '/companies',
16 | show: '/companies/:id',
17 | create: '/companies/new',
18 | edit: '/companies/edit/:id',
19 | meta: {
20 | label: 'Companies',
21 | icon:
22 | }
23 | },
24 | {
25 | name: 'tasks',
26 | list: '/tasks',
27 | create: '/tasks/new',
28 | edit: '/tasks/edit/:id',
29 | meta: {
30 | label: 'Tasks',
31 | icon:
32 | }
33 | }
34 | ]
--------------------------------------------------------------------------------
/src/constants/index.tsx:
--------------------------------------------------------------------------------
1 | import { AuditOutlined, ShopOutlined, TeamOutlined } from "@ant-design/icons";
2 |
3 | const IconWrapper = ({
4 | color,
5 | children,
6 | }: React.PropsWithChildren<{ color: string }>) => {
7 | return (
8 |
19 | {children}
20 |
21 | );
22 | };
23 |
24 | import {
25 | BusinessType,
26 | CompanySize,
27 | Contact,
28 | Industry,
29 | } from "@/graphql/schema.types";
30 |
31 | export type TotalCountType = "companies" | "contacts" | "deals";
32 |
33 | export const totalCountVariants: {
34 | [key in TotalCountType]: {
35 | primaryColor: string;
36 | secondaryColor?: string;
37 | icon: React.ReactNode;
38 | title: string;
39 | data: { index: string; value: number }[];
40 | };
41 | } = {
42 | companies: {
43 | primaryColor: "#1677FF",
44 | secondaryColor: "#BAE0FF",
45 | icon: (
46 |
47 |
53 |
54 | ),
55 | title: "Number of companies",
56 | data: [
57 | {
58 | index: "1",
59 | value: 3500,
60 | },
61 | {
62 | index: "2",
63 | value: 2750,
64 | },
65 | {
66 | index: "3",
67 | value: 5000,
68 | },
69 | {
70 | index: "4",
71 | value: 4250,
72 | },
73 | {
74 | index: "5",
75 | value: 5000,
76 | },
77 | ],
78 | },
79 | contacts: {
80 | primaryColor: "#52C41A",
81 | secondaryColor: "#D9F7BE",
82 | icon: (
83 |
84 |
90 |
91 | ),
92 | title: "Number of contacts",
93 | data: [
94 | {
95 | index: "1",
96 | value: 10000,
97 | },
98 | {
99 | index: "2",
100 | value: 19500,
101 | },
102 | {
103 | index: "3",
104 | value: 13000,
105 | },
106 | {
107 | index: "4",
108 | value: 17000,
109 | },
110 | {
111 | index: "5",
112 | value: 13000,
113 | },
114 | {
115 | index: "6",
116 | value: 20000,
117 | },
118 | ],
119 | },
120 | deals: {
121 | primaryColor: "#FA541C",
122 | secondaryColor: "#FFD8BF",
123 | icon: (
124 |
125 |
131 |
132 | ),
133 | title: "Total deals in pipeline",
134 | data: [
135 | {
136 | index: "1",
137 | value: 1000,
138 | },
139 | {
140 | index: "2",
141 | value: 1300,
142 | },
143 | {
144 | index: "3",
145 | value: 1200,
146 | },
147 | {
148 | index: "4",
149 | value: 2000,
150 | },
151 | {
152 | index: "5",
153 | value: 800,
154 | },
155 | {
156 | index: "6",
157 | value: 1700,
158 | },
159 | {
160 | index: "7",
161 | value: 1400,
162 | },
163 | {
164 | index: "8",
165 | value: 1800,
166 | },
167 | ],
168 | },
169 | };
170 |
171 | export const statusOptions: {
172 | label: string;
173 | value: Contact["status"];
174 | }[] = [
175 | {
176 | label: "New",
177 | value: "NEW",
178 | },
179 | {
180 | label: "Qualified",
181 | value: "QUALIFIED",
182 | },
183 | {
184 | label: "Unqualified",
185 | value: "UNQUALIFIED",
186 | },
187 | {
188 | label: "Won",
189 | value: "WON",
190 | },
191 | {
192 | label: "Negotiation",
193 | value: "NEGOTIATION",
194 | },
195 | {
196 | label: "Lost",
197 | value: "LOST",
198 | },
199 | {
200 | label: "Interested",
201 | value: "INTERESTED",
202 | },
203 | {
204 | label: "Contacted",
205 | value: "CONTACTED",
206 | },
207 | {
208 | label: "Churned",
209 | value: "CHURNED",
210 | },
211 | ];
212 |
213 | export const companySizeOptions: {
214 | label: string;
215 | value: CompanySize;
216 | }[] = [
217 | {
218 | label: "Enterprise",
219 | value: "ENTERPRISE",
220 | },
221 | {
222 | label: "Large",
223 | value: "LARGE",
224 | },
225 | {
226 | label: "Medium",
227 | value: "MEDIUM",
228 | },
229 | {
230 | label: "Small",
231 | value: "SMALL",
232 | },
233 | ];
234 |
235 | export const industryOptions: {
236 | label: string;
237 | value: Industry;
238 | }[] = [
239 | { label: "Aerospace", value: "AEROSPACE" },
240 | { label: "Agriculture", value: "AGRICULTURE" },
241 | { label: "Automotive", value: "AUTOMOTIVE" },
242 | { label: "Chemicals", value: "CHEMICALS" },
243 | { label: "Construction", value: "CONSTRUCTION" },
244 | { label: "Defense", value: "DEFENSE" },
245 | { label: "Education", value: "EDUCATION" },
246 | { label: "Energy", value: "ENERGY" },
247 | { label: "Financial Services", value: "FINANCIAL_SERVICES" },
248 | { label: "Food and Beverage", value: "FOOD_AND_BEVERAGE" },
249 | { label: "Government", value: "GOVERNMENT" },
250 | { label: "Healthcare", value: "HEALTHCARE" },
251 | { label: "Hospitality", value: "HOSPITALITY" },
252 | { label: "Industrial Manufacturing", value: "INDUSTRIAL_MANUFACTURING" },
253 | { label: "Insurance", value: "INSURANCE" },
254 | { label: "Life Sciences", value: "LIFE_SCIENCES" },
255 | { label: "Logistics", value: "LOGISTICS" },
256 | { label: "Media", value: "MEDIA" },
257 | { label: "Mining", value: "MINING" },
258 | { label: "Nonprofit", value: "NONPROFIT" },
259 | { label: "Other", value: "OTHER" },
260 | { label: "Pharmaceuticals", value: "PHARMACEUTICALS" },
261 | { label: "Professional Services", value: "PROFESSIONAL_SERVICES" },
262 | { label: "Real Estate", value: "REAL_ESTATE" },
263 | { label: "Retail", value: "RETAIL" },
264 | { label: "Technology", value: "TECHNOLOGY" },
265 | { label: "Telecommunications", value: "TELECOMMUNICATIONS" },
266 | { label: "Transportation", value: "TRANSPORTATION" },
267 | { label: "Utilities", value: "UTILITIES" },
268 | ];
269 |
270 | export const businessTypeOptions: {
271 | label: string;
272 | value: BusinessType;
273 | }[] = [
274 | {
275 | label: "B2B",
276 | value: "B2B",
277 | },
278 | {
279 | label: "B2C",
280 | value: "B2C",
281 | },
282 | {
283 | label: "B2G",
284 | value: "B2G",
285 | },
286 | ];
--------------------------------------------------------------------------------
/src/graphql/mutations.ts:
--------------------------------------------------------------------------------
1 | import gql from "graphql-tag";
2 |
3 | // Mutation to update user
4 | export const UPDATE_USER_MUTATION = gql`
5 | # The ! after the type means that it is required
6 | mutation UpdateUser($input: UpdateOneUserInput!) {
7 | # call the updateOneUser mutation with the input and pass the $input argument
8 | # $variableName is a convention for GraphQL variables
9 | updateOneUser(input: $input) {
10 | id
11 | name
12 | avatarUrl
13 | email
14 | phone
15 | jobTitle
16 | }
17 | }
18 | `;
19 |
20 | // Mutation to create company
21 | export const CREATE_COMPANY_MUTATION = gql`
22 | mutation CreateCompany($input: CreateOneCompanyInput!) {
23 | createOneCompany(input: $input) {
24 | id
25 | salesOwner {
26 | id
27 | }
28 | }
29 | }
30 | `;
31 |
32 | // Mutation to update company details
33 | export const UPDATE_COMPANY_MUTATION = gql`
34 | mutation UpdateCompany($input: UpdateOneCompanyInput!) {
35 | updateOneCompany(input: $input) {
36 | id
37 | name
38 | totalRevenue
39 | industry
40 | companySize
41 | businessType
42 | country
43 | website
44 | avatarUrl
45 | salesOwner {
46 | id
47 | name
48 | avatarUrl
49 | }
50 | }
51 | }
52 | `;
53 |
54 | // Mutation to update task stage of a task
55 | export const UPDATE_TASK_STAGE_MUTATION = gql`
56 | mutation UpdateTaskStage($input: UpdateOneTaskInput!) {
57 | updateOneTask(input: $input) {
58 | id
59 | }
60 | }
61 | `;
62 |
63 | // Mutation to create a new task
64 | export const CREATE_TASK_MUTATION = gql`
65 | mutation CreateTask($input: CreateOneTaskInput!) {
66 | createOneTask(input: $input) {
67 | id
68 | title
69 | stage {
70 | id
71 | title
72 | }
73 | }
74 | }
75 | `;
76 |
77 | // Mutation to update a task details
78 | export const UPDATE_TASK_MUTATION = gql`
79 | mutation UpdateTask($input: UpdateOneTaskInput!) {
80 | updateOneTask(input: $input) {
81 | id
82 | title
83 | completed
84 | description
85 | dueDate
86 | stage {
87 | id
88 | title
89 | }
90 | users {
91 | id
92 | name
93 | avatarUrl
94 | }
95 | checklist {
96 | title
97 | checked
98 | }
99 | }
100 | }
101 | `;
--------------------------------------------------------------------------------
/src/graphql/queries.ts:
--------------------------------------------------------------------------------
1 | import gql from "graphql-tag";
2 |
3 | // Query to get Total Company, Contact and Deal Counts
4 | export const DASHBOARD_TOTAL_COUNTS_QUERY = gql`
5 | query DashboardTotalCounts {
6 | companies {
7 | totalCount
8 | }
9 | contacts {
10 | totalCount
11 | }
12 | deals {
13 | totalCount
14 | }
15 | }
16 | `;
17 |
18 | // Query to get upcoming events
19 | export const DASHBORAD_CALENDAR_UPCOMING_EVENTS_QUERY = gql`
20 | query DashboardCalendarUpcomingEvents(
21 | $filter: EventFilter!
22 | $sorting: [EventSort!]
23 | $paging: OffsetPaging!
24 | ) {
25 | events(filter: $filter, sorting: $sorting, paging: $paging) {
26 | totalCount
27 | nodes {
28 | id
29 | title
30 | color
31 | startDate
32 | endDate
33 | }
34 | }
35 | }
36 | `;
37 |
38 | // Query to get deals chart
39 | export const DASHBOARD_DEALS_CHART_QUERY = gql`
40 | query DashboardDealsChart(
41 | $filter: DealStageFilter!
42 | $sorting: [DealStageSort!]
43 | $paging: OffsetPaging
44 | ) {
45 | dealStages(filter: $filter, sorting: $sorting, paging: $paging) {
46 | # Get all deal stages
47 | nodes {
48 | id
49 | title
50 | # Get the sum of all deals in this stage and group by closeDateMonth and closeDateYear
51 | dealsAggregate {
52 | groupBy {
53 | closeDateMonth
54 | closeDateYear
55 | }
56 | sum {
57 | value
58 | }
59 | }
60 | }
61 | # Get the total count of all deals in this stage
62 | totalCount
63 | }
64 | }
65 | `;
66 |
67 | // Query to get latest activities deals
68 | export const DASHBOARD_LATEST_ACTIVITIES_DEALS_QUERY = gql`
69 | query DashboardLatestActivitiesDeals(
70 | $filter: DealFilter!
71 | $sorting: [DealSort!]
72 | $paging: OffsetPaging
73 | ) {
74 | deals(filter: $filter, sorting: $sorting, paging: $paging) {
75 | totalCount
76 | nodes {
77 | id
78 | title
79 | stage {
80 | id
81 | title
82 | }
83 | company {
84 | id
85 | name
86 | avatarUrl
87 | }
88 | createdAt
89 | }
90 | }
91 | }
92 | `;
93 |
94 | // Query to get latest activities audits
95 | export const DASHBOARD_LATEST_ACTIVITIES_AUDITS_QUERY = gql`
96 | query DashboardLatestActivitiesAudits(
97 | $filter: AuditFilter!
98 | $sorting: [AuditSort!]
99 | $paging: OffsetPaging
100 | ) {
101 | audits(filter: $filter, sorting: $sorting, paging: $paging) {
102 | totalCount
103 | nodes {
104 | id
105 | action
106 | targetEntity
107 | targetId
108 | changes {
109 | field
110 | from
111 | to
112 | }
113 | createdAt
114 | user {
115 | id
116 | name
117 | avatarUrl
118 | }
119 | }
120 | }
121 | }
122 | `;
123 |
124 | // Query to get companies list
125 | export const COMPANIES_LIST_QUERY = gql`
126 | query CompaniesList(
127 | $filter: CompanyFilter!
128 | $sorting: [CompanySort!]
129 | $paging: OffsetPaging!
130 | ) {
131 | companies(filter: $filter, sorting: $sorting, paging: $paging) {
132 | totalCount
133 | nodes {
134 | id
135 | name
136 | avatarUrl
137 | # Get the sum of all deals in this company
138 | dealsAggregate {
139 | sum {
140 | value
141 | }
142 | }
143 | }
144 | }
145 | }
146 | `;
147 |
148 | // Query to get users list
149 | export const USERS_SELECT_QUERY = gql`
150 | query UsersSelect(
151 | $filter: UserFilter!
152 | $sorting: [UserSort!]
153 | $paging: OffsetPaging!
154 | ) {
155 | # Get all users
156 | users(filter: $filter, sorting: $sorting, paging: $paging) {
157 | totalCount # Get the total count of users
158 | # Get specific fields for each user
159 | nodes {
160 | id
161 | name
162 | avatarUrl
163 | }
164 | }
165 | }
166 | `;
167 |
168 | // Query to get contacts associated with a company
169 | export const COMPANY_CONTACTS_TABLE_QUERY = gql`
170 | query CompanyContactsTable(
171 | $filter: ContactFilter!
172 | $sorting: [ContactSort!]
173 | $paging: OffsetPaging!
174 | ) {
175 | contacts(filter: $filter, sorting: $sorting, paging: $paging) {
176 | totalCount
177 | nodes {
178 | id
179 | name
180 | avatarUrl
181 | jobTitle
182 | email
183 | phone
184 | status
185 | }
186 | }
187 | }
188 | `;
189 |
190 | // Query to get task stages list
191 | export const TASK_STAGES_QUERY = gql`
192 | query TaskStages(
193 | $filter: TaskStageFilter!
194 | $sorting: [TaskStageSort!]
195 | $paging: OffsetPaging!
196 | ) {
197 | taskStages(filter: $filter, sorting: $sorting, paging: $paging) {
198 | totalCount # Get the total count of task stages
199 | nodes {
200 | id
201 | title
202 | }
203 | }
204 | }
205 | `;
206 |
207 | // Query to get tasks list
208 | export const TASKS_QUERY = gql`
209 | query Tasks(
210 | $filter: TaskFilter!
211 | $sorting: [TaskSort!]
212 | $paging: OffsetPaging!
213 | ) {
214 | tasks(filter: $filter, sorting: $sorting, paging: $paging) {
215 | totalCount # Get the total count of tasks
216 | nodes {
217 | id
218 | title
219 | description
220 | dueDate
221 | completed
222 | stageId
223 | # Get user details associated with this task
224 | users {
225 | id
226 | name
227 | avatarUrl
228 | }
229 | createdAt
230 | updatedAt
231 | }
232 | }
233 | }
234 | `;
235 |
236 | // Query to get task stages for select
237 | export const TASK_STAGES_SELECT_QUERY = gql`
238 | query TaskStagesSelect(
239 | $filter: TaskStageFilter!
240 | $sorting: [TaskStageSort!]
241 | $paging: OffsetPaging!
242 | ) {
243 | taskStages(filter: $filter, sorting: $sorting, paging: $paging) {
244 | totalCount
245 | nodes {
246 | id
247 | title
248 | }
249 | }
250 | }
251 | `;
--------------------------------------------------------------------------------
/src/graphql/types.ts:
--------------------------------------------------------------------------------
1 | import type * as Types from "./schema.types";
2 |
3 | export type UpdateUserMutationVariables = Types.Exact<{
4 | input: Types.UpdateOneUserInput;
5 | }>;
6 |
7 | export type UpdateUserMutation = {
8 | updateOneUser: Pick<
9 | Types.User,
10 | "id" | "name" | "avatarUrl" | "email" | "phone" | "jobTitle"
11 | >;
12 | };
13 |
14 | export type CreateCompanyMutationVariables = Types.Exact<{
15 | input: Types.CreateOneCompanyInput;
16 | }>;
17 |
18 | export type CreateCompanyMutation = {
19 | createOneCompany: Pick & {
20 | salesOwner: Pick;
21 | };
22 | };
23 |
24 | export type UpdateCompanyMutationVariables = Types.Exact<{
25 | input: Types.UpdateOneCompanyInput;
26 | }>;
27 |
28 | export type UpdateCompanyMutation = {
29 | updateOneCompany: Pick<
30 | Types.Company,
31 | | "id"
32 | | "name"
33 | | "totalRevenue"
34 | | "industry"
35 | | "companySize"
36 | | "businessType"
37 | | "country"
38 | | "website"
39 | | "avatarUrl"
40 | > & { salesOwner: Pick };
41 | };
42 |
43 | export type UpdateTaskStageMutationVariables = Types.Exact<{
44 | input: Types.UpdateOneTaskInput;
45 | }>;
46 |
47 | export type UpdateTaskStageMutation = { updateOneTask: Pick };
48 |
49 | export type CreateTaskMutationVariables = Types.Exact<{
50 | input: Types.CreateOneTaskInput;
51 | }>;
52 |
53 | export type CreateTaskMutation = {
54 | createOneTask: Pick & {
55 | stage?: Types.Maybe>;
56 | };
57 | };
58 |
59 | export type UpdateTaskMutationVariables = Types.Exact<{
60 | input: Types.UpdateOneTaskInput;
61 | }>;
62 |
63 | export type UpdateTaskMutation = {
64 | updateOneTask: Pick<
65 | Types.Task,
66 | "id" | "title" | "completed" | "description" | "dueDate"
67 | > & {
68 | stage?: Types.Maybe>;
69 | users: Array>;
70 | checklist: Array>;
71 | };
72 | };
73 |
74 | export type DashboardTotalCountsQueryVariables = Types.Exact<{
75 | [key: string]: never;
76 | }>;
77 |
78 | export type DashboardTotalCountsQuery = {
79 | companies: Pick;
80 | contacts: Pick;
81 | deals: Pick;
82 | };
83 |
84 | export type DashboardCalendarUpcomingEventsQueryVariables = Types.Exact<{
85 | filter: Types.EventFilter;
86 | sorting?: Types.InputMaybe | Types.EventSort>;
87 | paging: Types.OffsetPaging;
88 | }>;
89 |
90 | export type DashboardCalendarUpcomingEventsQuery = {
91 | events: Pick & {
92 | nodes: Array<
93 | Pick
94 | >;
95 | };
96 | };
97 |
98 | export type DashboardDealsChartQueryVariables = Types.Exact<{
99 | filter: Types.DealStageFilter;
100 | sorting?: Types.InputMaybe | Types.DealStageSort>;
101 | paging?: Types.InputMaybe;
102 | }>;
103 |
104 | export type DashboardDealsChartQuery = {
105 | dealStages: Pick & {
106 | nodes: Array<
107 | Pick & {
108 | dealsAggregate: Array<{
109 | groupBy?: Types.Maybe<
110 | Pick<
111 | Types.DealStageDealsAggregateGroupBy,
112 | "closeDateMonth" | "closeDateYear"
113 | >
114 | >;
115 | sum?: Types.Maybe>;
116 | }>;
117 | }
118 | >;
119 | };
120 | };
121 |
122 | export type DashboardLatestActivitiesDealsQueryVariables = Types.Exact<{
123 | filter: Types.DealFilter;
124 | sorting?: Types.InputMaybe | Types.DealSort>;
125 | paging?: Types.InputMaybe;
126 | }>;
127 |
128 | export type DashboardLatestActivitiesDealsQuery = {
129 | deals: Pick & {
130 | nodes: Array<
131 | Pick & {
132 | stage?: Types.Maybe>;
133 | company: Pick;
134 | }
135 | >;
136 | };
137 | };
138 |
139 | export type DashboardLatestActivitiesAuditsQueryVariables = Types.Exact<{
140 | filter: Types.AuditFilter;
141 | sorting?: Types.InputMaybe | Types.AuditSort>;
142 | paging?: Types.InputMaybe;
143 | }>;
144 |
145 | export type DashboardLatestActivitiesAuditsQuery = {
146 | audits: Pick & {
147 | nodes: Array<
148 | Pick<
149 | Types.Audit,
150 | "id" | "action" | "targetEntity" | "targetId" | "createdAt"
151 | > & {
152 | changes: Array>;
153 | user?: Types.Maybe>;
154 | }
155 | >;
156 | };
157 | };
158 |
159 | export type CompaniesListQueryVariables = Types.Exact<{
160 | filter: Types.CompanyFilter;
161 | sorting?: Types.InputMaybe | Types.CompanySort>;
162 | paging: Types.OffsetPaging;
163 | }>;
164 |
165 | export type CompaniesListQuery = {
166 | companies: Pick & {
167 | nodes: Array<
168 | Pick & {
169 | dealsAggregate: Array<{
170 | sum?: Types.Maybe>;
171 | }>;
172 | }
173 | >;
174 | };
175 | };
176 |
177 | export type UsersSelectQueryVariables = Types.Exact<{
178 | filter: Types.UserFilter;
179 | sorting?: Types.InputMaybe | Types.UserSort>;
180 | paging: Types.OffsetPaging;
181 | }>;
182 |
183 | export type UsersSelectQuery = {
184 | users: Pick & {
185 | nodes: Array>;
186 | };
187 | };
188 |
189 | export type CompanyContactsTableQueryVariables = Types.Exact<{
190 | filter: Types.ContactFilter;
191 | sorting?: Types.InputMaybe | Types.ContactSort>;
192 | paging: Types.OffsetPaging;
193 | }>;
194 |
195 | export type CompanyContactsTableQuery = {
196 | contacts: Pick & {
197 | nodes: Array<
198 | Pick<
199 | Types.Contact,
200 | "id" | "name" | "avatarUrl" | "jobTitle" | "email" | "phone" | "status"
201 | >
202 | >;
203 | };
204 | };
205 |
206 | export type TaskStagesQueryVariables = Types.Exact<{
207 | filter: Types.TaskStageFilter;
208 | sorting?: Types.InputMaybe | Types.TaskStageSort>;
209 | paging: Types.OffsetPaging;
210 | }>;
211 |
212 | export type TaskStagesQuery = {
213 | taskStages: Pick & {
214 | nodes: Array>;
215 | };
216 | };
217 |
218 | export type TasksQueryVariables = Types.Exact<{
219 | filter: Types.TaskFilter;
220 | sorting?: Types.InputMaybe | Types.TaskSort>;
221 | paging: Types.OffsetPaging;
222 | }>;
223 |
224 | export type TasksQuery = {
225 | tasks: Pick & {
226 | nodes: Array<
227 | Pick<
228 | Types.Task,
229 | | "id"
230 | | "title"
231 | | "description"
232 | | "dueDate"
233 | | "completed"
234 | | "stageId"
235 | | "createdAt"
236 | | "updatedAt"
237 | > & { users: Array> }
238 | >;
239 | };
240 | };
241 |
242 | export type TaskStagesSelectQueryVariables = Types.Exact<{
243 | filter: Types.TaskStageFilter;
244 | sorting?: Types.InputMaybe | Types.TaskStageSort>;
245 | paging: Types.OffsetPaging;
246 | }>;
247 |
248 | export type TaskStagesSelectQuery = {
249 | taskStages: Pick & {
250 | nodes: Array>;
251 | };
252 | };
253 |
--------------------------------------------------------------------------------
/src/index.tsx:
--------------------------------------------------------------------------------
1 | import React from "react";
2 | import { createRoot } from "react-dom/client";
3 |
4 | import App from "./App";
5 |
6 | const container = document.getElementById("root") as HTMLElement;
7 | const root = createRoot(container);
8 |
9 | root.render(
10 |
11 |
12 |
13 | );
14 |
--------------------------------------------------------------------------------
/src/pages/company/contacts-table.tsx:
--------------------------------------------------------------------------------
1 | import { useParams } from "react-router-dom";
2 |
3 | import { FilterDropdown, useTable } from "@refinedev/antd";
4 | import { GetFieldsFromList } from "@refinedev/nestjs-query";
5 |
6 | import {
7 | MailOutlined,
8 | PhoneOutlined,
9 | SearchOutlined,
10 | TeamOutlined,
11 | } from "@ant-design/icons";
12 | import { Button, Card, Input, Select, Space, Table } from "antd";
13 |
14 | import { Contact } from "@/graphql/schema.types";
15 |
16 | import { statusOptions } from "@/constants";
17 | import { COMPANY_CONTACTS_TABLE_QUERY } from "@/graphql/queries";
18 |
19 | import { CompanyContactsTableQuery } from "@/graphql/types";
20 | import { Text } from "@/components/text";
21 | import CustomAvatar from "@/components/custom-avatar";
22 | import { ContactStatusTag } from "@/components/tags/contact-status-tag";
23 |
24 | export const CompanyContactsTable = () => {
25 | // get params from the url
26 | const params = useParams();
27 |
28 | /**
29 | * Refine offers a TanStack Table adapter with @refinedev/react-table that allows us to use the TanStack Table library with Refine.
30 | * All features such as sorting, filtering, and pagination come out of the box
31 | * Under the hood it uses useList hook to fetch the data.
32 | * https://refine.dev/docs/packages/tanstack-table/use-table/#installation
33 | */
34 | const { tableProps } = useTable>(
35 | {
36 | // specify the resource for which the table is to be used
37 | resource: "contacts",
38 | syncWithLocation: false,
39 | // specify initial sorters
40 | sorters: {
41 | /**
42 | * initial sets the initial value of sorters.
43 | * it's not permanent
44 | * it will be cleared when the user changes the sorting
45 | * https://refine.dev/docs/ui-integrations/ant-design/hooks/use-table/#sortersinitial
46 | */
47 | initial: [
48 | {
49 | field: "createdAt",
50 | order: "desc",
51 | },
52 | ],
53 | },
54 | // specify initial filters
55 | filters: {
56 | /**
57 | * similar to initial in sorters
58 | * https://refine.dev/docs/ui-integrations/ant-design/hooks/use-table/#filtersinitial
59 | */
60 | initial: [
61 | {
62 | field: "jobTitle",
63 | value: "",
64 | operator: "contains",
65 | },
66 | {
67 | field: "name",
68 | value: "",
69 | operator: "contains",
70 | },
71 | {
72 | field: "status",
73 | value: undefined,
74 | operator: "in",
75 | },
76 | ],
77 | /**
78 | * permanent filters are the filters that are always applied
79 | * https://refine.dev/docs/ui-integrations/ant-design/hooks/use-table/#filterspermanent
80 | */
81 | permanent: [
82 | {
83 | field: "company.id",
84 | operator: "eq",
85 | value: params?.id as string,
86 | },
87 | ],
88 | },
89 | /**
90 | * used to provide any additional information to the data provider.
91 | * https://refine.dev/docs/data/hooks/use-form/#meta-
92 | */
93 | meta: {
94 | // gqlQuery is used to specify the GraphQL query that should be used to fetch the data.
95 | gqlQuery: COMPANY_CONTACTS_TABLE_QUERY,
96 | },
97 | },
98 | );
99 |
100 | return (
101 |
109 |
110 | Contacts
111 |
112 | }
113 | // property used to render additional content in the top-right corner of the card
114 | extra={
115 | <>
116 | Total contacts:
117 |
118 | {/* if pagination is not disabled and total is provided then show the total */}
119 | {tableProps?.pagination !== false && tableProps.pagination?.total}
120 |
121 | >
122 | }
123 | >
124 |
202 |
203 | );
204 | };
205 |
--------------------------------------------------------------------------------
/src/pages/company/create.tsx:
--------------------------------------------------------------------------------
1 | import React from 'react'
2 | import { CompanyList } from './list'
3 | import { Form, Input, Modal, Select } from 'antd'
4 | import { useModalForm, useSelect } from '@refinedev/antd'
5 | import { useGo } from '@refinedev/core'
6 | import { CREATE_COMPANY_MUTATION } from '@/graphql/mutations'
7 | import { USERS_SELECT_QUERY } from '@/graphql/queries'
8 | import SelectOptionWithAvatar from '@/components/select-option-with-avatar'
9 | import { GetFieldsFromList } from '@refinedev/nestjs-query'
10 | import { UsersSelectQuery } from '@/graphql/types'
11 |
12 | const Create = () => {
13 | const go = useGo();
14 |
15 | const goToListPage = () => {
16 | go({
17 | to: { resource: 'companies', action: 'list' },
18 | options: { keepQuery: true },
19 | type: 'replace',
20 | })
21 | }
22 |
23 | const { formProps, modalProps } = useModalForm({
24 | action: 'create',
25 | defaultVisible: true,
26 | resource: 'companies',
27 | redirect: false,
28 | mutationMode: 'pessimistic',
29 | onMutationSuccess: goToListPage,
30 | meta: {
31 | gqlMutation: CREATE_COMPANY_MUTATION
32 | }
33 | })
34 |
35 | const { selectProps, queryResult } = useSelect>({
36 | resource: 'users',
37 | optionLabel: 'name',
38 | meta: {
39 | gqlQuery: USERS_SELECT_QUERY
40 | }
41 | })
42 |
43 | return (
44 |
45 |
52 |
58 |
59 |
60 |
65 | ({
70 | value: user.id,
71 | label: (
72 |
76 | )
77 | })) ?? []
78 | }
79 | />
80 |
81 |
82 |
83 |
84 | )
85 | }
86 |
87 | export default Create
--------------------------------------------------------------------------------
/src/pages/company/edit.tsx:
--------------------------------------------------------------------------------
1 | import { Col, Form, Input, InputNumber, Row, Select } from 'antd'
2 | import { Edit, useForm, useSelect } from '@refinedev/antd'
3 | import { UPDATE_COMPANY_MUTATION } from '@/graphql/mutations';
4 | import CustomAvatar from '@/components/custom-avatar';
5 | import { getNameInitials } from '@/utilities';
6 | import { GetFieldsFromList } from '@refinedev/nestjs-query';
7 | import { UsersSelectQuery } from '@/graphql/types';
8 | import { USERS_SELECT_QUERY } from '@/graphql/queries';
9 | import SelectOptionWithAvatar from '@/components/select-option-with-avatar';
10 | import { businessTypeOptions, companySizeOptions, industryOptions } from '@/constants';
11 | import { CompanyContactsTable } from './contacts-table';
12 |
13 | const EditPage = () => {
14 | const { saveButtonProps, formProps, formLoading, queryResult } = useForm({
15 | redirect: false,
16 | meta: {
17 | gqlMutation: UPDATE_COMPANY_MUTATION
18 | }
19 | });
20 |
21 | const { avatarUrl, name } = queryResult?.data?.data || {}
22 |
23 | const { selectProps, queryResult: queryResultUsers } = useSelect>({
24 | resource: 'users',
25 | optionLabel: 'name',
26 | pagination: {
27 | mode: 'off'
28 | },
29 | meta: {
30 | gqlQuery: USERS_SELECT_QUERY
31 | }
32 | })
33 |
34 | return (
35 |
36 |
37 |
38 |
43 |
50 | ({
55 | value: user.id,
56 | label: (
57 |
61 | )
62 | })) ?? []
63 | }
64 | />
65 |
66 |
67 |
68 |
69 |
70 |
76 |
77 |
78 |
79 |
80 |
81 |
82 |
83 |
84 |
85 |
86 |
87 |
88 |
89 |
90 |
91 |
92 |
93 |
94 |
95 |
96 |
97 | )
98 | }
99 |
100 | export default EditPage
--------------------------------------------------------------------------------
/src/pages/company/list.tsx:
--------------------------------------------------------------------------------
1 | import CustomAvatar from '@/components/custom-avatar';
2 | import { Text } from '@/components/text';
3 | import { COMPANIES_LIST_QUERY } from '@/graphql/queries';
4 | import { Company } from '@/graphql/schema.types';
5 | import { CompaniesListQuery } from '@/graphql/types';
6 | import { currencyNumber } from '@/utilities';
7 | import { SearchOutlined } from '@ant-design/icons';
8 | import { CreateButton, DeleteButton, EditButton, FilterDropdown, List, useTable } from '@refinedev/antd'
9 | import { HttpError, getDefaultFilter, useGo } from '@refinedev/core'
10 | import { GetFieldsFromList } from '@refinedev/nestjs-query';
11 | import { Input, Space, Table } from 'antd';
12 |
13 | export const CompanyList = ({ children }: React.PropsWithChildren) => {
14 | const go = useGo();
15 | const { tableProps, filters } = useTable<
16 | GetFieldsFromList,
17 | HttpError,
18 | GetFieldsFromList
19 | >({
20 | resource: 'companies',
21 | onSearch: (values) => {
22 | return [
23 | {
24 | field: 'name',
25 | operator: 'contains',
26 | value: values.name
27 | }
28 | ]
29 | },
30 | pagination: {
31 | pageSize: 12,
32 | },
33 | sorters: {
34 | initial: [
35 | {
36 | field: 'createdAt',
37 | order: 'desc'
38 | }
39 | ]
40 | },
41 | filters: {
42 | initial: [
43 | {
44 | field: 'name',
45 | operator: 'contains',
46 | value: undefined
47 | }
48 | ]
49 | },
50 | meta: {
51 | gqlQuery: COMPANIES_LIST_QUERY
52 | }
53 | })
54 |
55 | return (
56 |
57 |
(
60 | {
62 | go({
63 | to: {
64 | resource: 'companies',
65 | action: 'create'
66 | },
67 | options: {
68 | keepQuery: true
69 | },
70 | type: 'replace'
71 | })
72 | }}
73 | />
74 | )}
75 | >
76 |
122 |
123 | {children}
124 |
125 | )
126 | }
--------------------------------------------------------------------------------
/src/pages/forgotPassword/index.tsx:
--------------------------------------------------------------------------------
1 | import { AuthPage } from "@refinedev/antd";
2 |
3 | export const ForgotPassword = () => {
4 | return ;
5 | };
6 |
--------------------------------------------------------------------------------
/src/pages/home/index.tsx:
--------------------------------------------------------------------------------
1 | import { UpcomingEvents, DealsChart, DashboardTotalCountCard, LatestActivities } from "@/components"
2 | import { DASHBOARD_TOTAL_COUNTS_QUERY } from "@/graphql/queries"
3 | import { DashboardTotalCountsQuery } from "@/graphql/types"
4 | import { useCustom } from "@refinedev/core"
5 | import { Col, Row } from "antd"
6 |
7 | export const Home = () => {
8 | const { data, isLoading } = useCustom({
9 | url: '',
10 | method: 'get',
11 | meta: {
12 | gqlQuery: DASHBOARD_TOTAL_COUNTS_QUERY
13 | }
14 | })
15 | return (
16 |
17 |
18 |
19 |
24 |
25 |
26 |
31 |
32 |
33 |
38 |
39 |
40 |
46 |
54 |
55 |
56 |
64 |
65 |
66 |
67 |
73 |
74 |
75 |
76 |
77 |
78 |
79 | )
80 | }
--------------------------------------------------------------------------------
/src/pages/index.ts:
--------------------------------------------------------------------------------
1 | export * from './home';
2 | export * from './forgotPassword';
3 | export * from './login';
4 | export * from './register';
5 | export * from './company/list';
6 | export * from './company/create';
7 | export * from './company/edit';
8 |
9 |
--------------------------------------------------------------------------------
/src/pages/login/index.tsx:
--------------------------------------------------------------------------------
1 | import { authCredentials } from "@/providers";
2 | import { AuthPage } from "@refinedev/antd";
3 |
4 | export const Login = () => {
5 | return (
6 |
12 | );
13 | };
14 |
--------------------------------------------------------------------------------
/src/pages/register/index.tsx:
--------------------------------------------------------------------------------
1 | import { AuthPage } from "@refinedev/antd";
2 |
3 | export const Register = () => {
4 | return ;
5 | };
6 |
--------------------------------------------------------------------------------
/src/pages/tasks/create.tsx:
--------------------------------------------------------------------------------
1 | import { useSearchParams } from "react-router-dom";
2 |
3 | import { useModalForm } from "@refinedev/antd";
4 | import { useNavigation } from "@refinedev/core";
5 |
6 | import { Form, Input, Modal } from "antd";
7 |
8 | import { CREATE_TASK_MUTATION } from "@/graphql/mutations";
9 |
10 | const TasksCreatePage = () => {
11 | // get search params from the url
12 | const [searchParams] = useSearchParams();
13 |
14 | /**
15 | * useNavigation is a hook by Refine that allows you to navigate to a page.
16 | * https://refine.dev/docs/routing/hooks/use-navigation/
17 | *
18 | * list method navigates to the list page of the specified resource.
19 | * https://refine.dev/docs/routing/hooks/use-navigation/#list
20 | */ const { list } = useNavigation();
21 |
22 | /**
23 | * useModalForm is a hook by Refine that allows you manage a form inside a modal.
24 | * it extends the useForm hook from the @refinedev/antd package
25 | * https://refine.dev/docs/ui-integrations/ant-design/hooks/use-modal-form/
26 | *
27 | * formProps -> It's an instance of HTML form that manages form state and actions like onFinish, onValuesChange, etc.
28 | * Under the hood, it uses the useForm hook from the @refinedev/antd package
29 | * https://refine.dev/docs/ui-integrations/ant-design/hooks/use-modal-form/#formprops
30 | *
31 | * modalProps -> It's an instance of Modal that manages modal state and actions like onOk, onCancel, etc.
32 | * https://refine.dev/docs/ui-integrations/ant-design/hooks/use-modal-form/#modalprops
33 | */
34 | const { formProps, modalProps, close } = useModalForm({
35 | // specify the action to perform i.e., create or edit
36 | action: "create",
37 | // specify whether the modal should be visible by default
38 | defaultVisible: true,
39 | // specify the gql mutation to be performed
40 | meta: {
41 | gqlMutation: CREATE_TASK_MUTATION,
42 | },
43 | });
44 |
45 | return (
46 | {
49 | // close the modal
50 | close();
51 |
52 | // navigate to the list page of the tasks resource
53 | list("tasks", "replace");
54 | }}
55 | title="Add new card"
56 | width={512}
57 | >
58 |
73 |
74 |
75 |
76 |
77 | );
78 | };
79 |
80 | export default TasksCreatePage;
--------------------------------------------------------------------------------
/src/pages/tasks/edit.tsx:
--------------------------------------------------------------------------------
1 | import { useState } from "react";
2 |
3 | import { DeleteButton, useModalForm } from "@refinedev/antd";
4 | import { useNavigation } from "@refinedev/core";
5 |
6 | import {
7 | AlignLeftOutlined,
8 | FieldTimeOutlined,
9 | UsergroupAddOutlined,
10 | } from "@ant-design/icons";
11 | import { Modal } from "antd";
12 |
13 | import {
14 | Accordion,
15 | DescriptionForm,
16 | DescriptionHeader,
17 | DueDateForm,
18 | DueDateHeader,
19 | StageForm,
20 | TitleForm,
21 | UsersForm,
22 | UsersHeader,
23 | } from "@/components";
24 | import { Task } from "@/graphql/schema.types";
25 |
26 | import { UPDATE_TASK_MUTATION } from "@/graphql/mutations";
27 |
28 | const TasksEditPage = () => {
29 | const [activeKey, setActiveKey] = useState();
30 |
31 | // use the list method to navigate to the list page of the tasks resource from the navigation hook
32 | const { list } = useNavigation();
33 |
34 | // create a modal form to edit a task using the useModalForm hook
35 | // modalProps -> It's an instance of Modal that manages modal state and actions like onOk, onCancel, etc.
36 | // close -> It's a function that closes the modal
37 | // queryResult -> It's an instance of useQuery from react-query
38 | const { modalProps, close, queryResult } = useModalForm({
39 | // specify the action to perform i.e., create or edit
40 | action: "edit",
41 | // specify whether the modal should be visible by default
42 | defaultVisible: true,
43 | // specify the gql mutation to be performed
44 | meta: {
45 | gqlMutation: UPDATE_TASK_MUTATION,
46 | },
47 | });
48 |
49 | // get the data of the task from the queryResult
50 | const { description, dueDate, users, title } = queryResult?.data?.data ?? {};
51 |
52 | const isLoading = queryResult?.isLoading ?? true;
53 |
54 | return (
55 | {
59 | close();
60 | list("tasks", "replace");
61 | }}
62 | title={ }
63 | width={586}
64 | footer={
65 | {
68 | list("tasks", "replace");
69 | }}
70 | >
71 | Delete card
72 |
73 | }
74 | >
75 | {/* Render the stage form */}
76 |
77 |
78 | {/* Render the description form inside an accordion */}
79 | }
84 | isLoading={isLoading}
85 | icon={ }
86 | label="Description"
87 | >
88 | setActiveKey(undefined)}
91 | />
92 |
93 |
94 | {/* Render the due date form inside an accordion */}
95 | }
100 | isLoading={isLoading}
101 | icon={ }
102 | label="Due date"
103 | >
104 | setActiveKey(undefined)}
107 | />
108 |
109 |
110 | {/* Render the users form inside an accordion */}
111 | }
116 | isLoading={isLoading}
117 | icon={ }
118 | label="Users"
119 | >
120 | ({
123 | label: user.name,
124 | value: user.id,
125 | })),
126 | }}
127 | cancelForm={() => setActiveKey(undefined)}
128 | />
129 |
130 |
131 | );
132 | };
133 |
134 | export default TasksEditPage;
--------------------------------------------------------------------------------
/src/pages/tasks/list.tsx:
--------------------------------------------------------------------------------
1 | import { KanbanColumnSkeleton, ProjectCardSkeleton } from '@/components'
2 | import { KanbanAddCardButton } from '@/components/tasks/kanban/add-card-button'
3 | import { KanbanBoardContainer, KanbanBoard } from '@/components/tasks/kanban/board'
4 | import { ProjectCardMemo } from '@/components/tasks/kanban/card'
5 | import KanbanColumn from '@/components/tasks/kanban/column'
6 | import KanbanItem from '@/components/tasks/kanban/item'
7 | import { UPDATE_TASK_STAGE_MUTATION } from '@/graphql/mutations'
8 | import { TASKS_QUERY, TASK_STAGES_QUERY } from '@/graphql/queries'
9 | import { TaskStagesQuery, TasksQuery } from '@/graphql/types'
10 | import { DragEndEvent } from '@dnd-kit/core'
11 | import { useList, useNavigation, useUpdate } from '@refinedev/core'
12 | import { GetFieldsFromList } from '@refinedev/nestjs-query'
13 | import React from 'react'
14 |
15 | type Task = GetFieldsFromList
16 | type TaskStage = GetFieldsFromList & { tasks: Task[] }
17 |
18 | const List = ({ children }: React.PropsWithChildren) => {
19 | const { replace } = useNavigation()
20 |
21 | const { data: stages, isLoading: isLoadingStages } = useList({
22 | resource: 'taskStages',
23 | filters: [
24 | {
25 | field: 'title',
26 | operator: 'in',
27 | value: ['TODO', 'IN PROGRESS', 'IN REVIEW', 'DONE']
28 | }
29 | ],
30 | sorters: [
31 | {
32 | field: 'createdAt',
33 | order: 'asc'
34 | }
35 | ],
36 | meta: {
37 | gqlQuery: TASK_STAGES_QUERY
38 | }
39 | })
40 | const { data: tasks, isLoading: isLoadingTasks } = useList>({
41 | resource: 'tasks',
42 | sorters: [
43 | {
44 | field: 'dueDate',
45 | order: 'asc',
46 | }
47 | ],
48 | queryOptions: {
49 | enabled: !!stages,
50 | },
51 | pagination: {
52 | mode: 'off'
53 | },
54 | meta: {
55 | gqlQuery: TASKS_QUERY
56 | }
57 | })
58 |
59 | const { mutate: updateTask } = useUpdate();
60 |
61 | const taskStages = React.useMemo(() => {
62 | if (!tasks?.data || !stages?.data) {
63 | return {
64 | unassignedStage: [],
65 | stages: []
66 | }
67 | }
68 |
69 | const unassignedStage = tasks.data.filter((task) => task.stageId === null)
70 |
71 | const grouped: TaskStage[] = stages.data.map((stage) => ({
72 | ...stage,
73 | tasks: tasks.data.filter((task) => task.stageId?.toString() === stage.id)
74 | }))
75 |
76 | return {
77 | unassignedStage,
78 | columns: grouped
79 | }
80 | }, [stages, tasks])
81 |
82 | const handleAddCard = (args: { stageId: string}) => {
83 | const path = args.stageId === 'unassigned'
84 | ? '/tasks/new'
85 | : `/tasks/new?stageId=${args.stageId}`
86 |
87 | replace(path);
88 | }
89 |
90 | const handleOnDragEnd = (event: DragEndEvent) => {
91 | let stageId = event.over?.id as undefined | string | null
92 | const taskId = event.active.id as string
93 | const taskStageId = event.active.data.current?.stageId
94 |
95 | if(taskStageId === stageId) return;
96 |
97 | if(stageId === 'unassigned') {
98 | stageId = null
99 | }
100 |
101 | updateTask({
102 | resource: 'tasks',
103 | id: taskId,
104 | values: {
105 | stageId: stageId,
106 | },
107 | successNotification: false,
108 | mutationMode: 'optimistic',
109 | meta: {
110 | gqlMutation: UPDATE_TASK_STAGE_MUTATION
111 | }
112 | })
113 | }
114 |
115 | const isLoading = isLoadingStages || isLoadingTasks
116 |
117 | if(isLoading) return
118 |
119 | return (
120 | <>
121 |
122 |
123 | handleAddCard({ stageId: 'unassigned' })}
128 | >
129 | {taskStages.unassignedStage.map((task) => (
130 |
133 |
137 |
138 | ))}
139 |
140 | {!taskStages.unassignedStage.length && (
141 | handleAddCard({ stageId: 'unassigned' })}
143 | />
144 | )}
145 |
146 |
147 | {taskStages.columns?.map((column) => (
148 | handleAddCard({ stageId: column.id })}
154 | >
155 | {!isLoading && column.tasks.map((task) => (
156 |
157 |
161 |
162 | ))}
163 | {!column.tasks.length && (
164 | handleAddCard({ stageId: column.id })}
166 | />
167 | )}
168 |
169 | ))}
170 |
171 |
172 | {children}
173 | >
174 | )
175 | }
176 |
177 | export default List
178 |
179 | const PageSkeleton = () => {
180 | const columnCount = 6;
181 | const itemCount = 4;
182 |
183 | return (
184 |
185 | {Array.from({ length: columnCount }).map((_, index) => (
186 |
187 | {Array.from({length: itemCount}).map((_, index)=> (
188 |
189 | ))}
190 |
191 | ))}
192 |
193 | )
194 | }
--------------------------------------------------------------------------------
/src/providers/auth.ts:
--------------------------------------------------------------------------------
1 | import { AuthBindings } from "@refinedev/core";
2 |
3 | import { API_URL, dataProvider } from "./data";
4 |
5 | // For demo purposes and to make it easier to test the app, you can use the following credentials
6 | export const authCredentials = {
7 | email: "michael.scott@dundermifflin.com",
8 | password: "demodemo",
9 | };
10 |
11 | export const authProvider: AuthBindings = {
12 | login: async ({ email }) => {
13 | try {
14 | // call the login mutation
15 | // dataProvider.custom is used to make a custom request to the GraphQL API
16 | // this will call dataProvider which will go through the fetchWrapper function
17 | const { data } = await dataProvider.custom({
18 | url: API_URL,
19 | method: "post",
20 | headers: {},
21 | meta: {
22 | variables: { email },
23 | // pass the email to see if the user exists and if so, return the accessToken
24 | rawQuery: `
25 | mutation Login($email: String!) {
26 | login(loginInput: { email: $email }) {
27 | accessToken
28 | }
29 | }
30 | `,
31 | },
32 | });
33 |
34 | // save the accessToken in localStorage
35 | localStorage.setItem("access_token", data.login.accessToken);
36 |
37 | return {
38 | success: true,
39 | redirectTo: "/",
40 | };
41 | } catch (e) {
42 | const error = e as Error;
43 |
44 | return {
45 | success: false,
46 | error: {
47 | message: "message" in error ? error.message : "Login failed",
48 | name: "name" in error ? error.name : "Invalid email or password",
49 | },
50 | };
51 | }
52 | },
53 |
54 | // simply remove the accessToken from localStorage for the logout
55 | logout: async () => {
56 | localStorage.removeItem("access_token");
57 |
58 | return {
59 | success: true,
60 | redirectTo: "/login",
61 | };
62 | },
63 |
64 | onError: async (error) => {
65 | // a check to see if the error is an authentication error
66 | // if so, set logout to true
67 | if (error.statusCode === "UNAUTHENTICATED") {
68 | return {
69 | logout: true,
70 | ...error,
71 | };
72 | }
73 |
74 | return { error };
75 | },
76 |
77 | check: async () => {
78 | try {
79 | // get the identity of the user
80 | // this is to know if the user is authenticated or not
81 | await dataProvider.custom({
82 | url: API_URL,
83 | method: "post",
84 | headers: {},
85 | meta: {
86 | rawQuery: `
87 | query Me {
88 | me {
89 | name
90 | }
91 | }
92 | `,
93 | },
94 | });
95 |
96 | // if the user is authenticated, redirect to the home page
97 | return {
98 | authenticated: true,
99 | redirectTo: "/",
100 | };
101 | } catch (error) {
102 | // for any other error, redirect to the login page
103 | return {
104 | authenticated: false,
105 | redirectTo: "/login",
106 | };
107 | }
108 | },
109 |
110 | // get the user information
111 | getIdentity: async () => {
112 | const accessToken = localStorage.getItem("access_token");
113 |
114 | try {
115 | // call the GraphQL API to get the user information
116 | // we're using me:any because the GraphQL API doesn't have a type for the me query yet.
117 | // we'll add some queries and mutations later and change this to User which will be generated by codegen.
118 | const { data } = await dataProvider.custom<{ me: any }>({
119 | url: API_URL,
120 | method: "post",
121 | headers: accessToken
122 | ? {
123 | // send the accessToken in the Authorization header
124 | Authorization: `Bearer ${accessToken}`,
125 | }
126 | : {},
127 | meta: {
128 | // get the user information such as name, email, etc.
129 | rawQuery: `
130 | query Me {
131 | me {
132 | id
133 | name
134 | email
135 | phone
136 | jobTitle
137 | timezone
138 | avatarUrl
139 | }
140 | }
141 | `,
142 | },
143 | });
144 |
145 | return data.me;
146 | } catch (error) {
147 | return undefined;
148 | }
149 | },
150 | };
--------------------------------------------------------------------------------
/src/providers/data/fetch-wrapper.ts:
--------------------------------------------------------------------------------
1 | import { GraphQLFormattedError } from "graphql";
2 |
3 | type Error = {
4 | message: string;
5 | statusCode: string;
6 | }
7 |
8 | const customFetch = async (url: string, options: RequestInit) => {
9 | const accessToken = localStorage.getItem('access_token');
10 |
11 | const headers = options.headers as Record;
12 |
13 | return await fetch(url,{
14 | ...options,
15 | headers: {
16 | ...headers,
17 | Authorization: headers?.Authorization || `Bearer ${accessToken}`,
18 | "Content-Type": "application/json",
19 | "Apollo-Require-Preflight": "true",
20 | }
21 | })
22 | }
23 |
24 | const getGraphQLErrors = (body: Record<"errors", GraphQLFormattedError[] | undefined>): Error | null => {
25 | if(!body) {
26 | return {
27 | message: 'Unknown error',
28 | statusCode: "INTERNAL_SERVER_ERROR"
29 | }
30 | }
31 |
32 | if("errors" in body) {
33 | const errors = body?.errors;
34 |
35 | const messages = errors?.map((error) => error?.message)?.join("");
36 | const code = errors?.[0]?.extensions?.code;
37 |
38 | return {
39 | message: messages || JSON.stringify(errors),
40 | statusCode: code || 500
41 | }
42 | }
43 |
44 | return null;
45 | }
46 |
47 | export const fetchWrapper = async (url: string, options: RequestInit) => {
48 | const response = await customFetch(url, options);
49 |
50 | const responseClone = response.clone();
51 | const body = await responseClone.json();
52 |
53 | const error = getGraphQLErrors(body);
54 |
55 | if(error) {
56 | throw error;
57 | }
58 |
59 | return response;
60 | }
--------------------------------------------------------------------------------
/src/providers/data/index.tsx:
--------------------------------------------------------------------------------
1 | import graphqlDataProvider, {
2 | GraphQLClient,
3 | liveProvider as graphqlLiveProvider
4 | } from "@refinedev/nestjs-query";
5 | import { createClient } from 'graphql-ws'
6 | import { fetchWrapper } from "./fetch-wrapper";
7 |
8 | export const API_BASE_URL = 'https://api.crm.refine.dev'
9 | export const API_URL = `${API_BASE_URL}/graphql`
10 | export const WS_URL = 'wss://api.crm.refine.dev/graphql'
11 |
12 | export const client = new GraphQLClient(API_URL, {
13 | fetch: (url: string, options: RequestInit) => {
14 | try {
15 | return fetchWrapper(url, options);
16 | } catch (error) {
17 | return Promise.reject(error as Error);
18 | }
19 | }
20 | })
21 |
22 | export const wsClient = typeof window !== "undefined"
23 | ? createClient({
24 | url: WS_URL,
25 | connectionParams: () => {
26 | const accessToken = localStorage.getItem("access_token");
27 |
28 | return {
29 | headers: {
30 | Authorization: `Bearer ${accessToken}`,
31 | }
32 | }
33 | }
34 | })
35 | : undefined
36 |
37 | export const dataProvider = graphqlDataProvider(client);
38 | export const liveProvider = wsClient ? graphqlLiveProvider(wsClient) : undefined;
--------------------------------------------------------------------------------
/src/providers/index.ts:
--------------------------------------------------------------------------------
1 | export * from './data';
2 | export * from './auth';
--------------------------------------------------------------------------------
/src/utilities/currency-number.ts:
--------------------------------------------------------------------------------
1 | export const currencyNumber = (
2 | value: number,
3 | options?: Intl.NumberFormatOptions,
4 | ) => {
5 | if (
6 | typeof Intl == "object" &&
7 | Intl &&
8 | typeof Intl.NumberFormat == "function"
9 | ) {
10 | return new Intl.NumberFormat("en-US", {
11 | style: "currency",
12 | currency: "USD",
13 | ...options,
14 | }).format(value);
15 | }
16 |
17 | return value.toString();
18 | };
19 |
--------------------------------------------------------------------------------
/src/utilities/date/get-date-colors.ts:
--------------------------------------------------------------------------------
1 | import dayjs from "dayjs";
2 |
3 | type DateColors = "success" | "processing" | "error" | "default" | "warning";
4 |
5 | // returns a color based on the date
6 | export const getDateColor = (args: {
7 | date: string;
8 | defaultColor?: DateColors;
9 | }): DateColors => {
10 | const date = dayjs(args.date);
11 | const today = dayjs();
12 |
13 | if (date.isBefore(today)) {
14 | return "error";
15 | }
16 |
17 | if (date.isBefore(today.add(3, "day"))) {
18 | return "warning";
19 | }
20 |
21 | // ?? is the nullish coalescing operator. It returns the right-hand side operand when the left-hand side is null or undefined.
22 | return args.defaultColor ?? "default";
23 | };
24 |
--------------------------------------------------------------------------------
/src/utilities/date/index.ts:
--------------------------------------------------------------------------------
1 | export * from "./get-date-colors";
2 |
--------------------------------------------------------------------------------
/src/utilities/get-name-initials.ts:
--------------------------------------------------------------------------------
1 | export const getNameInitials = (name: string, count = 2) => {
2 | const initials = name
3 | .split(" ")
4 | .map((n) => n[0])
5 | .join("");
6 | const filtered = initials.replace(/[^a-zA-Z]/g, "");
7 | return filtered.slice(0, count).toUpperCase();
8 | };
--------------------------------------------------------------------------------
/src/utilities/get-random-color.ts:
--------------------------------------------------------------------------------
1 | /*
2 | * generates random colors from https://ant.design/docs/spec/colors. used.
3 | */
4 | export const getRandomColorFromString = (text: string) => {
5 | const colors = [
6 | "#ff9c6e",
7 | "#ff7875",
8 | "#ffc069",
9 | "#ffd666",
10 | "#fadb14",
11 | "#95de64",
12 | "#5cdbd3",
13 | "#69c0ff",
14 | "#85a5ff",
15 | "#b37feb",
16 | "#ff85c0",
17 | ];
18 |
19 | let hash = 0;
20 | for (let i = 0; i < text.length; i++) {
21 | hash = text.charCodeAt(i) + ((hash << 5) - hash);
22 | hash = hash & hash;
23 | }
24 | hash = ((hash % colors.length) + colors.length) % colors.length;
25 |
26 | return colors[hash];
27 | };
28 |
--------------------------------------------------------------------------------
/src/utilities/helpers.ts:
--------------------------------------------------------------------------------
1 | import dayjs from "dayjs";
2 | import { GetFieldsFromList } from "@refinedev/nestjs-query";
3 |
4 | import { DashboardDealsChartQuery } from "@/graphql/types";
5 |
6 | type DealStage = GetFieldsFromList;
7 |
8 | type DealAggregate = DealStage["dealsAggregate"][0];
9 |
10 | interface MappedDealData {
11 | timeUnix: number;
12 | timeText: string;
13 | value: number;
14 | state: string;
15 | }
16 |
17 | // Get the date in the format "MMM DD, YYYY - HH:mm"
18 | export const getDate = (startDate: string, endDate: string) => {
19 | const start = dayjs(startDate).format("MMM DD, YYYY - HH:mm");
20 | const end = dayjs(endDate).format("MMM DD, YYYY - HH:mm");
21 |
22 | return `${start} - ${end}`;
23 | };
24 |
25 | // Filter out deals that don't have a closeDateMonth or closeDateYear
26 | const filterDeal = (deal?: DealAggregate) =>
27 | deal?.groupBy && deal.groupBy?.closeDateMonth && deal.groupBy?.closeDateYear;
28 |
29 | const mapDeals = (
30 | deals: DealAggregate[] = [],
31 | state: string
32 | ): MappedDealData[] => {
33 | // filter out deals that don't have a closeDateMonth or closeDateYear
34 | return deals.filter(filterDeal).map((deal) => {
35 | // Get the closeDateMonth and closeDateYear from the deal
36 | const { closeDateMonth, closeDateYear } = deal.groupBy as NonNullable<
37 | DealAggregate["groupBy"]
38 | >;
39 |
40 | // Create a date object from the closeDateMonth and closeDateYear
41 | const date = dayjs(`${closeDateYear}-${closeDateMonth}-01`);
42 |
43 | // Return the mapped deal data
44 | return {
45 | // Convert the date to a unix timestamp i.e., 1622505600000
46 | timeUnix: date.unix(),
47 | // Convert the date to a string i.e., "May 2021"
48 | timeText: date.format("MMM YYYY"),
49 | // Get the sum of all deals in this stage
50 | value: deal.sum?.value ?? 0,
51 | state,
52 | };
53 | });
54 | };
55 |
56 | // Map deals data to the format required by the chart
57 | export const mapDealsData = (
58 | dealStages: DealStage[] = []
59 | ): MappedDealData[] => {
60 | // Get the deal stage with the title "WON"
61 | const won = dealStages.find((stage) => stage.title === "WON");
62 | const wonDeals = mapDeals(won?.dealsAggregate, "Won");
63 |
64 | // Get the deal stage with the title "LOST"
65 | const lost = dealStages.find((stage) => stage.title === "LOST");
66 | const lostDeals = mapDeals(lost?.dealsAggregate, "Lost");
67 |
68 | // Combine the won and lost deals and sort them by time
69 | return [...wonDeals, ...lostDeals].sort((a, b) => a.timeUnix - b.timeUnix);
70 | };
71 |
--------------------------------------------------------------------------------
/src/utilities/index.ts:
--------------------------------------------------------------------------------
1 | export * from "./currency-number";
2 | export * from "./date";
3 | export * from "./get-name-initials";
4 | export * from "./get-random-color";
5 |
--------------------------------------------------------------------------------
/src/vite-env.d.ts:
--------------------------------------------------------------------------------
1 | ///
2 |
--------------------------------------------------------------------------------
/tsconfig.json:
--------------------------------------------------------------------------------
1 | {
2 | "compilerOptions": {
3 | "target": "ESNext",
4 | "useDefineForClassFields": true,
5 | "lib": ["DOM", "DOM.Iterable", "ESNext"],
6 | "allowJs": false,
7 | "skipLibCheck": true,
8 | "esModuleInterop": false,
9 | "allowSyntheticDefaultImports": true,
10 | "strict": true,
11 | "forceConsistentCasingInFileNames": true,
12 | "module": "ESNext",
13 | "moduleResolution": "Node",
14 | "resolveJsonModule": true,
15 | "isolatedModules": true,
16 | "noEmit": true,
17 | "jsx": "react-jsx",
18 | "baseUrl": "./src",
19 | "paths": {
20 | "@/*": ["./*"]
21 | }
22 | },
23 | "include": ["src", "vite.config.ts"],
24 | "references": [
25 | {
26 | "path": "./tsconfig.node.json"
27 | }
28 | ]
29 | }
--------------------------------------------------------------------------------
/tsconfig.node.json:
--------------------------------------------------------------------------------
1 | {
2 | "compilerOptions": {
3 | "composite": true,
4 | "module": "ESNext",
5 | "moduleResolution": "node"
6 | },
7 | "include": ["vite.config.ts"]
8 | }
9 |
--------------------------------------------------------------------------------
/vite.config.ts:
--------------------------------------------------------------------------------
1 | import react from "@vitejs/plugin-react";
2 | import { defineConfig } from "vite";
3 | import tsconfigPaths from "vite-tsconfig-paths";
4 |
5 | export default defineConfig({
6 | plugins: [tsconfigPaths(), react()],
7 | });
8 |
--------------------------------------------------------------------------------