From ea4df6737fd7afc1785b844442360800b38dee19 Mon Sep 17 00:00:00 2001 From: Aditi Tipnis Date: Wed, 10 Jan 2024 10:46:13 +0530 Subject: [PATCH 01/39] temp commit --- app/assets/stylesheets/application.css | 2 + app/assets/stylesheets/mailers.css | 34 ++++++++++++ .../reset_password_instructions.html.erb | 19 +++++-- app/views/devise/shared/_footer.html.erb | 54 +++++++++++++++++++ 4 files changed, 106 insertions(+), 3 deletions(-) create mode 100644 app/assets/stylesheets/mailers.css create mode 100644 app/views/devise/shared/_footer.html.erb diff --git a/app/assets/stylesheets/application.css b/app/assets/stylesheets/application.css index 288b9ab718..cdffb1e839 100644 --- a/app/assets/stylesheets/application.css +++ b/app/assets/stylesheets/application.css @@ -13,3 +13,5 @@ *= require_tree . *= require_self */ + + @import "" \ No newline at end of file diff --git a/app/assets/stylesheets/mailers.css b/app/assets/stylesheets/mailers.css new file mode 100644 index 0000000000..f8b8e2bfa4 --- /dev/null +++ b/app/assets/stylesheets/mailers.css @@ -0,0 +1,34 @@ +.header { + font-family: system-ui, -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, Cantarell, 'Open Sans', 'Helvetica Neue', sans-serif; + font-style: normal; + font-weight: 800; + font-size: 24px; + line-height: 33px; + color: #5B34EA; +} + +.container { + text-align: center; + + .reset-password-button { + border-radius: 20px; + background: #5B34EA; + text-align: center; + display: inline-block; + + a { + width: 129px; + height: 24px; + font-family: system-ui, -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, Cantarell, 'Open Sans', 'Helvetica Neue', sans-serif; + font-weight: 700; + font-size: 12px; + line-height: 16px; + color: #FFFFFF; + text-decoration: none; + display: inline-block; + display: flex; + justify-content: center; + align-items: center; + } + } +} diff --git a/app/views/devise/mailer/reset_password_instructions.html.erb b/app/views/devise/mailer/reset_password_instructions.html.erb index 0420fd15b7..5639622cd7 100644 --- a/app/views/devise/mailer/reset_password_instructions.html.erb +++ b/app/views/devise/mailer/reset_password_instructions.html.erb @@ -1,8 +1,21 @@ -

Hello <%= @resource.first_name %>,

-

Let’s reset your password so you can get back to using Miru again!

+<%= stylesheet_link_tag "application" %> -

<%= link_to 'Reset password', edit_password_url(@resource, reset_password_token: @token) %>

+
+

Reset Your Miru Password

+ +

Hello, we received a request to change the password associated with

+

<%= @resource.email %>.

+

Please use the following link to reset your password

+ +

<%= link_to 'Reset password', edit_password_url(@resource, reset_password_token: @token) %>

+ +
+ +

If you did not request to change your password, rest assured and ignore this email. This link will expire after 24 hours.

+
+ +<%= render "devise/shared/footer" %>

After changing the password, you can login using this link: <%= link_to 'app.miru.so', "https://app.miru.so" %> diff --git a/app/views/devise/shared/_footer.html.erb b/app/views/devise/shared/_footer.html.erb new file mode 100644 index 0000000000..a099ba7306 --- /dev/null +++ b/app/views/devise/shared/_footer.html.erb @@ -0,0 +1,54 @@ +

From 45a237f13449f95ed1cf3936f5262c9eb5a4fc6c Mon Sep 17 00:00:00 2001 From: Aditi Tipnis Date: Tue, 16 Jan 2024 13:23:07 +0530 Subject: [PATCH 02/39] add footer --- app/assets/stylesheets/mailers.css | 30 +++ app/javascript/images/Banner Animation.svg | 172 ++++++++++++++++++ app/javascript/images/Miru Logo With Text.svg | 9 + .../reset_password_instructions.html.erb | 10 +- app/views/devise/shared/_footer.html.erb | 10 +- public/Background.svg | 3 + public/Banner.svg | 172 ++++++++++++++++++ 7 files changed, 394 insertions(+), 12 deletions(-) create mode 100644 app/javascript/images/Banner Animation.svg create mode 100644 app/javascript/images/Miru Logo With Text.svg create mode 100644 public/Background.svg create mode 100644 public/Banner.svg diff --git a/app/assets/stylesheets/mailers.css b/app/assets/stylesheets/mailers.css index f8b8e2bfa4..51e0a55b72 100644 --- a/app/assets/stylesheets/mailers.css +++ b/app/assets/stylesheets/mailers.css @@ -1,4 +1,12 @@ +.background { + position: absolute; +} + +.banner { + position: relative; +} .header { + padding-top: 40px; font-family: system-ui, -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, Cantarell, 'Open Sans', 'Helvetica Neue', sans-serif; font-style: normal; font-weight: 800; @@ -7,8 +15,21 @@ color: #5B34EA; } +body { + align-items: center; + display: flex; + flex-direction: column; +} .container { text-align: center; + width: 640px; + font-family: 'Manrope'; + font-style: normal; + font-weight: 400; + font-size: 14px; + line-height: 19px; + text-align: center; + color: #777683; .reset-password-button { border-radius: 20px; @@ -32,3 +53,12 @@ } } } + +.footer-container { + justify-content: center; + display: flex; + + #footer { + width: 640px; + } +} diff --git a/app/javascript/images/Banner Animation.svg b/app/javascript/images/Banner Animation.svg new file mode 100644 index 0000000000..2dd304c43b --- /dev/null +++ b/app/javascript/images/Banner Animation.svg @@ -0,0 +1,172 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/app/javascript/images/Miru Logo With Text.svg b/app/javascript/images/Miru Logo With Text.svg new file mode 100644 index 0000000000..237e455a54 --- /dev/null +++ b/app/javascript/images/Miru Logo With Text.svg @@ -0,0 +1,9 @@ + + + + + + + + + diff --git a/app/views/devise/mailer/reset_password_instructions.html.erb b/app/views/devise/mailer/reset_password_instructions.html.erb index 5639622cd7..a20c7133a8 100644 --- a/app/views/devise/mailer/reset_password_instructions.html.erb +++ b/app/views/devise/mailer/reset_password_instructions.html.erb @@ -2,6 +2,8 @@ <%= stylesheet_link_tag "application" %>
+ alt="Twitter Logo"> + alt="Twitter Logo">

Reset Your Miru Password

Hello, we received a request to change the password associated with

@@ -16,11 +18,3 @@
<%= render "devise/shared/footer" %> - -

After changing the password, you can login using this link: <%= link_to 'app.miru.so', "https://app.miru.so" %> - -

Note that the link will expire in 4 days.

- -

If you did not ask to reset your password, you may want to review your recent account access for any unusual activity.

- -

We're here to help if you need it. Visit the Help Centre for more info or <%= link_to 'Contact Us', "https://miru.so" %>

diff --git a/app/views/devise/shared/_footer.html.erb b/app/views/devise/shared/_footer.html.erb index a099ba7306..c916d23e9f 100644 --- a/app/views/devise/shared/_footer.html.erb +++ b/app/views/devise/shared/_footer.html.erb @@ -1,3 +1,4 @@ + + diff --git a/public/Background.svg b/public/Background.svg new file mode 100644 index 0000000000..0fb2bc90de --- /dev/null +++ b/public/Background.svg @@ -0,0 +1,3 @@ + + + diff --git a/public/Banner.svg b/public/Banner.svg new file mode 100644 index 0000000000..2dd304c43b --- /dev/null +++ b/public/Banner.svg @@ -0,0 +1,172 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + From 2ba596e7fd58b4b0b087dc25413e98f47fed3464 Mon Sep 17 00:00:00 2001 From: Aditi Tipnis Date: Tue, 16 Jan 2024 13:57:19 +0530 Subject: [PATCH 03/39] fix --- app/assets/stylesheets/application.css | 2 -- 1 file changed, 2 deletions(-) diff --git a/app/assets/stylesheets/application.css b/app/assets/stylesheets/application.css index cdffb1e839..288b9ab718 100644 --- a/app/assets/stylesheets/application.css +++ b/app/assets/stylesheets/application.css @@ -13,5 +13,3 @@ *= require_tree . *= require_self */ - - @import "" \ No newline at end of file From b52581311495ae125913cb25ce7ebdaecaffe949 Mon Sep 17 00:00:00 2001 From: Aditi Tipnis Date: Fri, 19 Jan 2024 13:56:56 +0530 Subject: [PATCH 04/39] Add password icon in header --- app/assets/stylesheets/mailers.css | 6 ++++++ .../mailer/reset_password_instructions.html.erb | 5 +++-- public/Password.svg | 15 +++++++++++++++ 3 files changed, 24 insertions(+), 2 deletions(-) create mode 100644 public/Password.svg diff --git a/app/assets/stylesheets/mailers.css b/app/assets/stylesheets/mailers.css index 51e0a55b72..2ca5773c66 100644 --- a/app/assets/stylesheets/mailers.css +++ b/app/assets/stylesheets/mailers.css @@ -5,6 +5,12 @@ .banner { position: relative; } + +.password { + position: relative; + top: -80px; +} + .header { padding-top: 40px; font-family: system-ui, -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, Cantarell, 'Open Sans', 'Helvetica Neue', sans-serif; diff --git a/app/views/devise/mailer/reset_password_instructions.html.erb b/app/views/devise/mailer/reset_password_instructions.html.erb index a20c7133a8..6718f09fd5 100644 --- a/app/views/devise/mailer/reset_password_instructions.html.erb +++ b/app/views/devise/mailer/reset_password_instructions.html.erb @@ -2,8 +2,9 @@ <%= stylesheet_link_tag "application" %>
- alt="Twitter Logo"> - alt="Twitter Logo"> + alt="Background"> + alt="Banner"> + alt="Password">

Reset Your Miru Password

Hello, we received a request to change the password associated with

diff --git a/public/Password.svg b/public/Password.svg new file mode 100644 index 0000000000..3841435946 --- /dev/null +++ b/public/Password.svg @@ -0,0 +1,15 @@ + + + + + + + + + + + + + + + From ba9c469a5ce46d81777482534f39cf988ccd71e5 Mon Sep 17 00:00:00 2001 From: Aditi Tipnis Date: Fri, 1 Mar 2024 15:22:59 +0530 Subject: [PATCH 05/39] Remove unused images --- app/assets/stylesheets/mailers.css | 14 +- app/javascript/images/Banner Animation.svg | 172 ------------------ app/javascript/images/Miru Logo With Text.svg | 9 - 3 files changed, 7 insertions(+), 188 deletions(-) delete mode 100644 app/javascript/images/Banner Animation.svg delete mode 100644 app/javascript/images/Miru Logo With Text.svg diff --git a/app/assets/stylesheets/mailers.css b/app/assets/stylesheets/mailers.css index 2ca5773c66..58244ccd0c 100644 --- a/app/assets/stylesheets/mailers.css +++ b/app/assets/stylesheets/mailers.css @@ -29,13 +29,13 @@ body { .container { text-align: center; width: 640px; - font-family: 'Manrope'; - font-style: normal; - font-weight: 400; - font-size: 14px; - line-height: 19px; - text-align: center; - color: #777683; + font-family: 'Manrope'; + font-style: normal; + font-weight: 400; + font-size: 14px; + line-height: 19px; + text-align: center; + color: #777683; .reset-password-button { border-radius: 20px; diff --git a/app/javascript/images/Banner Animation.svg b/app/javascript/images/Banner Animation.svg deleted file mode 100644 index 2dd304c43b..0000000000 --- a/app/javascript/images/Banner Animation.svg +++ /dev/null @@ -1,172 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/app/javascript/images/Miru Logo With Text.svg b/app/javascript/images/Miru Logo With Text.svg deleted file mode 100644 index 237e455a54..0000000000 --- a/app/javascript/images/Miru Logo With Text.svg +++ /dev/null @@ -1,9 +0,0 @@ - - - - - - - - - From 9027b4feccf77c0622a309c8e666d3e564593240 Mon Sep 17 00:00:00 2001 From: Aditi Tipnis Date: Fri, 1 Mar 2024 16:47:24 +0530 Subject: [PATCH 06/39] remove duplicate --- app/assets/stylesheets/mailers.css | 1 - 1 file changed, 1 deletion(-) diff --git a/app/assets/stylesheets/mailers.css b/app/assets/stylesheets/mailers.css index 58244ccd0c..c14fc52e0e 100644 --- a/app/assets/stylesheets/mailers.css +++ b/app/assets/stylesheets/mailers.css @@ -52,7 +52,6 @@ body { line-height: 16px; color: #FFFFFF; text-decoration: none; - display: inline-block; display: flex; justify-content: center; align-items: center; From 5ce3e93884eed90f210aa970bb7fd3b494e76663 Mon Sep 17 00:00:00 2001 From: Aditi Tipnis Date: Mon, 4 Mar 2024 21:40:10 +0530 Subject: [PATCH 07/39] Fix styling --- .../reset_password_instructions.html.erb | 27 ++++++++++--------- 1 file changed, 14 insertions(+), 13 deletions(-) diff --git a/app/views/devise/mailer/reset_password_instructions.html.erb b/app/views/devise/mailer/reset_password_instructions.html.erb index 6718f09fd5..80aec65ae8 100644 --- a/app/views/devise/mailer/reset_password_instructions.html.erb +++ b/app/views/devise/mailer/reset_password_instructions.html.erb @@ -1,21 +1,22 @@ <%= stylesheet_link_tag "application" %> +
+
+ alt="background"> + alt="banner"> + alt="Password"> +

Reset Your Miru Password

-
- alt="Background"> - alt="Banner"> - alt="Password"> -

Reset Your Miru Password

+

Hello, we received a request to change the password associated with

+

<%= @resource.email %>.

+

Please use the following link to reset your password

-

Hello, we received a request to change the password associated with

-

<%= @resource.email %>.

-

Please use the following link to reset your password

+

<%= link_to 'Reset password', edit_password_url(@resource, reset_password_token: @token) %>

-

<%= link_to 'Reset password', edit_password_url(@resource, reset_password_token: @token) %>

+
-
+

If you did not request to change your password, rest assured and ignore this email. This link will expire after 24 hours.

+
-

If you did not request to change your password, rest assured and ignore this email. This link will expire after 24 hours.

+ <%= render "devise/shared/footer" %>
- -<%= render "devise/shared/footer" %> From 0fbc7c3859ee430327406ce028d57d4c64604b50 Mon Sep 17 00:00:00 2001 From: Apoorv Tiwari Date: Tue, 5 Mar 2024 18:39:25 +0530 Subject: [PATCH 08/39] Move images from public to assets (#1687) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * move images from public to assets * add alt for images --------- Co-authored-by: “Apoorv <“tiwari.apoorv1316@gmail.com”> --- {public => app/assets/images}/Background.svg | 0 {public => app/assets/images}/Banner.svg | 0 {public => app/assets/images}/Password.svg | 0 .../devise/mailer/reset_password_instructions.html.erb | 6 +++--- app/views/devise/shared/_footer.html.erb | 8 ++++---- 5 files changed, 7 insertions(+), 7 deletions(-) rename {public => app/assets/images}/Background.svg (100%) rename {public => app/assets/images}/Banner.svg (100%) rename {public => app/assets/images}/Password.svg (100%) diff --git a/public/Background.svg b/app/assets/images/Background.svg similarity index 100% rename from public/Background.svg rename to app/assets/images/Background.svg diff --git a/public/Banner.svg b/app/assets/images/Banner.svg similarity index 100% rename from public/Banner.svg rename to app/assets/images/Banner.svg diff --git a/public/Password.svg b/app/assets/images/Password.svg similarity index 100% rename from public/Password.svg rename to app/assets/images/Password.svg diff --git a/app/views/devise/mailer/reset_password_instructions.html.erb b/app/views/devise/mailer/reset_password_instructions.html.erb index 80aec65ae8..cfdeb9f661 100644 --- a/app/views/devise/mailer/reset_password_instructions.html.erb +++ b/app/views/devise/mailer/reset_password_instructions.html.erb @@ -2,9 +2,9 @@ <%= stylesheet_link_tag "application" %>
- alt="background"> - alt="banner"> - alt="Password"> + <%= image_tag "Background.svg", class: "background", alt: "background" %> + <%= image_tag "Banner.svg", class: "banner", alt: "banner" %> + <%= image_tag "Password.svg", class: "password", alt: "password" %>

Reset Your Miru Password

Hello, we received a request to change the password associated with

diff --git a/app/views/devise/shared/_footer.html.erb b/app/views/devise/shared/_footer.html.erb index c916d23e9f..b4745af509 100644 --- a/app/views/devise/shared/_footer.html.erb +++ b/app/views/devise/shared/_footer.html.erb @@ -10,7 +10,7 @@ style="font-family: 'Manrope'; font-style: normal; font-size:14px; font-weight:400; line-height: 19px; color:#A5A3AD; display: inline-block; text-align: center;" >Powered by - alt="Miru Logo" height="48px" width="120px"> + <%= image_tag "miruLogoWithText.svg", height: '48px', width: '120px', alt: 'Miru logo' %> @@ -42,13 +42,13 @@ href="https://www.instagram.com/getmiru/" target="_blank" > - alt="Instagram Logo" height="16px" width="16px"> - + <%= image_tag "Instagram.svg", height: '16px', width: '16px', alt: 'Instagram logo' %> + - alt="Twitter Logo" height="16px" width="16px"> + <%= image_tag "Twitter.svg", height: '16px', width: '16px', alt: 'Twitter logo' %>
From 0ae2214b8ba6efa11535b53f7124ec2b8f8dfced Mon Sep 17 00:00:00 2001 From: Shruti-Apte <72149587+Shruti-Apte@users.noreply.github.com> Date: Tue, 5 Mar 2024 05:10:29 -0800 Subject: [PATCH 09/39] Fixed address field on organisation edit page (#1684) * address line input box replaced with textarea * address line 2 field changed to textarea --------- Co-authored-by: Saeloun --- .../Profile/Organization/Edit/StaticPage.tsx | 11 +++++++---- 1 file changed, 7 insertions(+), 4 deletions(-) diff --git a/app/javascript/src/components/Profile/Organization/Edit/StaticPage.tsx b/app/javascript/src/components/Profile/Organization/Edit/StaticPage.tsx index d977d65ea7..8f08026dc9 100644 --- a/app/javascript/src/components/Profile/Organization/Edit/StaticPage.tsx +++ b/app/javascript/src/components/Profile/Organization/Edit/StaticPage.tsx @@ -14,6 +14,7 @@ import "react-phone-number-input/style.css"; import { CustomAsyncSelect } from "common/CustomAsyncSelect"; import { CustomInputText } from "common/CustomInputText"; import CustomReactSelect from "common/CustomReactSelect"; +import { CustomTextareaAutosize } from "common/CustomTextareaAutosize"; import { ErrorSpan } from "common/ErrorSpan"; import FileAcceptanceText from "./FileAcceptanceText"; @@ -183,11 +184,12 @@ export const StaticPage = ({
- handleAddrChange(e, "addressLine1")} /> @@ -199,11 +201,12 @@ export const StaticPage = ({ )}
- handleAddrChange(e, "addressLine2")} /> From 69fe1d8211a9ce8b3337064627a0e64a57571a98 Mon Sep 17 00:00:00 2001 From: Shruti-Apte <72149587+Shruti-Apte@users.noreply.github.com> Date: Tue, 5 Mar 2024 22:37:50 -0800 Subject: [PATCH 10/39] Redesign team list page (#1673) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * show employment details on teams * teamlist page redesign * removed client column and some UI fixes * worked on review comments * common component Button added * static salary value removed and empty state added * intervalToDuration func used to calculate duration --------- Co-authored-by: “Apoorv <“tiwari.apoorv1316@gmail.com”> Co-authored-by: Apoorv Tiwari Co-authored-by: Saeloun --- .../src/common/HoverMoreOptions/index.tsx | 19 ++ .../src/components/Team/List/Header.tsx | 38 ++- .../Team/List/Table/MoreOptions.tsx | 93 ++++++ .../components/Team/List/Table/TableHead.tsx | 64 ++--- .../components/Team/List/Table/TableRow.tsx | 266 +++++++----------- .../src/components/Team/List/Table/index.tsx | 28 +- .../src/components/Team/List/index.tsx | 12 +- app/javascript/src/mapper/team.mapper.ts | 2 + app/javascript/src/utils/getBadgeStatus.ts | 1 + 9 files changed, 278 insertions(+), 245 deletions(-) create mode 100644 app/javascript/src/common/HoverMoreOptions/index.tsx create mode 100644 app/javascript/src/components/Team/List/Table/MoreOptions.tsx diff --git a/app/javascript/src/common/HoverMoreOptions/index.tsx b/app/javascript/src/common/HoverMoreOptions/index.tsx new file mode 100644 index 0000000000..aceaa7bc89 --- /dev/null +++ b/app/javascript/src/common/HoverMoreOptions/index.tsx @@ -0,0 +1,19 @@ +import React from "react"; + +type Iprops = { + children: any; + position?: string; +}; + +const HoverMoreOptions = ({ children, position }: Iprops) => ( + +); + +export default HoverMoreOptions; diff --git a/app/javascript/src/components/Team/List/Header.tsx b/app/javascript/src/components/Team/List/Header.tsx index 5e71d1cd8e..f654360811 100644 --- a/app/javascript/src/components/Team/List/Header.tsx +++ b/app/javascript/src/components/Team/List/Header.tsx @@ -1,18 +1,17 @@ import React from "react"; import { PlusIcon } from "miruIcons"; +import { Button } from "StyledComponents"; import teamApi from "apis/team"; import AutoSearch from "common/AutoSearch"; import { TeamModalType } from "constants/index"; import { useList } from "context/TeamContext"; -import { useUserContext } from "context/UserContext"; import { unmapList } from "mapper/team.mapper"; import SearchDataRow from "./SearchDataRow"; const Header = () => { - const { isAdminUser } = useUserContext(); const { setModalState } = useList(); const fetchTeamList = async searchString => { @@ -23,26 +22,21 @@ const Header = () => { }; return ( -
-

Team

- {isAdminUser && ( - <> - -
- -
- - )} +
+

Team

+ +
+ +
); }; diff --git a/app/javascript/src/components/Team/List/Table/MoreOptions.tsx b/app/javascript/src/components/Team/List/Table/MoreOptions.tsx new file mode 100644 index 0000000000..bfa5e731a6 --- /dev/null +++ b/app/javascript/src/components/Team/List/Table/MoreOptions.tsx @@ -0,0 +1,93 @@ +import React from "react"; + +import { DeleteIcon, EditIcon, ResendInviteIcon } from "miruIcons"; +import { Button, MobileMoreOptions, Tooltip } from "StyledComponents"; + +import teamApi from "apis/team"; +import HoverMoreOptions from "common/HoverMoreOptions"; +import { TeamModalType } from "constants/index"; +import { useList } from "context/TeamContext"; +import { useUserContext } from "context/UserContext"; + +type Iprops = { + item: any; + setShowMoreOptions?: React.Dispatch>; + showMoreOptions?: boolean; +}; + +const MoreOptions = ({ item, setShowMoreOptions, showMoreOptions }: Iprops) => { + const { setModalState } = useList(); + const { isDesktop } = useUserContext(); + + const handleResendInvite = async () => { + await teamApi.resendInvite(item.id); + }; + + const handleAction = (e, action) => { + e.preventDefault(); + e.stopPropagation(); + setModalState(action, item); + }; + + return isDesktop ? ( + + + + + + + + + + + + ) : ( + +
  • { + setShowMoreOptions(false); + handleAction(e, TeamModalType.ADD_EDIT); + }} + > + + Edit +
  • +
  • { + setShowMoreOptions(false); + handleAction(e, TeamModalType.DELETE); + }} + > + + Delete +
  • +
    + ); +}; + +export default MoreOptions; diff --git a/app/javascript/src/components/Team/List/Table/TableHead.tsx b/app/javascript/src/components/Team/List/Table/TableHead.tsx index f2ff7e7768..e2174ff166 100644 --- a/app/javascript/src/components/Team/List/Table/TableHead.tsx +++ b/app/javascript/src/components/Team/List/Table/TableHead.tsx @@ -1,47 +1,21 @@ -import React, { Fragment } from "react"; - -import { useUserContext } from "context/UserContext"; - -const TableHead = () => { - const { isAdminUser, isDesktop } = useUserContext(); - - if (isDesktop) { - return ( - - - - NAME - - - EMAIL ID - - - ROLE - - {isAdminUser && ( - - - - - )} - - - ); - } - - return ( - - - - NAME / EMAIL ID - - - ROLE - - - - - ); -}; +import React from "react"; +const TableHead = () => ( + + + + USER + + + SALARY + + + ROLE + + + TYPE + + + +); export default TableHead; diff --git a/app/javascript/src/components/Team/List/Table/TableRow.tsx b/app/javascript/src/components/Team/List/Table/TableRow.tsx index 22f82ec38d..cf15c3ee40 100644 --- a/app/javascript/src/components/Team/List/Table/TableRow.tsx +++ b/app/javascript/src/components/Team/List/Table/TableRow.tsx @@ -1,192 +1,136 @@ -import React, { Fragment } from "react"; +import React, { useState } from "react"; -import { - EditIcon, - DeleteIcon, - DotsThreeVerticalIcon, - ResendInviteIcon, -} from "miruIcons"; +import { intervalToDuration } from "date-fns"; +import { DotsThreeVerticalIcon } from "miruIcons"; import { useNavigate } from "react-router-dom"; -import { Badge, MobileMoreOptions } from "StyledComponents"; +import { Avatar, Badge, Button } from "StyledComponents"; -import teamApi from "apis/team"; -import { TeamModalType } from "constants/index"; -import { useList } from "context/TeamContext"; import { useUserContext } from "context/UserContext"; +import getStatusCssClass from "utils/getBadgeStatus"; + +import MoreOptions from "./MoreOptions"; const TableRow = ({ item }) => { - const { isAdminUser, isDesktop } = useUserContext(); - const { setModalState } = useList(); + const { isDesktop } = useUserContext(); + const navigate = useNavigate(); - const [showMoreOptions, setShowMoreOptions] = React.useState(false); - const { id, name, email, role, status } = item; + const [showMoreOptions, setShowMoreOptions] = useState(false); + const { + id, + name, + email, + role, + status, + profilePicture, + joinedAtDate, + employmentType, + } = item; - const actionIconVisible = isAdminUser && role !== "owner"; + const calculateWorkDuration = joinedAt => { + if (!joinedAt) { + return null; + } - const handleAction = (e, action) => { - e.preventDefault(); - e.stopPropagation(); - setModalState(action, item); - }; + const start = new Date(joinedAt); + const today = new Date(); - const handleResendInvite = async () => { - await teamApi.resendInvite(item.id); + const dur = intervalToDuration({ start, end: today }); + + const duration = + (dur.years ? `${dur.years}y ` : "") + + (dur.months ? ` ${dur.months}m ` : "") + + (dur.days ? ` ${dur.days}d` : ""); + + return duration; }; const handleRowClick = () => { if (status) return; if (isDesktop) { - isAdminUser ? navigate(`/team/${id}`, { replace: true }) : null; + navigate(`/team/${id}`, { replace: true }); } else { - isAdminUser ? navigate(`/team/${id}/options`, { replace: true }) : null; + navigate(`/team/${id}/options`, { replace: true }); } }; - const formattedRole = role - .split("_") - .map(word => word.charAt(0) + word.slice(1)) - .join(""); - if (isDesktop) { - return ( - - {name} - - {email} - - - {formattedRole} - - {isAdminUser && ( - - - {status && ( - - )} - - - {actionIconVisible && ( -
    - {status && ( - - )} - - -
    - )} - -
    - )} - - ); - } - return ( - <> - - -
    -
    {name}
    -
    -
    -
    {email}
    -
    - - - {formattedRole} - {status && ( -
    + + +
    + +
    +
    +
    +

    + {name} +

    + {status && (
    - )} - - - {actionIconVisible && ( - { - e.preventDefault(); - e.stopPropagation(); - setShowMoreOptions(true); - }} + )} + +
    + {email} +
    +
    + + + - + + +
    +

    + {role.replace(/_/g, " ")} +

    + {status && ( + )} - - - {showMoreOptions && ( - + + + +
    {employmentType || "-"}
    +
    + {calculateWorkDuration(joinedAtDate) || "-"} +
    + {isDesktop && } + + {showMoreOptions && ( + )} - + ); }; diff --git a/app/javascript/src/components/Team/List/Table/index.tsx b/app/javascript/src/components/Team/List/Table/index.tsx index 52a4191ddc..d71175fd34 100644 --- a/app/javascript/src/components/Team/List/Table/index.tsx +++ b/app/javascript/src/components/Team/List/Table/index.tsx @@ -1,5 +1,6 @@ -import React from "react"; +import React, { Fragment } from "react"; +import EmptyStates from "common/EmptyStates"; import { useList } from "context/TeamContext"; import TableHead from "./TableHead"; @@ -9,14 +10,23 @@ const Table = () => { const { teamList } = useList(); return ( - - - - {teamList.map((item, index) => ( - - ))} - -
    + + {teamList.length > 0 ? ( + + + + {teamList.map((item, index) => ( + + ))} + +
    + ) : ( + + )} +
    ); }; export default Table; diff --git a/app/javascript/src/components/Team/List/index.tsx b/app/javascript/src/components/Team/List/index.tsx index 052f40f1d2..a7c3cc8d64 100644 --- a/app/javascript/src/components/Team/List/index.tsx +++ b/app/javascript/src/components/Team/List/index.tsx @@ -1,4 +1,4 @@ -import React, { Fragment, useEffect, useState } from "react"; +import React, { useEffect, useState } from "react"; import Logger from "js-logger"; import { Pagination } from "StyledComponents"; @@ -101,14 +101,10 @@ const TeamList = () => { }} > {!hideContainer && ( - +
    -
    -
    - - - +
    { totalPages={pagy?.pages} /> - + )} diff --git a/app/javascript/src/mapper/team.mapper.ts b/app/javascript/src/mapper/team.mapper.ts index f2cdd1a852..513e283f69 100644 --- a/app/javascript/src/mapper/team.mapper.ts +++ b/app/javascript/src/mapper/team.mapper.ts @@ -8,6 +8,8 @@ const mapper = item => ({ status: item.status, profilePicture: item.profilePicture, isTeamMember: item.isTeamMember, + employmentType: item.employmentType, + joinedAtDate: item.joinedAtDate, }); const unmapList = input => { diff --git a/app/javascript/src/utils/getBadgeStatus.ts b/app/javascript/src/utils/getBadgeStatus.ts index 8831d1aa4f..9aa4ed5b61 100644 --- a/app/javascript/src/utils/getBadgeStatus.ts +++ b/app/javascript/src/utils/getBadgeStatus.ts @@ -12,6 +12,7 @@ const getStatusCssClass = status => { sending: "bg-miru-gray-6000 text-miru-black-1000", waived: "bg-miru-gray-1000 text-miru-black-1000", non_billable: "bg-miru-dark-purple-100 text-miru-dark-purple-600", + pending: "bg-miru-han-purple-100 text-miru-han-purple-1000", }; const lowerCaseStatus = status.toLowerCase(); From b4364f79fd7d7d37500df6eca563315d9c373a5d Mon Sep 17 00:00:00 2001 From: Apoorv Tiwari Date: Thu, 7 Mar 2024 20:49:29 +0530 Subject: [PATCH 11/39] Remove cursor pointer from summary (#1690) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit remove cursor pointer from summary Co-authored-by: “Apoorv <“tiwari.apoorv1316@gmail.com”> --- .../Reports/OutstandingInvoiceReport/Container/index.tsx | 1 + 1 file changed, 1 insertion(+) diff --git a/app/javascript/src/components/Reports/OutstandingInvoiceReport/Container/index.tsx b/app/javascript/src/components/Reports/OutstandingInvoiceReport/Container/index.tsx index a117d85d9c..7d6abdd40e 100644 --- a/app/javascript/src/components/Reports/OutstandingInvoiceReport/Container/index.tsx +++ b/app/javascript/src/components/Reports/OutstandingInvoiceReport/Container/index.tsx @@ -43,6 +43,7 @@ const Container = () => {
    From c8f6fb9a10343cb55b52f62bbb2d2de638c60d33 Mon Sep 17 00:00:00 2001 From: Shruti-Apte <72149587+Shruti-Apte@users.noreply.github.com> Date: Thu, 7 Mar 2024 22:46:33 -0800 Subject: [PATCH 12/39] Expense module: UI and Integration (#1625) * Basic structure and header * Table structure added * expense module UI and Integration * edit and delete API and integration * review comments and few icon changes * removed chart.js * worked on review commets * worked on review comments * before_actions updated --------- Co-authored-by: Saeloun --- .../internal_api/v1/expenses_controller.rb | 18 +- app/javascript/src/apis/expenses.ts | 27 ++ .../common/CustomCreatableSelect/index.tsx | 138 ++++++++ .../Mobile/AddEditModalHeader/index.tsx | 14 + .../components/Expenses/Details/Expense.tsx | 60 ++++ .../components/Expenses/Details/Header.tsx | 64 ++++ .../src/components/Expenses/Details/index.tsx | 122 +++++++ .../List/Container/ExpensesSummary.tsx | 36 ++ .../List/Container/Table/MoreOptions.tsx | 63 ++++ .../List/Container/Table/TableHeader.tsx | 54 +++ .../List/Container/Table/TableRow.tsx | 110 ++++++ .../Expenses/List/Container/Table/index.tsx | 27 ++ .../Expenses/List/Container/index.tsx | 24 ++ .../src/components/Expenses/List/Header.tsx | 53 +++ .../src/components/Expenses/List/index.tsx | 81 +++++ .../Expenses/Modals/AddExpenseModal.tsx | 35 ++ .../Expenses/Modals/DeleteExpenseModal.tsx | 45 +++ .../Expenses/Modals/EditExpenseModal.tsx | 37 ++ .../Expenses/Modals/ExpenseForm.tsx | 332 ++++++++++++++++++ .../Expenses/Modals/Mobile/AddExpense.tsx | 27 ++ .../Expenses/Modals/Mobile/EditExpense.tsx | 29 ++ .../src/components/Expenses/utils.js | 114 ++++++ .../components/Invoices/List/MoreOptions.tsx | 2 +- .../src/components/Navbar/utils.tsx | 19 + .../Organization/Holidays/HolidaysModal.tsx | 2 +- app/javascript/src/constants/index.tsx | 1 + app/javascript/src/constants/routes.ts | 13 + app/javascript/src/miruIcons/index.ts | 18 + .../src/miruIcons/svgIcons/expenseIcon.svg | 18 + app/models/expense_category.rb | 2 +- app/policies/expense_policy.rb | 8 + config/locales/en.yml | 3 + config/routes/internal_api.rb | 2 +- 33 files changed, 1593 insertions(+), 5 deletions(-) create mode 100644 app/javascript/src/apis/expenses.ts create mode 100644 app/javascript/src/common/CustomCreatableSelect/index.tsx create mode 100644 app/javascript/src/common/Mobile/AddEditModalHeader/index.tsx create mode 100644 app/javascript/src/components/Expenses/Details/Expense.tsx create mode 100644 app/javascript/src/components/Expenses/Details/Header.tsx create mode 100644 app/javascript/src/components/Expenses/Details/index.tsx create mode 100644 app/javascript/src/components/Expenses/List/Container/ExpensesSummary.tsx create mode 100644 app/javascript/src/components/Expenses/List/Container/Table/MoreOptions.tsx create mode 100644 app/javascript/src/components/Expenses/List/Container/Table/TableHeader.tsx create mode 100644 app/javascript/src/components/Expenses/List/Container/Table/TableRow.tsx create mode 100644 app/javascript/src/components/Expenses/List/Container/Table/index.tsx create mode 100644 app/javascript/src/components/Expenses/List/Container/index.tsx create mode 100644 app/javascript/src/components/Expenses/List/Header.tsx create mode 100644 app/javascript/src/components/Expenses/List/index.tsx create mode 100644 app/javascript/src/components/Expenses/Modals/AddExpenseModal.tsx create mode 100644 app/javascript/src/components/Expenses/Modals/DeleteExpenseModal.tsx create mode 100644 app/javascript/src/components/Expenses/Modals/EditExpenseModal.tsx create mode 100644 app/javascript/src/components/Expenses/Modals/ExpenseForm.tsx create mode 100644 app/javascript/src/components/Expenses/Modals/Mobile/AddExpense.tsx create mode 100644 app/javascript/src/components/Expenses/Modals/Mobile/EditExpense.tsx create mode 100644 app/javascript/src/components/Expenses/utils.js create mode 100644 app/javascript/src/miruIcons/svgIcons/expenseIcon.svg diff --git a/app/controllers/internal_api/v1/expenses_controller.rb b/app/controllers/internal_api/v1/expenses_controller.rb index 2469e7f907..2611fac0e2 100644 --- a/app/controllers/internal_api/v1/expenses_controller.rb +++ b/app/controllers/internal_api/v1/expenses_controller.rb @@ -1,7 +1,7 @@ # frozen_string_literal: true class InternalApi::V1::ExpensesController < ApplicationController - before_action :set_expense, only: :show + before_action :set_expense, only: [:show, :update, :destroy] def index authorize Expense @@ -27,6 +27,22 @@ def show render :show, locals: { expense: Expense::ShowPresenter.new(@expense).process } end + def update + authorize @expense + + @expense.update!(expense_params) + + render json: { notice: I18n.t("expenses.update") }, status: :ok + end + + def destroy + authorize @expense + + @expense.destroy! + + render json: { notice: I18n.t("expenses.destroy") }, status: :ok + end + private def expense_params diff --git a/app/javascript/src/apis/expenses.ts b/app/javascript/src/apis/expenses.ts new file mode 100644 index 0000000000..322a8b97b1 --- /dev/null +++ b/app/javascript/src/apis/expenses.ts @@ -0,0 +1,27 @@ +import axios from "./api"; + +const path = "/expenses"; + +const index = async () => await axios.get(path); + +const create = async payload => await axios.post(path, payload); + +const show = async id => await axios.get(`${path}/${id}`); + +const update = async (id, payload) => axios.patch(`${path}/${id}`, payload); + +const destroy = async id => axios.delete(`${path}/${id}`); + +const createCategory = async payload => + axios.post("/expense_categories", payload); + +const expensesApi = { + index, + create, + show, + update, + destroy, + createCategory, +}; + +export default expensesApi; diff --git a/app/javascript/src/common/CustomCreatableSelect/index.tsx b/app/javascript/src/common/CustomCreatableSelect/index.tsx new file mode 100644 index 0000000000..a2c9b71d42 --- /dev/null +++ b/app/javascript/src/common/CustomCreatableSelect/index.tsx @@ -0,0 +1,138 @@ +/* eslint-disable import/exports-last */ +import React from "react"; + +import CreatableSelect from "react-select/creatable"; + +import { + customErrStyles, + customStyles, + CustomValueContainer, +} from "common/CustomReactSelectStyle"; +import { useUserContext } from "context/UserContext"; + +type CustomCreatableSelectProps = { + id?: string; + styles?: any; + components?: any; + classNamePrefix?: string; + label?: string; + isErr?: any; + isSearchable?: boolean; + isDisabled?: boolean; + ignoreDisabledFontColor?: boolean; + hideDropdownIndicator?: boolean; + handleOnClick?: (e?: any) => void; // eslint-disable-line + handleOnChange?: (e?: any) => void; // eslint-disable-line + handleonFocus?: (e?: any) => void; // eslint-disable-line + onBlur?: (e?: any) => void; // eslint-disable-line + defaultValue?: object; + onMenuClose?: (e?: any) => void; // eslint-disable-line + onMenuOpen?: (e?: any) => void; // eslint-disable-line + className?: string; + autoFocus?: boolean; + value?: object; + getOptionLabel?: (e?: any) => any; // eslint-disable-line + wrapperClassName?: string; + options?: Array; + name?: string; +}; + +export const CustomCreatableSelect = ({ + id, + isSearchable, + classNamePrefix, + options, + label, + handleOnChange, + handleonFocus, + handleOnClick, + name, + value, + isErr, + isDisabled, + styles, + components, + onMenuClose, + onMenuOpen, + ignoreDisabledFontColor, + hideDropdownIndicator, + className, + autoFocus, + onBlur, + defaultValue, + getOptionLabel, + wrapperClassName, +}: CustomCreatableSelectProps) => { + const { isDesktop } = useUserContext(); + + const getStyle = () => { + if (isErr) { + return customErrStyles(isDesktop); + } + + return customStyles( + isDesktop, + ignoreDisabledFontColor, + hideDropdownIndicator + ); + }; + + return ( +
    + null, + }} + onBlur={onBlur} + onChange={handleOnChange} + onFocus={handleonFocus} + onMenuClose={onMenuClose} + onMenuOpen={onMenuOpen} + /> +
    + ); +}; + +CustomCreatableSelect.defaultProps = { + id: "", + styles: null, + components: null, + classNamePrefix: "react-select-filter", + label: "Select", + isErr: false, + isSearchable: true, + isDisabled: false, + ignoreDisabledFontColor: false, + hideDropdownIndicator: false, + handleOnClick: () => {}, // eslint-disable-line @typescript-eslint/no-empty-function + handleOnChange: () => {}, // eslint-disable-line @typescript-eslint/no-empty-function + handleonFocus: () => {}, // eslint-disable-line @typescript-eslint/no-empty-function + onBlur: () => {}, // eslint-disable-line @typescript-eslint/no-empty-function + defaultValue: null, + onMenuClose: () => {}, // eslint-disable-line @typescript-eslint/no-empty-function + onMenuOpen: () => {}, // eslint-disable-line @typescript-eslint/no-empty-function + className: "", + autoFocus: false, + value: null, + wrapperClassName: "", +}; + +export default CustomCreatableSelect; diff --git a/app/javascript/src/common/Mobile/AddEditModalHeader/index.tsx b/app/javascript/src/common/Mobile/AddEditModalHeader/index.tsx new file mode 100644 index 0000000000..22a5131032 --- /dev/null +++ b/app/javascript/src/common/Mobile/AddEditModalHeader/index.tsx @@ -0,0 +1,14 @@ +import React from "react"; + +import { XIcon } from "miruIcons"; + +const AddEditModalHeader = ({ title, handleOnClose }) => ( +
    + + {title} + + +
    +); + +export default AddEditModalHeader; diff --git a/app/javascript/src/components/Expenses/Details/Expense.tsx b/app/javascript/src/components/Expenses/Details/Expense.tsx new file mode 100644 index 0000000000..00ce2b9681 --- /dev/null +++ b/app/javascript/src/components/Expenses/Details/Expense.tsx @@ -0,0 +1,60 @@ +import React from "react"; + +import { currencyFormat } from "helpers"; + +const Expense = ({ expense, currency }) => ( +
    +
    + + Amount + + + {currencyFormat(currency, expense?.amount)} + +
    +
    +
    + + Date + + + {expense?.date || "-"} + +
    +
    + + Vendor + + + {expense?.vendorName || "-"} + +
    +
    + + Type + + + {expense?.type || "-"} + +
    +
    + + Receipt + + + {expense?.receipt || "-"} + +
    +
    +
    + + Description + + + {expense?.description || "-"} + +
    +
    +); + +export default Expense; diff --git a/app/javascript/src/components/Expenses/Details/Header.tsx b/app/javascript/src/components/Expenses/Details/Header.tsx new file mode 100644 index 0000000000..5780664780 --- /dev/null +++ b/app/javascript/src/components/Expenses/Details/Header.tsx @@ -0,0 +1,64 @@ +import React from "react"; + +import { ArrowLeftIcon, EditIcon, DeleteIcon } from "miruIcons"; +import { useNavigate } from "react-router-dom"; +import { Button } from "StyledComponents"; + +const Header = ({ expense, handleEdit, handleDelete }) => { + const navigate = useNavigate(); + + return ( +
    +
    + + + {expense?.categoryName} + +
    +
    + + +
    +
    + + +
    +
    + ); +}; + +export default Header; diff --git a/app/javascript/src/components/Expenses/Details/index.tsx b/app/javascript/src/components/Expenses/Details/index.tsx new file mode 100644 index 0000000000..694f678b24 --- /dev/null +++ b/app/javascript/src/components/Expenses/Details/index.tsx @@ -0,0 +1,122 @@ +import React, { useEffect, useState } from "react"; + +import Logger from "js-logger"; +import { useNavigate, useParams } from "react-router-dom"; + +import expensesApi from "apis/expenses"; +import { useUserContext } from "context/UserContext"; + +import Expense from "./Expense"; +import Header from "./Header"; + +import DeleteExpenseModal from "../Modals/DeleteExpenseModal"; +import EditExpenseModal from "../Modals/EditExpenseModal"; +import EditExpense from "../Modals/Mobile/EditExpense"; +import { setCategoryData, setVendorData } from "../utils"; + +const ExpenseDetails = () => { + const [showDeleteExpenseModal, setShowDeleteExpenseModal] = + useState(false); + + const [showEditExpenseModal, setShowEditExpenseModal] = + useState(false); + const [expense, setExpense] = useState(); + const [expenseData, setExpenseData] = useState(); + + const params = useParams(); + const navigate = useNavigate(); + const { company, isDesktop } = useUserContext(); + + const fetchExpense = async () => { + try { + const resData = await expensesApi.show(params.expenseId); + const res = await expensesApi.index(); + const data = setCategoryData(res.data.categories); + res.data.categories = data; + setVendorData(res.data.vendors); + setExpenseData(res.data); + setExpense(resData.data); + } catch (e) { + Logger.error(e); + navigate("/expenses"); + } + }; + + const getExpenseData = async () => { + try { + const res = await expensesApi.index(); + const data = setCategoryData(res.data.categories); + res.data.categories = data; + setVendorData(res.data.vendors); + setExpenseData(res.data); + } catch (e) { + Logger.error(e); + setShowEditExpenseModal(false); + } + }; + + const handleEditExpense = async payload => { + await expensesApi.update(expense.id, payload); + setShowEditExpenseModal(false); + fetchExpense(); + }; + + const handleDeleteExpense = async () => { + await expensesApi.destroy(expense.id); + navigate("/expenses"); + }; + + const handleDelete = () => { + setShowDeleteExpenseModal(true); + }; + + const handleEdit = () => { + setShowEditExpenseModal(true); + }; + + useEffect(() => { + fetchExpense(); + getExpenseData(); + }, []); + + return ( +
    + {!isDesktop && showEditExpenseModal ? null : ( +
    +
    + +
    + )} + {showEditExpenseModal && + (isDesktop ? ( + + ) : ( + + ))} + {showDeleteExpenseModal && ( + + )} +
    + ); +}; + +export default ExpenseDetails; diff --git a/app/javascript/src/components/Expenses/List/Container/ExpensesSummary.tsx b/app/javascript/src/components/Expenses/List/Container/ExpensesSummary.tsx new file mode 100644 index 0000000000..05719dac7b --- /dev/null +++ b/app/javascript/src/components/Expenses/List/Container/ExpensesSummary.tsx @@ -0,0 +1,36 @@ +import React from "react"; + +import { Categories } from "../../utils"; + +const ExpensesSummary = () => ( +
    +
    + {Categories?.map(category => ( +
    +
    + {category.icon} +
    +
    + + {category.label} + + + {category.color} + +
    +
    + ))} +
    +
    +); + +export default ExpensesSummary; diff --git a/app/javascript/src/components/Expenses/List/Container/Table/MoreOptions.tsx b/app/javascript/src/components/Expenses/List/Container/Table/MoreOptions.tsx new file mode 100644 index 0000000000..623fe45043 --- /dev/null +++ b/app/javascript/src/components/Expenses/List/Container/Table/MoreOptions.tsx @@ -0,0 +1,63 @@ +import React from "react"; + +import { DeleteIcon, EditIcon, DownloadSimpleIcon } from "miruIcons"; +import { useNavigate } from "react-router-dom"; +import { Tooltip, Modal, Button } from "StyledComponents"; + +const MoreOptions = ({ + expense, + isDesktop, + showMoreOptions, + setShowMoreOptions, +}) => { + const navigate = useNavigate(); + + return isDesktop ? ( +
    e.stopPropagation()} + > + + + + + + + + + +
    + ) : ( + setShowMoreOptions(false)} + > +
      +
    • + Download Expense +
    • +
    +
    + ); +}; + +export default MoreOptions; diff --git a/app/javascript/src/components/Expenses/List/Container/Table/TableHeader.tsx b/app/javascript/src/components/Expenses/List/Container/Table/TableHeader.tsx new file mode 100644 index 0000000000..cc39cfa106 --- /dev/null +++ b/app/javascript/src/components/Expenses/List/Container/Table/TableHeader.tsx @@ -0,0 +1,54 @@ +import React from "react"; + +const TableHeader = () => ( +
    + + + + + + + + + + +); + +export default TableHeader; diff --git a/app/javascript/src/components/Expenses/List/Container/Table/TableRow.tsx b/app/javascript/src/components/Expenses/List/Container/Table/TableRow.tsx new file mode 100644 index 0000000000..4ad9525de4 --- /dev/null +++ b/app/javascript/src/components/Expenses/List/Container/Table/TableRow.tsx @@ -0,0 +1,110 @@ +import React, { Fragment, useState } from "react"; + +import { currencyFormat } from "helpers"; +import { DotsThreeVerticalIcon, ExpenseIconSVG } from "miruIcons"; +import { useNavigate } from "react-router-dom"; + +import { useUserContext } from "context/UserContext"; + +import MoreOptions from "./MoreOptions"; + +import { Categories } from "../../../utils"; + +const TableRow = ({ expense, currency }) => { + const navigate = useNavigate(); + const { isDesktop } = useUserContext(); + const { id, expenseType, amount, categoryName, date, vendorName } = expense; + + const [showMoreOptions, setShowMoreOptions] = useState(false); + + const getCategoryIcon = () => { + const icon = Categories.find(category => category.label === categoryName) + ?.icon || ; + + return ( +
    + {icon} +
    + ); + }; + + const handleExpenseClick = id => { + navigate(`${id}`); + }; + + return ( + +
    handleExpenseClick(id)} + > + + + + + + + + {showMoreOptions && !isDesktop && ( + + )} + + ); +}; + +export default TableRow; diff --git a/app/javascript/src/components/Expenses/List/Container/Table/index.tsx b/app/javascript/src/components/Expenses/List/Container/Table/index.tsx new file mode 100644 index 0000000000..51497e0935 --- /dev/null +++ b/app/javascript/src/components/Expenses/List/Container/Table/index.tsx @@ -0,0 +1,27 @@ +import React from "react"; + +import { useUserContext } from "context/UserContext"; + +import TableHeader from "./TableHeader"; +import TableRow from "./TableRow"; + +const Table = ({ expenses }) => { + const { company } = useUserContext(); + + return ( +
    + CATEGORY + + DATE + + VENDOR + + TYPE + + CATEGORY/
    + Vendor +
    + TYPE/
    + DATE +
    + AMOUNT +
    +
    + {getCategoryIcon()} +
    + {categoryName} +
    +
    +
    + {date} + +
    + {!isDesktop && getCategoryIcon()} +
    + + {vendorName} + + + clientName + +
    +
    +
    +
    + {expenseType} +
    +
    {date}
    +
    +
    +
    + {currencyFormat(currency, amount)} + {isDesktop && ( + + )} + + { + e.preventDefault(); + e.stopPropagation(); + setShowMoreOptions(true); + }} + /> +
    + + + {expenses?.map(expense => ( + + ))} + +
    + ); +}; + +export default Table; diff --git a/app/javascript/src/components/Expenses/List/Container/index.tsx b/app/javascript/src/components/Expenses/List/Container/index.tsx new file mode 100644 index 0000000000..26401e074b --- /dev/null +++ b/app/javascript/src/components/Expenses/List/Container/index.tsx @@ -0,0 +1,24 @@ +import React from "react"; + +import EmptyStates from "common/EmptyStates"; + +import ExpensesSummary from "./ExpensesSummary"; +import Table from "./Table"; + +const Container = ({ expenseData }) => ( +
    + + {expenseData?.expenses?.length > 0 ? ( + + ) : ( + + )} + +); + +export default Container; diff --git a/app/javascript/src/components/Expenses/List/Header.tsx b/app/javascript/src/components/Expenses/List/Header.tsx new file mode 100644 index 0000000000..dc9c06f64e --- /dev/null +++ b/app/javascript/src/components/Expenses/List/Header.tsx @@ -0,0 +1,53 @@ +import React, { useState } from "react"; + +import { SearchIcon, PlusIcon, XIcon } from "miruIcons"; + +const Header = ({ setShowAddExpenseModal }) => { + const [searchQuery, setSearchQuery] = useState(""); + + return ( +
    +

    + Expenses +

    +
    +
    +
    + setSearchQuery(e.target.value)} + /> + +
    +
    +
    + {/* Todo: Uncomment when filter functionality is added + + */} +
    + +
    +
    + ); +}; + +export default Header; diff --git a/app/javascript/src/components/Expenses/List/index.tsx b/app/javascript/src/components/Expenses/List/index.tsx new file mode 100644 index 0000000000..ae3be51eb4 --- /dev/null +++ b/app/javascript/src/components/Expenses/List/index.tsx @@ -0,0 +1,81 @@ +import React, { Fragment, useEffect, useState } from "react"; + +import expensesApi from "apis/expenses"; +import Loader from "common/Loader"; +import withLayout from "common/Mobile/HOC/withLayout"; +import { useUserContext } from "context/UserContext"; + +import Container from "./Container"; +import Header from "./Header"; + +import AddExpenseModal from "../Modals/AddExpenseModal"; +import AddExpense from "../Modals/Mobile/AddExpense"; +import { setCategoryData, setVendorData } from "../utils"; + +const Expenses = () => { + const { isDesktop } = useUserContext(); + const [showAddExpenseModal, setShowAddExpenseModal] = + useState(false); + const [isLoading, setIsLoading] = useState(true); + const [expenseData, setExpenseData] = useState>([]); + + const fetchExpenses = async () => { + const res = await expensesApi.index(); + const data = setCategoryData(res.data.categories); + res.data.categories = data; + setVendorData(res.data.vendors); + setExpenseData(res.data); + setIsLoading(false); + }; + + const handleAddExpense = async payload => { + await expensesApi.create(payload); + setShowAddExpenseModal(false); + fetchExpenses(); + }; + + useEffect(() => { + fetchExpenses(); + }, []); + + const ExpensesLayout = () => ( +
    + {isLoading ? ( + + ) : ( + +
    + + {showAddExpenseModal && ( + + )} + + )} +
    + ); + + const Main = withLayout(ExpensesLayout, !isDesktop, !isDesktop); + + if (!isDesktop) { + if (showAddExpenseModal) { + return ( + + ); + } + + return
    ; + } + + return isDesktop ? ExpensesLayout() :
    ; +}; + +export default Expenses; diff --git a/app/javascript/src/components/Expenses/Modals/AddExpenseModal.tsx b/app/javascript/src/components/Expenses/Modals/AddExpenseModal.tsx new file mode 100644 index 0000000000..45139444b0 --- /dev/null +++ b/app/javascript/src/components/Expenses/Modals/AddExpenseModal.tsx @@ -0,0 +1,35 @@ +import React from "react"; + +import { XIcon } from "miruIcons"; +import { Button, Modal } from "StyledComponents"; + +import ExpenseForm from "./ExpenseForm"; + +const AddExpenseModal = ({ + showAddExpenseModal, + setShowAddExpenseModal, + expenseData, + handleAddExpense, +}) => ( + setShowAddExpenseModal(false)} + > +
    + Add New Expense + +
    +
    + +
    +
    +); + +export default AddExpenseModal; diff --git a/app/javascript/src/components/Expenses/Modals/DeleteExpenseModal.tsx b/app/javascript/src/components/Expenses/Modals/DeleteExpenseModal.tsx new file mode 100644 index 0000000000..f277f649af --- /dev/null +++ b/app/javascript/src/components/Expenses/Modals/DeleteExpenseModal.tsx @@ -0,0 +1,45 @@ +import React from "react"; + +import { Modal, Button } from "StyledComponents"; + +const DeleteExpenseModal = ({ + setShowDeleteExpenseModal, + showDeleteExpenseModal, + handleDeleteExpense, +}) => ( + setShowDeleteExpenseModal(false)} + > +
    +
    Delete Expense
    +

    + Are you sure you want to delete this expense? +
    This action cannot be reversed. +

    +
    +
    + + +
    +
    +); + +export default DeleteExpenseModal; diff --git a/app/javascript/src/components/Expenses/Modals/EditExpenseModal.tsx b/app/javascript/src/components/Expenses/Modals/EditExpenseModal.tsx new file mode 100644 index 0000000000..3af5f4ef2d --- /dev/null +++ b/app/javascript/src/components/Expenses/Modals/EditExpenseModal.tsx @@ -0,0 +1,37 @@ +import React from "react"; + +import { XIcon } from "miruIcons"; +import { Button, Modal } from "StyledComponents"; + +import ExpenseForm from "./ExpenseForm"; + +const EditExpenseModal = ({ + showEditExpenseModal, + setShowEditExpenseModal, + expenseData, + handleEditExpense, + expense, +}) => ( + setShowEditExpenseModal(false)} + > +
    + Edit Expense + +
    +
    + +
    +
    +); + +export default EditExpenseModal; diff --git a/app/javascript/src/components/Expenses/Modals/ExpenseForm.tsx b/app/javascript/src/components/Expenses/Modals/ExpenseForm.tsx new file mode 100644 index 0000000000..d5f8996bfa --- /dev/null +++ b/app/javascript/src/components/Expenses/Modals/ExpenseForm.tsx @@ -0,0 +1,332 @@ +import React, { useRef, useState, ChangeEvent, useEffect } from "react"; + +import dayjs from "dayjs"; +import { useOutsideClick } from "helpers"; +import { CalendarIcon, FileIcon, FilePdfIcon, XIcon } from "miruIcons"; +import { components } from "react-select"; +import { Button } from "StyledComponents"; + +import expensesApi from "apis/expenses"; +import CustomCreatableSelect from "common/CustomCreatableSelect"; +import CustomDatePicker from "common/CustomDatePicker"; +import { CustomInputText } from "common/CustomInputText"; +import CustomRadioButton from "common/CustomRadio"; +import CustomReactSelect from "common/CustomReactSelect"; +import { CustomTextareaAutosize } from "common/CustomTextareaAutosize"; +import { ErrorSpan } from "common/ErrorSpan"; + +const ExpenseForm = ({ + dateFormat, + expenseData, + handleFormAction, + expense = null, +}) => { + const wrapperCalendarRef = useRef(null); + const fileRef = useRef(null); + + const [showDatePicker, setShowDatePicker] = useState(false); + const [expenseDate, setExpenseDate] = useState( + dayjs(expense?.date) || dayjs() + ); + const [vendor, setVendor] = useState(""); + const [amount, setAmount] = useState(expense?.amount || ""); + const [category, setCategory] = useState(""); + const [newCategory, setNewCategory] = useState(""); + const [description, setDescription] = useState( + expense?.description || "" + ); + + const [expenseType, setExpenseType] = useState( + expense?.type || "personal" + ); + const [receipt, setReceipt] = useState(expense?.receipt || ""); + + const isFormActionDisabled = !( + expenseDate && + vendor && + amount && + (category || newCategory) + ); + + const { Option } = components; + const IconOption = props => ( + + ); + + const setExpenseData = () => { + if (expense) { + const selectedCategory = expenseData?.categories?.find( + category => expense.categoryName == category.label + ); + + const selectedVendor = expenseData?.vendors?.find( + vendor => expense.vendorName == vendor.label + ); + setCategory(selectedCategory); + setVendor(selectedVendor); + } + }; + + const handleDatePicker = date => { + setExpenseDate(date); + setShowDatePicker(false); + }; + + const handleCategory = async category => { + category.label = ( +
    + {category.icon} + {category.label} +
    + ); + if (expenseData.categories.includes(category)) { + setCategory(category); + } else { + const payload = { + expense_category: { + name: category.value, + }, + }; + + const res = await expensesApi.createCategory(payload); + const expenses = await expensesApi.index(); + + if (res.status == 200 && expenses.status == 200) { + const newCategoryValue = expenses.data.categories.find( + val => val.name == category.value + ); + + newCategoryValue.value = newCategoryValue.name; + newCategoryValue.label = newCategoryValue.name; + delete newCategoryValue.name; + + setNewCategory(newCategoryValue); + } + } + }; + + const handleFileUpload = () => { + if (fileRef.current) { + fileRef.current.click(); + } + }; + + const handleFileSelection = (event: ChangeEvent) => { + const selectedFile = event.target.files?.[0]; + if (selectedFile) { + setReceipt(selectedFile); + } + }; + + const handleSubmit = () => { + const payload = { + amount, + date: expenseDate, + description, + expense_type: expenseType, + expense_category_id: category?.id || newCategory?.id, + vendor_id: vendor.id, + receipts: receipt, + }; + handleFormAction(payload); + }; + + const ReceiptCard = () => ( +
    +
    + +
    +
    + {receipt.name} +
    + PDF +
    + {Math.ceil(receipt.size / 1024)}kb +
    +
    + +
    + ); + + const UploadCard = () => ( +
    + + + Upload file + + +
    + ); + + useOutsideClick(wrapperCalendarRef, () => { + setShowDatePicker(false); + }); + + useEffect(() => { + setExpenseData(); + }, []); + + return ( +
    +
    +
    +
    setShowDatePicker(!showDatePicker)} + > + {}} //eslint-disable-line + /> + +
    + {showDatePicker && ( + + )} +
    +
    + setVendor(vendor)} + id="vendor" + label="Vendor" + name="vendor" + options={expenseData.vendors} + value={vendor} + /> + +
    +
    + setAmount(e.target.value)} + /> + +
    +
    + +
    +
    + setDescription(e.target.value)} + /> +
    +
    + + Expense Type (optional) + +
    + { + setExpenseType("personal"); + }} + /> + { + setExpenseType("business"); + }} + /> +
    +
    +
    + + Receipt (optional) + + {receipt ? : } +
    +
    +
    + {expense ? ( + + ) : ( + + )} +
    +
    + ); +}; + +export default ExpenseForm; diff --git a/app/javascript/src/components/Expenses/Modals/Mobile/AddExpense.tsx b/app/javascript/src/components/Expenses/Modals/Mobile/AddExpense.tsx new file mode 100644 index 0000000000..937f63ebdc --- /dev/null +++ b/app/javascript/src/components/Expenses/Modals/Mobile/AddExpense.tsx @@ -0,0 +1,27 @@ +import React from "react"; + +import AddEditModalHeader from "common/Mobile/AddEditModalHeader"; + +import ExpenseForm from "../ExpenseForm"; + +const AddExpense = ({ + expenseData, + handleAddExpense, + setShowAddExpenseModal, +}) => ( +
    + { + setShowAddExpenseModal(false); + }} + /> + +
    +); + +export default AddExpense; diff --git a/app/javascript/src/components/Expenses/Modals/Mobile/EditExpense.tsx b/app/javascript/src/components/Expenses/Modals/Mobile/EditExpense.tsx new file mode 100644 index 0000000000..683c7da49a --- /dev/null +++ b/app/javascript/src/components/Expenses/Modals/Mobile/EditExpense.tsx @@ -0,0 +1,29 @@ +import React from "react"; + +import AddEditModalHeader from "common/Mobile/AddEditModalHeader"; + +import ExpenseForm from "../ExpenseForm"; + +const EditExpense = ({ + expenseData, + handleEditExpense, + setShowEditExpenseModal, + expense, +}) => ( +
    + { + setShowEditExpenseModal(false); + }} + /> + +
    +); + +export default EditExpense; diff --git a/app/javascript/src/components/Expenses/utils.js b/app/javascript/src/components/Expenses/utils.js new file mode 100644 index 0000000000..9cc4b97bd6 --- /dev/null +++ b/app/javascript/src/components/Expenses/utils.js @@ -0,0 +1,114 @@ +import React from "react"; + +import { + ExpenseIconSVG, + PaymentsIcon, + FoodIcon, + PercentIcon, + ShieldIcon, + WrenchIcon, + FurnitureIcon, + CarIcon, + HouseIcon, +} from "miruIcons"; + +export const Categories = [ + { + value: "Food", + label: "Food", + icon: , + iconColor: "#F5F7F9", + color: "#7768AE", + }, + { + value: "Salary", + label: "Salary", + icon: , + iconColor: "#F5F7F9", + color: "#7CC984", + }, + { + value: "Furniture", + label: "Furniture", + icon: , + iconColor: "#F5F7F9", + color: "#BF1363", + }, + { + value: "Repairs & Maintenance", + label: "Repairs & Maintenance", + icon: , + iconColor: "#F5F7F9", + color: "#058C42", + }, + { + value: "Travel", + label: "Travel", + icon: , + iconColor: "#F5F7F9", + color: "#0E79B2", + }, + { + value: "Health Insurance", + label: "Health Insurance", + icon: , + iconColor: "#4A485A", + color: "#F2D0E0", + }, + { + value: "Rent", + label: "Rent", + icon: , + iconColor: "#F5F7F9", + color: "#68AEAA", + }, + { + value: "Tax", + label: "Tax", + icon: , + iconColor: "#F5F7F9", + color: "#F39237", + }, + { + value: "Other", + label: "Other", + icon: , + iconColor: "#4A485A", + color: "#CFE4F0", + }, +]; + +export const setVendorData = vendors => { + vendors.map(vendor => { + vendor.value = vendor.name; + vendor.label = vendor.name; + + return vendor; + }); +}; + +export const setCategoryData = rawCategories => { + const newCategories = rawCategories.map(raw => { + const matchingCat = Categories.find( + category => category.value === raw.name + ); + + const newCat = { + ...raw, + value: raw.name, + label: raw.name, + icon: , + ...(matchingCat && { + icon: matchingCat.icon || , + iconColor: matchingCat.iconColor, + color: matchingCat.color, + }), + }; + delete newCat.name; + delete newCat.default; + + return newCat; + }); + + return newCategories; +}; diff --git a/app/javascript/src/components/Invoices/List/MoreOptions.tsx b/app/javascript/src/components/Invoices/List/MoreOptions.tsx index ac80ae7383..fdcda8e81a 100644 --- a/app/javascript/src/components/Invoices/List/MoreOptions.tsx +++ b/app/javascript/src/components/Invoices/List/MoreOptions.tsx @@ -37,7 +37,7 @@ const MoreOptions = ({ return isDesktop ? ( <>
    e.stopPropagation()} > diff --git a/app/javascript/src/components/Navbar/utils.tsx b/app/javascript/src/components/Navbar/utils.tsx index dbde5a78c3..60f6c2f151 100644 --- a/app/javascript/src/components/Navbar/utils.tsx +++ b/app/javascript/src/components/Navbar/utils.tsx @@ -10,6 +10,7 @@ import { PaymentsIcon, SettingIcon, CalendarIcon, + ExpenseIconSVG, } from "miruIcons"; import { NavLink } from "react-router-dom"; @@ -65,6 +66,12 @@ const navOptions = [ path: Paths.Leave_Management, allowedRoles: ["admin", "owner", "employee"], }, + { + logo: , + label: "Expenses", + path: Paths.EXPENSES, + allowedRoles: ["admin", "owner", "book_keeper"], + }, ]; const navAdminMobileOptions = [ @@ -103,6 +110,12 @@ const navAdminMobileOptions = [ label: "Payments", path: Paths.PAYMENTS, }, + { + logo: , + label: "Expenses", + dataCy: "expenses-tab", + path: Paths.EXPENSES, + }, ]; const navClientOptions = [ @@ -118,6 +131,12 @@ const navClientOptions = [ path: "/settings/profile", allowedRoles: ["admin", "owner", "book_keeper", "client"], }, + { + logo: , + label: "Expenses", + dataCy: "expenses-tab", + path: Paths.EXPENSES, + }, ]; const activeClassName = diff --git a/app/javascript/src/components/Profile/Organization/Holidays/HolidaysModal.tsx b/app/javascript/src/components/Profile/Organization/Holidays/HolidaysModal.tsx index d2940fd7ef..2a027d1f8f 100644 --- a/app/javascript/src/components/Profile/Organization/Holidays/HolidaysModal.tsx +++ b/app/javascript/src/components/Profile/Organization/Holidays/HolidaysModal.tsx @@ -53,7 +53,7 @@ const HolidayModal = ({ {yearCalendar[quarters].quarter.map( ( month, - key //eslint-disable-line + key //eslint-disable-line ) => ( + + + + + + + + + + + + + + + + + diff --git a/app/models/expense_category.rb b/app/models/expense_category.rb index c36e3e9eb1..2538ff4f49 100644 --- a/app/models/expense_category.rb +++ b/app/models/expense_category.rb @@ -22,7 +22,7 @@ class ExpenseCategory < ApplicationRecord DEFAULT_CATEGORIES = [ { name: "Salary", default: true }, - { name: "Repair & Maintenance", default: true }, + { name: "Repairs & Maintenance", default: true }, { name: "Rent", default: true }, { name: "Food", default: true }, { name: "Travel", default: true }, diff --git a/app/policies/expense_policy.rb b/app/policies/expense_policy.rb index 3bb43d4635..61636b5ecc 100644 --- a/app/policies/expense_policy.rb +++ b/app/policies/expense_policy.rb @@ -15,6 +15,14 @@ def show? authorize_current_user end + def update? + authorize_current_user + end + + def destroy? + authorize_current_user + end + def authorize_current_user unless user.current_workspace_id == record.company_id @error_message_key = :different_workspace diff --git a/config/locales/en.yml b/config/locales/en.yml index 043fb06b16..10ad81e95b 100644 --- a/config/locales/en.yml +++ b/config/locales/en.yml @@ -292,3 +292,6 @@ en: sessions: failure: invalid: Invalid email or password + expenses: + update: "Expense updated successfully" + destroy: "Expense deleted successfully" \ No newline at end of file diff --git a/config/routes/internal_api.rb b/config/routes/internal_api.rb index c0027dbe4e..3544e69123 100644 --- a/config/routes/internal_api.rb +++ b/config/routes/internal_api.rb @@ -141,7 +141,7 @@ resources :vendors, only: [:create] resources :expense_categories, only: [:create] - resources :expenses, only: [:create, :index, :show] + resources :expenses, only: [:create, :index, :show, :update, :destroy] resources :bulk_previous_employments, only: [:update] resources :leaves, as: "leave" do From 1d0ab36618c75aaeebde713c7d6a3830b9c99e56 Mon Sep 17 00:00:00 2001 From: Apoorv Tiwari Date: Fri, 8 Mar 2024 17:17:07 +0530 Subject: [PATCH 13/39] Send description to expense index action (#1694) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * send description in jbuilder * extract description --------- Co-authored-by: “Apoorv <“tiwari.apoorv1316@gmail.com”> --- app/views/internal_api/v1/expenses/index.json.jbuilder | 2 +- spec/requests/internal_api/v1/expenses/index_spec.rb | 3 ++- 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/app/views/internal_api/v1/expenses/index.json.jbuilder b/app/views/internal_api/v1/expenses/index.json.jbuilder index aef2f80835..f8f1724895 100644 --- a/app/views/internal_api/v1/expenses/index.json.jbuilder +++ b/app/views/internal_api/v1/expenses/index.json.jbuilder @@ -4,7 +4,7 @@ json.key_format! camelize: :lower json.deep_format_keys! json.expenses expenses do |expense| - json.extract! expense, :id, :amount, :expense_type + json.extract! expense, :id, :amount, :expense_type, :description json.category_name expense.expense_category.name json.vendor_name expense.vendor&.name json.date expense.formatted_date diff --git a/spec/requests/internal_api/v1/expenses/index_spec.rb b/spec/requests/internal_api/v1/expenses/index_spec.rb index 44067617b2..60b390d2a5 100644 --- a/spec/requests/internal_api/v1/expenses/index_spec.rb +++ b/spec/requests/internal_api/v1/expenses/index_spec.rb @@ -53,7 +53,8 @@ "date" => CompanyDateFormattingService.new(expense.date, company:).process, "expenseType" => expense.expense_type, "categoryName" => expense.expense_category.name, - "vendorName" => expense.vendor&.name + "vendorName" => expense.vendor&.name, + "description" => expense.description } end expect(json_response["expenses"]).to eq(expected_data) From fb8d8b82658b1aef5fd5b8c83a7ac0ea7dd5c503 Mon Sep 17 00:00:00 2001 From: Apoorv Tiwari Date: Mon, 11 Mar 2024 19:31:42 +0530 Subject: [PATCH 14/39] Export options for accounts aging (#1680) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * add pdf downlaod for accounts aging * csv export added * specs for services * fix-indentation * Review comments * fix rubocop * change filename --------- Co-authored-by: “Apoorv <“tiwari.apoorv1316@gmail.com”> --- .../v1/reports/accounts_aging_controller.rb | 5 + .../src/apis/reports/accountsAging.ts | 9 +- .../Reports/AccountsAgingReport/index.tsx | 18 +++- .../accounts_aging/download_service.rb | 52 ++++++++++ app/services/reports/download_service.rb | 37 ++++++++ app/services/reports/generate_csv.rb | 21 +++++ app/services/reports/generate_pdf.rb | 29 ++++++ .../reports/time_entries/download_service.rb | 43 +++++---- .../reports/time_entries/generate_csv.rb | 36 ------- .../reports/time_entries/generate_pdf.rb | 19 ---- app/views/pdfs/accounts_aging.html.erb | 94 +++++++++++++++++++ ...reports.html.erb => time_entries.html.erb} | 2 +- config/routes/internal_api.rb | 6 +- .../accounts_aging/download_service_spec.rb | 38 ++++++++ spec/services/reports/generate_csv_spec.rb | 27 ++++++ spec/services/reports/generate_pdf_spec.rb | 40 ++++++++ .../time_entries/download_service_spec.rb | 19 ++++ .../reports/time_entries/generate_csv_spec.rb | 33 ------- .../reports/time_entries/generate_pdf_spec.rb | 20 ---- 19 files changed, 418 insertions(+), 130 deletions(-) create mode 100644 app/services/reports/accounts_aging/download_service.rb create mode 100644 app/services/reports/download_service.rb create mode 100644 app/services/reports/generate_csv.rb create mode 100644 app/services/reports/generate_pdf.rb delete mode 100644 app/services/reports/time_entries/generate_csv.rb delete mode 100644 app/services/reports/time_entries/generate_pdf.rb create mode 100644 app/views/pdfs/accounts_aging.html.erb rename app/views/pdfs/{reports.html.erb => time_entries.html.erb} (98%) create mode 100644 spec/services/reports/accounts_aging/download_service_spec.rb create mode 100644 spec/services/reports/generate_csv_spec.rb create mode 100644 spec/services/reports/generate_pdf_spec.rb delete mode 100644 spec/services/reports/time_entries/generate_csv_spec.rb delete mode 100644 spec/services/reports/time_entries/generate_pdf_spec.rb diff --git a/app/controllers/internal_api/v1/reports/accounts_aging_controller.rb b/app/controllers/internal_api/v1/reports/accounts_aging_controller.rb index 7cb754f60b..9f5d029d71 100644 --- a/app/controllers/internal_api/v1/reports/accounts_aging_controller.rb +++ b/app/controllers/internal_api/v1/reports/accounts_aging_controller.rb @@ -5,4 +5,9 @@ def index authorize :report render :index, locals: Reports::AccountsAging::FetchOverdueAmount.process(current_company), status: :ok end + + def download + authorize :report + send_data Reports::AccountsAging::DownloadService.new(params, current_company).process + end end diff --git a/app/javascript/src/apis/reports/accountsAging.ts b/app/javascript/src/apis/reports/accountsAging.ts index f19a568614..de31c40aa2 100644 --- a/app/javascript/src/apis/reports/accountsAging.ts +++ b/app/javascript/src/apis/reports/accountsAging.ts @@ -4,6 +4,13 @@ const path = "/reports/accounts_aging"; const get = () => axios.get(path); -const accountsAgingApi = { get }; +const download = (type, queryParams) => + axios({ + method: "GET", + url: `${path}/download.${type}${queryParams}`, + responseType: "blob", + }); + +const accountsAgingApi = { get, download }; export default accountsAgingApi; diff --git a/app/javascript/src/components/Reports/AccountsAgingReport/index.tsx b/app/javascript/src/components/Reports/AccountsAgingReport/index.tsx index b41702f148..20d7c581ce 100644 --- a/app/javascript/src/components/Reports/AccountsAgingReport/index.tsx +++ b/app/javascript/src/components/Reports/AccountsAgingReport/index.tsx @@ -3,12 +3,14 @@ import React, { useState, useEffect } from "react"; import Logger from "js-logger"; import { useNavigate } from "react-router-dom"; +import accountsAgingApi from "apis/reports/accountsAging"; import Loader from "common/Loader/index"; import Container from "./Container"; import FilterSideBar from "./Filters"; import getReportData from "../api/accountsAging"; +import { getQueryParams } from "../api/applyFilter"; import EntryContext from "../context/EntryContext"; import OutstandingOverdueInvoiceContext from "../context/outstandingOverdueInvoiceContext"; import RevenueByClientReportContext from "../context/RevenueByClientContext"; @@ -75,6 +77,18 @@ const AccountsAgingReport = () => { }, }; + const handleDownload = async type => { + const queryParams = getQueryParams(selectedFilter).substring(1); + const response = await accountsAgingApi.download(type, `?${queryParams}`); + const url = window.URL.createObjectURL(new Blob([response.data])); + const link = document.createElement("a"); + // const filename = `${selectedFilter.dateRange.label}.${type}`; + const filename = `report.${type}`; + link.href = url; + link.setAttribute("download", filename); + link.click(); + }; + if (loading) { return ; } @@ -82,12 +96,12 @@ const AccountsAgingReport = () => { return (
    {}} // eslint-disable-line @typescript-eslint/no-empty-function setIsFilterVisible={setIsFilterVisible} - showExportButon={false} showNavFilters={showNavFilters} type="Accounts Aging Report" /> diff --git a/app/services/reports/accounts_aging/download_service.rb b/app/services/reports/accounts_aging/download_service.rb new file mode 100644 index 0000000000..b1805745e5 --- /dev/null +++ b/app/services/reports/accounts_aging/download_service.rb @@ -0,0 +1,52 @@ +# frozen_string_literal: true + +module Reports::AccountsAging + class DownloadService < Reports::DownloadService + attr_reader :current_company, :reports + + def initialize(params, current_company) + super + @reports = [] + end + + private + + def fetch_complete_report + @reports = FetchOverdueAmount.new(current_company).process + end + + def generate_pdf + Reports::GeneratePdf.new(:accounts_aging, reports, current_company).process + end + + def generate_csv + csv_data = [] + headers = ["Client Name", "0-30 Days", "31-60 Days", "61-90 Days", "90+ Days", "Total"] + reports[:clients].each do |client| + csv_data << [ + client[:name], + format_amount(client[:amount_overdue][:zero_to_thirty_days]), + format_amount(client[:amount_overdue][:thirty_one_to_sixty_days]), + format_amount(client[:amount_overdue][:sixty_one_to_ninety_days]), + format_amount(client[:amount_overdue][:ninety_plus_days]), + format_amount(client[:amount_overdue][:total]) + ] + end + + csv_data << [ + "Total Amounts", + reports[:total_amount_overdue_by_date_range][:zero_to_thirty_days], + reports[:total_amount_overdue_by_date_range][:thirty_one_to_sixty_days], + reports[:total_amount_overdue_by_date_range][:sixty_one_to_ninety_days], + reports[:total_amount_overdue_by_date_range][:ninety_plus_days], + reports[:total_amount_overdue_by_date_range][:total] + ] + + Reports::GenerateCsv.new(csv_data, headers).process + end + + def format_amount(amount) + FormatAmountService.new(reports[:base_currency], amount).process + end + end +end diff --git a/app/services/reports/download_service.rb b/app/services/reports/download_service.rb new file mode 100644 index 0000000000..4d3e484492 --- /dev/null +++ b/app/services/reports/download_service.rb @@ -0,0 +1,37 @@ +# frozen_string_literal: true + +class Reports::DownloadService + attr_reader :params, :current_company + + def initialize(params, current_company) + @params = params + @current_company = current_company + end + + def process + fetch_complete_report + format_report + end + + private + + def fetch_complete_report + raise NotImplementedError, "Subclasses must implement a 'fetch_complete_report' method." + end + + def format_report + if params[:format] == "pdf" + generate_pdf + else + generate_csv + end + end + + def generate_pdf + raise NotImplementedError, "Implement generate_pdf in the inheriting class" + end + + def generate_csv + raise NotImplementedError, "Implement generate_csv in the inheriting class" + end +end diff --git a/app/services/reports/generate_csv.rb b/app/services/reports/generate_csv.rb new file mode 100644 index 0000000000..f7471ff8e0 --- /dev/null +++ b/app/services/reports/generate_csv.rb @@ -0,0 +1,21 @@ +# frozen_string_literal: true + +require "csv" + +class Reports::GenerateCsv + attr_reader :data, :headers + + def initialize(data, headers) + @data = data + @headers = headers + end + + def process + CSV.generate do |csv| + csv << headers + data.each do |row| + csv << row + end + end + end +end diff --git a/app/services/reports/generate_pdf.rb b/app/services/reports/generate_pdf.rb new file mode 100644 index 0000000000..b1a9e1c4b3 --- /dev/null +++ b/app/services/reports/generate_pdf.rb @@ -0,0 +1,29 @@ +# frozen_string_literal: true + +class Reports::GeneratePdf + attr_reader :report_data, :current_company, :report_type + + def initialize(report_type, report_data, current_company) + @report_type = report_type + @report_data = report_data + @current_company = current_company + end + + def process + case report_type + when :time_entries, :accounts_aging + generate_pdf(report_type) + else + raise ArgumentError, "Unsupported report type: #{report_type}" + end + end + + private + + def generate_pdf(report_type) + Pdf::HtmlGenerator.new( + report_type, + locals: { report_data:, current_company: } + ).make + end +end diff --git a/app/services/reports/time_entries/download_service.rb b/app/services/reports/time_entries/download_service.rb index 7ce95635e2..bb70a22331 100644 --- a/app/services/reports/time_entries/download_service.rb +++ b/app/services/reports/time_entries/download_service.rb @@ -1,20 +1,13 @@ # frozen_string_literal: true -class Reports::TimeEntries::DownloadService - attr_reader :params, :current_company, :reports +class Reports::TimeEntries::DownloadService < Reports::DownloadService + attr_reader :reports def initialize(params, current_company) - @params = params - @current_company = current_company - + super @reports = [] end - def process - fetch_complete_report - format_report - end - private def fetch_complete_report @@ -31,12 +24,28 @@ def fetch_complete_report end end - def format_report - if params[:format] == "pdf" - Reports::TimeEntries::GeneratePdf.new(reports, current_company).process - else - flatten_reports = reports.map { |e| e[:entries] }.flatten - Reports::TimeEntries::GenerateCsv.new(flatten_reports, current_company).process - end + def generate_pdf + Reports::GeneratePdf.new(:time_entries, reports, current_company).process + end + + def generate_csv + data = [] + headers = ["Project", "Client", "Note", "Team Member", "Date", "Hours Logged"] + flatten_reports = reports.map { |e| e[:entries] }.flatten + flatten_reports.each do |entry| + data << [ + "#{entry.project_name}", + "#{entry.client_name}", + "#{entry.note}", + "#{entry.user_name}", + "#{format_date(entry.work_date)}", + "#{DurationFormatter.new(entry.duration).process}" + ] + end + Reports::GenerateCsv.new(data, headers).process + end + + def format_date(date) + CompanyDateFormattingService.new(date, company: current_company, es_date_presence: true).process end end diff --git a/app/services/reports/time_entries/generate_csv.rb b/app/services/reports/time_entries/generate_csv.rb deleted file mode 100644 index 97cfc43a9b..0000000000 --- a/app/services/reports/time_entries/generate_csv.rb +++ /dev/null @@ -1,36 +0,0 @@ -# frozen_string_literal: true - -require "csv" - -module Reports::TimeEntries - class GenerateCsv - attr_reader :entries, :current_company - - def initialize(entries, current_company) - @entries = entries - @current_company = current_company - end - - def process - CSV.generate(headers: true) do |csv| - csv << ["Project", "Client", "Note", "Team Member", "Date", "Hours Logged"] - entries.each do |entry| - csv << [ - "#{entry.project_name}", - "#{entry.client_name}", - "#{entry.note}", - "#{entry.user_name}", - "#{format_date(entry.work_date)}", - "#{DurationFormatter.new(entry.duration).process}" - ] - end - end - end - - private - - def format_date(date) - CompanyDateFormattingService.new(date, company: current_company, es_date_presence: true).process - end - end -end diff --git a/app/services/reports/time_entries/generate_pdf.rb b/app/services/reports/time_entries/generate_pdf.rb deleted file mode 100644 index 62f88b725e..0000000000 --- a/app/services/reports/time_entries/generate_pdf.rb +++ /dev/null @@ -1,19 +0,0 @@ -# frozen_string_literal: true - -module Reports::TimeEntries - class GeneratePdf - attr_reader :report_entries, :current_company - - def initialize(report_entries, current_company) - @report_entries = report_entries - @current_company = current_company - end - - def process - Pdf::HtmlGenerator.new( - :reports, - locals: { report_entries:, current_company: } - ).make - end - end -end diff --git a/app/views/pdfs/accounts_aging.html.erb b/app/views/pdfs/accounts_aging.html.erb new file mode 100644 index 0000000000..b7891565d4 --- /dev/null +++ b/app/views/pdfs/accounts_aging.html.erb @@ -0,0 +1,94 @@ +
    +

    Accounts Aging Report

    +
    + +
    +
    + + + + + + + + + + <% report_data[:clients].each do |client| %> + + + + + + + + + <% end %> + + + + + + + + + +
    +

    Client

    +
    + 0-30 days + +

    31-60 days

    +
    +

    61-90 days

    +
    +

    90+ days

    +
    +

    Total

    +
    +

    + <%= client[:name] %> +

    +
    +

    + <%= FormatAmountService.new(report_data[:base_currency],client[:amount_overdue][:zero_to_thirty_days]).process %> +

    +
    + <%= FormatAmountService.new(report_data[:base_currency],client[:amount_overdue][:thirty_one_to_sixty_days]).process %> + +

    + <%= FormatAmountService.new(report_data[:base_currency],client[:amount_overdue][:sixty_one_to_ninety_days]).process %> +

    +
    +

    + <%= FormatAmountService.new(report_data[:base_currency],client[:amount_overdue][:ninety_plus_days]).process %> +

    +
    +

    + <%= FormatAmountService.new(report_data[:base_currency],client[:amount_overdue][:total]).process %> +

    +
    +

    + Total +

    +
    +

    + <%= FormatAmountService.new(report_data[:base_currency],report_data[:total_amount_overdue_by_date_range][:zero_to_thirty_days]).process %> +

    +
    +

    + <%= FormatAmountService.new(report_data[:base_currency],report_data[:total_amount_overdue_by_date_range][:thirty_one_to_sixty_days]).process %> +

    +
    +

    + <%= FormatAmountService.new(report_data[:base_currency],report_data[:total_amount_overdue_by_date_range][:sixty_one_to_ninety_days]).process %> +

    +
    +

    + <%= FormatAmountService.new(report_data[:base_currency],report_data[:total_amount_overdue_by_date_range][:ninety_plus_days]).process %> +

    +
    +

    + <%= FormatAmountService.new(report_data[:base_currency],report_data[:total_amount_overdue_by_date_range][:total]).process %> +

    +
    +
    diff --git a/app/views/pdfs/reports.html.erb b/app/views/pdfs/time_entries.html.erb similarity index 98% rename from app/views/pdfs/reports.html.erb rename to app/views/pdfs/time_entries.html.erb index a5d0528928..917bc3cdbc 100644 --- a/app/views/pdfs/reports.html.erb +++ b/app/views/pdfs/time_entries.html.erb @@ -21,7 +21,7 @@ - <% report_entries.each do |report| %> + <% report_data.each do |report| %> <% report[:entries].each do |entry| %> diff --git a/config/routes/internal_api.rb b/config/routes/internal_api.rb index 3544e69123..169dfcf185 100644 --- a/config/routes/internal_api.rb +++ b/config/routes/internal_api.rb @@ -50,7 +50,11 @@ end end resources :outstanding_overdue_invoices, only: [:index] - resources :accounts_aging, only: [:index] + resources :accounts_aging, only: [:index] do + collection do + get :download + end + end end resources :workspaces, only: [:index, :update] diff --git a/spec/services/reports/accounts_aging/download_service_spec.rb b/spec/services/reports/accounts_aging/download_service_spec.rb new file mode 100644 index 0000000000..09878446c3 --- /dev/null +++ b/spec/services/reports/accounts_aging/download_service_spec.rb @@ -0,0 +1,38 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe Reports::AccountsAging::DownloadService do + let(:current_company) { create(:company) } + + describe "#process" do + let(:params) { { some_param: "value" } } + let(:reports_data) { { clients: [], total_amount_overdue_by_date_range: {} } } + + subject { described_class.new(params, current_company) } + + before do + allow(Reports::AccountsAging::FetchOverdueAmount).to receive(:new).and_return( + double( + "FetchOverdueAmount", + process: reports_data)) + allow(Reports::GeneratePdf).to receive(:new).and_return(double("Reports::GeneratePdf", process: nil)) + allow(Reports::GenerateCsv).to receive(:new).and_return(double("Reports::GenerateCsv", process: nil)) + end + + it "fetches complete report, generates PDF and CSV" do + allow(subject).to receive(:fetch_complete_report) + allow(subject).to receive(:generate_pdf) + allow(subject).to receive(:generate_csv) + + subject.process + end + + it "fetches complete report and generates CSV" do + allow(subject).to receive(:fetch_complete_report) + allow(subject).to receive(:generate_csv) + + subject.process + end + end +end diff --git a/spec/services/reports/generate_csv_spec.rb b/spec/services/reports/generate_csv_spec.rb new file mode 100644 index 0000000000..7feb8181e6 --- /dev/null +++ b/spec/services/reports/generate_csv_spec.rb @@ -0,0 +1,27 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe Reports::GenerateCsv do + describe "#process" do + let(:headers) { ["Name", "Age", "Email"] } + let(:data) do + [ + ["John Doe", "30", "john@example.com"], + ["Jane Smith", "25", "jane@example.com"] + ] + end + + subject { described_class.new(data, headers) } + + it "generates CSV data with headers and data" do + csv_data = subject.process + parsed_csv = CSV.parse(csv_data) + + expect(parsed_csv.first).to eq(headers) + + expect(parsed_csv[1]).to eq(data.first) + expect(parsed_csv[2]).to eq(data.second) + end + end +end diff --git a/spec/services/reports/generate_pdf_spec.rb b/spec/services/reports/generate_pdf_spec.rb new file mode 100644 index 0000000000..be927b3f81 --- /dev/null +++ b/spec/services/reports/generate_pdf_spec.rb @@ -0,0 +1,40 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe Reports::GeneratePdf do + let(:report_data) { double("report_data") } + let(:current_company) { double("current_company") } + + describe "#process" do + context "when report type is time_entries" do + subject { described_class.new(:time_entries, report_data, current_company) } + + it "generates PDF for time entries" do + allow(Pdf::HtmlGenerator).to receive(:new).with( + :time_entries, + locals: { report_data:, current_company: }).and_return(double("Pdf::HtmlGenerator", make: nil)) + subject.process + end + end + + context "when report type is accounts_aging" do + subject { described_class.new(:accounts_aging, report_data, current_company) } + + it "generates PDF for accounts aging" do + allow(Pdf::HtmlGenerator).to receive(:new).with( + :accounts_aging, + locals: { report_data:, current_company: }).and_return(double("Pdf::HtmlGenerator", make: nil)) + subject.process + end + end + + context "when report type is unsupported" do + it "raises ArgumentError" do + expect { + described_class.new(:unsupported_report_type, report_data, current_company).process + }.to raise_error(ArgumentError, "Unsupported report type: unsupported_report_type") + end + end + end +end diff --git a/spec/services/reports/time_entries/download_service_spec.rb b/spec/services/reports/time_entries/download_service_spec.rb index ed1e9a16f7..6a2f79a47d 100644 --- a/spec/services/reports/time_entries/download_service_spec.rb +++ b/spec/services/reports/time_entries/download_service_spec.rb @@ -6,6 +6,10 @@ let(:company) { create(:company) } let(:client) { create(:client, :with_logo, company:) } let(:project) { create(:project, client:) } + let(:csv_headers) do + "Project,Client,Note,Team Member,Date,Hours Logged" + end + let(:report_entries) { [double("TimeEntry")] } before do create_list(:user, 12) @@ -31,5 +35,20 @@ all_users_with_name = User.all.order(:first_name).map { |u| u.full_name } expect(data.pluck(:label)).to eq(all_users_with_name) end + + it "generates CSV report" do + data = subject.process + expect(data).to include(csv_headers) + end + + it "generates a PDF report using Pdf::HtmlGenerator" do + subject { described_class.new(report_entries, current_company) } + + html_generator = instance_double("Pdf::HtmlGenerator") + allow(Pdf::HtmlGenerator).to receive(:new).and_return(html_generator) + + allow(html_generator).to receive(:make) + subject.process + end end end diff --git a/spec/services/reports/time_entries/generate_csv_spec.rb b/spec/services/reports/time_entries/generate_csv_spec.rb deleted file mode 100644 index abcaf43bf2..0000000000 --- a/spec/services/reports/time_entries/generate_csv_spec.rb +++ /dev/null @@ -1,33 +0,0 @@ -# frozen_string_literal: true - -require "rails_helper" - -RSpec.describe Reports::TimeEntries::GenerateCsv do - let(:company) { create(:company) } - let!(:entry) { create(:timesheet_entry) } - - describe "#process" do - before do - TimesheetEntry.reindex - end - - subject { described_class.new(TimesheetEntry.search(load: false), company).process } - - let(:csv_headers) do - "Project,Client,Note,Team Member,Date,Hours Logged" - end - let(:csv_data) do - "#{entry.project_name}," \ - "#{entry.client_name}," \ - "#{entry.note}," \ - "#{entry.user_full_name}," \ - "#{entry.formatted_work_date}," \ - "#{entry.formatted_duration}" - end - - it "returns CSV string" do - expect(subject).to include(csv_headers) - expect(subject).to include(csv_data) - end - end -end diff --git a/spec/services/reports/time_entries/generate_pdf_spec.rb b/spec/services/reports/time_entries/generate_pdf_spec.rb deleted file mode 100644 index 09e1486b89..0000000000 --- a/spec/services/reports/time_entries/generate_pdf_spec.rb +++ /dev/null @@ -1,20 +0,0 @@ -# frozen_string_literal: true - -require "rails_helper" - -RSpec.describe Reports::TimeEntries::GeneratePdf do - let(:report_entries) { [double("TimeEntry")] } - let(:current_company) { double("Company") } - - subject { described_class.new(report_entries, current_company) } - - describe "#process" do - it "generates a PDF report using Pdf::HtmlGenerator" do - html_generator = instance_double("Pdf::HtmlGenerator") - allow(Pdf::HtmlGenerator).to receive(:new).and_return(html_generator) - - allow(html_generator).to receive(:make) - subject.process - end - end -end From 35b7addcce48d5a2276bc364ec6a2fa455bec754 Mon Sep 17 00:00:00 2001 From: Apoorv Tiwari Date: Tue, 12 Mar 2024 19:39:18 +0530 Subject: [PATCH 15/39] Fix N+1 query in expenses (#1696) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit fix N+1 query Co-authored-by: “Apoorv <“tiwari.apoorv1316@gmail.com”> --- app/services/expenses/fetch_service.rb | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/services/expenses/fetch_service.rb b/app/services/expenses/fetch_service.rb index 1beee52cfb..3f91838fc9 100644 --- a/app/services/expenses/fetch_service.rb +++ b/app/services/expenses/fetch_service.rb @@ -10,7 +10,7 @@ def initialize(current_company, params) end def process - @expenses = search_expenses + @expenses = search_expenses.includes(:expense_category, :vendor, :company) { expenses:, From 2051a261389f0fe4d6629c32542ef7f36ad962b6 Mon Sep 17 00:00:00 2001 From: Apoorv Tiwari Date: Tue, 12 Mar 2024 19:39:48 +0530 Subject: [PATCH 16/39] Fix n+1 query in clients (#1698) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit fix n+1 query Co-authored-by: “Apoorv <“tiwari.apoorv1316@gmail.com”> --- app/services/clients/index_service.rb | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/app/services/clients/index_service.rb b/app/services/clients/index_service.rb index cac51f6a89..f2689c2bca 100644 --- a/app/services/clients/index_service.rb +++ b/app/services/clients/index_service.rb @@ -23,9 +23,9 @@ def process def clients_list if query.present? - search_clients(search_term, where_clause) + search_clients(search_term, where_clause).includes(:logo_attachment) else - current_company.clients + current_company.clients.includes(:logo_attachment) end end From 00719de99a36c2469fe9f9dfa271fdc50658cc9b Mon Sep 17 00:00:00 2001 From: Nishant Samel Date: Wed, 13 Mar 2024 16:04:13 +0530 Subject: [PATCH 17/39] Disable Stripe for specific Invoice for desktop (#1692) --- .../ClientInvoices/Details/Header.tsx | 7 ++-- .../ClientInvoices/Details/index.tsx | 11 ++++++ .../src/components/Invoices/Edit/index.tsx | 24 ++++++------- .../components/Invoices/Generate/index.tsx | 14 +++++--- .../{Generate => }/InvoiceSettings.tsx | 11 +++--- .../common/InvoiceForm/Header/index.tsx | 18 ++++++++-- .../Invoices/popups/StripeDisabledInvoice.tsx | 36 +++++++++++++++++++ .../src/mapper/generateInvoice.mapper.ts | 1 + app/models/invoice.rb | 1 + app/policies/invoice_policy.rb | 3 +- .../v1/invoices/_invoice.json.jbuilder | 1 + .../v1/partial/_invoice.json.jbuilder | 1 + ...04143641_add_stripe_enabled_to_invoices.rb | 7 ++++ db/schema.rb | 3 +- spec/policies/invoice_policy_spec.rb | 2 +- .../internal_api/v1/invoices/create_spec.rb | 8 ++--- 16 files changed, 115 insertions(+), 33 deletions(-) rename app/javascript/src/components/Invoices/{Generate => }/InvoiceSettings.tsx (96%) create mode 100644 app/javascript/src/components/Invoices/popups/StripeDisabledInvoice.tsx create mode 100644 db/migrate/20240304143641_add_stripe_enabled_to_invoices.rb diff --git a/app/javascript/src/components/ClientInvoices/Details/Header.tsx b/app/javascript/src/components/ClientInvoices/Details/Header.tsx index f5c207a24a..e51536f20c 100644 --- a/app/javascript/src/components/ClientInvoices/Details/Header.tsx +++ b/app/javascript/src/components/ClientInvoices/Details/Header.tsx @@ -16,11 +16,12 @@ const Header = ({ stripeUrl, stripe_connected_account, setShowConnectPaymentDialog, + setShowStripeDisabledDialog, }) => { const [isMoreOptionsVisible, setIsMoreOptionsVisible] = useState(false); const wrapperRef = useRef(null); - const { invoice_number, status } = invoice; + const { invoice_number, status, stripe_enabled } = invoice; useOutsideClick( wrapperRef, @@ -79,7 +80,9 @@ const Header = ({ }`} onClick={() => { if (status != "paid") { - if (stripe_connected_account) { + if (stripe_connected_account && !stripe_enabled) { + setShowStripeDisabledDialog(true); + } else if (stripe_connected_account) { window.location.href = stripeUrl; } else { setShowConnectPaymentDialog(true); diff --git a/app/javascript/src/components/ClientInvoices/Details/index.tsx b/app/javascript/src/components/ClientInvoices/Details/index.tsx index f58e159c43..72619d49e8 100644 --- a/app/javascript/src/components/ClientInvoices/Details/index.tsx +++ b/app/javascript/src/components/ClientInvoices/Details/index.tsx @@ -5,6 +5,7 @@ import { useParams } from "react-router-dom"; import invoicesApi from "apis/invoices"; import Loader from "common/Loader/index"; import ConnectPaymentGateway from "components/Invoices/popups/ConnectPaymentGateway"; +import StripeDisabledInvoice from "components/Invoices/popups/StripeDisabledInvoice"; import { useUserContext } from "context/UserContext"; import Header from "./Header"; @@ -22,6 +23,9 @@ const ClientInvoiceDetails = () => { const [showConnectPaymentDialog, setShowConnectPaymentDialog] = useState(false); + const [showStripeDisabledDialog, setShowStripeDisabledDialog] = + useState(false); + useEffect(() => { fetchViewInvoice(); }, []); @@ -57,6 +61,7 @@ const ClientInvoiceDetails = () => {
    @@ -77,6 +82,12 @@ const ClientInvoiceDetails = () => { showConnectPaymentDialog={showConnectPaymentDialog} /> )} + {showStripeDisabledDialog && ( + + )}
    ) : ( diff --git a/app/javascript/src/components/Invoices/Edit/index.tsx b/app/javascript/src/components/Invoices/Edit/index.tsx index b899fc817d..99c8a24d54 100644 --- a/app/javascript/src/components/Invoices/Edit/index.tsx +++ b/app/javascript/src/components/Invoices/Edit/index.tsx @@ -5,7 +5,6 @@ import { useParams, useNavigate } from "react-router-dom"; import { Toastr } from "StyledComponents"; import invoicesApi from "apis/invoices"; -import paymentSettings from "apis/payment-settings"; import Loader from "common/Loader/index"; import { ApiStatus as InvoiceStatus } from "constants/index"; import { useUserContext } from "context/UserContext"; @@ -21,6 +20,7 @@ import SendInvoice from "../common/InvoiceForm/SendInvoice"; import InvoiceTable from "../common/InvoiceTable"; import InvoiceTotal from "../common/InvoiceTotal"; import { generateInvoiceLineItems } from "../common/utils"; +import InvoiceSettings from "../InvoiceSettings"; import ConnectPaymentGateway from "../popups/ConnectPaymentGateway"; import DeleteInvoice from "../popups/DeleteInvoice"; @@ -52,6 +52,7 @@ const EditInvoice = () => { const [invoiceToDelete, setInvoiceToDelete] = useState(null); const [showDeleteDialog, setShowDeleteDialog] = useState(false); const [isSendReminder, setIsSendReminder] = useState(false); + const [showInvoiceSetting, setShowInvoiceSetting] = useState(false); const [isStripeEnabled, setIsStripeEnabled] = useState(false); const [showConnectPaymentDialog, setShowConnectPaymentDialog] = useState(false); @@ -76,25 +77,16 @@ const EditInvoice = () => { setAmountDue(data.amountDue); setAmountPaid(data.amountPaid); setStatus(InvoiceStatus.SUCCESS); + setIsStripeEnabled(data.stripeEnabled); } catch { navigate("/invoices/error"); setStatus(InvoiceStatus.ERROR); } }; - const fetchPaymentSettings = async () => { - try { - const res = await paymentSettings.get(); - setIsStripeEnabled(res.data.providers.stripe.connected); - } catch { - Toastr.error("ERROR! CONNECTING TO PAYMENTS"); - } - }; - useEffect(() => { sendGAPageView(); fetchInvoice(); - fetchPaymentSettings(); }, []); const updateInvoice = async () => { @@ -120,6 +112,7 @@ const EditInvoice = () => { discount: Number(discount), tax: tax || invoiceDetails.tax, client_id: selectedClient.id, + stripe_enabled: isStripeEnabled, invoice_line_items_attributes: generateInvoiceLineItems( selectedLineItems, manualEntryArr, @@ -229,7 +222,7 @@ const EditInvoice = () => { id={invoiceDetails.id} invoiceNumber={invoiceDetails.invoiceNumber} setIsSendReminder={setIsSendReminder} - setShowInvoiceSetting={false} + setShowInvoiceSetting={setShowInvoiceSetting} deleteInvoice={() => { setShowDeleteDialog(true); setInvoiceToDelete(invoiceDetails.id); @@ -314,6 +307,13 @@ const EditInvoice = () => { showDeleteDialog={showDeleteDialog} /> )} + {showInvoiceSetting && ( + + )} ); } diff --git a/app/javascript/src/components/Invoices/Generate/index.tsx b/app/javascript/src/components/Invoices/Generate/index.tsx index c2b49e1e05..94905a2653 100644 --- a/app/javascript/src/components/Invoices/Generate/index.tsx +++ b/app/javascript/src/components/Invoices/Generate/index.tsx @@ -14,12 +14,12 @@ import { mapGenerateInvoice, unmapGenerateInvoice } from "mapper/mappedIndex"; import { sendGAPageView } from "utils/googleAnalytics"; import Container from "./Container"; -import InvoiceSettings from "./InvoiceSettings"; import MobileView from "./MobileView"; import Header from "../common/InvoiceForm/Header"; import SendInvoice from "../common/InvoiceForm/SendInvoice"; import { generateInvoiceLineItems } from "../common/utils"; +import InvoiceSettings from "../InvoiceSettings"; import ConnectPaymentGateway from "../popups/ConnectPaymentGateway"; const GenerateInvoices = () => { @@ -48,6 +48,7 @@ const GenerateInvoices = () => { const [showConnectPaymentDialog, setShowConnectPaymentDialog] = useState(false); const [isStripeConnected, setIsStripeConnected] = useState(null); + const [isStripeEnabled, setIsStripeEnabled] = useState(true); const amountPaid = 0; const clientId = searchParams.get("clientId"); @@ -63,8 +64,8 @@ const GenerateInvoices = () => { try { setIsLoading(true); const res = await companiesApi.index(); - const sanitzed = await unmapGenerateInvoice(res.data); - setInvoiceDetails(sanitzed); + const sanitized = await unmapGenerateInvoice(res.data); + setInvoiceDetails(sanitized); setIsLoading(false); } catch { navigate("invoices/error"); @@ -119,6 +120,7 @@ const GenerateInvoices = () => { tax, dateFormat: invoiceDetails.companyDetails.date_format, setShowSendInvoiceModal, + isStripeEnabled, }); return await invoicesApi.post(sanitized); @@ -255,7 +257,11 @@ const GenerateInvoices = () => { /> )} {showInvoiceSetting && ( - + )} ); diff --git a/app/javascript/src/components/Invoices/Generate/InvoiceSettings.tsx b/app/javascript/src/components/Invoices/InvoiceSettings.tsx similarity index 96% rename from app/javascript/src/components/Invoices/Generate/InvoiceSettings.tsx rename to app/javascript/src/components/Invoices/InvoiceSettings.tsx index 3b41ace593..bf195f8153 100644 --- a/app/javascript/src/components/Invoices/Generate/InvoiceSettings.tsx +++ b/app/javascript/src/components/Invoices/InvoiceSettings.tsx @@ -26,13 +26,16 @@ interface IProvider { acceptedPaymentMethods: Array; } -const InvoiceSettings = ({ setShowInvoiceSetting }) => { +const InvoiceSettings = ({ + isStripeEnabled, + setIsStripeEnabled, + setShowInvoiceSetting, +}) => { const [status, setStatus] = useState( PaymentSettingsStatus.IDLE ); const [isChecked, setIsChecked] = useState(true); const [isStripeConnected, setIsStripeConnected] = useState(null); - const [isStripeEnabled, setIsStripeEnabled] = useState(null); const [isPaypalConnected, setPaypalConnected] = useState(false); //eslint-disable-line const [accountLink, setAccountLink] = useState(null); const [stripeAcceptedPaymentMethods, setStripeAcceptedPaymentMethods] = @@ -51,7 +54,6 @@ const InvoiceSettings = ({ setShowInvoiceSetting }) => { const paymentsProviders = res.data.paymentsProviders; const stripe = paymentsProviders.find(p => p.name === "stripe"); setIsStripeConnected(!!stripe && stripe.connected); - setIsStripeEnabled(!!stripe && stripe.enabled); setStripeAcceptedPaymentMethods( !!stripe && stripe.acceptedPaymentMethods ); @@ -67,9 +69,6 @@ const InvoiceSettings = ({ setShowInvoiceSetting }) => { }; const toggleStripe = async () => { - await updatePaymentsProvidersSettings(stripe.id, { - enabled: !isStripeEnabled, - }); setIsStripeEnabled(!isStripeEnabled); }; diff --git a/app/javascript/src/components/Invoices/common/InvoiceForm/Header/index.tsx b/app/javascript/src/components/Invoices/common/InvoiceForm/Header/index.tsx index 377b09faf2..d031b283f8 100644 --- a/app/javascript/src/components/Invoices/common/InvoiceForm/Header/index.tsx +++ b/app/javascript/src/components/Invoices/common/InvoiceForm/Header/index.tsx @@ -1,8 +1,14 @@ import React, { useState, useRef } from "react"; import { useOutsideClick } from "helpers"; -import { XIcon, FloppyDiskIcon, PaperPlaneTiltIcon } from "miruIcons"; +import { + XIcon, + FloppyDiskIcon, + PaperPlaneTiltIcon, + SettingIcon, +} from "miruIcons"; import { Link } from "react-router-dom"; +import { Button } from "StyledComponents"; import MoreButton from "../../MoreButton"; import MoreOptions from "../../MoreOptions"; @@ -11,7 +17,7 @@ const Header = ({ formType = "generate", handleSaveInvoice, handleSendInvoice, - setShowInvoiceSetting, //eslint-disable-line + setShowInvoiceSetting, invoiceNumber = null, id = null, deleteInvoice = null, @@ -38,6 +44,14 @@ const Header = ({ ? `Edit Invoice #${invoiceNumber}` : "Generate Invoice"} +
    ( + setShowStripeDisabledDialog(false)} + > +
    +
    Stripe disabled for this invoice
    + +
    +
    +

    + The sender hasn't enabled Stripe payments for this invoice. +
    + You can reach out to them to activate it, or choose an alternative + payment method like ACH. +

    +
    +
    +); + +export default StripeDisabledInvoice; diff --git a/app/javascript/src/mapper/generateInvoice.mapper.ts b/app/javascript/src/mapper/generateInvoice.mapper.ts index 2a00519a65..20af5cfeb2 100644 --- a/app/javascript/src/mapper/generateInvoice.mapper.ts +++ b/app/javascript/src/mapper/generateInvoice.mapper.ts @@ -58,6 +58,7 @@ const mapGenerateInvoice = input => ({ amount: input.amount, discount: input.discount, tax: input.tax, + stripe_enabled: input.isStripeEnabled, invoice_line_items_attributes: input.invoiceLineItems.map(ilt => ({ name: ilt.name, description: ilt.description, diff --git a/app/models/invoice.rb b/app/models/invoice.rb index 43898c1bbb..8eb0b001cb 100644 --- a/app/models/invoice.rb +++ b/app/models/invoice.rb @@ -19,6 +19,7 @@ # reference :text # sent_at :datetime # status :integer default("draft"), not null +# stripe_enabled :boolean default(TRUE) # tax :decimal(20, 2) default(0.0) # created_at :datetime not null # updated_at :datetime not null diff --git a/app/policies/invoice_policy.rb b/app/policies/invoice_policy.rb index 5ab56ec0a7..58528f4c2b 100644 --- a/app/policies/invoice_policy.rb +++ b/app/policies/invoice_policy.rb @@ -42,7 +42,8 @@ def permitted_attributes :issue_date, :due_date, :status, :invoice_number, :reference, :amount, :outstanding_amount, :tax, :amount_paid, - :amount_due, :discount, :client_id, :external_view_key, + :amount_due, :discount, :client_id, + :external_view_key, :stripe_enabled, invoice_line_items_attributes: [ :id, :name, :description, :date, :timesheet_entry_id, diff --git a/app/views/internal_api/v1/invoices/_invoice.json.jbuilder b/app/views/internal_api/v1/invoices/_invoice.json.jbuilder index b6d01148e3..d2b3e4cd16 100644 --- a/app/views/internal_api/v1/invoices/_invoice.json.jbuilder +++ b/app/views/internal_api/v1/invoices/_invoice.json.jbuilder @@ -15,6 +15,7 @@ json.amount_due invoice.amount_due json.discount invoice.discount json.tax invoice.tax json.status invoice.status +json.stripe_enabled invoice.stripe_enabled json.invoice_line_items invoice.invoice_line_items do |invoice_line_item| json.id invoice_line_item.id json.name invoice_line_item.name diff --git a/app/views/internal_api/v1/partial/_invoice.json.jbuilder b/app/views/internal_api/v1/partial/_invoice.json.jbuilder index eeccc86689..5c61201716 100644 --- a/app/views/internal_api/v1/partial/_invoice.json.jbuilder +++ b/app/views/internal_api/v1/partial/_invoice.json.jbuilder @@ -11,3 +11,4 @@ json.amount_paid invoice.amount_paid json.amount_due invoice.amount_due json.discount invoice.discount json.status invoice.status +json.stripe_enabled invoice.stripe_enabled diff --git a/db/migrate/20240304143641_add_stripe_enabled_to_invoices.rb b/db/migrate/20240304143641_add_stripe_enabled_to_invoices.rb new file mode 100644 index 0000000000..fae6630dc9 --- /dev/null +++ b/db/migrate/20240304143641_add_stripe_enabled_to_invoices.rb @@ -0,0 +1,7 @@ +# frozen_string_literal: true + +class AddStripeEnabledToInvoices < ActiveRecord::Migration[7.0] + def change + add_column :invoices, :stripe_enabled, :boolean, default: true + end +end diff --git a/db/schema.rb b/db/schema.rb index a781ea059c..6c4ac1f09a 100644 --- a/db/schema.rb +++ b/db/schema.rb @@ -10,7 +10,7 @@ # # It's strongly recommended that you check this file into your version control system. -ActiveRecord::Schema[7.0].define(version: 2024_01_29_091400) do +ActiveRecord::Schema[7.0].define(version: 2024_03_04_143641) do # These are extensions that must be enabled in order to support this database enable_extension "plpgsql" @@ -299,6 +299,7 @@ t.datetime "sent_at" t.datetime "payment_sent_at" t.datetime "client_payment_sent_at" + t.boolean "stripe_enabled", default: true t.index ["client_id"], name: "index_invoices_on_client_id" t.index ["company_id"], name: "index_invoices_on_company_id" t.index ["discarded_at"], name: "index_invoices_on_discarded_at" diff --git a/spec/policies/invoice_policy_spec.rb b/spec/policies/invoice_policy_spec.rb index 168e0b0125..bf3175de26 100644 --- a/spec/policies/invoice_policy_spec.rb +++ b/spec/policies/invoice_policy_spec.rb @@ -108,7 +108,7 @@ let(:attributes) do %i[ issue_date due_date status invoice_number reference amount outstanding_amount - tax amount_paid amount_due discount client_id external_view_key + tax amount_paid amount_due discount client_id external_view_key stripe_enabled ].push(invoice_line_items_attributes:) end diff --git a/spec/requests/internal_api/v1/invoices/create_spec.rb b/spec/requests/internal_api/v1/invoices/create_spec.rb index 342bff8c18..4a83738bc1 100644 --- a/spec/requests/internal_api/v1/invoices/create_spec.rb +++ b/spec/requests/internal_api/v1/invoices/create_spec.rb @@ -37,10 +37,10 @@ it "creates invoice successfully & reindex it" do send_request :post, internal_api_v1_invoices_path(invoice:), headers: auth_headers(user) expect(response).to have_http_status(:ok) - expected_attrs = ["amount", "amountDue", "amountPaid", - "client", "discount", "dueDate", "id", - "invoiceLineItems", "invoiceNumber", "issueDate", - "outstandingAmount", "reference", "status", "tax"] + expected_attrs = + ["amount", "amountDue", "amountPaid", "client", "discount", "dueDate", + "id", "invoiceLineItems", "invoiceNumber", "issueDate", + "outstandingAmount", "reference", "status", "stripeEnabled", "tax"] expect(json_response.keys.sort).to match(expected_attrs) Invoice.reindex assert_equal ["SAI-C1-03"], Invoice.search("SAI-C1-03").map(&:invoice_number) From 9564069baf67b60eacae360a1c5cb066ac8f0741 Mon Sep 17 00:00:00 2001 From: Nishant Samel Date: Wed, 13 Mar 2024 16:05:07 +0530 Subject: [PATCH 18/39] Create Stripe provider only if it is not exists in the database (#1705) --- .../create_stripe_provider_service.rb | 17 ++++++++++------- 1 file changed, 10 insertions(+), 7 deletions(-) diff --git a/app/services/payment_providers/create_stripe_provider_service.rb b/app/services/payment_providers/create_stripe_provider_service.rb index f19393d748..517581b110 100644 --- a/app/services/payment_providers/create_stripe_provider_service.rb +++ b/app/services/payment_providers/create_stripe_provider_service.rb @@ -10,13 +10,16 @@ def initialize(current_company) end def process - current_company.payments_providers.create( - { - name: STRIPE_PROVIDER, - connected: true, - enabled: true, - accepted_payment_methods: ACCEPTED_PAYMENT_METHODS - }) if stripe_connected_account.present? && stripe_connected_account.details_submitted + if stripe_connected_account.present? && stripe_connected_account.details_submitted + unless current_company.payments_providers.exists?(name: STRIPE_PROVIDER) + current_company.payments_providers.create( + name: STRIPE_PROVIDER, + connected: true, + enabled: true, + accepted_payment_methods: ACCEPTED_PAYMENT_METHODS + ) + end + end end private From 82f098d0777c2dded1aefabe16a3001d182fddfa Mon Sep 17 00:00:00 2001 From: Nishant Samel Date: Wed, 13 Mar 2024 18:35:07 +0530 Subject: [PATCH 19/39] Fix stripe issue from invoice email (#1708) --- app/javascript/src/components/InvoiceEmail/Header.tsx | 7 ++++++- app/javascript/src/components/InvoiceEmail/index.tsx | 11 +++++++++++ 2 files changed, 17 insertions(+), 1 deletion(-) diff --git a/app/javascript/src/components/InvoiceEmail/Header.tsx b/app/javascript/src/components/InvoiceEmail/Header.tsx index 9fa01836bf..81c6b26c93 100644 --- a/app/javascript/src/components/InvoiceEmail/Header.tsx +++ b/app/javascript/src/components/InvoiceEmail/Header.tsx @@ -11,6 +11,7 @@ const Header = ({ isStripeConnected, setIsInvoiceEmail, setShowConnectPaymentDialog, + setShowStripeDisabledDialog, }: InvoiceEmailProps) => (
    @@ -56,7 +57,9 @@ const Header = ({ }`} onClick={() => { if (invoice.status != "paid") { - if (isStripeConnected) { + if (isStripeConnected && !invoice.stripe_enabled) { + setShowStripeDisabledDialog(true); + } else if (isStripeConnected) { window.location.href = stripeUrl; } else { setIsInvoiceEmail(true); @@ -83,6 +86,7 @@ const Header = ({ interface Invoice { invoice_number: number; status: string; + stripe_enabled: boolean; } interface InvoiceEmailProps { @@ -91,6 +95,7 @@ interface InvoiceEmailProps { isStripeConnected: boolean; setIsInvoiceEmail: (_value) => void; setShowConnectPaymentDialog: (_value) => void; + setShowStripeDisabledDialog: (_value) => void; } export default Header; diff --git a/app/javascript/src/components/InvoiceEmail/index.tsx b/app/javascript/src/components/InvoiceEmail/index.tsx index 8fd0edee47..aa64f4f347 100644 --- a/app/javascript/src/components/InvoiceEmail/index.tsx +++ b/app/javascript/src/components/InvoiceEmail/index.tsx @@ -7,6 +7,7 @@ import invoicesApi from "apis/invoices"; import Loader from "common/Loader"; import MobileView from "components/ClientInvoices/Details/MobileView"; import ConnectPaymentGateway from "components/Invoices/popups/ConnectPaymentGateway"; +import StripeDisabledInvoice from "components/Invoices/popups/StripeDisabledInvoice"; import { useUserContext } from "context/UserContext"; import Header from "./Header"; @@ -23,6 +24,9 @@ const InvoiceEmail = () => { const [showConnectPaymentDialog, setShowConnectPaymentDialog] = useState(false); + const [showStripeDisabledDialog, setShowStripeDisabledDialog] = + useState(false); + useEffect(() => { fetchViewInvoice(); }, []); @@ -55,6 +59,7 @@ const InvoiceEmail = () => { isStripeConnected={isStripeConnected} setIsInvoiceEmail={setIsInvoiceEmail} setShowConnectPaymentDialog={setShowConnectPaymentDialog} + setShowStripeDisabledDialog={setShowStripeDisabledDialog} stripeUrl={url} />
    @@ -74,6 +79,12 @@ const InvoiceEmail = () => { showConnectPaymentDialog={showConnectPaymentDialog} /> )} + {showStripeDisabledDialog && ( + + )}
    From 252307b4188d6a9a470fa6fd6b7b87aac9f3a489 Mon Sep 17 00:00:00 2001 From: Siddharth Shringi Date: Thu, 14 Mar 2024 17:11:20 +0530 Subject: [PATCH 20/39] Leave balance issue days per month (#1703) * Added logic for days per month case * Fixed rails tests for leave balance * Fixed leave balance date text * Fixed the date string * Fixed the file name * Fixed method naming --- .../LeaveManagement/Container/index.tsx | 4 ++-- .../src/components/LeaveManagement/index.tsx | 14 +++++++++-- app/models/user.rb | 5 ++++ app/services/timeoff_entries/index_service.rb | 24 ++++++++++++++++++- ...rvice_spec.rb.rb => index_service_spec.rb} | 15 ++++++------ 5 files changed, 50 insertions(+), 12 deletions(-) rename spec/services/timeoff_entries/{index_service_spec.rb.rb => index_service_spec.rb} (85%) diff --git a/app/javascript/src/components/LeaveManagement/Container/index.tsx b/app/javascript/src/components/LeaveManagement/Container/index.tsx index f3dec4ded9..db38b280e5 100644 --- a/app/javascript/src/components/LeaveManagement/Container/index.tsx +++ b/app/javascript/src/components/LeaveManagement/Container/index.tsx @@ -9,7 +9,7 @@ import LeaveBlock from "./LeaveBlock"; import Table from "./Table"; const Container = ({ - currentYear, + getLeaveBalanaceDateText, leaveBalance, timeoffEntries, totalTimeoffEntriesDuration, @@ -19,7 +19,7 @@ const Container = ({
    - Leave Balance Until 31st Dec {currentYear} + {getLeaveBalanaceDateText()} diff --git a/app/javascript/src/components/Profile/Layout.tsx b/app/javascript/src/components/Profile/Layout.tsx index 91aa5f6458..90c5dffcd0 100644 --- a/app/javascript/src/components/Profile/Layout.tsx +++ b/app/javascript/src/components/Profile/Layout.tsx @@ -4,6 +4,7 @@ import React, { Fragment, useState } from "react"; import { useUserContext } from "context/UserContext"; import Header from "./CommonComponents/Header"; +import { CompensationDetailsState } from "./context/CompensationDetailsState"; import { EmploymentDetailsState } from "./context/EmploymentDetailsState"; import EntryContext from "./context/EntryContext"; import { PersonalDetailsState } from "./context/PersonalDetailsState"; @@ -16,6 +17,7 @@ const Layout = ({ isAdminUser, user, company }) => { const [settingsStates, setSettingsStates] = useState({ profileSettings: PersonalDetailsState, employmentDetails: EmploymentDetailsState, + compensationDetails: CompensationDetailsState, organizationSettings: {}, bankAccDetails: {}, paymentSettings: {}, diff --git a/app/javascript/src/components/Profile/Layout/MobileNav.tsx b/app/javascript/src/components/Profile/Layout/MobileNav.tsx index 498caa1f3e..3f9d733816 100644 --- a/app/javascript/src/components/Profile/Layout/MobileNav.tsx +++ b/app/javascript/src/components/Profile/Layout/MobileNav.tsx @@ -28,6 +28,21 @@ const getSettingsNavUrls = memberId => [ text: "PERSONAL DETAILS", icon: , }, + { + url: "/settings/employment", + text: "EMPLOYMENT DETAILS", + icon: , + }, + { + url: "/settings/devices", + text: "ALLOCATED DEVICES", + icon: , + }, + { + url: "/settings/compensation", + text: "COMPENSATION", + icon: , + }, ], }, @@ -44,16 +59,6 @@ const getSettingsNavUrls = memberId => [ text: "PAYMENT SETTINGS", icon: , }, - { - url: "/settings/employment", - text: "EMPLOYMENT DETAILS", - icon: , - }, - { - url: "/settings/devices", - text: "ALLOCATED DEVICES", - icon: , - }, // { // url: "/settings/leaves", // text: "Leaves", @@ -77,6 +82,21 @@ const getEmployeeSettingsNavUrls = memberId => [ text: "PERSONAL DETAILS", icon: , }, + { + url: "/settings/employment", + text: "EMPLOYMENT DETAILS", + icon: , + }, + { + url: "/settings/devices", + text: "ALLOCATED DEVICES", + icon: , + }, + { + url: "/settings/compensation", + text: "COMPENSATION", + icon: , + }, ], }, ]; diff --git a/app/javascript/src/components/Profile/UserDetail/CompensationDetails/Edit/EditPage.tsx b/app/javascript/src/components/Profile/UserDetail/CompensationDetails/Edit/EditPage.tsx new file mode 100644 index 0000000000..f1779d2116 --- /dev/null +++ b/app/javascript/src/components/Profile/UserDetail/CompensationDetails/Edit/EditPage.tsx @@ -0,0 +1,196 @@ +import React from "react"; + +import { currencyFormat } from "helpers"; +import { + DeleteIcon, + CoinsIcon, + EarningsIconSVG, + DeductionIconSVG, +} from "miruIcons"; +import "react-phone-number-input/style.css"; +import { Button } from "StyledComponents"; + +import { CustomInputText } from "common/CustomInputText"; +import { ErrorSpan } from "common/ErrorSpan"; + +const EditPage = ({ + handleAddEarning, + handleAddDeduction, + updateDeductionValues, + updateEarningsValues, + handleDeleteEarning, + handleDeleteDeduction, + earnings, + deductions, + total, + currency, + errDetails, +}) => ( +
    +
    +
    + + + Earnings + +
    +
    + {earnings.length > 0 && + earnings.map((earning, index) => ( +
    +
    +
    + { + updateEarningsValues(earning, e); + }} + /> + {errDetails.earning_type_err && ( + + )} +
    +
    + { + updateEarningsValues(earning, e); + }} + /> + {errDetails.earning_amount_err && ( + + )} +
    +
    + +
    + ))} +
    + +
    +
    +
    +
    +
    +
    + + + Deductions + +
    +
    + {deductions.length > 0 && + deductions.map((deduction, index) => ( +
    +
    +
    + { + updateDeductionValues(deduction, e); + }} + /> + {errDetails.deduction_type_err && ( + + )} +
    +
    + { + updateDeductionValues(deduction, e); + }} + /> + {errDetails.deduction_amount_err && ( + + )} +
    +
    + +
    + ))} +
    + +
    +
    +
    +
    +
    +
    + + + Total + +
    +
    + + {currencyFormat(currency, total)} + +
    +
    +
    +); + +export default EditPage; diff --git a/app/javascript/src/components/Profile/UserDetail/CompensationDetails/Edit/MobileEditPage.tsx b/app/javascript/src/components/Profile/UserDetail/CompensationDetails/Edit/MobileEditPage.tsx new file mode 100644 index 0000000000..5d37051316 --- /dev/null +++ b/app/javascript/src/components/Profile/UserDetail/CompensationDetails/Edit/MobileEditPage.tsx @@ -0,0 +1,218 @@ +import React from "react"; + +import { currencyFormat } from "helpers"; +import { + CoinsIcon, + DeductionIconSVG, + DeleteIcon, + EarningsIconSVG, +} from "miruIcons"; +import "react-phone-number-input/style.css"; +import { Button } from "StyledComponents"; + +import { CustomInputText } from "common/CustomInputText"; +import { ErrorSpan } from "common/ErrorSpan"; + +const MobileEditPage = ({ + handleAddEarning, + handleAddDeduction, + updateDeductionValues, + updateEarningsValues, + handleDeleteEarning, + handleDeleteDeduction, + handleCancelDetails, + handleUpdateDetails, + earnings, + deductions, + total, + currency, + errDetails, +}) => ( +
    +
    +
    + + + Earnings + +
    +
    + {earnings.length > 0 ? ( + earnings.map((earning, index) => ( +
    +
    +
    + { + updateEarningsValues(earning, e); + }} + /> + {errDetails.earning_type_err && ( + + )} +
    + +
    +
    + { + updateEarningsValues(earning, e); + }} + /> + {errDetails.earning_amount_err && ( + + )} +
    +
    + )) + ) : ( +
    No Earnings found
    + )} +
    + +
    +
    +
    +
    +
    + + + Deductions + +
    +
    + {deductions.length > 0 ? ( + deductions.map((deduction, index) => ( +
    +
    +
    + { + updateDeductionValues(deduction, e); + }} + /> + {errDetails.deduction_type_err && ( + + )} +
    + +
    +
    + { + updateDeductionValues(deduction, e); + }} + /> + {errDetails.deduction_amount_err && ( + + )} +
    +
    + )) + ) : ( +
    No deductions found
    + )} +
    + +
    +
    +
    +
    +
    + + + Total + +
    +
    + + {currencyFormat(currency, total)} + +
    +
    +
    +
    + +
    +
    + +
    +
    +
    +); + +export default MobileEditPage; diff --git a/app/javascript/src/components/Profile/UserDetail/CompensationDetails/Edit/index.tsx b/app/javascript/src/components/Profile/UserDetail/CompensationDetails/Edit/index.tsx new file mode 100644 index 0000000000..942ba7c667 --- /dev/null +++ b/app/javascript/src/components/Profile/UserDetail/CompensationDetails/Edit/index.tsx @@ -0,0 +1,157 @@ +/* eslint-disable no-unused-vars */ +import React, { Fragment, useEffect, useState } from "react"; + +import { useNavigate } from "react-router-dom"; + +import Loader from "common/Loader/index"; +import { CompensationDetailsState } from "components/Profile/context/CompensationDetailsState"; +import { useUserContext } from "context/UserContext"; + +import EditPage from "./EditPage"; +import MobileEditPage from "./MobileEditPage"; + +import Header from "../../../Header"; + +const CompensationEditPage = () => { + const initialErrState = { + earning_type_err: "", + earning_amount_err: "", + deduction_type_err: "", + deduction_amount_err: "", + }; + const navigate = useNavigate(); + const { isDesktop, company } = useUserContext(); + + const [isLoading, setIsLoading] = useState(false); + const [earnings, setEarnings] = useState>( + CompensationDetailsState.earnings + ); + + const [deductions, setDeductions] = useState>( + CompensationDetailsState.deductions + ); + + const [total, setTotal] = useState( + CompensationDetailsState.total.amount + ); + const [errDetails, setErrDetails] = useState({}); + + useEffect(() => { + setIsLoading(true); + getDevicesDetail(); + setErrDetails(initialErrState); + }, []); + + useEffect(() => { + const totalEarnings = earnings.reduce( + (accumulator, currentValue) => accumulator + currentValue["amount"], + 0.0 + ); + + const totalDeductions = deductions.reduce( + (accumulator, currentValue) => accumulator + currentValue["amount"], + 0.0 + ); + setTotal(totalEarnings - totalDeductions); + }, [deductions, earnings]); + + const getDevicesDetail = async () => { + setIsLoading(false); + }; + + const handleAddDeduction = () => { + const newDeduction = [...deductions, { deduction_type: "", amount: "" }]; + setDeductions(newDeduction); + }; + + const handleAddEarning = () => { + const newEarning = [...earnings, { earning_type: "", amount: "" }]; + setEarnings(newEarning); + }; + + const handleDeleteDeduction = deduction => { + setDeductions(deductions.filter(d => d !== deduction)); + }; + + const handleDeleteEarning = earning => { + setEarnings(earnings.filter(e => e !== earning)); + }; + + const updateEarningsValues = (earning, event) => { + const { name, value } = event.target; + const updatedEarnings = earnings.map(e => + e == earning ? { ...e, [name]: value } : e + ); + setEarnings(updatedEarnings); + }; + + const updateDeductionValues = (deduction, event) => { + const { name, value } = event.target; + const updatedDeductions = deductions.map(d => + d == deduction ? { ...d, [name]: value } : d + ); + setDeductions(updatedDeductions); + }; + + const handleUpdateDetails = () => { + //Todo: API integration for update details + }; + + const handleCancelDetails = () => { + setIsLoading(true); + navigate(`/settings/compensation`, { replace: true }); + }; + + return ( + +
    + {isLoading ? ( + + ) : ( + + {isDesktop && ( + + )} + {!isDesktop && ( + + )} + + )} + + ); +}; + +export default CompensationEditPage; diff --git a/app/javascript/src/components/Profile/UserDetail/CompensationDetails/StaticPage.tsx b/app/javascript/src/components/Profile/UserDetail/CompensationDetails/StaticPage.tsx new file mode 100644 index 0000000000..42c702af43 --- /dev/null +++ b/app/javascript/src/components/Profile/UserDetail/CompensationDetails/StaticPage.tsx @@ -0,0 +1,100 @@ +import React from "react"; + +import { currencyFormat } from "helpers"; +import { EarningsIconSVG, DeductionIconSVG, CoinsIcon } from "miruIcons"; + +const StaticPage = ({ compensationDetails, currency }) => { + const { earnings, deductions, total } = compensationDetails; + + return ( +
    +
    +
    + + + Earnings + +
    +
    + {earnings ? ( + earnings.map((earning, index) => ( +
    +
    + + Earning Type + +

    + {earning.type || "-"} +

    +
    +
    + + Amount + +

    + {currencyFormat(currency, earning.amount) || "-"} +

    +
    +
    + )) + ) : ( +
    No earning(s) found
    + )} +
    +
    +
    +
    + + + Deductions + +
    +
    + {deductions ? ( + deductions.map((deduction, index) => ( +
    +
    + + Deduction Type + +

    + {deduction.type} +

    +
    +
    + + Amount + +

    + {currencyFormat(currency, deduction.amount) || "-"} +

    +
    +
    + )) + ) : ( +
    No deduction(s) found
    + )} +
    +
    +
    +
    + + + Total + +
    +
    + + {currencyFormat(currency, total.amount)} + +
    +
    +
    + ); +}; + +export default StaticPage; diff --git a/app/javascript/src/components/Profile/UserDetail/CompensationDetails/index.tsx b/app/javascript/src/components/Profile/UserDetail/CompensationDetails/index.tsx new file mode 100644 index 0000000000..b7e3eb9ae0 --- /dev/null +++ b/app/javascript/src/components/Profile/UserDetail/CompensationDetails/index.tsx @@ -0,0 +1,71 @@ +import React, { Fragment, useEffect, useState } from "react"; + +import { useNavigate } from "react-router-dom"; + +import Loader from "common/Loader/index"; +import { MobileEditHeader } from "common/Mobile/MobileEditHeader"; +import { useProfile } from "components/Profile/context/EntryContext"; +import DetailsHeader from "components/Profile/DetailsHeader"; +import { useUserContext } from "context/UserContext"; + +import StaticPage from "./StaticPage"; + +const CompensationDetails = () => { + const { isDesktop, company } = useUserContext(); + const { setUserState, compensationDetails } = useProfile(); + const navigate = useNavigate(); + const [isLoading, setIsLoading] = useState(false); + + const getDetails = async () => { + //fetch compensation details from backend and store it in compensationData + const compensationData = { + earnings: [ + { type: "Monthly Salary", amount: "125000" }, + { type: "SGST (9%)", amount: "11250" }, + { type: "CGST (9%)", amount: "11250" }, + ], + deductions: [{ type: "TDS", amount: "12500" }], + total: { + amount: "147500", + }, + }; + setUserState("compensationDetails", compensationData); + setIsLoading(false); + }; + + useEffect(() => { + setIsLoading(true); + getDetails(); + }, []); + + return ( + + {isDesktop ? ( + + navigate(`/settings/compensation/edit`, { replace: true }) + } + /> + ) : ( + + )} + {isLoading ? ( + + ) : ( + + )} + + ); +}; +export default CompensationDetails; diff --git a/app/javascript/src/components/Profile/UserDetail/EmploymentDetails/Edit/MobileEditPage.tsx b/app/javascript/src/components/Profile/UserDetail/EmploymentDetails/Edit/MobileEditPage.tsx index 3c373982d2..42ac5327bf 100644 --- a/app/javascript/src/components/Profile/UserDetail/EmploymentDetails/Edit/MobileEditPage.tsx +++ b/app/javascript/src/components/Profile/UserDetail/EmploymentDetails/Edit/MobileEditPage.tsx @@ -261,7 +261,7 @@ const MobileEditPage = ({ { @@ -273,7 +273,7 @@ const MobileEditPage = ({ { diff --git a/app/javascript/src/components/Profile/UserDetail/EmploymentDetails/Edit/StaticPage.tsx b/app/javascript/src/components/Profile/UserDetail/EmploymentDetails/Edit/StaticPage.tsx index 8539fc61e2..cb674a7de1 100644 --- a/app/javascript/src/components/Profile/UserDetail/EmploymentDetails/Edit/StaticPage.tsx +++ b/app/javascript/src/components/Profile/UserDetail/EmploymentDetails/Edit/StaticPage.tsx @@ -265,7 +265,7 @@ const StaticPage = ({ { @@ -277,7 +277,7 @@ const StaticPage = ({ { diff --git a/app/javascript/src/components/Profile/constants.js b/app/javascript/src/components/Profile/constants.js index 92b58f3a87..317fb0f3d7 100644 --- a/app/javascript/src/components/Profile/constants.js +++ b/app/javascript/src/components/Profile/constants.js @@ -33,6 +33,13 @@ export const personalSettingsList = [ icon: , authorisedRoles: [ADMIN, OWNER, BOOK_KEEPER, EMPLOYEE], }, + //TODO: Uncomment when Integrating with API + // { + // label: "COMPENSATION", + // link: "/settings/compensation", + // icon: , + // authorisedRoles: [ADMIN, OWNER, BOOK_KEEPER, EMPLOYEE], + // }, ]; export const companySettingsList = [ diff --git a/app/javascript/src/components/Profile/context/CompensationDetailsState.tsx b/app/javascript/src/components/Profile/context/CompensationDetailsState.tsx new file mode 100644 index 0000000000..f993aa7b2d --- /dev/null +++ b/app/javascript/src/components/Profile/context/CompensationDetailsState.tsx @@ -0,0 +1,7 @@ +export const CompensationDetailsState = { + earnings: [], + deductions: [], + total: { + amount: 0, + }, +}; diff --git a/app/javascript/src/components/Profile/context/EntryContext.tsx b/app/javascript/src/components/Profile/context/EntryContext.tsx index 13afc9c382..8fa61bc78b 100644 --- a/app/javascript/src/components/Profile/context/EntryContext.tsx +++ b/app/javascript/src/components/Profile/context/EntryContext.tsx @@ -1,11 +1,13 @@ import { createContext, useContext } from "react"; +import { CompensationDetailsState } from "./CompensationDetailsState"; import { EmploymentDetailsState } from "./EmploymentDetailsState"; import { PersonalDetailsState } from "./PersonalDetailsState"; const EntryContext = createContext({ profileSettings: PersonalDetailsState, employmentDetails: EmploymentDetailsState, + compensationDetails: CompensationDetailsState, organizationSettings: {}, bankAccDetails: {}, paymentSettings: {}, diff --git a/app/javascript/src/components/Profile/routes.ts b/app/javascript/src/components/Profile/routes.ts index 724f57ad8c..1673a4eca2 100644 --- a/app/javascript/src/components/Profile/routes.ts +++ b/app/javascript/src/components/Profile/routes.ts @@ -8,6 +8,8 @@ import Leaves from "./Organization/Leaves"; import PaymentSettings from "./Organization/Payment"; import AllocatedDevicesDetails from "./UserDetail/AllocatedDevicesDetails"; import AllocatedDevicesEdit from "./UserDetail/AllocatedDevicesDetails/Edit"; +import CompensationDetails from "./UserDetail/CompensationDetails"; +import CompensationDetailsEdit from "./UserDetail/CompensationDetails/Edit"; import UserDetailsEdit from "./UserDetail/Edit"; import EmploymentDetails from "./UserDetail/EmploymentDetails"; import EmploymentDetailsEdit from "./UserDetail/EmploymentDetails/Edit"; @@ -46,6 +48,16 @@ export const SETTINGS_ROUTES = [ Component: AllocatedDevicesEdit, authorisedRoles: [ADMIN, OWNER, BOOK_KEEPER, EMPLOYEE], }, + { + path: "/compensation", + Component: CompensationDetails, + authorisedRoles: [ADMIN, OWNER, BOOK_KEEPER, EMPLOYEE], + }, + { + path: "/compensation/edit", + Component: CompensationDetailsEdit, + authorisedRoles: [ADMIN, OWNER, BOOK_KEEPER, EMPLOYEE], + }, { path: "/", Component: MobileNav, diff --git a/app/javascript/src/components/Team/Details/CompensationDetails/CompensationDetailsState.tsx b/app/javascript/src/components/Team/Details/CompensationDetails/CompensationDetailsState.tsx new file mode 100644 index 0000000000..f993aa7b2d --- /dev/null +++ b/app/javascript/src/components/Team/Details/CompensationDetails/CompensationDetailsState.tsx @@ -0,0 +1,7 @@ +export const CompensationDetailsState = { + earnings: [], + deductions: [], + total: { + amount: 0, + }, +}; diff --git a/app/javascript/src/components/Team/Details/CompensationDetails/Edit/EditPage.tsx b/app/javascript/src/components/Team/Details/CompensationDetails/Edit/EditPage.tsx new file mode 100644 index 0000000000..235addf5ad --- /dev/null +++ b/app/javascript/src/components/Team/Details/CompensationDetails/Edit/EditPage.tsx @@ -0,0 +1,170 @@ +import React from "react"; + +import { currencyFormat } from "helpers"; +import { + DeleteIcon, + CoinsIcon, + EarningsIconSVG, + DeductionIconSVG, +} from "miruIcons"; +import "react-phone-number-input/style.css"; +import { Button } from "StyledComponents"; + +import { CustomInputText } from "common/CustomInputText"; + +const EditPage = ({ + handleAddEarning, + handleAddDeduction, + updateDeductionValues, + updateEarningsValues, + handleDeleteEarning, + handleDeleteDeduction, + earnings, + deductions, + total, + currency, +}) => ( +
    +
    +
    + + + Earnings + +
    +
    + {earnings.length > 0 && + earnings.map((earning, index) => ( +
    +
    +
    + { + updateEarningsValues(earning, e); + }} + /> +
    +
    + { + updateEarningsValues(earning, e); + }} + /> +
    +
    + +
    + ))} +
    + +
    +
    +
    +
    +
    +
    + + + Deductions + +
    +
    + {deductions.length > 0 && + deductions.map((deduction, index) => ( +
    +
    +
    + { + updateDeductionValues(deduction, e); + }} + /> +
    +
    + { + updateDeductionValues(deduction, e); + }} + /> +
    +
    + +
    + ))} +
    + +
    +
    +
    +
    +
    +
    + + + Total + +
    +
    + + {currencyFormat(currency, total)} + +
    +
    +
    +); + +export default EditPage; diff --git a/app/javascript/src/components/Team/Details/CompensationDetails/Edit/MobileEditPage.tsx b/app/javascript/src/components/Team/Details/CompensationDetails/Edit/MobileEditPage.tsx new file mode 100644 index 0000000000..e700055902 --- /dev/null +++ b/app/javascript/src/components/Team/Details/CompensationDetails/Edit/MobileEditPage.tsx @@ -0,0 +1,192 @@ +import React from "react"; + +import { currencyFormat } from "helpers"; +import { + CoinsIcon, + DeductionIconSVG, + DeleteIcon, + EarningsIconSVG, +} from "miruIcons"; +import "react-phone-number-input/style.css"; +import { Button } from "StyledComponents"; + +import { CustomInputText } from "common/CustomInputText"; + +const MobileEditPage = ({ + handleAddEarning, + handleAddDeduction, + updateDeductionValues, + updateEarningsValues, + handleDeleteEarning, + handleDeleteDeduction, + handleCancelDetails, + handleUpdateDetails, + earnings, + deductions, + total, + currency, +}) => ( +
    +
    +
    + + + Earnings + +
    +
    + {earnings.length > 0 ? ( + earnings.map((earning, index) => ( +
    +
    +
    + { + updateEarningsValues(earning, e); + }} + /> +
    + +
    +
    + { + updateEarningsValues(earning, e); + }} + /> +
    +
    + )) + ) : ( +
    No Earnings found
    + )} +
    + +
    +
    +
    +
    +
    + + + Deductions + +
    +
    + {deductions.length > 0 ? ( + deductions.map((deduction, index) => ( +
    +
    +
    + { + updateDeductionValues(deduction, e); + }} + /> +
    + +
    +
    + { + updateDeductionValues(deduction, e); + }} + /> +
    +
    + )) + ) : ( +
    No deductions found
    + )} +
    + +
    +
    +
    +
    +
    + + + Total + +
    +
    + + {currencyFormat(currency, total)} + +
    +
    +
    +
    + +
    +
    + +
    +
    +
    +); + +export default MobileEditPage; diff --git a/app/javascript/src/components/Team/Details/CompensationDetails/Edit/index.tsx b/app/javascript/src/components/Team/Details/CompensationDetails/Edit/index.tsx new file mode 100644 index 0000000000..214ef161e9 --- /dev/null +++ b/app/javascript/src/components/Team/Details/CompensationDetails/Edit/index.tsx @@ -0,0 +1,153 @@ +/* eslint-disable no-unused-vars */ +import React, { Fragment, useEffect, useState } from "react"; + +import { useNavigate } from "react-router-dom"; + +import Loader from "common/Loader/index"; +import { CompensationDetailsState } from "components/Profile/context/CompensationDetailsState"; +import Header from "components/Profile/Header"; +import { useUserContext } from "context/UserContext"; + +import EditPage from "./EditPage"; +import MobileEditPage from "./MobileEditPage"; + +const CompensationEditPage = () => { + //TODO: add state for errDetails after API integration + // const initialErrState = { + // earning_type_err: "", + // earning_amount_err: "", + // deduction_type_err: "", + // deduction_amount_err: "", + // }; + const navigate = useNavigate(); + const { isDesktop, company } = useUserContext(); + + const [isLoading, setIsLoading] = useState(false); + const [earnings, setEarnings] = useState>( + CompensationDetailsState.earnings + ); + + const [deductions, setDeductions] = useState>( + CompensationDetailsState.deductions + ); + + const [total, setTotal] = useState( + CompensationDetailsState.total.amount + ); + + useEffect(() => { + setIsLoading(true); + getDevicesDetail(); + }, []); + + useEffect(() => { + const totalEarnings = earnings.reduce( + (accumulator, currentValue) => accumulator + currentValue["amount"], + 0 + ); + + const totalDeductions = deductions.reduce( + (accumulator, currentValue) => accumulator + currentValue["amount"], + 0 + ); + setTotal(totalEarnings - totalDeductions); + }, [deductions, earnings]); + + const getDevicesDetail = async () => { + setIsLoading(false); + }; + + const handleAddDeduction = () => { + const newDeduction = [...deductions, { deduction_type: "", amount: "" }]; + setDeductions(newDeduction); + }; + + const handleAddEarning = () => { + const newEarning = [...earnings, { earning_type: "", amount: "" }]; + setEarnings(newEarning); + }; + + const handleDeleteDeduction = deduction => { + setDeductions(deductions.filter(d => d !== deduction)); + }; + + const handleDeleteEarning = earning => { + setEarnings(earnings.filter(e => e !== earning)); + }; + + const updateEarningsValues = (earning, event) => { + const { name, value } = event.target; + const updatedEarnings = earnings.map(e => + e == earning ? { ...e, [name]: value } : e + ); + setEarnings(updatedEarnings); + }; + + const updateDeductionValues = (deduction, event) => { + const { name, value } = event.target; + const updatedDeductions = deductions.map(d => + d == deduction ? { ...d, [name]: value } : d + ); + setDeductions(updatedDeductions); + }; + + const handleUpdateDetails = () => { + //Todo: API integration for update details + }; + + const handleCancelDetails = () => { + setIsLoading(true); + navigate(`/settings/compensation`, { replace: true }); + }; + + return ( + +
    + {isLoading ? ( + + ) : ( + + {isDesktop && ( + + )} + {!isDesktop && ( + + )} + + )} + + ); +}; + +export default CompensationEditPage; diff --git a/app/javascript/src/components/Team/Details/CompensationDetails/StaticPage.tsx b/app/javascript/src/components/Team/Details/CompensationDetails/StaticPage.tsx index 6b3aea2a86..42c702af43 100644 --- a/app/javascript/src/components/Team/Details/CompensationDetails/StaticPage.tsx +++ b/app/javascript/src/components/Team/Details/CompensationDetails/StaticPage.tsx @@ -1,61 +1,100 @@ import React from "react"; -const StaticPage = () => ( -
    -
    -
    - - Earnings - -
    -
    -
    -
    - Earning Type -

    Monthly Salary

    -
    -
    - Amount -

    ₹1,25,000

    -
    +import { currencyFormat } from "helpers"; +import { EarningsIconSVG, DeductionIconSVG, CoinsIcon } from "miruIcons"; + +const StaticPage = ({ compensationDetails, currency }) => { + const { earnings, deductions, total } = compensationDetails; + + return ( +
    +
    +
    + + + Earnings +
    -
    -
    -
    -
    - - Deductions - -
    -
    -
    -
    - Deduction Type -

    TDS

    -
    -
    - Amount -

    ₹12,500

    -
    +
    + {earnings ? ( + earnings.map((earning, index) => ( +
    +
    + + Earning Type + +

    + {earning.type || "-"} +

    +
    +
    + + Amount + +

    + {currencyFormat(currency, earning.amount) || "-"} +

    +
    +
    + )) + ) : ( +
    No earning(s) found
    + )}
    -
    -
    -
    - - Total - +
    +
    + + + Deductions + +
    +
    + {deductions ? ( + deductions.map((deduction, index) => ( +
    +
    + + Deduction Type + +

    + {deduction.type} +

    +
    +
    + + Amount + +

    + {currencyFormat(currency, deduction.amount) || "-"} +

    +
    +
    + )) + ) : ( +
    No deduction(s) found
    + )} +
    -
    -
    -
    -
    -

    ₹1,12,500

    -
    +
    +
    + + + Total + +
    +
    + + {currencyFormat(currency, total.amount)} +
    -
    -); + ); +}; export default StaticPage; diff --git a/app/javascript/src/components/Team/Details/CompensationDetails/index.tsx b/app/javascript/src/components/Team/Details/CompensationDetails/index.tsx index a53347a84b..b7e3eb9ae0 100644 --- a/app/javascript/src/components/Team/Details/CompensationDetails/index.tsx +++ b/app/javascript/src/components/Team/Details/CompensationDetails/index.tsx @@ -1,14 +1,71 @@ -import React, { Fragment } from "react"; +import React, { Fragment, useEffect, useState } from "react"; + +import { useNavigate } from "react-router-dom"; + +import Loader from "common/Loader/index"; +import { MobileEditHeader } from "common/Mobile/MobileEditHeader"; +import { useProfile } from "components/Profile/context/EntryContext"; +import DetailsHeader from "components/Profile/DetailsHeader"; +import { useUserContext } from "context/UserContext"; import StaticPage from "./StaticPage"; -const CompensationDetails = () => ( - -
    -

    Compensation Details

    -
    - -
    -); +const CompensationDetails = () => { + const { isDesktop, company } = useUserContext(); + const { setUserState, compensationDetails } = useProfile(); + const navigate = useNavigate(); + const [isLoading, setIsLoading] = useState(false); + + const getDetails = async () => { + //fetch compensation details from backend and store it in compensationData + const compensationData = { + earnings: [ + { type: "Monthly Salary", amount: "125000" }, + { type: "SGST (9%)", amount: "11250" }, + { type: "CGST (9%)", amount: "11250" }, + ], + deductions: [{ type: "TDS", amount: "12500" }], + total: { + amount: "147500", + }, + }; + setUserState("compensationDetails", compensationData); + setIsLoading(false); + }; + + useEffect(() => { + setIsLoading(true); + getDetails(); + }, []); + return ( + + {isDesktop ? ( + + navigate(`/settings/compensation/edit`, { replace: true }) + } + /> + ) : ( + + )} + {isLoading ? ( + + ) : ( + + )} + + ); +}; export default CompensationDetails; diff --git a/app/javascript/src/components/Team/Details/EmploymentDetails/Edit/StaticPage.tsx b/app/javascript/src/components/Team/Details/EmploymentDetails/Edit/StaticPage.tsx index 8539fc61e2..cb674a7de1 100644 --- a/app/javascript/src/components/Team/Details/EmploymentDetails/Edit/StaticPage.tsx +++ b/app/javascript/src/components/Team/Details/EmploymentDetails/Edit/StaticPage.tsx @@ -265,7 +265,7 @@ const StaticPage = ({ { @@ -277,7 +277,7 @@ const StaticPage = ({ { diff --git a/app/javascript/src/components/Team/Details/Layout/SideNav.tsx b/app/javascript/src/components/Team/Details/Layout/SideNav.tsx index 6e179fb3d8..1268ffcbc7 100644 --- a/app/javascript/src/components/Team/Details/Layout/SideNav.tsx +++ b/app/javascript/src/components/Team/Details/Layout/SideNav.tsx @@ -25,6 +25,10 @@ const getTeamUrls = memberId => [ url: `/team/${memberId}/employment`, text: "EMPLOYMENT DETAILS", }, + { + url: `/team/${memberId}/compensation`, + text: "COMPENSATION", + }, ]; const UserInformation = ({ memberId }) => { diff --git a/app/javascript/src/components/Team/Details/index.tsx b/app/javascript/src/components/Team/Details/index.tsx index 19e0d4dbfc..2b92994079 100644 --- a/app/javascript/src/components/Team/Details/index.tsx +++ b/app/javascript/src/components/Team/Details/index.tsx @@ -3,6 +3,7 @@ import React, { Fragment, useState } from "react"; import { TeamDetailsContext } from "context/TeamDetailsContext"; import { useUserContext } from "context/UserContext"; +import { CompensationDetailsState } from "./CompensationDetails/CompensationDetailsState"; import { EmploymentDetailsState } from "./EmploymentDetails/EmploymentDetailsState"; import Header from "./Layout/Header"; import OutletWrapper from "./Layout/OutletWrapper"; @@ -15,7 +16,7 @@ const TeamDetails = () => { employmentDetails: EmploymentDetailsState, documentDetails: {}, deviceDetails: {}, - compensationDetails: {}, + compensationDetails: CompensationDetailsState, reimburstmentDetails: {}, }); const { isDesktop } = useUserContext(); diff --git a/app/javascript/src/components/Team/RouteConfig.tsx b/app/javascript/src/components/Team/RouteConfig.tsx index 0a8a10157a..1fe95241f1 100644 --- a/app/javascript/src/components/Team/RouteConfig.tsx +++ b/app/javascript/src/components/Team/RouteConfig.tsx @@ -3,6 +3,8 @@ import React from "react"; import { Route, Routes } from "react-router-dom"; import Details from "./Details"; +import CompensationDetails from "./Details/CompensationDetails"; +import CompensationEdit from "./Details/CompensationDetails/Edit"; import EmploymentDetails from "./Details/EmploymentDetails"; import EmploymentEdit from "./Details/EmploymentDetails/Edit"; import MobileNav from "./Details/Layout/MobileNav"; @@ -18,6 +20,8 @@ const RouteConfig = () => ( } path="details" /> } path="employment" /> } path="employment_edit" /> + } path="compensation" /> + } path="compensation_edit" /> ); diff --git a/app/javascript/src/context/TeamDetailsContext.tsx b/app/javascript/src/context/TeamDetailsContext.tsx index d36f197a55..d9467d3537 100644 --- a/app/javascript/src/context/TeamDetailsContext.tsx +++ b/app/javascript/src/context/TeamDetailsContext.tsx @@ -1,5 +1,6 @@ import { createContext, useContext } from "react"; +import { CompensationDetailsState } from "components/Team/Details/CompensationDetails/CompensationDetailsState"; import { EmploymentDetailsState } from "components/Team/Details/EmploymentDetails/EmploymentDetailsState"; import { PersonalDetailsState } from "components/Team/Details/PersonalDetails/PersonalDetailsState"; // Context Creation @@ -10,7 +11,7 @@ export const TeamDetailsContext = createContext({ employmentDetails: EmploymentDetailsState, documentDetails: {}, deviceDetails: {}, - compensationDetails: {}, + compensationDetails: CompensationDetailsState, reimburstmentDetails: {}, }, updateDetails: (key, payload) => {}, //eslint-disable-line diff --git a/app/javascript/src/miruIcons/index.ts b/app/javascript/src/miruIcons/index.ts index 8142a308cf..744ab1b0b5 100644 --- a/app/javascript/src/miruIcons/index.ts +++ b/app/javascript/src/miruIcons/index.ts @@ -54,6 +54,7 @@ import { Cake, DeviceMobileCamera, ArrowCounterClockwise, + Coins, Percent, House, ShieldCheck, @@ -73,6 +74,8 @@ const reportcalendarIcon = require("./svgIcons/Calendar.svg"); const calendarBlack = require("./svgIcons/calendarBlack.svg"); const calendarHoverIcon = require("./svgIcons/CalendarHover.svg"); const car = require("./svgIcons/car.svg"); +const deductions = require("./svgIcons/Deductions.svg"); +const earnings = require("./svgIcons/Earnings.svg"); const emptyState = require("./svgIcons/emptyState.svg"); const expenseIcon = require("./svgIcons/expenseIcon.svg"); const flower = require("./svgIcons/flower.svg"); @@ -216,6 +219,7 @@ export const GoogleIcon = GoogleLogo; export const IntegrateIcon = Plugs; export const CakeIcon = Cake; export const MobileIcon = DeviceMobileCamera; +export const CoinsIcon = Coins; export const FoodIcon = ForkKnife; export const PercentIcon = Percent; export const HouseIcon = House; @@ -320,4 +324,6 @@ export const CarIconSVG = car; export const UserIconSVG = user; export const CalendarBlackIconSVG = calendarBlack; export const MedicineIconSVG = medicine; +export const EarningsIconSVG = earnings; +export const DeductionIconSVG = deductions; export const ExpenseIconSVG = expenseIcon; diff --git a/app/javascript/src/miruIcons/svgIcons/Deductions.svg b/app/javascript/src/miruIcons/svgIcons/Deductions.svg new file mode 100644 index 0000000000..87a2e47f71 --- /dev/null +++ b/app/javascript/src/miruIcons/svgIcons/Deductions.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/app/javascript/src/miruIcons/svgIcons/Earnings.svg b/app/javascript/src/miruIcons/svgIcons/Earnings.svg new file mode 100644 index 0000000000..7f9561300d --- /dev/null +++ b/app/javascript/src/miruIcons/svgIcons/Earnings.svg @@ -0,0 +1,5 @@ + + + + + From 6211ff1b27a693866671624458a77d708b2bc773 Mon Sep 17 00:00:00 2001 From: Apoorv Tiwari Date: Thu, 14 Mar 2024 19:48:26 +0530 Subject: [PATCH 22/39] Leave types validations (#1702) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * validation for frquency and allocations * rubocop fix * spec fix and review comment * validation for carry forward days * fix failing specs * rubocop fixes * fixed allocation_value erro * refactored to use concerns --------- Co-authored-by: “Apoorv <“tiwari.apoorv1316@gmail.com”> --- app/models/concerns/leave_type_validatable.rb | 84 +++++++++++++ app/models/leave_type.rb | 1 + .../concerns/leave_type_validatable_spec.rb | 98 +++++++++++++++ spec/models/leave_type_spec.rb | 116 ++++++++++-------- .../v1/timeoff_entries/create_spec.rb | 5 +- .../v1/timeoff_entries/destroy_spec.rb | 5 +- .../v1/timeoff_entries/update_spec.rb | 5 +- 7 files changed, 263 insertions(+), 51 deletions(-) create mode 100644 app/models/concerns/leave_type_validatable.rb create mode 100644 spec/models/concerns/leave_type_validatable_spec.rb diff --git a/app/models/concerns/leave_type_validatable.rb b/app/models/concerns/leave_type_validatable.rb new file mode 100644 index 0000000000..09faf670b3 --- /dev/null +++ b/app/models/concerns/leave_type_validatable.rb @@ -0,0 +1,84 @@ +# frozen_string_literal: true + +module LeaveTypeValidatable + extend ActiveSupport::Concern + + included do + validate :valid_allocation_combination + validate :valid_allocation_value + validate :valid_carry_forward + end + + private + + def valid_allocation_combination + if allocation_period == "weeks" && allocation_frequency == "per_week" + errors.add(:base, "Invalid combination: Allocation period in weeks cannot have frequency per week") + end + + if allocation_period == "months" && !(allocation_frequency == "per_quarter" || allocation_frequency == "per_year") + errors.add( + :base, + "Invalid combination: Allocation period in months can only have frequency per quarter or per year") + end + end + + def valid_allocation_value + max_values = { + ["days", "per_week"] => 7, + ["days", "per_month"] => 31, + ["days", "per_quarter"] => 92, + ["days", "per_year"] => 366, + ["weeks", "per_month"] => 5, + ["weeks", "per_quarter"] => 13, + ["weeks", "per_year"] => 52, + ["months", "per_quarter"] => 3, + ["months", "per_year"] => 12 + } + + if allocation_value.present? + key = [allocation_period, allocation_frequency] + if max_values[key] && allocation_value > max_values[key] + errors.add( + :allocation_value, + "cannot exceed #{max_values[key]} #{allocation_period} for #{allocation_frequency} frequency") + end + end + end + + def valid_carry_forward + total_days = convert_allocation_to_days + + if carry_forward_days.present? && total_days.present? && carry_forward_days > total_days + errors.add(:carry_forward_days, "cannot exceed the total allocated days") + end + end + + def convert_allocation_to_days + return nil unless allocation_value + + base_days = case allocation_period + when "days" + allocation_value + when "weeks" + allocation_value * 7 + when "months" + allocation_value * 31 + else + return nil + end + + case allocation_frequency + when "per_week" + base_days * 52 + when "per_month" + base_days * 12 + when "per_quarter" + base_days * 4 + when "per_year" + base_days + else + nil + end + end +end diff --git a/app/models/leave_type.rb b/app/models/leave_type.rb index 9410cb66df..f9a4178fe5 100644 --- a/app/models/leave_type.rb +++ b/app/models/leave_type.rb @@ -30,6 +30,7 @@ # class LeaveType < ApplicationRecord include Discard::Model + include LeaveTypeValidatable enum :color, { chart_blue: 0, diff --git a/spec/models/concerns/leave_type_validatable_spec.rb b/spec/models/concerns/leave_type_validatable_spec.rb new file mode 100644 index 0000000000..450ef4da08 --- /dev/null +++ b/spec/models/concerns/leave_type_validatable_spec.rb @@ -0,0 +1,98 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe LeaveTypeValidatable, type: :model do + let(:leave) { create(:leave) } + + describe "Custom Validations" do + context "when validating allocation combinations" do + it "is not valid with weekly allocation period and weekly frequency" do + leave_type = build(:leave_type, allocation_period: "weeks", allocation_frequency: "per_week", leave:) + expect(leave_type.valid?).to be false + expect(leave_type.errors[:base]).to include( + "Invalid combination: Allocation period in weeks cannot have frequency per week") + end + + it "is not valid with monthly allocation period and weekly or monthly frequencies" do + ["per_week", "per_month"].each do |freq| + leave_type = build(:leave_type, allocation_period: "months", allocation_frequency: freq, leave:) + expect(leave_type.valid?).to be false + expect(leave_type.errors[:base]).to include( + "Invalid combination: Allocation period in months can only have frequency per quarter or per year") + end + end + + it "is valid with monthly allocation period and quarterly or yearly frequencies" do + ["per_quarter", "per_year"].each do |freq| + leave_type = build( + :leave_type, allocation_period: "months", allocation_frequency: freq, allocation_value: 2, + leave:) + expect(leave_type.valid?).to be true + end + end + end + + context "when validating allocation values" do + it "is not valid with an allocation value exceeding the limit for the period and frequency" do + combinations = { + ["days", "per_week"] => 8, + ["weeks", "per_month"] => 6 + } + combinations.each do |(period, freq), value| + leave_type = build( + :leave_type, allocation_period: period, allocation_frequency: freq, + allocation_value: value, leave:) + expect(leave_type).not_to be_valid + expect(leave_type.errors[:allocation_value]).to include( + "cannot exceed #{value - 1} #{period} for #{freq} frequency") + end + end + + it "is valid with an allocation value within the limit for the period and frequency" do + combinations = { + ["days", "per_week"] => 7, + ["weeks", "per_month"] => 5 + } + combinations.each do |(period, freq), value| + leave_type = build( + :leave_type, allocation_period: period, allocation_frequency: freq, + allocation_value: value, leave:) + expect(leave_type).to be_valid + end + end + end + + context "when validating carry forward limits with frequency considerations" do + it "is valid when carry_forward is less than total days in a year for days per week" do + leave_type = build( + :leave_type, allocation_period: "days", allocation_frequency: "per_week", + allocation_value: 4, carry_forward_days: 5, leave:) + expect(leave_type).to be_valid + end + + it "is not valid when carry_forward exceeds total days for weeks per year" do + leave_type = build( + :leave_type, allocation_period: "weeks", allocation_frequency: "per_year", + allocation_value: 2, carry_forward_days: 15, leave:) + expect(leave_type).not_to be_valid + expect(leave_type.errors[:carry_forward_days]).to include("cannot exceed the total allocated days") + end + + it "is valid when carry_forward does not exceed total days for months per quarter" do + leave_type = build( + :leave_type, allocation_period: "months", allocation_frequency: "per_quarter", + allocation_value: 1, carry_forward_days: 30, leave:) + expect(leave_type).to be_valid + end + + it "is not valid when carry_forward exceeds total days for months per year" do + leave_type = build( + :leave_type, allocation_period: "months", allocation_frequency: "per_year", + allocation_value: 2, carry_forward_days: 63, leave:) + expect(leave_type).not_to be_valid + expect(leave_type.errors[:carry_forward_days]).to include("cannot exceed the total allocated days") + end + end + end +end diff --git a/spec/models/leave_type_spec.rb b/spec/models/leave_type_spec.rb index bd22aad41a..dab14a51c9 100644 --- a/spec/models/leave_type_spec.rb +++ b/spec/models/leave_type_spec.rb @@ -6,53 +6,73 @@ let(:leave) { create(:leave) } describe "validations" do - it "is valid with valid attributes" do - leave_type = build(:leave_type, leave:) - expect(leave_type).to be_valid - end - - it "is not valid without a name" do - leave_type = build(:leave_type, name: nil, leave:) - expect(leave_type).not_to be_valid - expect(leave_type.errors[:name]).to include("can't be blank") - end - - it "is not valid with a duplicate color within the same leave" do - existing_leave_type = create(:leave_type, leave:) - leave_type = build(:leave_type, color: existing_leave_type.color, leave:) - expect(leave_type).not_to be_valid - expect(leave_type.errors[:color]).to include("has already been taken for this leave") - end - - it "is not valid with a duplicate icon within the same leave" do - existing_leave_type = create(:leave_type, leave:) - leave_type = build(:leave_type, icon: existing_leave_type.icon, leave:) - expect(leave_type).not_to be_valid - expect(leave_type.errors[:icon]).to include("has already been taken for this leave") - end - - it "is not valid without a allocation value" do - leave_type = build(:leave_type, allocation_value: nil, leave:) - expect(leave_type).not_to be_valid - expect(leave_type.errors[:allocation_value]).to include("can't be blank") - end - - it "is not valid if allocation value is less than 1" do - leave_type = build(:leave_type, allocation_value: 0, leave:) - expect(leave_type).not_to be_valid - expect(leave_type.errors[:allocation_value]).to include("must be greater than or equal to 1") - end - - it "is not valid without a allocation frequency" do - leave_type = build(:leave_type, allocation_frequency: nil, leave:) - expect(leave_type).not_to be_valid - expect(leave_type.errors[:allocation_frequency]).to include("can't be blank") - end - - it "is not valid without a carry forward days" do - leave_type = build(:leave_type, carry_forward_days: nil, leave:) - expect(leave_type).not_to be_valid - expect(leave_type.errors[:carry_forward_days]).to include("can't be blank") - end + it "is valid with valid attributes" do + leave_type = build( + :leave_type, leave:, allocation_period: "weeks", allocation_frequency: "per_year", + allocation_value: 2) + expect(leave_type).to be_valid end + + it "is not valid without a name" do + leave_type = build( + :leave_type, name: nil, leave:, allocation_period: "days", allocation_frequency: "per_week", + allocation_value: 5) + expect(leave_type).not_to be_valid + expect(leave_type.errors[:name]).to include("can't be blank") + end + + it "is not valid with a duplicate color within the same leave" do + existing_leave_type = create( + :leave_type, leave:, allocation_period: "months", + allocation_frequency: "per_quarter", allocation_value: 1) + leave_type = build( + :leave_type, color: existing_leave_type.color, leave:, allocation_period: "months", + allocation_frequency: "per_quarter", allocation_value: 1) + expect(leave_type).not_to be_valid + expect(leave_type.errors[:color]).to include("has already been taken for this leave") + end + + it "is not valid with a duplicate icon within the same leave" do + existing_leave_type = create( + :leave_type, leave:, allocation_period: "weeks", allocation_frequency: "per_month", + allocation_value: 2) + leave_type = build( + :leave_type, icon: existing_leave_type.icon, leave:, allocation_period: "weeks", + allocation_frequency: "per_month", allocation_value: 2) + expect(leave_type).not_to be_valid + expect(leave_type.errors[:icon]).to include("has already been taken for this leave") + end + + it "is not valid without an allocation value" do + leave_type = build( + :leave_type, allocation_value: nil, leave:, allocation_period: "days", + allocation_frequency: "per_week") + expect(leave_type).not_to be_valid + expect(leave_type.errors[:allocation_value]).to include("can't be blank") + end + + it "is not valid if allocation value is less than 1" do + leave_type = build( + :leave_type, allocation_value: 0, leave:, allocation_period: "days", + allocation_frequency: "per_month") + expect(leave_type).not_to be_valid + expect(leave_type.errors[:allocation_value]).to include("must be greater than or equal to 1") + end + + it "is not valid without an allocation frequency" do + leave_type = build( + :leave_type, allocation_frequency: nil, leave:, allocation_period: "weeks", + allocation_value: 3) + expect(leave_type).not_to be_valid + expect(leave_type.errors[:allocation_frequency]).to include("can't be blank") + end + + it "is not valid without carry forward days" do + leave_type = build( + :leave_type, carry_forward_days: nil, leave:, allocation_period: "months", + allocation_frequency: "per_year", allocation_value: 2) + expect(leave_type).not_to be_valid + expect(leave_type.errors[:carry_forward_days]).to include("can't be blank") + end +end end diff --git a/spec/requests/internal_api/v1/timeoff_entries/create_spec.rb b/spec/requests/internal_api/v1/timeoff_entries/create_spec.rb index 136b87bc0b..f04e8a0d17 100644 --- a/spec/requests/internal_api/v1/timeoff_entries/create_spec.rb +++ b/spec/requests/internal_api/v1/timeoff_entries/create_spec.rb @@ -6,7 +6,10 @@ let(:company) { create(:company) } let(:user) { create(:user, current_workspace_id: company.id) } let(:leave) { create(:leave, company:) } - let!(:leave_type) { create(:leave_type, name: "Annual", leave:) } + let!(:leave_type) { create( + :leave_type, name: "Annual", leave:, allocation_period: "months", allocation_frequency: "per_quarter", + allocation_value: 1, carry_forward_days: 30,) +} context "when user is an admin" do before do diff --git a/spec/requests/internal_api/v1/timeoff_entries/destroy_spec.rb b/spec/requests/internal_api/v1/timeoff_entries/destroy_spec.rb index d040ce8800..683395da98 100644 --- a/spec/requests/internal_api/v1/timeoff_entries/destroy_spec.rb +++ b/spec/requests/internal_api/v1/timeoff_entries/destroy_spec.rb @@ -6,7 +6,10 @@ let(:company) { create(:company) } let(:user) { create(:user, current_workspace_id: company.id) } let(:leave) { create(:leave, company:) } - let!(:leave_type) { create(:leave_type, leave:) } + let!(:leave_type) { create( + :leave_type, leave:, allocation_period: "months", allocation_frequency: "per_quarter", + allocation_value: 1, carry_forward_days: 30,) +} let!(:timeoff_entry) { create(:timeoff_entry, user:, leave_type:) } context "when user is an admin" do diff --git a/spec/requests/internal_api/v1/timeoff_entries/update_spec.rb b/spec/requests/internal_api/v1/timeoff_entries/update_spec.rb index d9727f3c84..f72a072f19 100644 --- a/spec/requests/internal_api/v1/timeoff_entries/update_spec.rb +++ b/spec/requests/internal_api/v1/timeoff_entries/update_spec.rb @@ -6,7 +6,10 @@ let(:company) { create(:company) } let(:user) { create(:user, current_workspace_id: company.id) } let(:leave) { create(:leave, company:) } - let!(:leave_type) { create(:leave_type, name: "Annaul", leave:) } + let!(:leave_type) { create( + :leave_type, name: "Annaul", leave:, allocation_period: "months", allocation_frequency: "per_quarter", + allocation_value: 1, carry_forward_days: 30,) +} let!(:timeoff_entry) { create(:timeoff_entry, user:, leave_type:) } context "when user is an admin" do From 982e77f0ba6999b26054154336cead47bbc0a3f5 Mon Sep 17 00:00:00 2001 From: Prasanth Chaduvula Date: Fri, 15 Mar 2024 12:02:00 +0530 Subject: [PATCH 23/39] Mark time off responsive (#1710) * added web and mobile form for mark timeoff * changed the class names priority on tailwindcss * created new entry buttons component * fixed the web mark timeoff entry duplicate bug * fixed the web mark timeoff entry duplicate bug * Upated Button component size styles to be overrided * Updated increment decrement time --- .../src/StyledComponents/Button.tsx | 6 +- .../TimeoffEntries/EntryCard/index.tsx | 8 +- .../TimeoffForm/DesktopTimeoffForm.tsx | 165 +++++++++ .../TimeoffForm/MobileTimeoffForm.tsx | 313 ++++++++++++++++++ .../TimeoffEntries/TimeoffForm/index.tsx | 300 ++++++++--------- .../TimesheetEntries/EntryButtons.tsx | 77 +++++ .../src/components/TimesheetEntries/index.tsx | 53 +-- tailwind.config.js | 8 +- 8 files changed, 714 insertions(+), 216 deletions(-) create mode 100644 app/javascript/src/components/TimeoffEntries/TimeoffForm/DesktopTimeoffForm.tsx create mode 100644 app/javascript/src/components/TimeoffEntries/TimeoffForm/MobileTimeoffForm.tsx create mode 100644 app/javascript/src/components/TimesheetEntries/EntryButtons.tsx diff --git a/app/javascript/src/StyledComponents/Button.tsx b/app/javascript/src/StyledComponents/Button.tsx index 109a99414b..b234194ad5 100644 --- a/app/javascript/src/StyledComponents/Button.tsx +++ b/app/javascript/src/StyledComponents/Button.tsx @@ -28,9 +28,9 @@ const DASHED = const DELETE = "bg-miru-red-400 hover:bg-miru-red-200 text-white"; -const SMALL = "px-5/100 py-1vh text-xs font-bold leading-4"; -const MEDIUM = "px-10/100 py-1vh text-base font-bold leading-5"; -const LARGE = "px-15/100 py-1vh text-xl font-bold leading-7"; +const SMALL = "p-2 text-xs font-bold leading-4"; +const MEDIUM = "p-2 text-base font-bold leading-5"; +const LARGE = "p-2 text-xl font-bold leading-7"; type ButtonProps = { id?: string; diff --git a/app/javascript/src/components/TimeoffEntries/EntryCard/index.tsx b/app/javascript/src/components/TimeoffEntries/EntryCard/index.tsx index e4697c0c1a..5c4807e163 100644 --- a/app/javascript/src/components/TimeoffEntries/EntryCard/index.tsx +++ b/app/javascript/src/components/TimeoffEntries/EntryCard/index.tsx @@ -2,6 +2,7 @@ import React, { useEffect, useState } from "react"; import { format } from "date-fns"; +import dayjs from "dayjs"; import { minToHHMM } from "helpers"; import timeoffEntryApi from "apis/timeoff-entry"; @@ -31,6 +32,7 @@ const TimeoffEntryCard = ({ fetchEntriesOfMonths, setEditEntryId, setNewEntryView, + setNewTimeoffEntryView, } = useTimesheetEntries(); const { id, note, duration, bill_status } = timeoffEntry; const [isHolidayTimeoffEntry, setIsHolidayTimeoffEntry] = @@ -48,7 +50,7 @@ const TimeoffEntryCard = ({ if (timeOffEntry) { const payload: Payload = { duration: timeOffEntry.duration, - leave_date: timeOffEntry.leave_date, + leave_date: dayjs(timeoffEntry.leave_date).format("YYYY-MM-DD"), user_id: selectedEmployeeId, note, }; @@ -64,9 +66,9 @@ const TimeoffEntryCard = ({ }; const handleCardClick = () => { - const isDisableCardClick = true; - if (!isDesktop && !isDisableCardClick) { + if (!isDesktop) { setEditTimeoffEntryId(id); + setNewTimeoffEntryView(true); } }; diff --git a/app/javascript/src/components/TimeoffEntries/TimeoffForm/DesktopTimeoffForm.tsx b/app/javascript/src/components/TimeoffEntries/TimeoffForm/DesktopTimeoffForm.tsx new file mode 100644 index 0000000000..ef3342372e --- /dev/null +++ b/app/javascript/src/components/TimeoffEntries/TimeoffForm/DesktopTimeoffForm.tsx @@ -0,0 +1,165 @@ +/* eslint-disable @typescript-eslint/no-var-requires */ +import React, { useRef } from "react"; + +import { format } from "date-fns"; +import dayjs from "dayjs"; +import TextareaAutosize from "react-textarea-autosize"; +import { Button, BUTTON_STYLES, TimeInput } from "StyledComponents"; + +import CustomDatePicker from "common/CustomDatePicker"; +import { useTimesheetEntries } from "context/TimesheetEntries"; + +const DesktopTimeoffForm = ({ + isDisplayEditTimeoffEntryForm, + leaveTypeId, + setLeaveTypeId, + holidayId, + setHolidayId, + isHolidayEntry, + isShowHolidayList, + holidayOptions, + note, + setNote, + displayDatePicker, + setDisplayDatePicker, + selectedDate, + handleDateChangeFromDatePicker, + duration, + handleDurationChange, + isValidTimeEntry, + handleSubmit, +}) => { + const datePickerRef = useRef(); + const { + leaveTypes, + setNewTimeoffEntryView, + editTimeoffEntryId, + setEditTimeoffEntryId, + } = useTimesheetEntries(); + + return ( +
    +
    +
    + + {isHolidayEntry() && ( + + )} +
    + setNote(e.target["value"])} + /> +
    +
    +
    +
    + {displayDatePicker && ( +
    +
    + +
    +
    + )} +
    { + setDisplayDatePicker(true); + }} + > + {format(new Date(selectedDate), "do MMM, yyyy")} +
    +
    + +
    +
    +
    + + +
    +
    + ); +}; + +export default DesktopTimeoffForm; diff --git a/app/javascript/src/components/TimeoffEntries/TimeoffForm/MobileTimeoffForm.tsx b/app/javascript/src/components/TimeoffEntries/TimeoffForm/MobileTimeoffForm.tsx new file mode 100644 index 0000000000..d36d17135d --- /dev/null +++ b/app/javascript/src/components/TimeoffEntries/TimeoffForm/MobileTimeoffForm.tsx @@ -0,0 +1,313 @@ +import React, { useRef } from "react"; + +import dayjs from "dayjs"; +import { + CalendarIcon, + CaretDownIcon, + CopyIcon, + DeleteIcon, + MinusIcon, + PlusIcon, + XIcon, +} from "miruIcons"; +import { + Button, + MobileMoreOptions, + SidePanel, + TimeInput, +} from "StyledComponents"; + +import CustomDatePicker from "common/CustomDatePicker"; +import { CustomInputText } from "common/CustomInputText"; +import { CustomTextareaAutosize } from "common/CustomTextareaAutosize"; +import DeleteEntryModal from "components/TimesheetEntries/MobileView/DeleteEntryModal"; +import { useTimesheetEntries } from "context/TimesheetEntries"; + +const MobileTimeoffForm = ({ + handleClose, + showLeavesList, + setShowLeavesList, + leaveType, + setLeaveType, + setLeaveTypeId, + holiday, + setHoliday, + setHolidayId, + isHolidayEntry, + isShowHolidayList, + setIsShowHolidayList, + holidayOptions, + note, + setNote, + displayDatePicker, + setDisplayDatePicker, + selectedDate, + setSelectedDate, + handleDateChangeFromDatePicker, + incrementOrDecrementTime, + duration, + handleDurationChange, + handleDuplicateTimeoffEntry, + showDeleteDialog, + setShowDeleteDialog, + isValidTimeEntry, + handleSubmit, + handleDeleteTimeoffEntry, +}) => { + const datePickerRef = useRef(); + const { + leaveTypes, + setNewTimeoffEntryView, + editTimeoffEntryId, + setEditTimeoffEntryId, + } = useTimesheetEntries(); + + return ( + <> + + + + {editTimeoffEntryId ? "Edit Mark Time Off" : "Mark Time Off"} + + + + +
    +
    +
    +
    { + setShowLeavesList(true); + }} + > + + +
    + {showLeavesList && ( + + {leaveTypes.length > 0 ? ( + leaveTypes.map((eachLeavetype, index) => ( +
  • { + setLeaveType(eachLeavetype.name); + setLeaveTypeId(eachLeavetype.id || 0); + setHoliday(""); + setHolidayId(""); + setShowLeavesList(false); + }} + > + {eachLeavetype.name} +
  • + )) + ) : ( +
    + No Leavetypes present. +
    + )} +
    + )} +
    + {isHolidayEntry() && ( +
    +
    { + setIsShowHolidayList(true); + }} + > + + +
    + {isShowHolidayList && ( + + {holidayOptions.length > 0 ? ( + holidayOptions.map((eachHoliday, index) => ( +
  • { + setHoliday(eachHoliday.name); + setHolidayId(eachHoliday.id || 0); + setIsShowHolidayList(false); + }} + > + {eachHoliday.name} +
  • + )) + ) : ( +
    + No holidays present. +
    + )} +
    + )} +
    + )} +
    + setNote(e.target["value"])} + /> +
    +
    +
    setDisplayDatePicker(!displayDatePicker)} + > + { + setSelectedDate(e.target.value); + }} + /> + +
    + {displayDatePicker && ( + + )} +
    +
    + + + +
    +
    + {editTimeoffEntryId ? ( +
    + + +
    + ) : null} +
    + + + +
    +
    + {showDeleteDialog ? ( + + ) : null} + + ); +}; + +export default MobileTimeoffForm; diff --git a/app/javascript/src/components/TimeoffEntries/TimeoffForm/index.tsx b/app/javascript/src/components/TimeoffEntries/TimeoffForm/index.tsx index 74dfa23598..31085f786b 100644 --- a/app/javascript/src/components/TimeoffEntries/TimeoffForm/index.tsx +++ b/app/javascript/src/components/TimeoffEntries/TimeoffForm/index.tsx @@ -1,21 +1,20 @@ /* eslint-disable @typescript-eslint/no-var-requires */ -import React, { useState, useRef, useEffect } from "react"; +import React, { useState, useEffect } from "react"; -import { format } from "date-fns"; import dayjs from "dayjs"; import { minFromHHMM, minToHHMM } from "helpers"; -import TextareaAutosize from "react-textarea-autosize"; -import { Button, BUTTON_STYLES, TimeInput } from "StyledComponents"; import timeoffEntryApi from "apis/timeoff-entry"; -import CustomDatePicker from "common/CustomDatePicker"; import { HOLIDAY_TYPES } from "constants/index"; import { useTimesheetEntries } from "context/TimesheetEntries"; import { useUserContext } from "context/UserContext"; -const TimeoffForm = ({ isDisplayEditTimeoffEntryForm = false }: Iprops) => { - const datePickerRef = useRef(); +import DesktopTimeoffForm from "./DesktopTimeoffForm"; +import MobileTimeoffForm from "./MobileTimeoffForm"; + +const TimeoffForm = ({ isDisplayEditTimeoffEntryForm = false }) => { const { isDesktop } = useUserContext(); + const { leaveTypes, setLeaveTypes, @@ -42,13 +41,16 @@ const TimeoffForm = ({ isDisplayEditTimeoffEntryForm = false }: Iprops) => { const [displayDatePicker, setDisplayDatePicker] = useState(false); const [duration, setDuration] = useState(""); const [selectedDate, setSelectedDate] = useState(selectedFullDate); - const [leaveTypeId, setLeaveTypeId] = useState(0); - const [holidayId, setHolidayId] = useState(0); + const [leaveTypeId, setLeaveTypeId] = useState(""); + const [leaveType, setLeaveType] = useState(""); + const [holidayId, setHolidayId] = useState(""); + const [holiday, setHoliday] = useState(""); const [isShowHolidayList, setIsShowHolidayList] = useState(false); const [holidayOptions, setHolidayOptions] = useState([]); + const [showLeavesList, setShowLeavesList] = useState(false); + const [showDeleteDialog, setShowDeleteDialog] = useState(false); useEffect(() => { - // Append National and Optional holiday as a leave type const tempLeaveTypes = [...leaveTypes]; if (hasNationalHoliday) { const isNationalHolidayAlreadyAdded = leaveTypes?.find( @@ -91,11 +93,10 @@ const TimeoffForm = ({ isDisplayEditTimeoffEntryForm = false }: Iprops) => { useEffect(() => { if (isHolidayEntry()) { - setIsShowHolidayList(true); const tempHolidayOptions = holidayList?.filter(holiday => holiday?.category === leaveTypeId) || []; setHolidayOptions([...tempHolidayOptions]); - setSuggestedHolidayBasedOnDate(tempHolidayOptions); + handleSuggestedHolidayBasedOnDate(tempHolidayOptions); } else { setIsShowHolidayList(false); } @@ -107,7 +108,7 @@ const TimeoffForm = ({ isDisplayEditTimeoffEntryForm = false }: Iprops) => { } }, [selectedFullDate, isDisplayEditTimeoffEntryForm]); - const setSuggestedHolidayBasedOnDate = (currentHolidayOptions: any[]) => { + const handleSuggestedHolidayBasedOnDate = (currentHolidayOptions: any[]) => { if (!isDisplayEditTimeoffEntryForm && currentHolidayOptions?.length > 0) { const suggestedHoliday = currentHolidayOptions?.find( holiday => holiday?.date === selectedDate @@ -116,6 +117,10 @@ const TimeoffForm = ({ isDisplayEditTimeoffEntryForm = false }: Iprops) => { } }; + const isHolidayEntry = () => + leaveTypeId === HOLIDAY_TYPES.NATIONAL || + leaveTypeId === HOLIDAY_TYPES.OPTIONAL; + const handleFillData = () => { const timeoffEntry = entryList[selectedFullDate]?.find( entry => entry.id === editTimeoffEntryId @@ -128,11 +133,19 @@ const TimeoffForm = ({ isDisplayEditTimeoffEntryForm = false }: Iprops) => { if (timeoffEntry?.holiday_info_id) { const currentHolidayId = timeoffEntry.holiday_info_id || 0; const currentHolidayDetails = holidaysHashObj[currentHolidayId]; - - setLeaveTypeId(currentHolidayDetails?.category); + const selectedLeaveType = leaveTypes.find( + leaveType => leaveType.id === currentHolidayDetails?.category + ); + setLeaveTypeId(selectedLeaveType?.id); + setLeaveType(selectedLeaveType?.name); setHolidayId(currentHolidayId); + setHoliday(currentHolidayDetails?.name); } else { + const selectedLeaveType = leaveTypes.find( + leaveType => leaveType.id === timeoffEntry.leave_type_id + ); setLeaveTypeId(timeoffEntry.leave_type_id); + setLeaveType(selectedLeaveType.name); } } }; @@ -146,13 +159,18 @@ const TimeoffForm = ({ isDisplayEditTimeoffEntryForm = false }: Iprops) => { setDuration(val); }; - const isHolidayEntry = () => - leaveTypeId === HOLIDAY_TYPES.NATIONAL || - leaveTypeId === HOLIDAY_TYPES.OPTIONAL; + const incrementOrDecrementTime = (increment = true) => { + const currentMinutes = minFromHHMM(duration); + const updatedMinutes = increment + ? currentMinutes + 15 + : currentMinutes - 15; + const updatedDuration = minToHHMM(updatedMinutes); + setDuration(updatedDuration); + }; const isValidTimeEntry = () => { const isValidLeaveTypeOrHolidayId = - (isHolidayEntry() && holidayId > 0) || Number(leaveTypeId) > 0; + (isHolidayEntry() && Number(holidayId) > 0) || Number(leaveTypeId) > 0; return ( isValidLeaveTypeOrHolidayId && @@ -161,19 +179,25 @@ const TimeoffForm = ({ isDisplayEditTimeoffEntryForm = false }: Iprops) => { ); }; - const getPayload = () => { + const getPayload = (timeoffEntry?: any) => { if (isValidTimeEntry()) { - const payload: Payload = { - duration: minFromHHMM(duration), - leave_date: selectedDate, + const payload = { + duration: timeoffEntry?.duration || minFromHHMM(duration), + leave_date: timeoffEntry?.leave_date + ? dayjs(timeoffEntry.leave_date).format("YYYY-MM-DD") + : selectedDate, user_id: selectedEmployeeId, - note, + note: timeoffEntry?.note || note, }; if (isHolidayEntry()) { - payload["holiday_info_id"] = Number(holidayId); + payload["holiday_info_id"] = + timeoffEntry?.holiday_info_id || Number(holidayId); + payload["leave_type_id"] = null; } else { - payload["leave_type_id"] = Number(leaveTypeId); + payload["leave_type_id"] = + timeoffEntry?.leave_type_id || Number(leaveTypeId); + payload["holiday_info_id"] = null; } return { timeoff_entry: { ...payload } }; @@ -211,6 +235,7 @@ const TimeoffForm = ({ isDisplayEditTimeoffEntryForm = false }: Iprops) => { const handleEditTimeoffEntry = async () => { const payload = getPayload(); + if (payload) { const updateRes = await timeoffEntryApi.update( editTimeoffEntryId, @@ -237,141 +262,100 @@ const TimeoffForm = ({ isDisplayEditTimeoffEntryForm = false }: Iprops) => { } }; + const handleClose = () => { + setNewTimeoffEntryView(false); + setEditTimeoffEntryId(0); + }; + + const handleDeleteTimeoffEntry = async timeoffEntryId => { + if (!timeoffEntryId) return; + setEditTimeoffEntryId(0); + setNewTimeoffEntryView(false); + const res = await timeoffEntryApi.destroy(timeoffEntryId); + + if (res.status === 200) { + await handleFilterEntry(selectedFullDate, timeoffEntryId); + } + }; + + const handleDuplicateTimeoffEntry = async () => { + if (!editTimeoffEntryId) return; + setEditTimeoffEntryId(0); + setNewTimeoffEntryView(false); + const timeoffEntry = entryList[selectedFullDate]?.find( + entry => entry.id === editTimeoffEntryId + ); + + if (timeoffEntry) { + const payload = getPayload(timeoffEntry); + if (payload) { + const res = await timeoffEntryApi.create(payload, selectedEmployeeId); + if (res.status === 200) { + await fetchEntries(selectedFullDate, selectedFullDate); + await fetchEntriesOfMonths(); + } + } + } + }; + return ( -
    -
    -
    - - {isShowHolidayList && ( - - )} -
    - setNote(e.target["value"])} + <> + {isDesktop ? ( + -
    -
    -
    -
    - {displayDatePicker && ( -
    -
    - -
    -
    - )} -
    { - setDisplayDatePicker(true); - }} - > - {format(new Date(selectedDate), "do MMM, yyyy")} -
    -
    - -
    -
    -
    - - -
    -
    + ) : ( + + )} + ); }; -interface Payload { - duration: number; - note?: string; - leave_date: string; - user_id: number; - leave_type_id?: number; - holiday_info_id?: number; -} -interface Iprops { - isDisplayEditTimeoffEntryForm?: boolean; -} - export default TimeoffForm; diff --git a/app/javascript/src/components/TimesheetEntries/EntryButtons.tsx b/app/javascript/src/components/TimesheetEntries/EntryButtons.tsx new file mode 100644 index 0000000000..a851b27e13 --- /dev/null +++ b/app/javascript/src/components/TimesheetEntries/EntryButtons.tsx @@ -0,0 +1,77 @@ +import React from "react"; + +import { VacationIconSVG } from "miruIcons"; +import { Button } from "StyledComponents"; + +import { useTimesheetEntries } from "context/TimesheetEntries"; +import { useUserContext } from "context/UserContext"; + +const EntryButtons = () => { + const { + setEditEntryId, + setNewEntryView, + setEditTimeoffEntryId, + setNewTimeoffEntryView, + } = useTimesheetEntries(); + + const { isDesktop } = useUserContext(); + + const DesktopButtons = () => ( +
    + + +
    + ); + + const MobileButtons = () => ( +
    + + +
    + ); + + return
    {isDesktop ? : }
    ; +}; + +export default EntryButtons; diff --git a/app/javascript/src/components/TimesheetEntries/index.tsx b/app/javascript/src/components/TimesheetEntries/index.tsx index 9956e4ca15..bc32d163a7 100644 --- a/app/javascript/src/components/TimesheetEntries/index.tsx +++ b/app/javascript/src/components/TimesheetEntries/index.tsx @@ -24,7 +24,7 @@ import TimeEntryManager from "./TimeEntryManager"; import ViewToggler from "./ViewToggler"; import { TimesheetEntriesContext } from "context/TimesheetEntries"; import TimeoffForm from "components/TimeoffEntries/TimeoffForm"; -import { VacationIconSVG } from "miruIcons"; +import EntryButtons from "./EntryButtons"; dayjs.extend(updateLocale); dayjs.extend(weekday); @@ -333,7 +333,7 @@ const TimesheetEntries = ({ user, isAdminUser }: Iprops) => { if (!id) return; const entry = entryList[selectedFullDate].find(entry => entry.id === id); const data = { - work_date: entry.work_date, + work_date: dayjs(entry.work_date).format("YYYY-MM-DD"), duration: entry.duration, note: entry.note, bill_status: @@ -717,52 +717,9 @@ const TimesheetEntries = ({ user, isAdminUser }: Iprops) => {
    {!editEntryId && newEntryView && view !== "week" && } {newTimeoffEntryView && } -
    - {view !== "week" && - !newEntryView && - !newTimeoffEntryView && - isDesktop && ( - - )} - {/* --- On mobile view we don't need New Entry button for Empty States --- */} - {view !== "week" && - !newEntryView && - !isDesktop && - entryList[selectedFullDate] && ( - - )} - {view !== "week" && - !newEntryView && - !newTimeoffEntryView && - isDesktop && ( - - )} -
    + {view !== "week" && !newEntryView && !newTimeoffEntryView && ( + + )}
    {/* Render existing time entry cards in bottom */} diff --git a/tailwind.config.js b/tailwind.config.js index 44a5c370ed..bbacf09eb8 100644 --- a/tailwind.config.js +++ b/tailwind.config.js @@ -146,10 +146,6 @@ module.exports = { 800: "#ADA4CE", 50: "#CDD6DF33", }, - "miru-red": { - 400: "#E04646", - 200: "#EB5B5B", - }, "miru-white": { 1000: "#FFFFFF", }, @@ -206,6 +202,10 @@ module.exports = { 600: "#3111A6", 400: "#8062EF", }, + "miru-red": { + 400: "#E04646", + 200: "#EB5B5B", + }, }, fontFamily: { manrope: "'Manrope', serif", From 10e99456ab6f2fd7455b1d00c8401c3ef4ab8da7 Mon Sep 17 00:00:00 2001 From: Shruti-Apte <72149587+Shruti-Apte@users.noreply.github.com> Date: Thu, 14 Mar 2024 23:52:19 -0700 Subject: [PATCH 24/39] Add members button text replaced (#1713) --- .../src/components/Projects/Details/EditMembersListForm.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/javascript/src/components/Projects/Details/EditMembersListForm.tsx b/app/javascript/src/components/Projects/Details/EditMembersListForm.tsx index 1b1659b2ab..dd9df2ff5d 100644 --- a/app/javascript/src/components/Projects/Details/EditMembersListForm.tsx +++ b/app/javascript/src/components/Projects/Details/EditMembersListForm.tsx @@ -318,7 +318,7 @@ const EditMembersListForm = ({ disabled={!isSubmitBtnActive(members)} name="commit" type="submit" - value="Add team members to project" + value="Save Changes" className={`form__button whitespace-nowrap text-tiny md:text-base ${ isSubmitBtnActive(members) ? "cursor-pointer" From 805a59cbdf49afa98a995012c1f6230cb8ef228c Mon Sep 17 00:00:00 2001 From: Nishant Samel Date: Mon, 18 Mar 2024 12:15:12 +0530 Subject: [PATCH 25/39] Remove partial and use jbuilder itself (#1719) - Fixes https://github.com/saeloun/miru-web/issues/1697 - In this case, each timesheet_entry and timeoff_entry from time tracking page are rendered in their own partial - There is no need of partial in this case - Hence, removed the same and used jbuilder itself Co-authored-by: Nishant Samel --- .../v1/partial/_timeoff_entry.json.jbuilder | 10 ---------- .../v1/partial/_timesheet_entry.json.jbuilder | 12 ------------ .../v1/time_tracking/index.json.jbuilder | 17 +++++++++++++++-- 3 files changed, 15 insertions(+), 24 deletions(-) delete mode 100644 app/views/internal_api/v1/partial/_timeoff_entry.json.jbuilder delete mode 100644 app/views/internal_api/v1/partial/_timesheet_entry.json.jbuilder diff --git a/app/views/internal_api/v1/partial/_timeoff_entry.json.jbuilder b/app/views/internal_api/v1/partial/_timeoff_entry.json.jbuilder deleted file mode 100644 index 0349dd9f4a..0000000000 --- a/app/views/internal_api/v1/partial/_timeoff_entry.json.jbuilder +++ /dev/null @@ -1,10 +0,0 @@ -# frozen_string_literal: true - -json.id entry[:id] -json.duration entry[:duration] -json.note entry[:note] -json.type "leave" -json.leave_date CompanyDateFormattingService.new(entry[:leave_date], company:).process -json.holiday_info_id entry[:holiday_info_id] -json.leave_type_id entry[:leave_type_id] -json.user_id entry[:user_id] diff --git a/app/views/internal_api/v1/partial/_timesheet_entry.json.jbuilder b/app/views/internal_api/v1/partial/_timesheet_entry.json.jbuilder deleted file mode 100644 index 879933f9d4..0000000000 --- a/app/views/internal_api/v1/partial/_timesheet_entry.json.jbuilder +++ /dev/null @@ -1,12 +0,0 @@ -# frozen_string_literal: true - -json.id entry[:id] -json.duration entry[:duration] -json.note entry[:note] -json.bill_status entry[:bill_status] -json.work_date CompanyDateFormattingService.new(entry[:work_date], company:).process -json.type entry[:type] -json.client entry[:client] -json.project entry[:project] -json.project_id entry[:project_id] -json.team_member entry[:team_member] diff --git a/app/views/internal_api/v1/time_tracking/index.json.jbuilder b/app/views/internal_api/v1/time_tracking/index.json.jbuilder index b022ca7ec0..9ddee8c426 100644 --- a/app/views/internal_api/v1/time_tracking/index.json.jbuilder +++ b/app/views/internal_api/v1/time_tracking/index.json.jbuilder @@ -15,10 +15,23 @@ json.entries do json.set! date do if data.is_a?(Array) json.array! data do |entry| + json.id entry[:id] + json.duration entry[:duration] + json.note entry[:note] if entry[:type] == "timesheet" - json.partial! "internal_api/v1/partial/timesheet_entry", locals: { entry:, company: } + json.type entry[:type] + json.work_date CompanyDateFormattingService.new(entry[:work_date], company:).process + json.bill_status entry[:bill_status] + json.client entry[:client] + json.project entry[:project] + json.project_id entry[:project_id] + json.team_member entry[:team_member] else - json.partial! "internal_api/v1/partial/timeoff_entry", locals: { entry:, company: } + json.type "leave" + json.leave_date CompanyDateFormattingService.new(entry[:leave_date], company:).process + json.holiday_info_id entry[:holiday_info_id] + json.leave_type_id entry[:leave_type_id] + json.user_id entry[:user_id] end end end From 976ca09c452ed84d852cc2a8f11cf0701e43c6aa Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 18 Mar 2024 15:34:37 +0530 Subject: [PATCH 26/39] Bump follow-redirects from 1.15.4 to 1.15.6 in /docs (#1721) Bumps [follow-redirects](https://github.com/follow-redirects/follow-redirects) from 1.15.4 to 1.15.6. - [Release notes](https://github.com/follow-redirects/follow-redirects/releases) - [Commits](https://github.com/follow-redirects/follow-redirects/compare/v1.15.4...v1.15.6) --- updated-dependencies: - dependency-name: follow-redirects dependency-type: indirect ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- docs/yarn.lock | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/docs/yarn.lock b/docs/yarn.lock index 38d587fcd4..fbdec317ec 100644 --- a/docs/yarn.lock +++ b/docs/yarn.lock @@ -3947,9 +3947,9 @@ flux@^4.0.1: fbjs "^3.0.1" follow-redirects@^1.0.0, follow-redirects@^1.14.7: - version "1.15.4" - resolved "https://registry.yarnpkg.com/follow-redirects/-/follow-redirects-1.15.4.tgz#cdc7d308bf6493126b17ea2191ea0ccf3e535adf" - integrity sha512-Cr4D/5wlrb0z9dgERpUL3LrmPKVDsETIJhaCMeDfuFYcqa5bldGV6wBsAN6X/vxlXQtFBMrXdXxdL8CbDTGniw== + version "1.15.6" + resolved "https://registry.yarnpkg.com/follow-redirects/-/follow-redirects-1.15.6.tgz#7f815c0cda4249c74ff09e95ef97c23b5fd0399b" + integrity sha512-wWN62YITEaOpSK584EZXJafH1AGpO8RVgElfkuXbTOrPX4fIfOyEpW/CsiNd8JdYrAoOvafRTOEnvsO++qCqFA== fork-ts-checker-webpack-plugin@^6.5.0: version "6.5.2" From f79d5d09d66d8f0e97dc7aef1f190706b126881d Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 18 Mar 2024 15:35:27 +0530 Subject: [PATCH 27/39] Bump follow-redirects from 1.15.4 to 1.15.6 (#1722) Bumps [follow-redirects](https://github.com/follow-redirects/follow-redirects) from 1.15.4 to 1.15.6. - [Release notes](https://github.com/follow-redirects/follow-redirects/releases) - [Commits](https://github.com/follow-redirects/follow-redirects/compare/v1.15.4...v1.15.6) --- updated-dependencies: - dependency-name: follow-redirects dependency-type: indirect ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> Co-authored-by: Akhil G Krishnan --- yarn.lock | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/yarn.lock b/yarn.lock index 4e940d1c5b..bd82f37517 100644 --- a/yarn.lock +++ b/yarn.lock @@ -3925,9 +3925,9 @@ flatted@^3.1.0: integrity sha512-5nqDSxl8nn5BSNxyR3n4I6eDmbolI6WT+QqR547RwxQapgjQBmtktdP+HTBb/a/zLsbzERTONyUB5pefh5TtjQ== follow-redirects@^1.0.0, follow-redirects@^1.15.0: - version "1.15.4" - resolved "https://registry.yarnpkg.com/follow-redirects/-/follow-redirects-1.15.4.tgz#cdc7d308bf6493126b17ea2191ea0ccf3e535adf" - integrity sha512-Cr4D/5wlrb0z9dgERpUL3LrmPKVDsETIJhaCMeDfuFYcqa5bldGV6wBsAN6X/vxlXQtFBMrXdXxdL8CbDTGniw== + version "1.15.6" + resolved "https://registry.yarnpkg.com/follow-redirects/-/follow-redirects-1.15.6.tgz#7f815c0cda4249c74ff09e95ef97c23b5fd0399b" + integrity sha512-wWN62YITEaOpSK584EZXJafH1AGpO8RVgElfkuXbTOrPX4fIfOyEpW/CsiNd8JdYrAoOvafRTOEnvsO++qCqFA== for-each@^0.3.3: version "0.3.3" From 5072df8bb069f6e82fd0ac09128ed5897de9331f Mon Sep 17 00:00:00 2001 From: Nishant Samel Date: Tue, 19 Mar 2024 16:06:31 +0530 Subject: [PATCH 28/39] Fix issue with filter dates issue (#1724) * Fix issue with filter dates issue * Fix `last_quarter` dates issue --------- Co-authored-by: Nishant Samel --- .../src/components/Reports/api/revenueByClient.ts | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/app/javascript/src/components/Reports/api/revenueByClient.ts b/app/javascript/src/components/Reports/api/revenueByClient.ts index 7d49523efd..066279141b 100644 --- a/app/javascript/src/components/Reports/api/revenueByClient.ts +++ b/app/javascript/src/components/Reports/api/revenueByClient.ts @@ -21,8 +21,8 @@ const getReportData = async ({ const currentYear = dayjs().year(); const lastMonthOfQuarter = dayjs()["quarter"]() * 3 - 1; const firstMonthOfQuarter = dayjs()["quarter"]() * 3 - 3; - const thisQuarterFirstDate = dayjs() - .month(firstMonthOfQuarter) + const thisQuarterFirstDate = dayjs(dayjs().month(firstMonthOfQuarter)) + .startOf("month") .format("DD-MM-YYYY"); const thisQuarterLastDate = dayjs(dayjs().month(lastMonthOfQuarter)) @@ -38,10 +38,12 @@ const getReportData = async ({ case "last_quarter": fromDate = dayjs(dayjs().month(firstMonthOfQuarter)) .subtract(3, "month") + .startOf("month") .format("DD-MM-YYYY"); toDate = dayjs(dayjs().month(lastMonthOfQuarter)) .subtract(3, "month") + .endOf("month") .format("DD-MM-YYYY"); break; From 9ffe677e2969f8764624427d1c3d9abcd5c9277d Mon Sep 17 00:00:00 2001 From: Shruti-Apte <72149587+Shruti-Apte@users.noreply.github.com> Date: Tue, 19 Mar 2024 19:13:24 +0530 Subject: [PATCH 29/39] Fixed:Not able to add client (#1726) * state and city dropdown changed to textbox * payload updated * max char limit increased * updated mobile files --------- Co-authored-by: Saeloun --- .../common/FormikFields/InputErrors/index.tsx | 8 ++- .../Clients/ClientForm/MobileClientForm.tsx | 62 +++++------------ .../ClientForm/formValidationSchema.ts | 24 +++---- .../components/Clients/ClientForm/index.tsx | 69 ++++++------------- .../components/Clients/ClientForm/utils.ts | 7 +- 5 files changed, 56 insertions(+), 114 deletions(-) diff --git a/app/javascript/src/common/FormikFields/InputErrors/index.tsx b/app/javascript/src/common/FormikFields/InputErrors/index.tsx index f971a84b7f..dc80a55dbf 100644 --- a/app/javascript/src/common/FormikFields/InputErrors/index.tsx +++ b/app/javascript/src/common/FormikFields/InputErrors/index.tsx @@ -1,8 +1,12 @@ import React from "react"; -const InputErrors = ({ fieldErrors, fieldTouched }) => +const InputErrors = ({ fieldErrors, fieldTouched, addMargin = true }) => fieldErrors && fieldTouched ? ( -
    +
    {fieldErrors}
    ) : null; diff --git a/app/javascript/src/components/Clients/ClientForm/MobileClientForm.tsx b/app/javascript/src/components/Clients/ClientForm/MobileClientForm.tsx index 912a9aa696..58048c9478 100644 --- a/app/javascript/src/components/Clients/ClientForm/MobileClientForm.tsx +++ b/app/javascript/src/components/Clients/ClientForm/MobileClientForm.tsx @@ -1,7 +1,7 @@ /* eslint-disable @typescript-eslint/no-var-requires */ import React, { useEffect, useState } from "react"; -import { Country, State, City } from "country-state-city"; +import { Country } from "country-state-city"; import { Form, Formik, FormikProps } from "formik"; import { XIcon } from "miruIcons"; import PhoneInput from "react-phone-number-input"; @@ -51,32 +51,6 @@ const MobileClientForm = ({ assignCountries(allCountries); }, []); - const updatedStates = countryCode => - State.getStatesOfCountry(countryCode).map(state => ({ - label: state.name, - value: state.name, - code: state.isoCode, - ...state, - })); - - const updatedCities = values => { - const allStates = State.getAllStates(); - const currentCity = allStates.filter( - state => state.name == values.state.label - ); - - const cities = City.getCitiesOfState( - values.country.code, - currentCity[0] && currentCity[0].isoCode - ).map(city => ({ - label: city.name, - value: city.name, - ...city, - })); - - return cities; - }; - const handleSubmit = async values => { const formData = new FormData(); @@ -260,32 +234,32 @@ const MobileClientForm = ({ />
    - { - setFieldValue("state", state); - setFieldValue("city", ""); - setFieldValue("zipcode", ""); - updatedCities(values); - }} - options={updatedStates( - values.country.code ? values.country.code : "US" - )} + setFieldValue={setFieldValue} + /> +
    - setFieldValue("city", city)} - isErr={!!errors.city && touched.city} + +
    diff --git a/app/javascript/src/components/Clients/ClientForm/formValidationSchema.ts b/app/javascript/src/components/Clients/ClientForm/formValidationSchema.ts index 675015131d..4777f74b39 100644 --- a/app/javascript/src/components/Clients/ClientForm/formValidationSchema.ts +++ b/app/javascript/src/components/Clients/ClientForm/formValidationSchema.ts @@ -19,12 +19,12 @@ export const clientSchema = Yup.object().shape({ country: Yup.object().shape({ value: Yup.string().required("Country cannot be blank"), }), - state: Yup.object().shape({ - value: Yup.string().required("State cannot be blank"), - }), - city: Yup.object().shape({ - value: Yup.string().required("City cannot be blank"), - }), + state: Yup.string() + .required("State cannot be blank") + .max(50, "Maximum 50 characters are allowed"), + city: Yup.string() + .required("City cannot be blank") + .max(50, "Maximum 50 characters are allowed"), zipcode: Yup.string() .required("Zipcode line cannot be blank") .max(10, "Maximum 10 characters are allowed"), @@ -51,16 +51,8 @@ export const getInitialvalues = (client?: any) => ({ code: client?.address?.country || "", value: client?.address?.country || "", }, - state: { - label: client?.address?.state || "", - code: client?.address?.state || "", - value: client?.address?.state || "", - }, - city: { - label: client?.address?.city || "", - code: client?.address?.city || "", - value: client?.address?.city || "", - }, + state: client?.address?.state || "", + city: client?.address?.city || "", zipcode: client?.address?.pin || "", minutes: client?.minutes || "", logo: client?.logo || null, diff --git a/app/javascript/src/components/Clients/ClientForm/index.tsx b/app/javascript/src/components/Clients/ClientForm/index.tsx index 8e6062d7fd..45e52dadba 100644 --- a/app/javascript/src/components/Clients/ClientForm/index.tsx +++ b/app/javascript/src/components/Clients/ClientForm/index.tsx @@ -2,7 +2,7 @@ import React, { useEffect, useState } from "react"; import "react-phone-number-input/style.css"; //eslint-disable-line -import { Country, State, City } from "country-state-city"; +import { Country } from "country-state-city"; import { Formik, Form, FormikProps } from "formik"; import PhoneInput from "react-phone-number-input"; import flags from "react-phone-number-input/flags"; @@ -49,32 +49,6 @@ const ClientForm = ({ assignCountries(allCountries); }, []); - const updatedStates = countryCode => - State.getStatesOfCountry(countryCode).map(state => ({ - label: state.name, - value: state.name, - code: state?.isoCode && state.isoCode, - ...state, - })); - - const updatedCities = values => { - const allStates = State.getAllStates(); - const currentCity = allStates.filter( - state => state.name == values.state.label - ); - - const cities = City.getCitiesOfState( - values.country.code, - currentCity[0] && currentCity[0].isoCode - ).map(city => ({ - label: city.name, - value: city.name, - ...city, - })); - - return cities; - }; - const handleSubmit = async values => { setSubmitting(true); const formData = new FormData(); @@ -229,34 +203,35 @@ const ClientForm = ({ />
    - { - setFieldValue("state", state); - setFieldValue("city", ""); - setFieldValue("zipcode", ""); - updatedCities(values); - }} - options={updatedStates( - values.country.code ? values.country.code : "US" - )} + setFieldValue={setFieldValue} + /> +
    - setFieldValue("city", city)} - id="city-list" - isErr={!!errors.city && touched.city} + +
    @@ -314,8 +289,8 @@ interface FormValues { address1: string; address2: string; country: any; - state: any; - city: any; + state: string; + city: string; zipcode: string; logo: any; } diff --git a/app/javascript/src/components/Clients/ClientForm/utils.ts b/app/javascript/src/components/Clients/ClientForm/utils.ts index d97fd5b3a5..36be3ea918 100644 --- a/app/javascript/src/components/Clients/ClientForm/utils.ts +++ b/app/javascript/src/components/Clients/ClientForm/utils.ts @@ -23,12 +23,9 @@ export const formatFormData = ( values.address2 ); - formData.append( - "client[addresses_attributes[0][state]]", - values.state?.value - ); + formData.append("client[addresses_attributes[0][state]]", values.state); - formData.append("client[addresses_attributes[0][city]]", values.city?.value); + formData.append("client[addresses_attributes[0][city]]", values.city); formData.append( "client[addresses_attributes[0][country]]", From bcdd1b1f9ed86e875b37220025a59951cade9b86 Mon Sep 17 00:00:00 2001 From: Shruti-Apte <72149587+Shruti-Apte@users.noreply.github.com> Date: Wed, 20 Mar 2024 11:12:55 +0530 Subject: [PATCH 30/39] Team page bug fixes (#1714) team page bugs fixed Co-authored-by: Saeloun --- app/javascript/src/components/Team/List/Table/TableRow.tsx | 6 +----- app/javascript/src/components/Team/List/index.tsx | 6 ++---- app/javascript/src/components/Team/modals/TeamForm.tsx | 1 - 3 files changed, 3 insertions(+), 10 deletions(-) diff --git a/app/javascript/src/components/Team/List/Table/TableRow.tsx b/app/javascript/src/components/Team/List/Table/TableRow.tsx index cf15c3ee40..205c720f81 100644 --- a/app/javascript/src/components/Team/List/Table/TableRow.tsx +++ b/app/javascript/src/components/Team/List/Table/TableRow.tsx @@ -48,11 +48,7 @@ const TableRow = ({ item }) => { const handleRowClick = () => { if (status) return; - if (isDesktop) { - navigate(`/team/${id}`, { replace: true }); - } else { - navigate(`/team/${id}/options`, { replace: true }); - } + navigate(`/team/${id}`, { replace: true }); }; return ( diff --git a/app/javascript/src/components/Team/List/index.tsx b/app/javascript/src/components/Team/List/index.tsx index a7c3cc8d64..cbafd401cd 100644 --- a/app/javascript/src/components/Team/List/index.tsx +++ b/app/javascript/src/components/Team/List/index.tsx @@ -50,10 +50,8 @@ const TeamList = () => { }; useEffect(() => { - if (modal == TeamModalType.NONE) { - getTeamList(); - } - }, [modal]); + getTeamList(); + }, []); const handlePageChange = async (pageData, items = pagy.items) => { if (pageData == "...") return; diff --git a/app/javascript/src/components/Team/modals/TeamForm.tsx b/app/javascript/src/components/Team/modals/TeamForm.tsx index ca905816cb..672b80e6b5 100644 --- a/app/javascript/src/components/Team/modals/TeamForm.tsx +++ b/app/javascript/src/components/Team/modals/TeamForm.tsx @@ -169,7 +169,6 @@ const TeamForm = ({ className={`w-full p-2 text-center text-base font-bold ${ !isValid || (!dirty && "bg-miru-gray-400") }`} - onClick={() => handleSubmit(values)} > {isEdit ? "SAVE CHANGES" : "SEND INVITE"} From 3a6e07f64bae3f974fd4ef8e3882f0eb2b260002 Mon Sep 17 00:00:00 2001 From: Prasanth Chaduvula Date: Wed, 20 Mar 2024 11:48:04 +0530 Subject: [PATCH 31/39] Hide week view and made day view as default view (#1730) --- app/javascript/src/components/TimesheetEntries/index.tsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/app/javascript/src/components/TimesheetEntries/index.tsx b/app/javascript/src/components/TimesheetEntries/index.tsx index bc32d163a7..95a0f12149 100644 --- a/app/javascript/src/components/TimesheetEntries/index.tsx +++ b/app/javascript/src/components/TimesheetEntries/index.tsx @@ -49,7 +49,7 @@ const TimesheetEntries = ({ user, isAdminUser }: Iprops) => { const [endOfTheMonth, setEndOfTheMonth] = useState( dayjs().endOf("month").format("YYYY-MM-DD") ); - const [view, setView] = useState("month"); + const [view, setView] = useState("day"); const [newEntryView, setNewEntryView] = useState(false); const [newTimeoffEntryView, setNewTimeoffEntryView] = useState(false); @@ -676,7 +676,7 @@ const TimesheetEntries = ({ user, isAdminUser }: Iprops) => {
    {isDesktop && (